diff --git a/README.md b/README.md
index 96eb1a9f..50550342 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, Windows, macOS or Linux desktop. One source tree, multiple targets.
-
+
π **Try it now:** flash an ESP32 straight from your browser β β step-by-step in the [Getting started guide](docs/gettingstarted.md).
@@ -90,7 +90,7 @@ The numbers above are observations. The **contracts** projectMM commits to, what
**ESP32: flash from your browser.** Open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge; it walks you through release, device and firmware selection, flashing, and network setup. The installer lists stable releases and a `latest` build (published automatically on every merge to main) carrying the newest unreleased changes.
-
+
**Desktop: download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases):
@@ -113,7 +113,7 @@ uv run scripts/moondeck.py
Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, Live tab to discover devices. Full per-command reference: [scripts/MoonDeck.md](scripts/MoonDeck.md).
-
+
## Documentation
@@ -165,6 +165,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th
- **[hpwit](https://github.com/hpwit) (Yves Bazin)** β the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive.
- **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))** β author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) app. Its source let us reverse-engineer exactly what the WLED app reads, so projectMM devices appear in (and are controllable from) the native WLED apps.
- **The [Improv Wi-Fi](https://github.com/improv-wifi) project** β the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB.
+- **[FastLED](https://github.com/FastLED/FastLED)** β the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the colour/animation primitives β `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family β so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes.
## Contributing
diff --git a/docs/architecture.md b/docs/architecture.md
index cf101a8a..2257f900 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -259,7 +259,7 @@ The defining line is the **data relationship, not the connector**: *does the mod
Peripherals are **user-add/deletable children of SystemModule**: 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 (SystemModule declares `acceptsChildRoles("peripheral")`). Direction is per-module, not a role: a peripheral may read (gyro), write (relay), or both, so one `Peripheral` 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/` (enforced by `check_specs.py`). Most poll in `loop20ms`/`loop1s`; the exception is a peripheral whose data an effect consumes *every frame*: [AudioModule](moonmodules/core/AudioModule.md) reads + analyses its IΒ²S microphone in `loop()` 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 peripheral's data** via the shared-struct pull pattern from [Β§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral 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: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](moonmodules/light/effects/AudioSpectrumEffect.md) consume. It reaches the frame through a static `AudioModule::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 `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely.
+**An effect reads a peripheral's data** via the shared-struct pull pattern from [Β§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral 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: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects/effects.md) consume. It reaches the frame through a static `AudioModule::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 `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely.
## Multi-device runtime
@@ -369,17 +369,32 @@ Effects know nothing about hardware, protocols, physical LED layout, or mapping.
**Speed convention.** Effects with a speed control use BPM (beats per minute). `uint8_t`, default 60 (= 1 beat per second). Human-readable, musically meaningful, DMX-compatible. The effect converts BPM to animation rate internally using elapsed millis.
+### Buffer persistence β the layer does not clear each frame
+
+The Layer's buffer **persists** frame to frame: `Layer::loop()` does not clear it before running effects. The buffer holds the previous frame until an effect overwrites or fades it, and is zeroed **once** on allocation/resize. (This is the standard LED-animation model β FastLED, WLED, and MoonLight all persist their frame buffer rather than auto-clear.) Persistence holds **between frames, not across rebuilds**: `Layer::onBuildState()` clears the buffer once after `rebuildLUT()`, so adding, replacing, or reconfiguring an effect (or a resize) starts from black, then persistence takes over frame to frame again. Each effect owns its own background:
+
+- A **full-grid** effect (Plasma, Rainbow, Fire, Noise) writes every pixel each frame β the previous frame is simply overwritten.
+- A **trail** effect calls `layer()->fadeToBlackBy(amt)` to decay the previous frame before painting its new pixels β a comet leaves a fading tail because the old pixels are still there to fade.
+- A **read-prior** effect (a scroll like FreqMatrix, Game-of-Life, a blur) reads last frame's pixels via `draw::get` / `draw::blur` and acts on them β the persistence *is* its state.
+- A **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose draw touches only surface voxels).
+
+There is deliberately **no per-effect "persistence" flag**: persistence is universal, so a flag would change no framework behaviour (unlike `dimensions()`, which drives extrude). Multiple effects on one layer *interact* through the shared persistent buffer β that is a feature.
+
+**Collected fade (`Layer::fadeToBlackBy`).** Fade is a Layer operation, not a per-effect buffer pass (MoonLight's `VirtualLayer::fadeToBlackBy`). Effects register a fade amount; the Layer keeps the **MIN** across all requesting effects (the gentlest fade wins, so the longest requested trail is honoured) and applies **one** whole-buffer pass at the start of the next frame, then resets the collected amount. So N fading effects on one layer cost a single pass, not N, and one effect's fade never darkens another's just-painted pixels mid-frame. An auto-clear would make trails and read-prior effects impossible without a shadow buffer; this model gets them for free.
+
### Dimensionality
Every effect declares its native dimensionality through `EffectBase::dimensions()`, returning `Dim::D1`, `Dim::D2`, or `Dim::D3` (default: "I iterate every axis the layer gives me"). The Layer uses this to **extrude** lower-dimensional output across the unused axes after each effect's `loop()`:
-- **D1**: the effect writes only the row at `(y=0, z=0)`. Layer copies that row across every other y in z=0, then copies z=0 across every z.
-- **D2**: the effect writes only the z=0 slice. Layer copies z=0 across every z.
+- **D1**: the effect writes only the column at `(x=0, z=0)` β **1D runs along Y**. Layer copies that column across every other x in z=0, then copies z=0 across every z.
+- **D2**: the effect writes only the z=0 slice (the front `(x, y)` face). Layer copies z=0 across every z.
- **D3**: the effect writes every axis itself. Extrude is a one-comparison no-op.
-D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or y and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer.
+D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or x and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer.
-Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D layer) the inner loops are guarded by `depth_ > 1` / `height_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication.
+**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D β 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D β 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form β write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* β a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 Γ N` grid, width 1, height N); on an `N Γ 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/drivers/HueDriver.md)) maps to a `1 Γ N` grid is a layout concern β see the layout docs.
+
+Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D `1 Γ N` layer) the inner loops are guarded by `depth_ > 1` / `width_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication.
Each effect's `dimensions()` is a claim about which axes its loop iterates, not which axes its math could in principle vary along. A "D2 fire" can in future be promoted to D3 by adding z-aware heat propagation; until then declaring it D2 honestly describes what the loop does today.
@@ -520,7 +535,7 @@ How lighting uses the core [multi-device runtime](#multi-device-runtime) (discov
# Web UI
-
+
The UI is a handful of hand-maintained files: `index.html`, `app.js`, `style.css`, plus two focused ES modules `app.js` imports (`preview3d.js` for the WebGL 3D preview, `install-picker.js` shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server.
@@ -534,6 +549,20 @@ Adding a new MoonModule with controls needs **zero changes** to the UI files. Th
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/drivers/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`, β¦, `peripheral`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md).
+## Tag emoji legend
+
+A module's chips come from three sources, rendered identically on the card and the type picker: a **role** chip (UI-derived from `role`), a **dimensional** chip (UI-derived from `dim`), and the curated **`tags()`** string (a flash literal the module returns; the UI splits it into grapheme clusters, one chip each). Role and dim are *not* repeated in `tags()` β only the categories below are. The `ROLE_EMOJI` / `DIM_EMOJI` maps in `app.js` are the single source of truth for the UI-derived chips; the legend takes [MoonLight](https://github.com/MoonModules/MoonLight)'s set as the canonical basis:
+
+| Category | Emoji | Meaning |
+|---|---|---|
+| **Role** (UI-derived) | π₯ effect Β· π modifier Β· π₯ layout Β· βΈοΈ driver Β· π°οΈ peripheral Β· π₯ layer Β· βοΈ generic | what kind of module (from `role`, via `ROLE_EMOJI`) |
+| **Dimensionality** (UI-derived) | π 1D Β· π¦ 2D Β· π§ 3D | native axes (from `dim`) |
+| **Origin / library** (`tags()`) | π« MoonLight Β· π WLED Β· β‘οΈ FastLED Β· *(projectMM-native is the default origin β an origin emoji marks a module that came from elsewhere)* | which library the module came from; the [migration](../backlog/moonlight-effect-inventory.md) files docs by this, the emoji filters by it |
+| **Creator** (`tags()`) | π¦
a named contributor (credited at the introduction site) | individual authorship credit |
+| **Audio** (`tags()`) | π audio-reactive | reads `AudioModule::latestFrame()` |
+
+`tags()` carries **only** origin + creator + audio (+ any genuinely module-specific marker); a module can carry several (e.g. `π«π¦
` = MoonLight origin, a named creator). Role and dim are added by the UI, so a module never duplicates them in its string. When migrating, set each module's `tags()` from this legend so the chip set is consistent across the library.
+
## What we leave undesigned
Genuinely open questions, *not* the same as a π§ marker. A π§ item has a settled, committed design (two-core handover, clock sync, device-to-device light distribution) β code is written toward it; the items here are ones where the *design itself* is still open, deferred until a concrete need forces the decision:
diff --git a/docs/assets/screenshots/Devices module.png b/docs/assets/core/Devices module.png
similarity index 100%
rename from docs/assets/screenshots/Devices module.png
rename to docs/assets/core/Devices module.png
diff --git a/docs/assets/screenshots/FilesystemModule.png b/docs/assets/core/FilesystemModule.png
similarity index 100%
rename from docs/assets/screenshots/FilesystemModule.png
rename to docs/assets/core/FilesystemModule.png
diff --git a/docs/assets/screenshots/FirmwareUpdateModule.png b/docs/assets/core/FirmwareUpdateModule.png
similarity index 100%
rename from docs/assets/screenshots/FirmwareUpdateModule.png
rename to docs/assets/core/FirmwareUpdateModule.png
diff --git a/docs/assets/core/Hue device disco.png b/docs/assets/core/Hue device disco.png
new file mode 100644
index 00000000..cec4b461
Binary files /dev/null and b/docs/assets/core/Hue device disco.png differ
diff --git a/docs/assets/screenshots/Layers.png b/docs/assets/core/Layers.png
similarity index 100%
rename from docs/assets/screenshots/Layers.png
rename to docs/assets/core/Layers.png
diff --git a/docs/assets/screenshots/Layouts.png b/docs/assets/core/Layouts.png
similarity index 100%
rename from docs/assets/screenshots/Layouts.png
rename to docs/assets/core/Layouts.png
diff --git a/docs/assets/screenshots/NetworkModule.png b/docs/assets/core/NetworkModule.png
similarity index 100%
rename from docs/assets/screenshots/NetworkModule.png
rename to docs/assets/core/NetworkModule.png
diff --git a/docs/assets/screenshots/SystemModule.png b/docs/assets/core/SystemModule.png
similarity index 100%
rename from docs/assets/screenshots/SystemModule.png
rename to docs/assets/core/SystemModule.png
diff --git a/docs/assets/screenshots/WLED Native discovers projectMM.jpeg b/docs/assets/core/WLED Native discovers projectMM.jpeg
similarity index 100%
rename from docs/assets/screenshots/WLED Native discovers projectMM.jpeg
rename to docs/assets/core/WLED Native discovers projectMM.jpeg
diff --git a/docs/assets/screenshots/Wled discovers projectMM.png b/docs/assets/core/Wled discovers projectMM.png
similarity index 100%
rename from docs/assets/screenshots/Wled discovers projectMM.png
rename to docs/assets/core/Wled discovers projectMM.png
diff --git a/docs/assets/screenshots/Drivers.png b/docs/assets/light/drivers/Drivers.png
similarity index 100%
rename from docs/assets/screenshots/Drivers.png
rename to docs/assets/light/drivers/Drivers.png
diff --git a/docs/assets/light/drivers/Hue driver.png b/docs/assets/light/drivers/Hue driver.png
new file mode 100644
index 00000000..be88ac39
Binary files /dev/null and b/docs/assets/light/drivers/Hue driver.png differ
diff --git a/docs/assets/light/drivers/Hue friendly effect.png b/docs/assets/light/drivers/Hue friendly effect.png
new file mode 100644
index 00000000..57e92a46
Binary files /dev/null and b/docs/assets/light/drivers/Hue friendly effect.png differ
diff --git a/docs/assets/screenshots/NetworkSendDriver.png b/docs/assets/light/drivers/NetworkSendDriver.png
similarity index 100%
rename from docs/assets/screenshots/NetworkSendDriver.png
rename to docs/assets/light/drivers/NetworkSendDriver.png
diff --git a/docs/assets/screenshots/PreviewDriver.png b/docs/assets/light/drivers/PreviewDriver.png
similarity index 100%
rename from docs/assets/screenshots/PreviewDriver.png
rename to docs/assets/light/drivers/PreviewDriver.png
diff --git a/docs/assets/screenshots/FireEffect.gif b/docs/assets/light/effects/FireEffect.gif
similarity index 100%
rename from docs/assets/screenshots/FireEffect.gif
rename to docs/assets/light/effects/FireEffect.gif
diff --git a/docs/assets/screenshots/FireEffect.png b/docs/assets/light/effects/FireEffect.png
similarity index 100%
rename from docs/assets/screenshots/FireEffect.png
rename to docs/assets/light/effects/FireEffect.png
diff --git a/docs/assets/screenshots/LavaLampEffect.gif b/docs/assets/light/effects/LavaLampEffect.gif
similarity index 100%
rename from docs/assets/screenshots/LavaLampEffect.gif
rename to docs/assets/light/effects/LavaLampEffect.gif
diff --git a/docs/assets/screenshots/LavaLampEffect.png b/docs/assets/light/effects/LavaLampEffect.png
similarity index 100%
rename from docs/assets/screenshots/LavaLampEffect.png
rename to docs/assets/light/effects/LavaLampEffect.png
diff --git a/docs/assets/screenshots/LinesEffect.gif b/docs/assets/light/effects/LinesEffect.gif
similarity index 100%
rename from docs/assets/screenshots/LinesEffect.gif
rename to docs/assets/light/effects/LinesEffect.gif
diff --git a/docs/assets/screenshots/LinesEffect.png b/docs/assets/light/effects/LinesEffect.png
similarity index 100%
rename from docs/assets/screenshots/LinesEffect.png
rename to docs/assets/light/effects/LinesEffect.png
diff --git a/docs/assets/screenshots/MetaballsEffect.gif b/docs/assets/light/effects/MetaballsEffect.gif
similarity index 100%
rename from docs/assets/screenshots/MetaballsEffect.gif
rename to docs/assets/light/effects/MetaballsEffect.gif
diff --git a/docs/assets/screenshots/MetaballsEffect.png b/docs/assets/light/effects/MetaballsEffect.png
similarity index 100%
rename from docs/assets/screenshots/MetaballsEffect.png
rename to docs/assets/light/effects/MetaballsEffect.png
diff --git a/docs/assets/screenshots/NoiseEffect.gif b/docs/assets/light/effects/NoiseEffect.gif
similarity index 100%
rename from docs/assets/screenshots/NoiseEffect.gif
rename to docs/assets/light/effects/NoiseEffect.gif
diff --git a/docs/assets/screenshots/NoiseEffect.png b/docs/assets/light/effects/NoiseEffect.png
similarity index 100%
rename from docs/assets/screenshots/NoiseEffect.png
rename to docs/assets/light/effects/NoiseEffect.png
diff --git a/docs/assets/screenshots/ParticlesEffect.gif b/docs/assets/light/effects/ParticlesEffect.gif
similarity index 100%
rename from docs/assets/screenshots/ParticlesEffect.gif
rename to docs/assets/light/effects/ParticlesEffect.gif
diff --git a/docs/assets/screenshots/ParticlesEffect.png b/docs/assets/light/effects/ParticlesEffect.png
similarity index 100%
rename from docs/assets/screenshots/ParticlesEffect.png
rename to docs/assets/light/effects/ParticlesEffect.png
diff --git a/docs/assets/screenshots/PlasmaEffect.gif b/docs/assets/light/effects/PlasmaEffect.gif
similarity index 100%
rename from docs/assets/screenshots/PlasmaEffect.gif
rename to docs/assets/light/effects/PlasmaEffect.gif
diff --git a/docs/assets/screenshots/PlasmaEffect.png b/docs/assets/light/effects/PlasmaEffect.png
similarity index 100%
rename from docs/assets/screenshots/PlasmaEffect.png
rename to docs/assets/light/effects/PlasmaEffect.png
diff --git a/docs/assets/screenshots/RainbowEffect.gif b/docs/assets/light/effects/RainbowEffect.gif
similarity index 100%
rename from docs/assets/screenshots/RainbowEffect.gif
rename to docs/assets/light/effects/RainbowEffect.gif
diff --git a/docs/assets/screenshots/RainbowEffect.png b/docs/assets/light/effects/RainbowEffect.png
similarity index 100%
rename from docs/assets/screenshots/RainbowEffect.png
rename to docs/assets/light/effects/RainbowEffect.png
diff --git a/docs/assets/screenshots/RingsEffect.gif b/docs/assets/light/effects/RingsEffect.gif
similarity index 100%
rename from docs/assets/screenshots/RingsEffect.gif
rename to docs/assets/light/effects/RingsEffect.gif
diff --git a/docs/assets/screenshots/RingsEffect.png b/docs/assets/light/effects/RingsEffect.png
similarity index 100%
rename from docs/assets/screenshots/RingsEffect.png
rename to docs/assets/light/effects/RingsEffect.png
diff --git a/docs/assets/screenshots/RipplesEffect.gif b/docs/assets/light/effects/RipplesEffect.gif
similarity index 100%
rename from docs/assets/screenshots/RipplesEffect.gif
rename to docs/assets/light/effects/RipplesEffect.gif
diff --git a/docs/assets/screenshots/RipplesEffect.png b/docs/assets/light/effects/RipplesEffect.png
similarity index 100%
rename from docs/assets/screenshots/RipplesEffect.png
rename to docs/assets/light/effects/RipplesEffect.png
diff --git a/docs/assets/screenshots/SpiralEffect.gif b/docs/assets/light/effects/SpiralEffect.gif
similarity index 100%
rename from docs/assets/screenshots/SpiralEffect.gif
rename to docs/assets/light/effects/SpiralEffect.gif
diff --git a/docs/assets/screenshots/SpiralEffect.png b/docs/assets/light/effects/SpiralEffect.png
similarity index 100%
rename from docs/assets/screenshots/SpiralEffect.png
rename to docs/assets/light/effects/SpiralEffect.png
diff --git a/docs/assets/light/effects/StarSkyEffect.gif b/docs/assets/light/effects/StarSkyEffect.gif
new file mode 100644
index 00000000..76ef363f
Binary files /dev/null and b/docs/assets/light/effects/StarSkyEffect.gif differ
diff --git a/docs/assets/light/layouts/CarLightsLayout.gif b/docs/assets/light/layouts/CarLightsLayout.gif
new file mode 100644
index 00000000..d8bcffe2
Binary files /dev/null and b/docs/assets/light/layouts/CarLightsLayout.gif differ
diff --git a/docs/assets/screenshots/GridLayout.png b/docs/assets/light/layouts/GridLayout.png
similarity index 100%
rename from docs/assets/screenshots/GridLayout.png
rename to docs/assets/light/layouts/GridLayout.png
diff --git a/docs/assets/light/layouts/TorontoBarGourdsLayout.gif b/docs/assets/light/layouts/TorontoBarGourdsLayout.gif
new file mode 100644
index 00000000..60b1e196
Binary files /dev/null and b/docs/assets/light/layouts/TorontoBarGourdsLayout.gif differ
diff --git a/docs/assets/screenshots/CheckerboardModifier.gif b/docs/assets/light/modifiers/CheckerboardModifier.gif
similarity index 100%
rename from docs/assets/screenshots/CheckerboardModifier.gif
rename to docs/assets/light/modifiers/CheckerboardModifier.gif
diff --git a/docs/assets/screenshots/CheckerboardModifier.png b/docs/assets/light/modifiers/CheckerboardModifier.png
similarity index 100%
rename from docs/assets/screenshots/CheckerboardModifier.png
rename to docs/assets/light/modifiers/CheckerboardModifier.png
diff --git a/docs/assets/screenshots/MirrorModifier.gif b/docs/assets/light/modifiers/MirrorModifier.gif
similarity index 100%
rename from docs/assets/screenshots/MirrorModifier.gif
rename to docs/assets/light/modifiers/MirrorModifier.gif
diff --git a/docs/assets/screenshots/MirrorModifier.png b/docs/assets/light/modifiers/MirrorModifier.png
similarity index 100%
rename from docs/assets/screenshots/MirrorModifier.png
rename to docs/assets/light/modifiers/MirrorModifier.png
diff --git a/docs/assets/screenshots/MultiplyModifier.gif b/docs/assets/light/modifiers/MultiplyModifier.gif
similarity index 100%
rename from docs/assets/screenshots/MultiplyModifier.gif
rename to docs/assets/light/modifiers/MultiplyModifier.gif
diff --git a/docs/assets/screenshots/MultiplyModifier.png b/docs/assets/light/modifiers/MultiplyModifier.png
similarity index 100%
rename from docs/assets/screenshots/MultiplyModifier.png
rename to docs/assets/light/modifiers/MultiplyModifier.png
diff --git a/docs/assets/screenshots/CheckerboardEffect.gif b/docs/assets/screenshots/CheckerboardEffect.gif
deleted file mode 100644
index b52a9756..00000000
Binary files a/docs/assets/screenshots/CheckerboardEffect.gif and /dev/null differ
diff --git a/docs/assets/screenshots/CheckerboardEffect.png b/docs/assets/screenshots/CheckerboardEffect.png
deleted file mode 100644
index e3795c78..00000000
Binary files a/docs/assets/screenshots/CheckerboardEffect.png and /dev/null differ
diff --git a/docs/assets/screenshots/GameOfLifeEffect.gif b/docs/assets/screenshots/GameOfLifeEffect.gif
deleted file mode 100644
index a0715a51..00000000
Binary files a/docs/assets/screenshots/GameOfLifeEffect.gif and /dev/null differ
diff --git a/docs/assets/screenshots/GameOfLifeEffect.png b/docs/assets/screenshots/GameOfLifeEffect.png
deleted file mode 100644
index 1fae96ad..00000000
Binary files a/docs/assets/screenshots/GameOfLifeEffect.png and /dev/null differ
diff --git a/docs/assets/screenshots/GlowParticlesEffect.gif b/docs/assets/screenshots/GlowParticlesEffect.gif
deleted file mode 100644
index 704e7842..00000000
Binary files a/docs/assets/screenshots/GlowParticlesEffect.gif and /dev/null differ
diff --git a/docs/assets/screenshots/GlowParticlesEffect.png b/docs/assets/screenshots/GlowParticlesEffect.png
deleted file mode 100644
index 80760ec7..00000000
Binary files a/docs/assets/screenshots/GlowParticlesEffect.png and /dev/null differ
diff --git a/docs/assets/screenshots/PlasmaPaletteEffect.gif b/docs/assets/screenshots/PlasmaPaletteEffect.gif
deleted file mode 100644
index bca860f4..00000000
Binary files a/docs/assets/screenshots/PlasmaPaletteEffect.gif and /dev/null differ
diff --git a/docs/assets/screenshots/PlasmaPaletteEffect.png b/docs/assets/screenshots/PlasmaPaletteEffect.png
deleted file mode 100644
index b6d76108..00000000
Binary files a/docs/assets/screenshots/PlasmaPaletteEffect.png and /dev/null differ
diff --git a/docs/assets/screenshots/installer-board-picker-collapsed.png b/docs/assets/ui/installer-board-picker-collapsed.png
similarity index 100%
rename from docs/assets/screenshots/installer-board-picker-collapsed.png
rename to docs/assets/ui/installer-board-picker-collapsed.png
diff --git a/docs/assets/screenshots/installer-board-picker-expanded.png b/docs/assets/ui/installer-board-picker-expanded.png
similarity index 100%
rename from docs/assets/screenshots/installer-board-picker-expanded.png
rename to docs/assets/ui/installer-board-picker-expanded.png
diff --git a/docs/assets/screenshots/installer.png b/docs/assets/ui/installer.png
similarity index 100%
rename from docs/assets/screenshots/installer.png
rename to docs/assets/ui/installer.png
diff --git a/docs/assets/screenshots/installer2.png b/docs/assets/ui/installer2.png
similarity index 100%
rename from docs/assets/screenshots/installer2.png
rename to docs/assets/ui/installer2.png
diff --git a/docs/assets/screenshots/installer3.png b/docs/assets/ui/installer3.png
similarity index 100%
rename from docs/assets/screenshots/installer3.png
rename to docs/assets/ui/installer3.png
diff --git a/docs/assets/screenshots/moondeck_esp32.png b/docs/assets/ui/moondeck_esp32.png
similarity index 100%
rename from docs/assets/screenshots/moondeck_esp32.png
rename to docs/assets/ui/moondeck_esp32.png
diff --git a/docs/assets/screenshots/moondeck_live.png b/docs/assets/ui/moondeck_live.png
similarity index 100%
rename from docs/assets/screenshots/moondeck_live.png
rename to docs/assets/ui/moondeck_live.png
diff --git a/docs/assets/screenshots/moondeck_pc.png b/docs/assets/ui/moondeck_pc.png
similarity index 100%
rename from docs/assets/screenshots/moondeck_pc.png
rename to docs/assets/ui/moondeck_pc.png
diff --git a/docs/assets/screenshots/ui_light.png b/docs/assets/ui/ui_light.png
similarity index 100%
rename from docs/assets/screenshots/ui_light.png
rename to docs/assets/ui/ui_light.png
diff --git a/docs/assets/screenshots/ui_overview.png b/docs/assets/ui/ui_overview.png
similarity index 100%
rename from docs/assets/screenshots/ui_overview.png
rename to docs/assets/ui/ui_overview.png
diff --git a/docs/assets/screenshots/ui_theme.gif b/docs/assets/ui/ui_theme.gif
similarity index 100%
rename from docs/assets/screenshots/ui_theme.gif
rename to docs/assets/ui/ui_theme.gif
diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md
index 48e63761..aac5ef01 100644
--- a/docs/backlog/backlog-core.md
+++ b/docs/backlog/backlog-core.md
@@ -25,7 +25,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co
DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change):
-- **More discovery plugins** β ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only β never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for.
+- **More discovery plugins** β ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only β never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/drivers/HueDriver.md) β bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** β pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP β do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.)
- **The command half** β `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** β one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5β1 ms vs REST's 10β50 ms β REST would visibly de-sync).
- **Live peer state** β a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half.
- **Non-IP transports (board-gated, far future)** β Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway β the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate.
diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md
index f85e8600..7dd18e8c 100644
--- a/docs/backlog/backlog-light.md
+++ b/docs/backlog/backlog-light.md
@@ -76,6 +76,14 @@ Study the proven audio pipeline in MoonLight / WLED-MM (FFT band layout, AGC, be
## Effects and preview
+### DemoReel: extrude hosted lower-D effects (pending)
+
+DemoReel hosts one effect at a time and drives its `loop()` directly. A hosted **D1/D2** effect only writes its own slice (D1 β the x=0 column; D2 β the z=0 plane), and β unlike a normal Layer child β it does NOT get `Layer::extrude()` applied, so on a 2D/3D grid its output stays on one column/slice instead of spreading. A first fix (call `layer()->extrude(child->dimensions())` after the child's `loop()`, mirroring `Layer::loop`) **crashed the test suite with a heap/vtable smash** β `Layer::extrude` copies within `buffer_` using the Layer's `width_/height_/depth_`, and something in the reel's host path leaves those out of sync with the allocated buffer (root-cause not yet pinned). Redo it carefully: verify the Layer's dims match its buffer at the extrude call, add a bounds guard in `extrude` (or a reel-local extrude that reads the child's real dims/buffer), and pin it with a DemoReel test that hosts a D1 and a D2 effect on a 3D grid and checks the spread. Until then the reel renders D1/D2 hosts on a single slice (visible but not full-grid) β acceptable, not a crash.
+
+### A real 2D/3D PacMan (pending)
+
+The migrated 1D PacMan and 1D Ant effects were removed β a chase rendered on a single strip reads as a blob of moving dots, not a game, so they weren't worth keeping. A proper **2D (or 3D) PacMan** β an actual maze, Pac-Man and ghosts navigating it, power dots, the blue-ghost flee state β would be a genuinely fun signature effect. Build it fresh as a grid game (not a strip port); the WLED/MoonLight 1D versions are reference for the state machine only, not the rendering.
+
### Add real z-axis variation to 2D effects (pending)
Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer.
diff --git a/docs/backlog/backlog-mixed.md b/docs/backlog/backlog-mixed.md
index c477d657..99d50537 100644
--- a/docs/backlog/backlog-mixed.md
+++ b/docs/backlog/backlog-mixed.md
@@ -4,6 +4,18 @@ Forward-looking items whose work genuinely spans **both** the core and light dom
## Cross-domain
+### LightsControl β the central control hub (palette + global params + presets + external controllers)
+
+The eventual home for **global light control**, modelled on MoonLight's `ModuleLightsControl` ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Modules/ModuleLightsControl.h), [docs](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/lightscontrol.md) β our own project, study don't copy). The concept worth carrying forward: **one module is the integration point between the device and the outside world** (IR remotes, DMX-in, MQTT / Home Assistant, hardware buttons, PIR) *and* the owner of the global light state effects read β so effects respond to one normalised interface instead of N disparate inputs. It owns: master on/off + brightness, RGB tint multipliers, the **active palette**, global **bpm / intensity** sliders broadcast to all active effects, and the **preset** system (below).
+
+**Pragmatic interim (decided 2026-06-30):** we are *not* building this module now. Its two pieces we need first live on the **Drivers** container (already the owner of global render params β brightness, lightPreset, the shared Correction): the **active palette** lands there in the palette stage (a `palette` select; effects read it via a static `Palettes::active()` seam, the `AudioModule::latestFrame()` pattern), and global **bpm / intensity** can follow the same way when a consumer needs them. When LightsControl is eventually built, it **absorbs** these controls from Drivers and becomes the hub the external controllers feed. Not a blocker for the palette work.
+
+### Light presets β save / load / loop a whole effect configuration
+
+MoonLight's preset system (part of `ModuleLightsControl`): **64 named slots**, each capturing the *complete* effect-tree configuration (all module control values), with save / load / delete, **preset looping** on a timer (rotate between a first/last slot), and Home-Assistant registration for external recall. The product owner has flagged this as **definitely needed**.
+
+Not a dependency of palettes β a separate feature, its natural home the LightsControl module above (or Drivers in the interim). Persistence already round-trips control values (the same overlay that restores the device-tree), so a preset is "snapshot the relevant subtree's JSON to a named file, restore it on recall" β the mechanism mostly exists; what's missing is the slot management + UI + loop timer. Complements the existing [presets UI note](backlog-core.md) (control-value bundles) β this is the light-domain, whole-effect-config version with looping. Build as its own stage when LightsControl (or its interim) is ready.
+
### MultiplyModifier mapping-LUT memory at large grids (investigation, re-verify on classic)
`scenario_perf_full` on the S3 (2026-06-17) measured the MultiplyModifier's cost across grid sizes. The finding, stated correctly: the modifier **reduces compute** (with the default 2Γ2 kaleidoscope the effect renders only the ΒΌ-size logical quadrant β Noise+Multiply at 16K is 29,647Β΅s vs 50,555Β΅s for Noise alone), and its real cost is **memory** β the mapping LUT's destinations array. Measured modifier heap cost on the S3: 16Β²β1.7KB, 32Β²β10.8KB, 64Β²β23.5KB, **128Β²(16K)β93KB** (`nrOfLightsType` is `uint32_t` on a PSRAM board). On the S3's 8MB PSRAM this is trivial. Under the physicalβlogical fold build each physical light contributes β€1 destination, so the destinations array is bounded by the real light count regardless of chain depth β there is no build-time fan-out.
diff --git a/docs/backlog/folder-structure-proposal.md b/docs/backlog/folder-structure-proposal.md
new file mode 100644
index 00000000..fb7cb191
--- /dev/null
+++ b/docs/backlog/folder-structure-proposal.md
@@ -0,0 +1,99 @@
+# Folder-structure consistency β decision
+
+A *Refactor for simplicity* decision (per CLAUDE.md). Alternatives were weighed; the product owner chose. This records the chosen structure and the work to reach it. Nothing moves until each move is executed deliberately.
+
+## The three axes β and where each one earns a place
+
+1. **Domain** β `core` vs `light`. Already structured (src, docs, tests).
+2. **Module type** β effects / modifiers / layouts / drivers. Structured in `src/light/` + `docs/moonmodules/light/`; **missing** in `test/` and `assets/` β added here.
+3. **Library** β a module's *origin* (MoonLight, WLED, MoonModules, projectMM-native). **Used only as a doc split and a UI tag β NOT a folder axis** (decision below).
+
+## Decision: `domain / type` folders; library is a tag (+ a doc split)
+
+The structure is **` / / Module`**, flat within type. Library does **not** become a folder level.
+
+**Why library is not a folder** (the deciding analysis): an effect's origin is frequently *blended*, not a single fact β e.g. `DistortionWavesEffect` cites MoonLight + WLED + v1 + v2; `GameOfLifeEffect` cites MoonLight + MoonModules + v1; several have no clear origin. A folder forces one answer to a multi-valued question, and a *wrong* or *shifting* answer means a multi-file move (src + assets + tests + the registered `.md` path). It also duplicates the dimension the `tags()` emoji already carries (and the emoji can carry *several* origins; a folder can't). **The end user does not care about a module's library** β they filter by the emoji chip in the UI if they want origin at all. So library stays where it's free and non-duplicative:
+
+- **In code:** the `tags()` emoji (already there; drives the UI origin-filter; can be multi-valued).
+- **In docs:** the page split (below) β the one place library earns a structural role, because docs have an explosion problem src doesn't.
+
+This drops every drawback of library-as-folder (fuzzy-origin filing, two-places-disagree, reclassification churn, sparse subfolders, deep paths) at once.
+
+### The target tree
+
+```text
+src/light/
+ effects/ EffectBase.h, Rainbow.h, Wave.h, DistortionWaves.h, β¦ (flat per type)
+ modifiers/ ModifierBase.h, Multiply.h, Rotate.h, β¦
+ layouts/ GridLayout.h, SphereLayout.h, β¦
+ drivers/ Drivers.h, Correction.h, RmtLedDriver.h, HueDriver.h, β¦
+src/core/ β¦ (unchanged β no type or library axis)
+```
+
+Identical shape for `docs/assets/` and `test/` (below). `src/` itself is **unchanged** β it's already `domain/type`, flat within type; library was never there as a folder and stays out.
+
+## Docs: per-library pages with compact rows (solves explosion *and* giant-file)
+
+Per-module `.md` would be ~65 files post-migration (explosion); one all-effects file would be ~2100 lines (MoonLight's mistake). The middle ground: **one page per library**, each effect a **compact table row**.
+
+```text
+docs/moonmodules/light/
+ effects_moonlight.md β ~30 effects, one row each (~120-150 lines)
+ effects_wled.md β ~20
+ effects_projectmm.md β ~15
+ modifiers_.md layouts_.md drivers_.md (where a library has them)
+docs/moonmodules/core/ β unchanged: per-module .md (stable count, no explosion)
+```
+
+- **Row format** (MoonLight-style): `| Name + tags | gif | one-line description | controls |`. Drops the per-module `Tests` / `Design notes` / `Source` sections β source is derivable from the name, tests are auto-discovered. ~4 lines/effect, so a 30-effect page is ~120 lines.
+- **Why library splits docs but not src:** docs are the only area with the explosion problem, and a doc page is *forgiving* about fuzzy origin β a blended-lineage effect goes on one page with its full origin in the row's tags/prose; mis-filing is a one-line edit, not a multi-file move. So the drawbacks that made library-as-*folder* bad are soft for library-as-*doc-page*.
+- **`check_specs.py` rewrite:** every registered module's control names must appear somewhere in its library page (preserves the anti-drift guarantee). The registered `.md` arg changes from `light/effects/Rainbow.md` to `light/effects_moonlight.md` (or `#rainbow`). This is migration **Stage 2** work.
+
+## assets + tests: add the missing type-split (mirror src)
+
+The consistency win that's independent of library β make `test/` and `assets/` match src's existing `domain/type` shape:
+
+```text
+docs/assets/
+ core/ DevicesModule.png, Drivers.png, β¦
+ light/
+ effects/ Rainbow.gif, Wave.gif, DistortionWaves.gif, β¦
+ modifiers/ layouts/ drivers/
+ boards/ gettingstarted/ β kept as-is
+
+test/unit/light/
+ effects/ unit_Rainbow.cpp, unit_Wave.cpp, β¦
+ modifiers/ layouts/ drivers/
+test/unit/core/ β unchanged
+test/scenarios/light/{effects,layouts,drivers,β¦}/ (mirror)
+```
+
+## The one rule, across all four areas
+
+| | core/light | type | leaf | library |
+|---|---|---|---|---|
+| **src** | `light/` | `effects/` | `DistortionWaves.h` | tag in `tags()` |
+| **assets** | `light/` | `effects/` | `DistortionWaves.gif` | β |
+| **tests** | `light/` | `effects/` | `unit_DistortionWaves.cpp` | β |
+| **docs** | `light/` | the type+library collapse into the page name β `effects_wled.md` | (row inside) | the page split |
+
+`docs` is the one area where `type` is expressed as part of a **page name** (`effects_.md`) rather than a folder, because the migration compacts docs to per-library pages β and library, the only axis with an explosion problem, rides along in that name. Everywhere else: plain `domain/type` folders, library as a tag.
+
+## Impacted folders + the work
+
+| Folder | Change | Cost | When |
+|---|---|---|---|
+| `docs/assets/` | flat `screenshots/` β `assets/{core, light/{effects,drivers,layouts,modifiers}}/`; keep `boards/`, `gettingstarted/`. Move 63 files, update ~34 referencing docs. | Medium, mechanical | **Now** (clearly-broken flat area; PO specified the asset structure) |
+| `test/unit/`, `test/scenarios/` | add the type-split under `core/`/`light/`; re-path the 81 CMake test entries. | Medium, mechanical | **Now or fold into next test-touching change** |
+| `docs/moonmodules/` | per-library pages (`effects_.md`) with compact rows + the `check_specs.py` rewrite; delete the ~21 per-module effect `.md`s. | Medium | **Migration Stage 2** (already planned) |
+| `src/` | **unchanged** (already `domain/type`, flat). | none | β |
+
+**Not reshaped** (correctly orthogonal): `test/js`, `test/python` (host-side; test scripts/installer, not modules), `src/platform/{desktop,esp32}` (platform split, already consistent), `src/{core,light}/moonlive` (feature sub-tree).
+
+## Sequencing
+
+1. **Now:** `docs/assets/` reorg (the broken flat area).
+2. **Now / next test change:** `test/` type-split.
+3. **Migration Stage 2:** per-library doc pages (compact rows) + the `check_specs.py` rewrite + delete the per-module `.md`s.
+
+`src/` needs no move at all β the leanest possible outcome.
diff --git a/docs/backlog/moonlight-effect-inventory.md b/docs/backlog/moonlight-effect-inventory.md
new file mode 100644
index 00000000..055953f6
--- /dev/null
+++ b/docs/backlog/moonlight-effect-inventory.md
@@ -0,0 +1,69 @@
+# MoonLight effect inventory (migration reference)
+
+The full set of MoonLight effects to migrate, grouped by **origin library** (the doc-page split: `effects_.md`), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files β studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) Stage-3 batches; it is *what to build*, not a copy of how.
+
+**Markers:** β« / βͺ audio-reactive Β· π§ native 3D. **Status:** β
already in projectMM Β· β¬ to migrate.
+
+## MoonLight library β `effects_moonlight.md`
+
+| Effect | Markers | Status | Notes |
+|---|---|---|---|
+| Solid | | β¬ | background/base colour |
+| Lines | | β
| LinesEffect |
+| Frequency Saws | β« | β¬ | audio (Stage 3d) |
+| Moon Man | | β¬ | |
+| Particles | π§ | β
| ParticlesEffect |
+| Rainbow | | β
| RainbowEffect |
+| Random | | β¬ | |
+| Ripples | π§ | β
| RipplesEffect |
+| Rubik's Cube | π§ | β¬ | 3D |
+| Scrolling Text | | β¬ | needs a font/glyph blitter (Stage 3e) |
+| Sinus | | β
| SineEffect |
+| Sphere Move | π§ | β¬ | 3D; pairs with SphereLayout |
+| StarField | | β¬ | |
+| Praxis | | β¬ | |
+| Wave | | β
| WaveEffect |
+| Fixed Rectangle | | β¬ | |
+| Star Sky | | β¬ | |
+
+## MoonModules library β `effects_moonmodules.md`
+
+| Effect | Markers | Status | Notes |
+|---|---|---|---|
+| GEQ 3D | β« | β¬ | audio + 3D |
+| PaintBrush | β« π§ | β¬ | audio + 3D |
+| Game Of Life | π§ | β
β οΈ | **re-port** β current version flagged not faithful (migration Stage 1 proof) |
+
+## WLED library β `effects_wled.md`
+
+| Effect | Markers | Status | Notes |
+|---|---|---|---|
+| Blackhole | | β¬ | |
+| Bouncing Balls | | β¬ | physics (Stage 3c) |
+| Blurz | β« | β¬ | audio |
+| Distortion Waves | | β
| DistortionWavesEffect |
+| Frequency Matrix | βͺ | β¬ | audio |
+| GEQ | β« | β¬ | audio |
+| Lissajous | | β¬ | geometric (Stage 3a) |
+| Noise 2D | | β
| NoiseEffect |
+| Noise Meter | βͺ | β¬ | audio |
+| PopCorn | βͺ | β¬ | physics + audio |
+| Waverly | βͺ | β¬ | audio |
+
+## Moving-head library β `effects_movingheads.md` (Stage 5, DMX fixtures)
+
+| Effect | Markers | Status |
+|---|---|---|
+| Troy1 Color / Troy1 Move / Troy2 Color / Troy2 Move | β« | β¬ |
+| FreqColors Β· Wowi Move Β· Ambient Move | β« | β¬ |
+
+## projectMM-native (no external origin) β `effects_projectmm.md`
+
+Already in projectMM, our own (not from a MoonLight library β kept here so the inventory is complete):
+AudioSpectrumEffect β«, AudioVolumeEffect β«, FireEffect, GlowParticlesEffect, LavaLampEffect, MetaballsEffect, NetworkReceiveEffect, PlasmaEffect, PlasmaPaletteEffect, RingsEffect, SpiralEffect, CheckerboardEffect.
+
+*(Several have a MoonLight/WLED lineage in their prior-art notes; "origin" here is the page they'll file under β settle per-effect at migration time, per the [folder-structure decision](folder-structure-proposal.md): the page is the primary-steward bucket, the `tags()` emoji carries full lineage.)*
+
+## Tally
+
+47 MoonLight-listed effects. **Already covered: ~7** (Lines, Particles, Rainbow, Ripples, Sinus, Wave, Distortion Waves, Noise 2D β direct equivalents) + GoL (re-port). **To migrate: ~38**, of which ~14 are audio (Stage 3d) and 7 are moving-head (Stage 5). The non-audio, non-moving-head remainder (~17) are the Stage 3a/b/c batches β the bulk of the parity work the [rename gate](rename-to-moonlight.md#must--the-rename-is-a-downgrade-without-these) needs.
diff --git a/docs/backlog/moonlight-fidelity-tensions.md b/docs/backlog/moonlight-fidelity-tensions.md
new file mode 100644
index 00000000..529bb748
--- /dev/null
+++ b/docs/backlog/moonlight-fidelity-tensions.md
@@ -0,0 +1,112 @@
+# MoonLight migration β fidelity tensions
+
+A running log of places where **strict fidelity to MoonLight's behaviour** (the migration mandate:
+end users must see the same effect they always have) collides with a **projectMM principle**
+(robustness / no-crash-at-any-grid-size, correctness, hot-path discipline, *common patterns first*).
+
+For each: what MoonLight does, what the principle wants, what was shipped, and the **decision needed**.
+The product owner resolves these β the agent records them rather than silently choosing.
+
+Status legend: π‘ open (needs PO decision) Β· π’ resolved (decision recorded) Β· βͺ accepted-as-is (kept faithful, no change wanted)
+
+---
+
+## 1. π’ GEQ3D β `cols / NUM_BANDS` narrow-grid collapse β RESOLVED (2026-07-01)
+
+Resolved per the "same UX, improvements allowed" rule: **clamp the drawn band count to the column
+count** so bars spread instead of piling at x=0 on a narrow grid. Invisible on normal grids
+(cols β₯ numBands β no-op), so no fidelity loss where it matters. See
+[moonlight-improvements.md](moonlight-improvements.md). (GEQ β the flat 2D one β was *not* affected:
+it maps each column to a band, so it never had the collapse.)
+
+## 2. π’ GEQ3D β frame-counter sweep β time-based β RESOLVED (2026-07-01)
+
+Resolved: converted the projector sweep to a **time-based triangle wave** (`triwave8(beat8(...))`),
+so `speed` means the same on every device (frame-rate-independent). Not throttled β a fast board
+renders the same sweep more smoothly, a slow one choppier. Once-per-frame, no per-pixel cost. See
+[moonlight-improvements.md](moonlight-improvements.md).
+
+---
+
+## 3. βͺ SolidEffect / general β `scale8` vs integer `*bri/255` rounding
+
+- **MoonLight:** brightness in several effects is plain integer `channel * brightness / 255`
+ (truncating), not FastLED's `scale8` (which has a +1 video-rounding so `scale8(x,255)==x`).
+- **Principle in tension:** projectMM's standard channel-scale op is `scale8`. Using it would be the
+ "common patterns first" choice, but it rounds ~1 LSB higher than MoonLight at non-255 brightness.
+- **Shipped:** kept faithful β used MoonLight's exact `* bri / 255` where the source does (the 3a
+ verifier caught and corrected a `scale8` slip in SolidEffect). At brightness=255 (default) both
+ agree; the difference is sub-perceptual at other values. Accepted as-is (faithful), recorded so we
+ know the rule: **match MoonLight's exact rounding per effect, don't reflexively swap in scale8.**
+
+## 4. π‘ Audio effects β `volume` normalization scale (0..1 float vs our 0..255 `level`)
+
+- **MoonLight:** audio effects read `sharedData.volume` as a normalized float (roughly 0.0..1.0+),
+ e.g. FreqMatrix's gate `volume > 0.25`. projectMM's `AudioFrame::level` is a small integer
+ (~0..255, the VU value).
+- **Principle in tension:** fidelity needs the *same trigger points* (a beat that lights MoonLight
+ must light ours), but the units differ, so a literal `> 0.25` is wrong against `level`.
+- **Shipped (3d batch):** the ports scale the thresholds proportionally (e.g. `0.25` of full-scale β
+ `~64` on the 0..255 `level`) and flag each with a comment. This is a **reconstruction**, not a
+ verbatim match β the exact trigger point depends on how our AudioModule scales `level` vs how
+ MoonLight scales `volume`.
+- **Decision needed:** confirm the level-scale mapping on hardware (does our `level` reach ~255 at
+ the same loudness MoonLight's `volume` reaches ~1.0?), and whether `volumeRaw` (which we don't
+ have separately β NoiseMeter uses it) needs a real raw value added to AudioFrame, or `level` is a
+ good-enough stand-in. **PO call after bench.**
+- **Synth-audio bench (2026-07-01, P4 mic-less + `simulate=music`):** under a known-good synthesized
+ frame the FreqMatrix gate (`peakHz > 80 && levelSmoothed > 64`) opens on **398/400** frames, so the
+ *threshold and code path are correct* β a synth signal lights the effect. This isolates the open
+ question to **real-mic scaling only**: whether a live INMP441 `level`/`levelSmoothed` reaches the
+ same ~64+ the synth does at the loudness a user calls "music playing". The earlier "FreqMatrix/Blurz
+ show nothing on the S3 with music" report is therefore a *mic gain/scale* question (does the real
+ `gain`/`floor` bring the signal over the gate), not an effect bug. **Cross-check on the S3 with the
+ synth as the reference: if the synth lights it and the real mic doesn't, raise `gain` / lower the
+ gate, don't touch the effect.**
+
+## 5. π’ Audio effects β raw vs smoothed level β RESOLVED (2026-07-01)
+
+The premise was backwards: `AudioFrame::level` was NOT smoothed β `computeLevel` recomputes it raw
+per audio block, so it's already WLED's `volumeRaw` (the instantaneous, transient-snapping value).
+NoiseMeter using it was correct, not a stand-in. The real gap was the *other* direction: no smoothed
+value. Resolved by **adding `AudioFrame::levelSmoothed`** (an EMA of `level` in AudioModule) so
+effects that want WLED's calm `volume`/`volumeSmth` can read it, and doing an **audio-effect sweep**
+to point each effect at the value matching its behaviour: NoiseMeter β raw `level` (unchanged, VU
+snaps to beats); FreqMatrix, AudioSpectrum's VU bar, AudioVolume β `levelSmoothed` (breathing/flowing
+look). Bands-driven effects (GEQ, GEQ3D, PaintBrush, FreqSaws, Blurz) read per-band magnitudes,
+unaffected. See [moonlight-improvements.md](moonlight-improvements.md).
+
+## 6. π‘ Reconstructed logic β effects whose MoonLight source was incomplete (cross-check on bench)
+
+The fetched MoonLight source for some effects was partial, so parts were **reconstructed** from the
+WLED algorithm + the documented intent and marked `// RECONSTRUCTED` in the code. These render and
+are plausible, but the exact behaviour needs a side-by-side bench check vs MoonLight/WLED:
+
+- **Tetrix** β *no full source was available* (only condensed pseudocode). The fall-speed physics
+ (`map(speed,1,255,40000,250)` descending, the per-frame drop, the brick-width formula) are
+ reconstructed from the spec. (A real bug was caught + fixed: the speed-map result 250..40000 was
+ being truncated into a `uint8_t`, wrapping the physics β now widened.) **Cross-check the fall
+ cadence vs WLED 2D Tetrix.**
+- **FreqMatrix** β the scroll-throttle gate is reconstructed (WLED uses a `micros()`-based 0..15
+ "second hand"; the port reproduces the *intent* with an elapsed()-ms period). A **real D1
+ dimensionality bug was caught + fixed** (it was writing along X; D1 writes the x=0 column along Y).
+ **Cross-check scroll speed + orientation.**
+- **Blurz** β two position branches (the `freqMap` log-frequency placement and the band-scan) were
+ reconstructed (source incomplete). **Cross-check dot placement vs WLED Blurz.**
+- **FreqSaws** β the `targetSpeed = volume * increaser * 257` overflow/scaling behaviour is
+ uncertain (`volume*increaser` already exceeds 16-bit before `*257`); reproduced as-described but
+ flagged. **Cross-check band-speed response.**
+- **GEQ** β the falling-peak (`ripple`) markers and `fadeOut` are reconstructed to the standard WLED
+ 2D GEQ (the core bandβbar math is verbatim-equivalent). **Cross-check peak-dot fall.**
+- **NoiseMeter** β the `aux0/aux1` per-frame noise-scroll increments were unspecified in source;
+ reproduced with `beatsin8(5/4,...)` (matches WLED mode). Minor; **cross-check drift speed.**
+- **BouncingBalls / PacMan / Ant** β had full source; only standard `map()`/`random` helper bodies
+ were reconstructed (FastLED-equivalent). Note: `random16(min,max)` was done as the contract's
+ modulo form, NOT FastLED's scaled-draw, so the *seeded random sequence* differs slightly (Ant's
+ initial velocities) β visually equivalent, not bit-identical.
+
+These are all in the code with `// RECONSTRUCTED` markers; grep for them to find the exact lines.
+
+## Resolved / accepted
+
+_(βͺ entries above are kept-faithful, no change wanted unless the PO revisits; π‘ entries await a decision.)_
diff --git a/docs/backlog/moonlight-improvements.md b/docs/backlog/moonlight-improvements.md
new file mode 100644
index 00000000..592d82a9
--- /dev/null
+++ b/docs/backlog/moonlight-improvements.md
@@ -0,0 +1,35 @@
+# Effect improvements over MoonLight
+
+Where a migrated projectMM effect **deliberately behaves differently from the MoonLight original** β a
+change that *improves* the effect (more correct, smoother, works at more grid sizes, a control that
+matches its label) rather than a straight port. The migration mandate is fidelity ("effects look like
+MoonLight"), so every intentional divergence is registered here with its reason, so it's a decision on
+record, not accidental drift.
+
+Distinct from [moonlight-fidelity-tensions.md](moonlight-fidelity-tensions.md): that log
+holds *undecided* fidelity-vs-principle conflicts awaiting a call; this doc holds *decided* improvements
+the product owner approved (correctness/UX wins that ship).
+
+Product-owner ruling (2026-07-01): improvements that increase user satisfaction are allowed even when
+they diverge from MoonLight β register each here.
+
+| Effect / primitive | MoonLight behaviour | projectMM behaviour | Why it's an improvement |
+|---|---|---|---|
+| `math8::map8` (audio bar heights) | `lo + scale8(in, hi-lo)` β the input top never reaches `hi`, so a one-step span (bar height 1) collapses to 0 | `lo + in*(hi-lo)/255` β the input top reaches `hi` exactly (FastLED's documented `map8 == map(in,0,255,lo,hi)`) | Audio bars reach full height; a 1-row bar is now possible. Matches FastLED's *documented* semantics. Affects every audio effect's bar heights slightly. |
+| **FreqSaws** (band timing on wide panels) | Each band's sawtooth physics advanced once per **column**, so a band spanning K columns integrated KΓ per frame β it ran and decayed ~KΓ too fast | Each of the 16 bands integrates exactly **once per frame**; the column loop just draws the cached per-band Y | Animation speed/decay no longer depend on panel width β identical on a 32-wide and a 256-wide grid. Matches WLED's per-band-per-frame physics. |
+| **SphereMove** (motion smoothness) | `time_interval` did the first divide as **integer** (`ms/(100-speed)`), so the origin/diameter only advanced when that ticked to a new whole number β visible stutter (~20 updates/s at 60 FPS, worse at low speed) | Full expression in float, so the shell sweeps and breathes smoothly every frame | Smooth motion at all speeds instead of discrete jumps. (MoonLight *intended* float here, so this is also more faithful.) |
+| **Lissajous** (thin grids) | A 1-wide or 1-tall grid mapped every sample to coordinate **1**, which clips (only index 0 is valid) β blank | The size-1 axis maps to coordinate **0**, so the figure draws | Produces visible output on degenerate/thin grids instead of nothing; normal grids (both axes β₯2) unchanged. |
+| **PaintBrush** (large grids) | Oscillator endpoints truncated into `uint8_t`, so on grids >256 per axis strokes swept only a small low corner; the top band/hue were unreachable (off-by-one) | Oscillators generated 0..255 then scaled to the full grid; loop uses `numLines-1` as the map high bound | Strokes span the full grid at any size and use the full palette/band range. β€256-per-axis grids: identical pixels. |
+| **FixedRectangle** (RGBW white channel) | On RGBW with `alternateWhite`, the **W channel was written on every box cell** (tinting coloured tiles), and left stale when `white==0` | W follows the checker: only white tiles carry `W=white`; coloured tiles clear W to 0 | Coloured tiles render as pure RGB instead of RGB+white; no stale W persists. The checker actually alternates colour vs white. |
+| **GEQ3D** (projector sweep cadence) | The sweep advanced by a per-frame counter (`counter++ % (11-speed)`), so its speed tracked the device frame rate β faster on a 280 FPS board than a 30 FPS one at the same `speed` | Time-based triangle wave (`triwave8(beat8(speedΒ·3, elapsed()))`), so the projector is at the same wall-clock position on every device | The `speed` control means the same thing on every device (frame-rate-independent, the projectMM convention). A fast board renders the same sweep more smoothly, a slow one choppier β neither is throttled to the other. Once-per-frame calc, no per-pixel cost. |
+| **GEQ3D** (narrow-grid bars) | Bar width is `cols / NUM_BANDS`, which truncates to 0 when `cols < numBands` (e.g. 8 cols, 16 bands) β every bar collapses to x=0 | The drawn band count is clamped to the column count, so bar width stays β₯ 1 and bars spread across the available width | Bars render on grids narrower than the band count instead of piling at x=0. Invisible on normal grids (cols β₯ numBands β the clamp is a no-op). |
+| **AudioFrame** (raw + smoothed level) | WLED exposes both `volumeRaw` (instant) and `volume`/`volumeSmth` (smoothed); a straight port only had our raw `level` | Added `levelSmoothed` (an EMA of `level`); each audio effect reads the value matching its intent β NoiseMeter uses raw `level` (VU snaps to beats), FreqMatrix / AudioSpectrum VU bar / AudioVolume use `levelSmoothed` (breathing/flowing) | Effects that should glide with the music no longer jitter per audio block, and beat-reactive ones stay snappy β the full WLED raw/smoothed pair instead of one value forced everywhere. |
+
+
+
+
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png b/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png
new file mode 100644
index 00000000..2cbeaaa4
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/Art-Net-In.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png
new file mode 100644
index 00000000..26276898
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetInControls.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png
new file mode 100644
index 00000000..9e9e535b
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/ArtNetOutControls.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png
new file mode 100644
index 00000000..ebbac7c0
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Audio.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png
new file mode 100644
index 00000000..3f521622
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/FastLED-Driver.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png b/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png
new file mode 100644
index 00000000..cfa2b966
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/IMUDriverControls.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg b/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg
new file mode 100644
index 00000000..d40c192e
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg b/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg
new file mode 100644
index 00000000..8f50cc7a
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/MPU-6050.jpg differ
diff --git a/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png b/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png
new file mode 100644
index 00000000..bbca0d99
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif
new file mode 100644
index 00000000..2634919a
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Ball2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif
new file mode 100644
index 00000000..d7c76736
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Cosmic3D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif
new file mode 100644
index 00000000..5b3bdfc7
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Geq2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif
new file mode 100644
index 00000000..b75ce65b
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Hello1D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif
new file mode 100644
index 00000000..64dd706c
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Noise2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif
new file mode 100644
index 00000000..51e08a19
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Noise3D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif
new file mode 100644
index 00000000..a9423438
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Orbit2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif
new file mode 100644
index 00000000..2d69f7e6
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Oscillate2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif
new file mode 100644
index 00000000..4fe658a2
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Random1D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif
new file mode 100644
index 00000000..6e304f34
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Sweep2D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif b/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif
new file mode 100644
index 00000000..212337c4
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/E_Vu1D.gif differ
diff --git a/docs/backlog/moonlight_images/moonlight/effects/layers.gif b/docs/backlog/moonlight_images/moonlight/effects/layers.gif
new file mode 100644
index 00000000..8b5ca66a
Binary files /dev/null and b/docs/backlog/moonlight_images/moonlight/effects/layers.gif differ
diff --git a/docs/backlog/rename-to-moonlight.md b/docs/backlog/rename-to-moonlight.md
index ea87fe74..2ac14878 100644
--- a/docs/backlog/rename-to-moonlight.md
+++ b/docs/backlog/rename-to-moonlight.md
@@ -105,7 +105,7 @@ This is parity-to-take-the-name, not parity-for-parity's-sake β projectMM's ar
These are pointers to existing backlog items; the rename doesn't create new work so much as set a **bar** for which items gate it. Each links to its detailed entry rather than restating it.
### Must β the rename is a downgrade without these
-- **Effect breadth at a credible fraction of 60+** β not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.)
+- **Effect breadth at a credible fraction of 60+** β not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) **This gate is executed by the staged MoonLight migration** β its plan ([`Plan-20260630 - MoonLight migration (multi-stage)`](../history/plans/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md)) brings the predecessor's effects / modifiers / layouts across in batches on a shared palette + primitive foundation; the rename's bar is "enough batches landed to not feel thin," not "all stages done."
- **Mapping / layout parity for real fixtures** β the predecessor's "memory-optimised mapping" across non-trivial fixtures (matrices, rings, cubes, custom). projectMM has Grid/Sphere/Wheel + modifiers; a Must is that a user's existing physical layout from the predecessor has a path here.
- **OTA continuity for in-field devices** (also in Phase 2/3) β a predecessor user's deployed devices must keep updating across the rename, not brick on a dead URL.
diff --git a/docs/building.md b/docs/building.md
index 903d57bc..8b9d324f 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -59,7 +59,7 @@ uv run scripts/test/test_desktop.py # unit tests
Or use MoonDeck's PC tab for the same operations with a status dot per card. The desktop run detaches and outlives the launching script β the same model as flashing an ESP32, where the device runs independently afterwards.
-
+
Each host writes into its own build dir: `build/macos/`, `build/linux/`, `build/windows/`. The per-host layout mirrors the ESP32 side's `build/esp32-/` shape β one directory per target, no cross-target clobbering on a multi-host dev machine.
@@ -126,7 +126,7 @@ On Windows, the `--port` argument is a `COM*` name (e.g. `COM3`) instead of `/de
The ESP32 tab in MoonDeck wraps the same steps as cards (Setup β Firmware β Build β Port β Flash β Run). The Network bar at the top is the same one shown on the Live tab β it remembers which serial port and WiFi credentials belong to the current LAN, so moving the laptop between networks doesn't require re-picking.
-
+
### ESP-IDF version
diff --git a/docs/history/FastLED-FastLED.md b/docs/history/FastLED-FastLED.md
index 10cbdc68..33fb2702 100644
--- a/docs/history/FastLED-FastLED.md
+++ b/docs/history/FastLED-FastLED.md
@@ -2,6 +2,40 @@
What landed on [FastLED](https://github.com/FastLED/FastLED)'s main branch, month by month. External-context reference (like the v1/v2/MoonLight inventories) β a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these digests lives in [README.md](README.md).
+## June 2026 (up to 3.10.4)
+
+Released **3.10.4** (2026-06-16), cut from `master`.
+
+**New**
+- STM32: Arduino UNO Q board support.
+- New NXP LPC8xx family drivers land (LPC804 PLU, LPC845 bit-bang + PWM/DMA-to-GPIO, LPC11xx) β early bring-up, bench-validated.
+- Unified `fl::Watchdog` API with real hardware implementations across platforms (ESP32, Teensy 4, AVR, Apollo3, RP2040, STM32) plus a non-allocating reset/crash-classification helper.
+- Wave simulation: opt-in 9-point isotropic Laplacian (smoother 2D waves), exposed as a UI toggle in several example sketches.
+- ScreenMap gains a v2 schema (auto-detected), and a new `.fled` container format for video/screenmaps that `FxSdCard` can load.
+- FFT now auto-detects ESP-DSP on ESP32 (no opt-in macro needed).
+
+**Fixed**
+- RGBW colorimetric path reworked: native LED gamut + D65 as the default source, improved strict/boosted solvers, corrected dual- and 3-channel solving.
+- ESP32: reliable streaming for SPI strips over ~680 LEDs; ESP32-C6 routes Serial to HWCDC with non-blocking writes and rejects USB-serial pins for LED output.
+- Teensy Audio selection fixed on low-memory boards; nRF52 now honors configured SPI data rates.
+- LuminescentGrand example: corrected serpentine column wiring/orientation.
+
+## June 2026 (post-3.10.4)
+
+**New**
+- Teensy 4.x LED driver bring-up: ObjectFLED and FlexIO parallel output engines plus a new LPUART-based WS2812 driver (inverted-TX + eDMA), and FlexPWM-based RX capture.
+- New WS2812-style RX capture path on classic ESP32 (RMT4) and LPC845 (SCT+DMA).
+- ARM Cortex-M DSP-extension SIMD backend wired up for Teensy 3.x/4.x (faster scale/blend on those chips).
+
+**Fixed**
+- SM16824E chipset timing corrected to match the datasheet.
+- RGBW gamut configuration is now kept per strip.
+- Fixed a memory leak in the chunked `fl::deque` container.
+
+Note: the bulk of June's ~481 commits were internal (LPC bring-up scaffolding, the AutoResearch hardware-test harness, a PythonβRust C++ linter migration, RPC/JSON size-and-speed tuning, and test-file splits) and are not user-visible. Notable issue traffic was dominated by the Teensy driver and LPC845 bring-up trackers; a user-reported "multi strip problem" (#3340) was triaged and closed, and a deque refactor briefly broke a macOS-arm64 audio unit test (#3286, fixed same month).
+
+_Auditability: 481 commits with author-date in 2026-06-01..2026-06-30 on `master` (first-parent/merged view); split at the 3.10.4 release (published 2026-06-16, an ancestor of `master`). Issues reviewed via `search/issues` for `created:2026-06-01..2026-06-30` and `closed:2026-06-01..2026-06-30` (~50 each); user-facing ones folded in above, the remainder were internal bring-up/CI/linter trackers._
+
## May 2026
*Summarised from 150 first-parent commits on `master`, 2026-05-01 β¦ 2026-05-31.*
diff --git a/docs/history/MoonModules-WLED-MM.md b/docs/history/MoonModules-WLED-MM.md
index 055816aa..f21276ae 100644
--- a/docs/history/MoonModules-WLED-MM.md
+++ b/docs/history/MoonModules-WLED-MM.md
@@ -2,6 +2,24 @@
What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (default) branch, month by month. External-context reference β a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). Months are split at versioned-release boundaries (the rolling `nightly` tag is not a release).
+## June 2026
+
+*Summarised from 38 commits on `mdev`, 2026-06-01 β¦ 2026-06-25 (no versioned release cut this month, so the month is not split; the `nightly` prerelease is not a release).*
+
+**New**
+- Waveshare ESP32-S3 Matrix Driver board profile added.
+- Audio-reactive is now an out-of-tree usermod (pulled in as a dependency) rather than baked into the tree β no user-facing change to how it works, but a cleaner build.
+
+**Fixed**
+- Output settings no longer revert to defaults after a reboot (regression in 14.7.2 nightly, reported on ESP32-D0WDQ5 and ESP32-S3; issue #367).
+- LEDs no longer flicker on startup and during GIF playback.
+- Custom palettes reload immediately after you upload a palette file (previously needed a manual refresh; relates to long-standing issue #105).
+- Fixed `esp32dev_compat` firmware build failing (issue #371) and a broken `-D WLEDMM` build flag.
+- ArtiFX (ARTI effect engine) robustness: call-stack bounds checks, safer string/lexer handling, and fixes for glitches seen only in speed-optimised builds (relates to issue #295); ARTI status now shown in the Info panel.
+- Upstream compatibility: accepts `I2CSDAPIN`/`I2CSCLPIN` as alternatives to the older I2C pin defines; fixed the arduinoFFT dependency; brown-out restart info now checked on both cores.
+
+*Auditability: 38 commits on `mdev`, author-date 2026-06-01..2026-06-30 (range 84669c3 β¦ 70fe1b8; several are CHANGELOG/version-bump/CodeRabbit-config/internal-refactor commits, omitted as not user-facing). Issues checked: created 2026-06-01..2026-06-30 and closed 2026-06-01..2026-06-30 β 4 relevant surfaced (#367, #371 fixed this month; #105, #295 long-standing, closed/addressed this month).*
+
## May 2026
*Summarised from 34 first-parent commits on `mdev`, 2026-05-01 β¦ 2026-05-30.*
diff --git a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md
index d3e43bcb..262f222d 100644
--- a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md
+++ b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md
@@ -4,6 +4,38 @@ What landed on [NightDriverStrip](https://github.com/PlummersSoftwareLLC/NightDr
Summarised via the GitHub commits API (no local clone), so counts are all commits on `main`, not first-parent merges β the bullets filter out dependency bumps, whitespace, and pure refactors. The one release in the window, **v1.3.0**, was published 2026-01-10 but tagged from a late-November commit; it isn't a clean month boundary, so months are kept whole with the release noted as context.
+## June 2026 (up to v2.0.0)
+
+The big one: **NightDriverStrip 2.0.0** shipped on June 14 β a major release cut from `main`.
+
+**New**
+- Brand-new Web UI replacing the old one, plus a new browser-based web installer for flashing devices.
+- Runtime-selectable output drivers: settings that used to be compile-time (like strip type) can now be changed on the device without rebuilding.
+- APA102 / SK9822 strips are now a first-class output type alongside WS281x and HUB75.
+- Multi-layered, categorized device settings structure.
+- New optional WiFi-activity output pin that goes HIGH while WiFi is drawing; projects can now define zero active effects and stay idle until then.
+- Effect-timeout reset on a remote-control effect switch is now a user setting (previous always-reset behavior stays the default).
+- Effects reworked (weather, stocks, subscribers, etc.) to display usefully on short 48x16 matrices.
+- New and revised effects, plus smarter memory handling (targeted mix of internal RAM and PSRAM).
+
+**Fixed**
+- Render hiccup/stutter when settings auto-saved: SPIFFS/JSON writes no longer block the render thread.
+- Effect-manager crash shortly after load when the effect set changed.
+- Serpentine-matrix visualization corrected.
+- Lower COLORDATA server framerate optimized for smoother remote color streaming.
+- PSRAM-related instability during flash/cache writes reduced by keeping save-time JSON in internal RAM.
+
+## June 2026 (post-v2.0.0)
+
+- **v2.0.1** (also June 14): patch that fixes the web installer failing to build for the 2.0.0 release (removed a stale project entry).
+- Stock-ticker effect now shows correct live data, fetched through the new V2 API.
+- Fixed 64x32 (wide-and-short) displays that were rotating and doubling their content instead of scaling β effect previews in the Web UI and CLI now match the active output driver's pixel mapping (issue #878).
+
+**Watching**
+- Issue #877 ("networking seems broken") drew heavy discussion (38 comments) around the WebUI being unreachable after a recent merge on some setups; closed in June.
+
+Auditability: ~40 first-parent merges/commits on `main` with author-date in 2026-06-01..2026-06-30 (`commits?sha=main&since=β¦until=β¦`); two versioned releases published June 14 β v2.0.0 (commit ce00eaa) and v2.0.1 (commit 835015b), both ancestors of `main`, so the month is split at v2.0.0. Issues checked via `search/issues` for `created:2026-06-01..2026-06-30` (0 opened in range) and `closed:2026-06-01..2026-06-30` (#877, #878, #825 closed; #878 and #877 user-facing).
+
## May 2026
*~75 commits on `main`, 2026-05-01 β¦ 2026-05-31.*
diff --git a/docs/history/README.md b/docs/history/README.md
index 249b7230..7789ef64 100644
--- a/docs/history/README.md
+++ b/docs/history/README.md
@@ -35,13 +35,13 @@ One-time surveys of earlier projects, used to decide what to harvest into projec
## Cross-repo trends
-Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem converged on over this release cycle (Sept 2025 β May 2026):
+Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem converged on over this release cycle (Sept 2025 β June 2026):
- **ESP32-P4 / S3 parallel output.** FastLED poured effort into the PARLIO and LCD_CAM drivers (P4/S3 parallel LED output, big encode speedups); NightDriverStrip added custom RMT output; hpwit's I2SClocklessLedDriver pushed IDF-5.5 + arduino-less-ESP-IDF support for its I2S/LCD DMA driver (the canonical implementation of this technique), and troyhacks ran ESP32-P4 bring-up branches. The frontier is parallel, DMA-driven output on the newer chips.
- **PSRAM strategy is unsettled everywhere.** All four wrestled with PSRAM this cycle β WLED-MM moved preview buffers into PSRAM, NightDriverStrip did a full PSRAM-default reversal (then tuned the threshold), WLED added S3-no-PSRAM builds. Nobody has a clean answer; the cache-disabled-during-flash hazard recurs across repos.
- **Audio-reactive maturing.** FastLED added a silence-gate + ESP-DSP FFT backend; WLED-MM and WLED both refined audio sync and auto-disable-during-realtime; NightDriverStrip modernised its SoundAnalyzer/FFT. Audio-reactive is table stakes now, and the polish is in *not* reacting to noise/silence.
- **The FastLED dependency question.** WLED merged a *full FastLED replacement* (its own colour/math); projectMM already made the same call (own colour math, no FastLED in core). Two independent projects concluded the dependency wasn't worth it.
-- **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" β exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design.
+- **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" β exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design. NightDriverStrip's **2.0.0** (June 2026) crystallised this: a brand-new web UI, a browser-based installer, and settings (like strip type) moved from compile-time to *runtime-selectable* on the device β the same "reconfigure live, no reflash" direction projectMM builds around.
- **Effect velocity.** WLED and WLED-MM shipped many new effects (PacMan, Color Clouds, Shimmer, the user_fx pack); new effects remain the most visible user-facing output.
- **Display / HDMI output beyond LED strips.** troyhacks ran a cluster of branches probing HDMI video output and large hardware panels (WaveShare 10.1β³, M5Stack, ESP32-P4 panels) β driving *displays*, not just addressable strips, off the same firmware.
- **On-device live scripting.** hpwit's ESPLiveScript compiles small C-like effect scripts that run live on the ESP32 with no reflash β a different answer to effect authoring than C++ recompilation or a fixed effect table.
@@ -65,10 +65,11 @@ To add a new month (or a new friend repo), run the digest prompt below. When a t
> **Friend-repo monthly digest.** For the repo `` (local clone at ``, or via `gh api repos//`), summarise what landed on its **main/default branch** during ``.
>
> 1. Read the merged commits on the default branch with author-date in that calendar month (`git log --first-parent --since/--until` on the local clone, or the GitHub API). Use `--first-parent` so it's the merged-feature view, not every squashed sub-commit. The default branch isn't always `main`/`master` β check (`git remote show origin`); e.g. WLED-MM's is `mdev`.
+> 1b. **Also investigate the issues over that month.** The REST `gh api repos///issues` endpoint returns **pull requests too** β filter them out (`--jq '.[] | select(.pull_request == null)'`) or use the GitHub **search** API, which already excludes them: `search/issues?q=repo:/+is:issue+created:` (and the same with `closed:`). Only real issues, not PRs. The commit log shows what shipped; the issues show what users *hit* and what the maintainers are prioritising β the two together are the real activity picture. Skim: notable bugs opened (recurring pain points, hardware quirks), fixes closed that map to a commit, and any heavily-discussed feature request or design thread. Fold the user-facing ones into the summary below (a widely-reported bug that got fixed, a feature the community is pushing for); an issue with no user-facing outcome yet is still worth a one-line "watching:" note if it signals a direction. Don't list every issue β surface the few that matter, the same bar as the commit summary.
> 2. **Split a month at any release boundary β but only if the release was cut from the branch you're summarising.** If a *versioned* release was published mid-month (check `git for-each-ref refs/tags` / the GitHub releases API; ignore rolling tags like `nightly` and prereleases), AND the tag is an ancestor of the digest branch (`git merge-base --is-ancestor `), split that month at the release date into `## (up to v)` / `## (post-v)`. If the tag is NOT an ancestor (the project cuts releases from a separate release branch β e.g. upstream WLED tags off `0_15`/release branches, not `main`), do NOT split: keep the month whole and just note which release shipped that month as context, since the trunk you're summarising feeds future releases rather than being the release line. Whole months with no in-branch release stay one section.
> 3. Write an **end-user-readable** summary: what changed that a *user of the library* would notice or care about β new features, new hardware/platform support, notable fixes, breaking changes. Skip internal refactors, CI, test-only, and dependency bumps unless they affect users.
> 4. Format as **short bullet points**, each one line, plainest language, minimal jargon. Group only if there's a natural split (e.g. "New" / "Fixed"); otherwise a flat list.
> 5. Add it as a `## ` section to `docs/history/.md`, newest month on top. Don't editorialise or compare to projectMM β just report what they shipped.
-> 6. State the commit range / count summarised so the digest is auditable.
+> 6. State the commit range / count **and the issue query** summarised so the digest is auditable.
>
> When backfilling several months (e.g. since the last release), run this once per month for a consistent timeline, then optionally add a `## Since v β overview` intro at the top with 3β5 bullets naming the multi-month threads the per-month slices can't show on their own.
diff --git a/docs/history/decisions.md b/docs/history/decisions.md
index a73cbaea..2187016d 100644
--- a/docs/history/decisions.md
+++ b/docs/history/decisions.md
@@ -537,16 +537,29 @@ The apparent blocker was "core uses `lengthType`." On inspection the uses were i
**The rule that held:** don't accept "core uses X" at face value β check whether the use is *essential* or *incidental*. Every tie here turned out incidental and severable: a comment (`Control.h`), a serializer following a misplaced struct's field type (`PreviewFrame`/`put16`), and a SFINAE constraint that named a type it immediately discarded (`Dim`). The fixes: move the real owner and give core a domain-neutral seam β `BinaryBroadcaster` for the preview bytes, a return-type-agnostic probe for `dimensions()`. End state: **no `core/types.h`; core names zero light types.**
-## Time-gated effects must still paint every frame (GameOfLifeEffect)
+## The layer buffer persists frame-to-frame; effects own their background (fade/fill/read-prior)
-`GameOfLifeEffect` advances one generation per `bpm`-derived "beat", so most frames don't step the simulation. The first version skipped the render on non-step frames as an optimisation β and the display went black between beats, "a flash now and then". The cause: `Layer::loop()` calls `buffer_.clear()` **before every effect frame**, so an effect that doesn't write produces a black frame, not a held one. The render hot path has no frame-to-frame persistence; the buffer is the effect's to fill, every time.
+The render buffer is **not** cleared each frame β `Layer::loop()` leaves last frame's pixels in place, the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist; none auto-clears). An earlier design cleared the buffer before every effect frame ("the buffer is the effect's to fill, every time"), but that silently broke every persistence effect: a scroll (FreqMatrix) reads the prior column via `draw::get` and shifts it β reading a wiped buffer, so only the freshly-painted pixel survived; a trail effect calls `fadeToBlackBy` to decay the previous frame β fading zeroes, so the trail never formed; Game-of-Life reads its prior cell state β gone. The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing.
-**The rule:** separate *when the simulation advances* from *when the effect paints*. Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`), but **always repaint the current state**. The sim runs at `bpm`; the paint runs at frame rate. This applies to any future effect whose internal clock is slower than the tick (a slow automaton, a beat-synced pattern). A `unit_GameOfLifeEffect` case ("renders every frame between generations") pins it.
+**The model (matches the three reference frameworks):**
+- **Persistence is the default and universal** β the buffer holds the previous frame; there is no per-effect "persist" flag (a flag would be bespoke and change no framework behaviour, unlike `dimensions()` which drives `extrude`). The buffer is zeroed **once** on allocation/resize, then persists.
+- **Each effect owns its background inside its own `loop()`:** a full-grid effect overwrites every pixel; a **trail** effect calls `layer()->fadeToBlackBy(amt)`; a **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose `drawCube` writes only surface voxels). Multiple effects on one layer deliberately *interact* through the shared persistent buffer β that's a feature, not a defect.
+- **`fadeToBlackBy` is a Layer operation, collected once per frame** (MoonLight's `VirtualLayer::fadeToBlackBy`): effects register an amount, the Layer keeps the **MIN** across them (the gentlest fade wins, so the longest requested trail is honoured), and applies **one** buffer pass at the next frame's start, then resets. N fading effects cost one pass, not N, and never fade each other's fresh pixels. `unit_Layer_persistence` pins persistence + the MIN-collect + reset.
+
+**GoL corollary (still holds, now for the right reason):** separate *when the simulation advances* from *when the effect paints*. `GameOfLifeEffect` steps one generation per `bpm` beat but its state lives in the persistent buffer, so between beats it simply leaves the buffer untouched (the automaton holds). Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`); the sim runs at `bpm`, the buffer persists at frame rate. A `unit_GameOfLifeEffect` case pins it.
**Two adjacent traps the same effect hit:**
- **First-frame `dt`.** `lastElapsed_` starts at 0, so the first `now - lastElapsed_` is the whole device uptime β a huge `dt` that pins the step accumulator above the beat threshold *permanently* (max rate forever, `bpm` ignored). Bootstrap `lastElapsed_` on the first call and take `dt = 0` that frame.
- **Width of the change delta.** The stagnation check narrowed `alive - lastAlive_` to `uint16_t`; at the 512Γ512 max grid on a PSRAM board (`nrOfLightsType == uint32_t`, 262144 cells) that truncates and triggers false re-seeds. Counters derived from cell counts must be `nrOfLightsType`, not a fixed width.
+## A static "current instance" pointer needs re-election, not just claim/vacate
+
+`AudioModule::latestFrame()` hands effects the active mic via a process-wide `static AudioModule* active_`. The original design was "setup() claims the seat, teardown() vacates it" β which silently breaks with **two** mics: removing the one that holds `active_` leaves the seat null while a second, still-running mic sits captured-but-unread, and every audio effect goes silent. The fix is a three-part protocol: the **first** live module claims in `setup()`, `teardown()` vacates, and any running module **re-claims an empty seat in `loop()`** β so the survivor takes over on its next tick for *any* add/remove order (the robustness rule). Rule: a singleton-accessor backed by a static pointer must have a *self-election* path in the periodic loop, not only claim-on-setup β otherwise it's only correct for exactly one instance. (`unit_AudioModule` pins the two-mic first-wins + re-election.) Tempting CodeRabbit "fix": gate the claim on `inited_` β rejected, because a claimed-but-uninited module publishes valid *silence* (the documented contract), and a mic-less board running `simulate` publishes synth frames without being `inited_`; gating on init breaks both.
+
+## An "Effect-role" module is not guaranteed to be an `EffectBase`
+
+DemoReel hosts a child effect and needs its `dimensions()` to extrude it. The first cut did `static_cast(current_)->dimensions()` every frame on the `MoonModule*` child β which **crashed** (SIGBUS in RTTI/vtable) when the eligible list included a test `EffectStub` that registers with `ModuleRole::Effect` but is a bare `MoonModule`, not an `EffectBase`. Two lessons: (1) **role is a registration property, not a type guarantee** β a module can carry any `role()` without deriving from the matching base, so a downcast keyed on role is undefined behaviour; and (2) the data was already available without the cast β the factory probes `dimensions()` at registration via `if constexpr` and stores it, so `ModuleFactory::typeDim(index)` gives the dimension RTTI-free (ESP32 builds `-fno-rtti`, so `dynamic_cast` isn't even an option). Reach for the factory's probed metadata instead of a cross-tree downcast whenever you need a registered type's declared property.
+
## uint16 intermediate overflow blanks the display β and a status check doesn't prove the render works (MultiplyModifier)
A high fan-out modifier (`MultiplyModifier` at 8Γ8Γ4) black-screened the no-PSRAM Olimex while the desktop showed it working. Root cause: `Layer::rebuildLUT` computed `maxDest = logicalCount * mod->maxMultiplier()` in `nrOfLightsType`. On no-PSRAM that's `uint16_t`, and `256 * 256 = 65536` **wraps to 0** β the LUT was sized to ~nothing, so almost every light mapped nowhere and the frame went black. On desktop (`nrOfLightsType == uint32_t`) the product fits, so the bug was invisible there. **Fix:** compute the product in `uint64_t`, clamp to the ceiling, then narrow back. This is the same family as the GameOfLife delta-width trap above, but for an *intermediate product*, not a stored counter β **any `nrOfLightsType * nrOfLightsType` (or `Γ a multiplier`) can overflow uint16 even when both operands are individually small; do the arithmetic in a wider type before narrowing.**
diff --git a/docs/history/hpwit-ESPLiveScript.md b/docs/history/hpwit-ESPLiveScript.md
index a845f676..b8d4878a 100644
--- a/docs/history/hpwit-ESPLiveScript.md
+++ b/docs/history/hpwit-ESPLiveScript.md
@@ -6,6 +6,12 @@ The library: Yves Bazin's (hpwit) C-like compiler/interpreter for the ESP32 β
**Branch note:** `main` is quiet (last touched June 2025), but this repo develops on a long series of **version branches** (`v2`β¦`v4.3`, plus `vjson`/`vjson2`/`vdrop`/`memory*`), and that's where the recent work is. The activity below is read across those branches, not just `main`.
+## June 2026
+
+No user-facing activity: no commits on `main` **or any of the ~30 version branches** (v2.x/v3.x, dev, mem*) in June 2026, and no notable issues. (Latest commit on `main` predates the window β June 2025.)
+
+_Checked: commits author-dated 2026-06-01..2026-06-30 on `main` and every version branch (`v2`β¦`v3.3`, `dev`, `mem2`β¦`mem4`, `memory`) β 0 on each; issues created / closed / updated 2026-06-01..2026-06-30 (0 each); PRs created in-window (0); no versioned release published in June 2026._
+
## February 2026
*Latest in-window activity, on the `vjson2` branch.*
diff --git a/docs/history/hpwit-I2SClocklessLedDriver.md b/docs/history/hpwit-I2SClocklessLedDriver.md
index f9bdebf3..84572fb6 100644
--- a/docs/history/hpwit-I2SClocklessLedDriver.md
+++ b/docs/history/hpwit-I2SClocklessLedDriver.md
@@ -6,6 +6,12 @@ The library: Yves Bazin's (hpwit) clockless-LED driver that clocks WS2812-class
> **Authorship note.** Most of the activity in this window is projectMM's own β `ewowi` authored ~53 of the in-window commits, with the rest from the maintainer (Yves Bazin / hpwit) and a couple of others. The IDF 5.5 / arduino-less ESP-IDF / RGBCCT / >65K-LED work below is largely projectMM upstreaming its driver needs into hpwit's library, then tracking the result here.
+## June 2026
+
+No user-facing activity: no commits merged to `main` (latest activity is April 6, 2026) and no notable issues.
+
+_Auditability: commits on `main` author-dated 2026-06-01..2026-06-30 = 0 (0 merged); issues created/closed/updated in June 2026 = 0. No versioned release published in June (latest tag `1.4`, 2026-04-06)._
+
## April 2026
*~17 commits on `main`, 2026-04-01 β¦ 2026-04-30.*
diff --git a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md
index 8185bb86..a7038d4c 100644
--- a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md
+++ b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md
@@ -4,6 +4,12 @@ What landed on [hpwit/I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2S
The library: Yves Bazin's (hpwit) "virtual pins" variant of the I2S clockless driver β drives far more strips than the chip has usable pins by fanning the I2S output through external shift registers. This multiplex technique is the load-bearing idea projectMM's LED-driver analysis singles out (factoring the shift-register multiplex out of the I2S/LCD peripheral code). Summarised via the GitHub commits API, read across all branches (`main`, `integration`, `int2`, `variable`, `hpwit-patch-1`), not just `main`.
+## June 2026
+
+No user-facing activity this month: no commits merged to `main` (latest commit on `main` dates to November 2024), no releases published, and no issues opened, closed, or updated.
+
+_Checked: commits on `main` with author-date 2026-06-01..2026-06-30 (0 commits); issues created 2026-06-01..2026-06-30 (0), closed in that range (0), and updated in that range (0); releases (none in June β latest versioned tag is 2.1, Jan 2024)._
+
## No activity in the digest window (Sept 2025 β May 2026)
No branch has any commits in the window these digests cover; the project went quiet at the end of 2024. Its last active stretch:
diff --git a/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md b/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md
new file mode 100644
index 00000000..379c5bf4
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - Doc consolidation - per-type compact pages (effects-modifiers-layouts).md
@@ -0,0 +1,57 @@
+# Plan β Doc consolidation: per-type compact pages (effects / modifiers / layouts)
+
+This is **Stage 2** of the [MoonLight migration](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md), narrowed by two product-owner decisions made at planning time:
+
+1. **Docs only.** `src/` stays one-`.h`-per-module (the recorded [folder-structure decision](../../backlog/folder-structure-proposal.md): a blended-origin effect in a per-library *folder* forces a wrong, costly multi-file move). This change touches only `docs/` + `check_specs.py`.
+2. **One page per *type*, not per *library* β for now.** Our effect origins are lopsided (10 MoonLight, 1 WLED, 2 FastLED, 4 projectMM-native); a strict per-library page split (the proposal's first draft) yields 1β2-effect sparse pages. Instead: one `effects.md` with **library *sections* inside** (MoonLight / WLED / FastLED / projectMM-native), exactly like the [MoonLight effects page](https://moonmodules.org/MoonLight/moonlight/effects/). The per-library *file* split stays in the backlog as **future growth** β when the WLED/FastLED sets grow, a section lifts into its own file with no row rework.
+
+Inspiration for the compact per-module presentation: the MoonLight effects page (one table row per effect: name + tags, preview gif, one-line description, controls screenshot).
+
+## Scope
+
+- **Effects:** ~17 per-module `.md` β one `docs/moonmodules/light/effects.md`, compact rows, library sections.
+- **Modifiers:** ~5 per-module `.md` β one `docs/moonmodules/light/modifiers.md`.
+- **Layouts:** ~4 per-module `.md` β one `docs/moonmodules/light/layouts.md`.
+- **Drivers:** **unchanged** β one `.md` per driver (PO decision: drivers are larger and structurally distinct β RMT/LCD/Parlio/Network/Hue avg ~215 lines with platform-specific wire contracts a compact row can't hold).
+- **Core modules:** unchanged (stable count, no explosion).
+
+## Row format (per module)
+
+`| Name (+ tags emoji) | preview | one-line description | controls |`
+
+- **Drops** the per-module `Tests` / `Prior art` / `Source` / `Design notes` sections: source is derivable from the name, tests are auto-discovered by `generate_test_docs.py`, prior-art/origin rides in the tags emoji + a short note in the description cell. Wire contracts (the one thing the row CAN'T hold) β none of effects/modifiers/layouts have integrator-facing wire contracts except `NetworkReceiveEffect`, which keeps a short protocol note in its description cell (or a footnote under its section).
+- ~3β4 lines per module, so `effects.md` β 17 rows β 90 lines (vs ~1100 lines across 17 files today).
+
+## `check_specs.py` rewrite (the contract change)
+
+Today: `check_specs.py` rglobs each module `.h` β requires a matching per-module `.md` whose body mentions every control name. New contract:
+
+- A module registered with `light/effects.md` (the page, not a per-module file) passes if **every one of its control names appears somewhere on that page** (in its row). Same anti-drift guarantee, page-scoped instead of file-scoped.
+- The registered `.md` arg in `main.cpp` changes from `light/effects/Rainbow.md` β `light/effects.md` (+ optional `#rainbow` anchor) for every effect; same for modifiers/layouts. Drivers keep their per-driver `.md`.
+- Map each module β its page by type suffix (`*Effect` β `effects.md`, `*Modifier` β `modifiers.md`, `*Layout`/`Layouts` β `layouts.md`, `*Driver`/`Drivers` β its own per-driver page), mirroring `asset_dir_for()`.
+
+## Files
+
+- **New:** `docs/moonmodules/light/effects.md`, `modifiers.md`, `layouts.md` (compact-row pages with library sections).
+- **Delete:** the ~17 `docs/moonmodules/light/effects/*.md`, ~5 `modifiers/*.md`, ~4 `layouts/*.md` (folded into the pages).
+- **Edit:** `src/main.cpp` (registered `.md` path per effect/modifier/layout β the page), `scripts/check/check_specs.py` (page-scoped control-name check + the typeβpage map), `scripts/docs/generate_test_docs.py` if it links per-module `.md` anchors, and any doc that links a deleted per-module page (re-point to `effects.md#name`).
+- **Unchanged:** `src/light/effects/*.h` etc. (code), `docs/moonmodules/light/drivers/*.md` (per-driver), `docs/moonmodules/core/*`.
+
+## Origin sections (from each module's `tags()` today)
+
+- **Effects** β MoonLight π«: DistortionWaves, LavaLamp, Lines, Metaballs, Particles, Plasma, Rainbow, Rings, Ripples, Spiral Β· WLED π: Wave Β· FastLED β‘οΈ: Fire, Noise Β· projectMM-native: AudioSpectrum π, AudioVolume π, Sine π, NetworkReceive π‘π.
+- **Modifiers** β MoonLight π«: Checkerboard, Multiply Β· projectMM-native: RandomMap, Region, Rotate.
+- **Layouts** β projectMM-native: Grid, Sphere, Wheel, Layouts.
+
+## Verification
+
+- `check_specs.py` green under the new page-scoped contract (every control name present on its page).
+- Build/tests/scenarios unaffected (docs-only + a script change; no `src/` compile impact).
+- No dangling links: every former per-module `.md` link re-points to `page.md#anchor`; `generate_test_docs.py` output still resolves.
+- The three pages render as compact tables with library sections; drivers + core docs untouched.
+
+## Out of scope (future growth, kept in backlog)
+
+- **Per-library *file* split** (`effects_moonlight.md`, β¦) β revisit when a library's effect count earns its own page; the within-page sections make it a lift-not-rewrite.
+- **Driver doc consolidation** β drivers stay per-file.
+- **gif previews** β adding MoonLight preview gifs into the rows is the migration's separate asset work.
diff --git a/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md
new file mode 100644
index 00000000..7b98dc27
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md
@@ -0,0 +1,83 @@
+# Plan β HueDriver: Philips Hue lights as a projectMM effect output (shipped)
+
+## Context
+
+The product owner has Hue lights and a bridge ("Hue Ewoud", BSB002, API 1.77, at 192.168.1.143). The reframe that drives this plan, from the product owner: **Hue is an *output*, not a device to list.** projectMM already drives "an array of lights" through the effect β layout β buffer β driver pipeline; Hue maps onto that directly β a handful of bulbs are a small **grid** (e.g. 5Γ1Γ1), an **effect** runs on them, and a **`HueDriver`** (a sibling of `RmtLedDriver` / `NetworkSendDriver` in the Drivers container) reads its window of the output buffer and pushes each pixel's colour to the corresponding bulb. The bulbs are *pixels of an effect*, not rows in DevicesModule.
+
+This is *Common patterns first* + *Concrete first, abstract later*: a new driver is the recognised unit of "a new output target," and the architecture already has the seam. No new core concept β one new `DriverBase` subclass + a small outbound-HTTP helper.
+
+### Verified on the wire (not assumed)
+
+- The bridge advertises `_hue._tcp` (mDNS) and answers N-UPnP β discoverable.
+- **The bridge still allows the plain-HTTP Hue v1 API**: `http:///api/0/config` β 200 (not HTTPS-only). So **no TLS / self-signed-cert handling on the ESP32** β the single biggest simplifier. CLIP v2 exists (`/clip/v2` β 403) but is HTTPS-only + event-stream; **not used**.
+- Hue v1 is HTTP + JSON: `POST /api` (link-button pairing β app key), `GET /api//lights` (list), `PUT /api//lights//state` (`{"on":bool,"bri":0-254}`, optional `xy`/`hue`/`sat` later).
+
+## Decisions locked (product owner)
+
+- **Hue is an output driver** (`HueDriver : DriverBase`), sibling of `NetworkSendDriver`. Not a DevicesModule entry. (Listing the bridge in DevicesModule + auto-filling the driver's IP from discovery is a **follow-up**, per *concrete first* β build the working output, add the discovery nicety after.)
+- **Scope: on/off + brightness** from the effect's per-pixel value (luminance β `bri`). Colour (`xy`/`hue`/`sat`) is a clean later extension on the same PUT.
+- **Update model: throttled, changed-lights-only.** Hue's bridge rate-limits to ~10 commands/s/light; a real-time stream would need the Entertainment API (DTLS) β out of scope. The driver samples its window on a **slow tick** (target β€ ~10 Hz total across its lights) and PUTs **only the lights whose colour changed** since the last push. This is the standard way apps drive Hue from animations.
+- **Plain-HTTP Hue v1 API** (no TLS). The bridge IP + app key are **controls on the HueDriver** (self-contained config, like NetworkSendDriver owns its target IP/universe); a **Pair button** runs the link-button POST to fill the app key. Persisted with the module.
+
+## Design
+
+### 1. Outbound HTTP helper (platform seam)
+
+The repo had `httpGet`; it was removed as dead code when DevicesModule's HTTP sweep went away. The HueDriver re-introduces the outbound-HTTP capability β but **minimal and with a real consumer this time** (the prior removal's lesson: don't keep an unused seam). Add a small `platform::httpRequest`:
+
+```
+// Outbound HTTP request to a LAN host (plain HTTP, no TLS β Hue v1 allows it). Builds the
+// request, returns the status code (0 on failure), fills `body` (NUL-terminated, truncated).
+// Synchronous + bounded by `timeoutMs`. Desktop + ESP32 over the existing TcpConnection
+// (which has connect()/writeSome()/read()). GET/PUT/POST via `method`.
+int httpRequest(const char* method, const char* host, uint16_t port, const char* path,
+ const char* reqBody, uint32_t timeoutMs, char* body, size_t bodyLen);
+```
+
+- Desktop + ESP32 build it over `TcpConnection::connect()` + `writeSome()` + non-blocking `read()` (the same primitives the HTTP *server* uses). Plain socket, no libcurl, LAN HTTP only β exactly what the removed `httpGet` did, generalised to GET/PUT/POST.
+- **Off the render hot path**: the HueDriver calls this on its slow tick (loop1s-cadence), never `loop()`. A bounded blocking call there is fine (same rule as the old mDNS browse / the OTA fetch).
+
+### 2. `HueDriver : DriverBase` (`src/light/drivers/HueDriver.h`)
+
+Header-only light module, mirroring `NetworkSendDriver`'s shape:
+
+- **Controls** (`onBuildControls`): `bridgeIp` (IPv4), `appKey` (Text, persisted β the credential), `pair` (Button β link-button pairing), `start`/`count` (via `addWindowControls()` β its slice of the buffer). A read-only `status` line ("paired, N lights" / "press the bridge button" / "unpaired").
+- **`setSourceBuffer` / `setLayer` / `setWindow`** β standard DriverBase wiring (Drivers container passes the shared buffer + the active layer for dimensions).
+- **`loop()`** β does NOTHING on the render tick (Hue can't keep up; never block the hot path here). The driver's window is sampled in `loop1s()` instead.
+- **`loop1s()`** (the throttle): read the window slice from the source buffer (`windowSlice()`), map each light's RGB β on/off + `bri` (luminance), and for each light whose value **changed** since the last push, `httpRequest("PUT", bridgeIp, 80, "/api//lights//state", "{\"on\":β¦,\"bri\":β¦}", β¦)`. A per-light `lastSent` cache (small fixed array, bounded by the window count, capped at e.g. 32 Hue lights) drives the changed-only filter. PUTs are spread (one or a few per tick) so a tick never blocks long.
+- **`pair`** (Button β `onUpdate`): for ~a few seconds, `POST /api {"devicetype":"projectMM#"}`; on success store the returned `username` into `appKey` (persist), then `GET /api//lights` to learn the light id list. The "press the link button" instruction shows in `status`.
+- **Light-id mapping**: window index β Hue light id. First cut: the lights list from `GET /api//lights` in id order maps to window indices 0..N-1. (A future control could let the user reorder / pick which bulbs.)
+
+### 3. Drivers registration + UI
+
+- Register `HueDriver` in the driver factory next to the other drivers (one `ModuleFactory::registerType` line) so it's addable from the UI like any driver.
+- The generic UI renders its controls with zero per-driver code (the whole point of the module tree).
+
+### 4. Desktop testability
+
+- `httpRequest` works on desktop (real sockets) β the PUT/GET formatting + the changed-only diff + the windowβlight mapping are **host-unit-testable** against a tiny stub HTTP responder (or by asserting the formatted request bytes from a seam, no live bridge needed). The product owner's real bridge is the bench cross-check.
+
+## Files
+
+- **New:** `src/light/drivers/HueDriver.h` (the driver), `docs/moonmodules/light/drivers/HueDriver.md` (spec β controls, the Hue v1 wire contract, pairing flow, the rate-limit rationale, prior art).
+- **Edit:** `src/platform/platform.h` (+ `src/platform/esp32/` + `src/platform/desktop/` impls) for `httpRequest`; the driver registration in `src/main.cpp`; `test/CMakeLists.txt` + a `test/unit/light/unit_HueDriver.cpp` (request formatting + changed-only diff + window mapping); `docs/backlog/backlog-light.md` (mark the Hue-driver item building / add the follow-ups: colour, DevicesModule bridge discovery, Entertainment-API streaming).
+
+## Riskiest parts
+
+1. **Rate limit / not blocking the loop.** The throttle (changed-only, β€~10 Hz, a few PUTs per `loop1s`) must keep the bridge happy AND keep each tick short. A PUT is a bounded blocking `httpRequest` off the render path β but many lights Γ a slow bridge could still make `loop1s` long. Mitigation: cap PUTs-per-tick (round-robin the changed lights across ticks), short `timeoutMs` (~200 ms), and degrade gracefully on a 429/timeout (skip, retry next tick).
+2. **Pairing UX is asynchronous + physical.** The user must press the bridge button within the window. The Button handler can't block the loop for seconds β so pairing runs as a short bounded poll across a few `loop1s` ticks (a small state machine: "pairingβ¦ press the button" β key obtained / timed out), status-reported. Don't block the render loop during pairing.
+3. **Re-introducing outbound HTTP** β keep `httpRequest` minimal (the lesson from deleting `httpGet`: an unused seam is debt). It ships *with* its consumer (HueDriver), so it's earned.
+4. **App key is a credential** β persisted in the module JSON like other settings; it's a LAN bridge key (low sensitivity), stored the same way as e.g. a static IP. Note it in the spec.
+
+## Verification
+
+- Desktop build (0 warnings); `ctest` incl. the new HueDriver unit test (request formatting + changed-only diff); scenarios + spec-check green; ESP32 all variants build.
+- **Bench (the real test):** on the product owner's bridge β add a 5Γ1Γ1 layout, an effect, a HueDriver (window [0,5)), press Pair + the bridge button β key obtained, lights listed; the effect animates the 5 bulbs (on/off + brightness) at the throttled rate, no bridge 429s, render FPS unaffected (the PUTs are on loop1s, off the hot path).
+- Save this plan (done); mark `(shipped)` when it lands.
+
+## Out of scope (clean follow-ups)
+
+- **Colour** (`xy` / `hue`/`sat` from the pixel RGB) β same PUT, one more field; the obvious next slice.
+- **DevicesModule lists the Hue bridge** + auto-fills the driver's `bridgeIp` from discovery (the product owner's "list it in devices" idea, done as the second step β discovery feeds the output).
+- **Hue Entertainment API** (DTLS streaming, ~25β50 Hz) for true real-time effect sync β a major separate feature (TLS-PSK on ESP32, entertainment-area setup, v2 API).
+- **DMX lights** as another such output driver (the product owner noted this is coming β Hue maps the "array of foreign lights" pattern that DMX will reuse).
diff --git a/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md
new file mode 100644
index 00000000..7ce8b133
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md
@@ -0,0 +1,111 @@
+# Plan β Migrate MoonLight effects / modifiers / layouts (multi-stage)
+
+## Goal & shape
+
+Bring MoonLight's full library of **effects, modifiers and layouts** into projectMM. This is large, so it is **staged**: each stage ships independently, builds on the previous, and is its own `/plan` + commit. This document is the *map* β the per-stage plans get written when we reach them. Stages 1β2 are specified enough to start; later stages are scoped, not detailed.
+
+**Why this matters beyond features:** this migration is the execution vehicle for the **effect-breadth parity gate** in the [projectMM β MoonLight rename plan](../../backlog/rename-to-moonlight.md#must--the-rename-is-a-downgrade-without-these) β taking the MoonLight name requires the library not to feel thin next to the predecessor's 60+ effects. The rename's bar is "enough batches landed," not "every stage done"; this plan is *how* that bar is reached. (The two docs stay in their folders β the rename is the forward-looking backlog item that sets the bar; this is the approved staged plan that meets it β linked, not duplicated.)
+
+Two cross-cutting rules govern every stage, from [CLAUDE.md](../../../CLAUDE.md):
+
+- **Industry standards, our own code.** MoonLight effects are studied for *behaviour and algorithm*, then written **fresh** against our architecture (our `EffectBase`, our primitives, our names). We do **not** trace MoonLight/WLED/FastLED structure or copy code. For *effects specifically* the **visual behaviour is the spec** β we reproduce what the effect looks like faithfully (the product owner's clarification), but the implementation is ours. Prior art credited per-module + in `history/`.
+- **A shared light primitive library.** Effects need a common set of small math/colour helpers (a beat/sine oscillator, integer noise, saturating add/subtract, scale, fade, a colour blend, a fast PRNG, draw primitives). projectMM provides these, extending the `color.h` set (`scale8`, `sin8`, `cos8`, `hsvToRgb` already there): **hot-path-tuned** (integer-only, LUT-backed, no float in the per-light path) and **dimension-agnostic where it makes sense** (the product owner's steer: our 3D-native model means a primitive like `drawLine` works 1Dβ3D, written once, not re-implemented per effect).
+ - **Naming follows *Common patterns first* + *Industry standards, our own code*: the recognisable name AND our own implementation.** The LED-embedded world's canonical resource is FastLED, and its names (`beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`/`random16`, `ColorFromPalette`) are exactly the ones a contributor recognises in 30 seconds β and consistent with the `scale8`/`sin8` we already ship. So **we use those names** (carrying the established convention), **write our own implementation** against our engine, and **credit FastLED as prior art** in each module's "Prior art" section. The point of the principle is independence-by-construction (own code, own architecture, behaviour pinned by tests), *not* a renamed copy β so the names stay recognisable; only the implementation is ours. Each primitive's design is justified at its introduction site, and we reorganise a borrowed concept when ours is genuinely cleaner (e.g. the dimension-agnostic draw set).
+
+## What exists today (baseline)
+
+- **Primitives:** `src/core/color.h` has `RGB`, `hsvToRgb`, `scale8`, `sin8`/`cos8` (LUT). `src/light/light_types.h` has `Coord3D`, `Dim`, `lengthType`. That's it β no beat/noise/blend/random helpers, no shared palette, no draw primitives.
+- **Palette:** none shared. `PlasmaPaletteEffect` hard-codes a 256-entry `RGB palette_[256]` in flash β the pattern to generalise.
+- **Effects:** ~21 already ported (Rainbow, Noise, Plasma, Fire, Particles, Metaballs, GameOfLife, Wave, β¦). GameOfLife (272 lines) is flagged by the product owner as **not faithful β re-port from the real algorithm**.
+- **Modifiers:** Multiply, Rotate, Region, Checkerboard, RandomMap. **Layouts:** Grid, Sphere, Wheel.
+- **Tags/emoji:** projectMM already has `tags()` + UI-derived role/dim emoji (architecture.md Β§ Web UI). MoonLight's legend (π₯ effect, π modifier, β« audio, π§ 3D, β¦) becomes the **canonical basis** (product owner's choice).
+- **Docs:** one `.md` per module (21 effect specs already), enforced by `check_specs.py` (it `rglob`s each `.h` β a matching `.md`). Moving to **per-library pages** (`effects_.md`, compact table rows) β see Stage 2 and the [folder-structure decision](../../backlog/folder-structure-proposal.md). This requires changing the spec-check contract.
+- **Assets:** **already reorganised** to `docs/assets/{core, light/{effects,modifiers,layouts,drivers}, ui}/` (the per-module move done ahead of the migration). Stage 2's gif work is *adding* MoonLight previews into this structure, not re-homing.
+
+## Dependency analysis (what must come first)
+
+1. **Palette** β hard prerequisite. Many MoonLight effects colour via `ColorFromPalette`. Nothing palette-dependent can be faithfully ported until this lands. **Stage 1.**
+2. **The shared primitive library** (beat / noise / blend / scale / random / draw) β most effects need several. **Stage 1.**
+3. **Tags/emoji legend** β must be settled before batch-migrating, so every migrated module is consistent from the first batch. Cheap; **Stage 1** (a doc + a sweep of existing `tags()`).
+4. **Doc model change** β must land before the doc explosion, i.e. before batch migration. A page per **library** (type-first name, underscore-joined): `effects_moonlight.md`, `effects_wled.md`, β¦ (and `modifiers_.md` etc. only where a library has them; most are effects-only). Library is a *doc* split only β NOT a `src`/`assets`/`tests` folder (those stay `domain/type` flat; library is the `tags()` emoji there). Fixed by the [folder-structure decision](../../backlog/folder-structure-proposal.md). **Stage 2**.
+5. **Audio** β audio-reactive effects (β«) depend on `AudioModule::latestFrame()` (already exists). A later stage; not a blocker for non-audio effects.
+6. **Moving heads / Art-Net fixtures** β `E_MovingHeads` targets DMX moving heads; depends on fixture-layout + Art-Net (partly present). Last, separate.
+
+No other hidden hard dependencies: our `EffectBase` + extrude (now 1D-along-Y, matching MoonLight) + `Buffer` already provide the render context.
+
+## Stages
+
+### Stage 1 β Foundations (palette + primitives + GoL re-port) β start here
+
+The proving-ground stage: build the shared tools, prove them on one hard effect.
+
+- **Palette.** Take **MoonLight's palette set** (~80 gradient palettes, [palettes.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Modules/palettes.h) β study + carry the gradient *data*, written into our own format). The definition format is the textbook **gradient-stop** one: a compact `{position, R, G, B, β¦}` list (position 0..255, terminating at 255), expanded off-loop into a 256-entry lookup. Our `Palette` type + `colorFromPalette(palette, index, brightness)`: the per-light lookup is an array index + one `scale8` (hot-path-tuned; the 256-entry table precomputed on selection, not per frame). Generalises `PlasmaPaletteEffect`'s hard-coded table.
+ - **Ownership (decided 2026-06-30):** the **active palette is global**, owned by the **Drivers** container (already the home of global render params β brightness, lightPreset, the shared Correction) via a new `palette` select control. Effects read it through a static `Palettes::active()` seam (the `AudioModule::latestFrame()` pattern), so an effect just calls `colorFromPalette(Palettes::active(), idx)`. This mirrors MoonLight's global `layerP.palette` without needing MoonLight's `ModuleLightsControl` β which, with **presets** and the **external-controller hub** concept, is **backlogged** ([backlog-mixed.md](../../backlog/backlog-mixed.md)) and will absorb the palette control from Drivers when built. Presets are *not* a palette dependency β separate feature, backlogged.
+ - Palettes are light-domain β live under `src/light/` (file split decided in the stage plan).
+- **The shared primitive library** (file split β one `light/Fx.h` vs focused `light/Beat.h`/`Noise.h`/`Blend.h` β decided in the stage plan; recognisable names, our implementation, FastLED credited as prior art). Hot-path-tuned, integer-only, LUT-backed:
+ - *timing/beat:* `beatsin8/16`, `beat8/16`, `triwave8` (on `sin8` + `elapsed()`).
+ - *noise:* `inoise8` 1D/2D/3D (promote + generalise `NoiseEffect`'s existing hash β the textbook value/Perlin noise).
+ - *blend/scale:* `qadd8`/`qsub8` (saturating), `nscale8`, `fadeToBlackBy`, `blend(RGB, RGB, amt)` (`scale8` already in `color.h`).
+ - *random:* `random8`/`random16` β a small fast seedable PRNG, hot-path-cheap (not `std::rand`).
+ - *draw (the dimension-agnostic part the product owner called out):* `drawPixel`/`drawLine` (and later `drawCircle`/fill) operating on `Coord3D`, working **1Dβ3D** against the `Buffer`, so effects and modifiers share one set instead of re-rolling Bresenham per effect. This is the "core absorbs the hard part" principle β geometry primitives live once.
+- **Re-port Game of Life** properly β the *real* MoonLight GoL algorithm (the cellular-automaton rules + its palette colouring + blur/mutation it actually uses), on top of the new palette + primitives, replacing the current 272-line version. This is the stage's proof: a real effect that exercises palette + random + neighbour math, done faithfully.
+- **Tags/emoji legend.** Write the canonical legend (MoonLight as basis) into architecture.md Β§ Web UI / a tags reference, and sweep existing effects' `tags()` to match. Lightweight.
+
+Stage-1 exit: palette + primitives compile (-Werror), are unit-tested (each primitive pinned: `beatsin8` range, `inoise8` determinism, `qadd8` saturation, `drawLine` endpoints in 1D/2D/3D), GoL re-port renders correctly + has a scenario, tags legend documented. **No doc explosion yet** (GoL keeps its existing single `.md`; the doc-model change is Stage 2).
+
+### Stage 2 β Doc model: per-library pages
+
+Before migrating dozens of effects (which would create dozens of `.md`s), switch the doc model. The naming + structure is fixed by the [folder-structure decision](../../backlog/folder-structure-proposal.md): **`src`/`assets`/`tests` are `domain/type` folders, flat β library is NOT a folder there**, only a `tags()` emoji; **docs** are the one place library splits, as a **page name** (type-first, underscore-joined, matching how you'd read the folder path): `effects_moonlight.md`, `effects_wled.md`, `effects_projectmm.md`, β¦ (and `modifiers_.md` etc. only where a library has that type β most libraries are effects-only).
+
+- **New per-library pages:** each effect is a **compact table row** β `| name + tags | gif | one-line description | controls |` β dropping the per-module `Tests`/`Design notes`/`Source` boilerplate (source is derivable, tests auto-discovered), so a ~30-effect page is ~120 lines (avoids both the per-module explosion *and* the one-giant-file extreme). Migrate the ~21 existing per-module effect specs into the right `effects_.md` by origin (from each effect's "Prior art"/tags β see the effect inventory reference). A short index page links the set.
+- **Rewrite `check_specs.py`** to the new contract: every registered module's **control names** must appear *somewhere in its library page* (preserves the anti-drift guarantee the per-module check gave). The `registerType` second arg changes from `Foo.md` to the library page (`effects_moonlight.md`, or `β¦#foo`).
+- **Gifs:** the per-module asset *move* is **already done** (assets are now `docs/assets/{core, light/{effects,β¦}}/` per the folder decision). What remains for this stage: **download MoonLight's preview gifs** (the WLED-Utils `FX_*.gif` set + the user-attachment gifs listed on MoonLight's effects/layouts/modifiers/drivers pages) into the matching `light/effects/β¦` folders as the new effects land, crediting source.
+- **Wire-contract docs** that don't fit a table row (the genuinely technical ones β HueDriver's API, NetworkSend's protocols) keep a deeper section; the library page links to it.
+
+Stage-2 exit: the library pages render with gifs, `check_specs.py` green on the new contract, the per-module effect `.md`s deleted (subtraction). This is the "kills the explosion permanently" stage.
+
+### Stage 3+ β Effect migration in batches
+
+With foundations + doc model in place, migrate MoonLight effects in **themed batches**, each a stage/commit: study behaviour β write fresh on our primitives β unit + scenario test β add to `effects.md` + gif. Batching keeps each commit reviewable.
+
+**Scope: ALL effects across MoonLight's `Nodes/Effects/E_*.h` files**, not a cherry-picked subset β the [breadth-parity gate](../../backlog/rename-to-moonlight.md) needs the full set. The source files (each an effect library, mapped to our origin sections + future per-library doc pages):
+- **`E_MoonModules.h`** (MoonModules-authored, 3): **GameOfLife** (Conway, 2D/3D, rulesets/wrap/colour-aging/infinite-mode), **GEQ3D** β« (perspective 3D equalizer bars), **PaintBrush** β« (frequency-modulated animated lines, chaos/softness). β verified 2026-06-30 from source.
+- **`E_MoonLight.h`** (MoonLight-original geometric set).
+- **`E_WLED.h`** (WLED ports/enhancements).
+- moving-head / DMX effect files β Stage 5.
+
+The batch order below is by dependency/complexity (refine per batch), and **cuts ACROSS the source files** (an audio-reactive batch pulls GEQ3D+PaintBrush from E_MoonModules and the GEQ/Blurz family from E_WLED together) rather than migrating one file at a time β themed batches keep each commit coherent:
+
+- **3a β simple 2D/3D non-audio** (the `E_MoonLight` / `E_WLED` geometric ones: lines, scrolling, lissajous, distortion, starfieldβ¦).
+- **3b β palette-heavy** (now that palettes exist: the gradient/noise/plasma family not yet ported).
+- **3c β particle/physics** (bouncing balls, popcorn, blackhole β build on the draw primitives + PRNG).
+- **3d β audio-reactive (β«)** (GEQ, Blurz, Waverly, FreqMatrixβ¦ β depend on `AudioModule::latestFrame()`; a shared audio-read helper may be its own small sub-stage).
+- **3e β text/scrolling** (scrolling text needs a font + glyph blitter β its own primitive).
+
+### Stage 4 β Modifiers + layouts migration
+
+The MoonLight modifiers (mirror/tile/kaleidoscope/pinwheel/transposeβ¦) and layouts (panel/cube/ring/sphere/spiral/fixture variants) not yet ported. Modifiers are pure geometry (they fit our modifier model cleanly β and per architecture.md, geometry transforms belong in modifiers, not effects). Layouts are coordinate iterators. Smaller than the effect batches; can interleave with Stage 3.
+
+### Stage 5 β Moving heads / DMX fixtures (last)
+
+`E_MovingHeads` + fixture layouts + Art-Net moving-head control. Most specialised, fewest dependencies on the rest; deferred to last.
+
+## Riskiest parts
+
+1. **Palette + primitives are the load-bearing wall** β if their API or performance is wrong, every later effect inherits it. Stage 1 must get the hot-path shape right (measure tick cost; these run per-light). Worth over-investing in.
+2. **Primitive implementation is ours** β the temptation under deadline is to copy a source's implementation, not just its recognisable name. The names follow the established FastLED convention (what a contributor recognises); the *code* is written fresh against our engine, behaviour pinned by tests, FastLED credited as prior art. Guard: independence-by-construction (own implementation + own architecture), not a renamed copy and not a traced one.
+3. **Dimension-agnostic draw** β making `drawLine` etc. genuinely 1Dβ3D (not 2D with a z-loop bolted on) needs thought; get the abstraction right in Stage 1 or effects will work around it.
+4. **Doc-model migration is a one-way door** β deleting 21 per-module `.md`s and rewriting the spec-check; do it as one coherent Stage-2 change, not piecemeal, so docs are never half-migrated.
+5. **GoL "done right"** β we already got it wrong once; Stage 1 must pin the real algorithm against a reference (the actual rules + colouring), tested, so it's faithful this time.
+6. **Scope discipline** β "migrate all of it" is dozens of modules. The batching is what keeps it from becoming one un-reviewable mega-diff; resist merging batches.
+
+## Verification (per stage)
+
+Every stage: desktop build (-Werror), `ctest` (new primitives/effects pinned), scenarios, spec-check, ESP32 build, KPI (watch the per-light hot-path cost as primitives land). Bench on hardware for the visual effects. Each stage saves its own `/plan` and commits independently.
+
+## Open questions (settle at each stage, not now)
+
+- Exact home + file split of the primitive library (one `Fx.h` vs several focused headers) β Stage 1 plan.
+- Palette storage (flash tables vs computed gradients) + how the UI selects a palette (a `palette` control type?) β Stage 1 plan.
+- The `registerType` β category-page mapping mechanics β Stage 2 plan.
+- Per-batch effect list + order β each Stage-3 sub-plan.
diff --git a/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md
new file mode 100644
index 00000000..6b6fe263
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md
@@ -0,0 +1,40 @@
+# Plan β Stage 1 (palette) of the MoonLight migration
+
+The first executable slice of the [migration plan](Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md): the palette foundation. Design already decided in [moonlight-palettes-data.md](../../backlog/moonlight-palettes-data.md); this plan is the file split + the implementation specifics. The shared **primitive library** (beat/noise/blend/draw) and the **GoL re-port** are *separate* slices of Stage 1, planned + committed after this β palette is the load-bearing one, done first and alone so it's reviewable.
+
+## What ships
+
+1. **`src/light/Palette.h`** β a light-domain header (sibling of `light_types.h`), holding:
+ - **`Palette`** β the active palette: **16 RGB entries** (the `CRGBPalette16` model, recognisable name carried, our implementation on `RGB`/`scale8`). 48 bytes.
+ - **gradient-stop β 16-entry expansion** (`fromGradient(const uint8_t* stops, size_t n)`): the textbook two-point lerp across the stop list, sampling 16 evenly-spaced positions. Off the hot path (called on selection).
+ - **`RGB colorFromPalette(const Palette& p, uint8_t index, uint8_t brightness = 255)`** β the per-light lookup: map `index` (0β255, wraps) to a position across the 16 entries, blend the two bracketing entries with `scale8`, apply `brightness` with `scale8`. Integer-only, hot-path-cheap.
+ - **Built-in palette set** β the gradient `{pos,R,G,B}` definitions from the [captured data](../../backlog/moonlight-palettes-data.md) as flash `constexpr`, plus the trivially-generated ones (rainbow via `hsvToRgb`, and a few solids). A `kPaletteNames[]` / `kPaletteCount` parallel to feed the select control. Start with a curated subset (~12β16: rainbow, party, ocean, lava, forest, heat, a couple of the named gradients) β not all ~54; the rest are data we can add later without design change.
+2. **`Palettes::active()`** β the static seam effects read (the `AudioModule::latestFrame()` pattern): a `Palettes` holder with a static `const Palette* active()` + `setActive(index)` that expands the selected built-in into the live `Palette`. Lives in `Palette.h`.
+3. **Drivers wiring** β a `palette` **select** control on the `Drivers` container (beside `brightness`/`lightPreset`), index into `kPaletteNames`. `Drivers::onUpdate` on a `palette` change calls `Palettes::setActive(index)` (rebuild the 16-entry lookup β cheap, like the `correction_.rebuild` it sits next to; `controlChangeTriggersBuildState` stays false, no pipeline realloc). `setup()` sets the initial active palette.
+4. **Migrate `PlasmaPaletteEffect`** β replace its hard-coded `static constexpr RGB palette_[256]` with `colorFromPalette(Palettes::active(), idx)`. Proves the seam end-to-end on a real effect + removes a 256-entry duplicate (subtraction). (Its current fixed fire-ocean look changes to "whatever palette is active" β that's the point; the effect becomes palette-driven like its MoonLight original.)
+
+## Decided (from the design doc β not re-opened here)
+
+- 16-entry model, interpolate at lookup (not a 256 table); hard-swap on select (crossfade backlogged); global active palette owned by Drivers; `Palette` is an interface shape so a later **MoonLivePalette** (dynamic, script-authored) drops into the same `colorFromPalette` seam β Stage 1 builds only the gradient case but leaves `colorFromPalette` dispatchable.
+- Naming: `Palette`, `colorFromPalette`, the `CRGBPalette16` model β recognisable names carried, FastLED credited (README + the spec's Prior art), implementation ours.
+
+## Files
+
+- **New:** `src/light/Palette.h`; `docs/moonmodules/light/Palette.md` (spec β the `Palette` type, `colorFromPalette` contract, the built-in set, the active-palette seam, Prior art crediting FastLED's gradient-palette model); `test/unit/light/unit_Palette.cpp`.
+- **Edit:** `src/light/drivers/Drivers.h` (the `palette` select + onUpdate/setup); `src/light/effects/PlasmaPaletteEffect.h` (use the shared palette); `test/CMakeLists.txt`; the migration plan (mark palette slice landing).
+
+## Riskiest parts
+
+1. **The expansion + lookup must be correct *and* cheap** β pin both with tests (endpoints exact, a mid-gradient colour interpolates, the wheel wraps at 255β0, brightness folds correctly). It runs per-light, so eyeball the KPI tick after wiring PlasmaPalette.
+2. **`colorFromPalette` dispatchable without a per-pixel cost** β Stage 1 has only the gradient case, so it's a direct call now; the *interface shape* (so MoonLivePalette slots in later) must not impose a per-pixel branch today. Keep it a plain function over the 16-entry `Palette`; the static/dynamic dispatch is a later per-frame concern, not built now.
+3. **PlasmaPalette visual change** β it goes from a fixed palette to the active one; confirm it still looks good on the default palette and that the effect's index math still maps sensibly.
+
+## Verification
+
+Desktop build (-Werror); `ctest` incl. `unit_Palette` (expand/lookup/wrap/brightness) + the existing PlasmaPalette test still green; scenarios; spec-check (new `Palette.md`); ESP32 build; KPI (watch the per-light cost β PlasmaPalette is the canary). Bench: on desktop, switch the `palette` control and confirm PlasmaPalette (and the preview) recolours live.
+
+## Out of scope (later Stage-1 slices / later stages)
+
+- The primitive library (beat/noise/blend/draw) β next Stage-1 slice.
+- GoL re-port β next Stage-1 slice.
+- The full ~54 palette set, MoonLivePalette (dynamic), crossfade, the per-library doc pages (Stage 2).
diff --git a/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md
new file mode 100644
index 00000000..c1918033
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - Stage 1 primitive library (math8 + noise + draw + blend) (shipped).md
@@ -0,0 +1,52 @@
+# Plan β Stage 1 primitive library (math8 + noise + draw + blend)
+
+The remaining foundation of [MoonLight migration Stage 1](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) (palette + tags-legend already shipped in `d00559c`). Builds the shared, hot-path-tuned integer primitives every migrated effect (Stage 3+) will call, so each effect stays short by leaning on one recognisable set instead of re-rolling beat/noise/blend/draw per effect.
+
+**Prior art:** FastLED β the canonical 8-bit-fixed-point LED library. We carry its *ideas and recognisable names* (`beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`, `fadeToBlackBy`, `blend`) and write our own implementation against our architecture, crediting FastLED at each file's header. FastLED's own split is the model: `lib8tion` (math+timing+random), `noise` (inoise), `colorutils` (blend/fade), `hsv2rgb` (color) β draw/Bresenham lives in its 2D/matrix add-ons, not core.
+
+## File split (decided 2026-06-30) β split-by-concern, math in core / draw in light
+
+**Core (`src/core/`)** β domain-neutral integer math:
+- **`color.h`** (slimmed) β keeps `RGB`, `hsvToRgb`, `scale8` (the color surface). `sin8`/`cos8`/`atan2_8`/`dist8` MOVE OUT (they're trig/geometry, not color).
+- **`math8.h`** (new) β the `lib8tion` surface: `sin8`/`cos8`/`triwave8` (moved from color.h), `beatsin8`/`beatsin16`/`beat8` (timing, on `sin8`+`elapsed()`), `qadd8`/`qsub8`/`nscale8`, `map8`, a small seedable PRNG β the **`Random8` class** (`next8()`/`next16()`/`below(n)`/`below(min,max)`, not free `random8`/`random16` functions, so each effect owns an independent reproducible stream), `atan2_8`/`dist8` (moved from color.h). *(As shipped, `beatsin8` is the FastLED 5-arg form `(bpm, ms, low, high, timebase, phase)`.)*
+- **`noise.h`** (new) β `inoise8` 1D/2D/3D (promotes + generalises `NoiseEffect`'s existing hash into the textbook value/Perlin noise; the effect then calls it).
+
+**Light (`src/light/`)** β operates on the light `Buffer`/`Coord3D`:
+- **`draw.h`** (new) β `drawPixel(buf, Coord3D, RGB)` / `drawLine(buf, Coord3D a, Coord3D b, RGB)` working 1Dβ3D against the `Buffer` (integer Bresenham). Geometry lives once (the "core absorbs the hard part" principle), light-domain because it touches the light Buffer. Circle/fill are a later add.
+- **`Palette.h`** (extend) β fold in `fadeToBlackBy(RGB&, amt)` and `blend(RGB, RGB, amt)` next to `colorFromPalette` (FastLED's `colorutils` shape; RGB-blend ops belong with the palette/color-lookup file, not a new file for two functions).
+
+Net: 2 new core files + 1 new light file + 2 edits (color.h slim, Palette.h extend). ~20 primitives across 4β5 focused files β recognisable to any FastLED/embedded dev in 30s, no per-function explosion, color.h cleaned to color-only.
+
+## The `color.h` move (the churn this incurs)
+
+`sin8`/`cos8`/`atan2_8`/`dist8` move `color.h` β `math8.h`. 18 files include `core/color.h`; the ones using sin8/dist8 (effects: Wave, Spiral, Plasma, Metaballs, DistortionWaves, Ripples, Sineβ¦) add `#include "core/math8.h"`. `color.h` includes nothing new. Mechanical, caught by -Werror (missing symbol) if an include is missed.
+
+## Files
+
+- **New:** `src/core/math8.h`, `src/core/noise.h`, `src/light/draw.h`.
+- **Edit:** `src/core/color.h` (remove sin8/cos8/atan2_8/dist8 β math8.h; keep RGB/hsvToRgb/scale8), `src/light/Palette.h` (add fadeToBlackBy/blend), every effect that used sin8/dist8 (add the math8 include), `NoiseEffect.h` (call `inoise8` instead of its inline hash β proves the promotion).
+- **Tests (new):** `test/unit/core/unit_math8.cpp` (beatsin8 range + period, qadd8/qsub8 saturation, nscale8, random8 determinism+distribution, triwave8), `test/unit/core/unit_noise.cpp` (inoise8 determinism + smoothness + 1D/2D/3D bounds), `test/unit/light/unit_draw.cpp` (drawPixel in-bounds/clipped, drawLine endpoints + a known diagonal in 1D/2D/3D).
+- **Docs:** a short `docs/coding-standards.md` or architecture note pointing effects at the primitive set (one reference, not per-primitive docs). No module `.md` (these are libraries, not MoonModules).
+
+## Hard-rule conformance
+
+- **Hot path:** all integer, LUT-backed where applicable (`sin8` already a 256-LUT; `inoise8` interpolates a hash, no float). No heap, no allocation. `random8` is a 1-line LCG/xorshift, not `std::rand`.
+- **Platform boundary:** pure computation, no platform calls β lives outside `src/platform/`. `beatsin8` reads time via the existing `elapsed()`/`platform::millis()` seam the effects already use (passed in, not called inside core β confirm the cleanest signature in implementation: likely `beatsin8(bpm, low, high, timeMs)` so core stays time-source-agnostic).
+- **Domain boundary:** math8/noise are domain-neutral (core); draw touches the light Buffer (light). No `#ifdef`.
+
+## Verification
+
+- Build -Werror (the color.h move surfaces any missed include immediately).
+- `ctest`: each primitive pinned (ranges, saturation, determinism, endpoints) per the stage-1 exit criteria. Scenarios still green (NoiseEffect now via inoise8 must still render non-zero + vary).
+- KPI: tick unchanged or better (primitives are the same work the effects already did, now shared β NoiseEffect's hash promotion should be perf-neutral).
+- No P4/S3 regression (bench after, given the recent stack lesson β though these add no large members).
+
+## Stage-1 exit (from the migration plan)
+
+After this: palette β
(shipped), tags legend β
(shipped), primitives β
(here, unit-tested), doc-model β
(shipped early in Stage 2's compact pages). The one remaining Stage-1 item β **the GoL re-port** (the proof effect on palette+primitives) β is the NEXT plan after this, since it depends on `inoise8`/`random8`/`drawPixel` landing first.
+
+## Out of scope
+
+- GoL re-port (next plan β needs these primitives first).
+- `drawCircle`/fill, font/glyph blitter (Stage 3e), `ease8`/gamma (add when an effect needs them β concrete-first).
+- Touching `colorFromPalette` itself (shipped, working).
diff --git a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md
new file mode 100644
index 00000000..24f187ca
--- /dev/null
+++ b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md
@@ -0,0 +1,55 @@
+# Plan β Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush)
+
+The first effect-migration batch of [MoonLight migration Stage 3](./Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md): port all three effects in MoonLight's `Nodes/Effects/E_MoonModules.h` (MoonModules-authored set), built fresh on the Stage-1 primitives (palette, `math8`, `noise`, `draw`). One commit (PO decision). Each effect: study behaviour β reimplement against EffectBase β unit + scenario test β `effects.md` row.
+
+**Method (CLAUDE.md):** study the MoonLight source for *behaviour* (controls, algorithm, state), then write our own code on our architecture β never trace/copy. FastLED/MoonLight credited as prior art in each `tags()` + the effect's header + the `effects.md` row.
+
+## Decisions locked (product owner, 2026-06-30)
+
+- **All three in one commit** (the E_MoonModules batch).
+- **GameOfLife: FULL faithful port** β all 7 rulesets + custom `B#/S#` parser, CRC16 stasis/oscillator/spaceship detection, infinite-mode pentomino/glider respawn, color-by-age, blur, 2D **and** 3D (8- vs 26-neighbour). The Stage-1 "proof effect" done properly, on the new palette + `random8`.
+- **Skip soft/anti-aliased lines** β our `draw::line` is hard-edged; drop the `soft` control on GEQ3D/PaintBrush. Crisp lines read fine on an LED grid; an AA `draw::line` is a later foundation add if visibly needed.
+
+## The three effects
+
+### GameOfLifeEffect (2D/3D, π«π MoonModules)
+- **Controls:** `backgroundColor` (Coord3D/RGB), `ruleset` (select: Custom/Conway B3-S23/HighLife/InverseLife/Maze/Mazecentric/DrighLife), `customRule` (text "B#/S#"), `speed` (0-100, gen/s), `density` (10-90% initial life), `mutation` (0-100%), `wrap` (bool), `colorByAge` (bool: greenβred), `infinite` (bool: respawn), `blur` (0-255 dead-cell fade). (`disablePause` dropped β UI nicety, not behaviour.)
+- **State (heap, onBuildState β like Fire's heat_/Wave's trail_):** `cells_`/`future_` bit-packed (count/8 bytes each), `colors_` palette-index per cell (count bytes), `generation_`, `step_` ms, parsed `birth_[9]`/`survive_[9]` bool arrays, CRC history (`oscCrc_`/`shipCrc_`) for stasis detection. ~20 KB at 128Β² β PSRAM via `platform::alloc`, freed in teardown + dtor. NOT inline members (the HueDriver stack lesson).
+- **Algorithm:** gen 0 = random fill by `density`, random palette colour per live cell. Each gen (throttled by `speed`): count neighbours (8 in 2D, 26 in 3D, `wrap` toroidal), apply `birth_/survive_`, newborns inherit a neighbour's colour with `mutation` chance of a fresh one; dead cells fade toward `backgroundColor` by `blur`. `colorByAge` overrides colour (green new β red aging). After each gen, CRC16 the grid; on detected oscillation/extinction, `infinite` ? place an R-pentomino/glider : reset to gen 0.
+- **New primitive needed:** `crc16` (textbook CCITT) β add to a small `core/crc.h` (a hash, not math8; reusable, ~15 lines). Uses `Random8` (math8) for fill/mutation/respawn placement, `colorFromPalette` for colour, `fadeToBlackBy`/`blend` for the dead-cell blur.
+
+### GEQ3DEffect (2D, audio β«, π«π)
+- **Controls:** `speed` (1-10 projector sweep), `frontFill` (0-255), `horizon` (0..width-1 vanishing row), `depth` (0-255 perspective), `numBands` (2-16), `borders` (bool). (`softHack` dropped.)
+- **State:** `projector_` x-position + `projectorDir_` (Β±1), `counter_` frame throttle.
+- **Algorithm:** sweep the projector (vanishing point) left/right at `speed`, bounce at edges. Map `AudioFrame::bands[]` β bar heights (75-85% of height); scale band indices if `numBands` < 16. Per band draw a 3D-perspective bar: darker side edges, a top surface with perspective lines toward the projector, a front fill (`frontFill` blend) bottomβheight, optional `borders`. Bands left of the projector paint right-to-left, right side left-to-right. Reads `AudioModule::latestFrame()` (the AudioSpectrum pattern); uses `draw::line` + `blend`.
+
+### PaintBrushEffect (3D, audio β«, π«π)
+- **Controls:** `oscillatorOffset` (0-16 phase mult), `numLines` (2-255), `fadeRate` (0-128 bg decay), `minLength` (0-255 draw threshold), `colorChaos` (bool per-line hue), `phaseChaos` (bool per-frame jitter). (`soft` dropped.)
+- **State:** `hue_` (cycles per frame), `chaos_` (per-frame random phase or 0).
+- **Algorithm:** each frame: advance `hue_`, fade the whole field by `fadeRate` (`fadeToBlackBy` over the buffer). Per line (0..numLines): map lineβaudio band; build two 3D endpoints via `beatsin8(... oscillatorOffset, ms)` modulated by band amplitude; Euclidean distance Γ band magnitude = length; if length > `minLength` draw `draw::line(a, b, colour)`. Colour = `colorChaos` ? per-line hue+`hue_` : per-band gradient. Uses `beatsin8` (math8), `AudioFrame::bands[]`, `draw::line`, `fadeToBlackBy`.
+
+## Files
+
+- **New:** `src/light/effects/GameOfLifeEffect.h`, `GEQ3DEffect.h`, `PaintBrushEffect.h`; `src/core/crc.h` (crc16 for GoL stasis).
+- **Edit:** `src/main.cpp` (register the 3, each β `light/effects/effects.md#`), `test/scenario_runner.cpp` (register for scenarios), `docs/moonmodules/light/effects/effects.md` (3 rows + `## Source` links + anchors), `test/CMakeLists.txt` (the new unit tests).
+- **Tests (new):** `unit_GameOfLifeEffect.cpp` (Conway still-life stays, blinker oscillates, B/S parser, neighbour count 2D+3D, no-crash on 0Γ0Γ0), `unit_crc.cpp` (crc16 known vectors), and GEQ3D/PaintBrush covered by the shared render test (`unit_effects_render` STATELESS / non-zero with a fed AudioFrame) + a scenario. Audio effects render dark on silence (safe) β assert structure on a synthetic frame where testable.
+- **Scenarios:** add the 3 to the perf/all-effects sweep; GoL gets its own scenario (gen progression renders + doesn't crash at any grid size).
+
+## Hard-rule conformance
+
+- **Hot path:** integer-only; GoL's grid state is heap (PSRAM), allocated off the hot path in `onBuildState`, freed in teardown/dtor β never inline (sizeof stays small; the registerType-probe stack stays tiny, per the HueDriver/P4 lesson). The neighbour sweep is integer; the per-frame render writes the buffer directly.
+- **Effects at every grid size:** all three guard 0Γ0Γ0 and tiny grids (GoL with <1 cell = no-op; GEQ3D/PaintBrush clip via `draw::`). Animation math doesn't truncate to zero on a fast tick.
+- **Audio safety:** GEQ3D/PaintBrush read `latestFrame()`; no mic β zero bands β dark, safe on any target (the AudioSpectrum/Volume contract).
+- **Domain/platform:** pure light-domain effects on EffectBase; no `#ifdef`, no platform calls (heap via `platform::alloc`).
+
+## Verification
+
+- Build -Werror (desktop + ESP32). `ctest` green incl. the new GoL + crc tests. Scenarios green incl. the new GoL scenario + the 3 in the sweep. `check_specs.py` green (3 new control sets all named in `effects.md`).
+- KPI: GoL is the heaviest (full-grid neighbour sweep) β measure its tick; it throttles by `speed` so the per-frame cost is the render, not the update, most frames.
+- Bench on P4 + S3: each effect renders; GoL progresses generations + respawns; the two audio effects react to sound. (Given the stack lesson, confirm GoL's heap state doesn't bloat sizeof β it's pointers, not arrays.)
+
+## Out of scope
+
+- `disablePause` / `softHack` / `soft` controls (UI niceties / AA we don't do yet).
+- Anti-aliased `draw::line` (a later foundation add).
+- The other `E_*.h` files (E_MoonLight, E_WLED) β later Stage 3 batches.
diff --git a/docs/history/troyhacks-WLED.md b/docs/history/troyhacks-WLED.md
index 2c1561a0..28631ae8 100644
--- a/docs/history/troyhacks-WLED.md
+++ b/docs/history/troyhacks-WLED.md
@@ -6,6 +6,12 @@ This is a personal fork of [MoonModules/WLED-MM](https://github.com/MoonModules/
**Branch note β the experiments live off `mdev`.** troyhacks branches heavily: `mdev` is the merge/alignment stream, but the distinctive work happens in named experimental branches (HDMI output, ESP32-P4, W5500 Ethernet, hardware-panel ports, voice control, a pure-IDFv5 port, a new settings subsystem). Those are *experiments*, not necessarily destined for `mdev`, so each month below carries a separate **Experimental branches** line for what moved on them β the frontier of what this fork is probing.
+## June 2026
+
+No user-facing activity: no commits were merged to `mdev` in June 2026 (the branch's most recent commit is dated 2026-05-20), and no versioned release was published. The repository's issue tracker is disabled, so no issues were opened or closed.
+
+_Checked: merged commits on `mdev` for author-date 2026-06-01..2026-06-30 (0 commits); releases published in June 2026 (none); issue search `repo:troyhacks/WLED is:issue created:2026-06-01..2026-06-30` and `closed:2026-06-01..2026-06-30` (0 results β issues disabled on this fork)._
+
## May 2026
*~18 commits on `mdev`, 2026-05-01 β¦ 2026-05-31.*
diff --git a/docs/history/wled-WLED.md b/docs/history/wled-WLED.md
index 97d5d114..5053d566 100644
--- a/docs/history/wled-WLED.md
+++ b/docs/history/wled-WLED.md
@@ -4,6 +4,33 @@ What landed on [wled/WLED](https://github.com/wled/WLED)'s `main` branch, month
Months are **not** split at release dates: upstream WLED cuts releases from separate release branches (`0_15`, `16_x`), so the version tags aren't on `main` β `main` is the development trunk that feeds future releases. Each month notes which release shipped, as context.
+## June 2026
+
+Post-16.0 stabilisation month: no new version tag (v16.0.0 shipped 2026-05-03 off a release branch, so the month is not split), just a steady stream of bugfixes and small additions landing on `main`.
+
+**New**
+- HUB75 matrix panels: added a "Seengreat" pinout, plus fixes for 4-scan and chained-panel setups.
+- Renamed the "CW" LED type to "CCT" for clarity when configuring CCT white strips.
+- V5-C6 boards are now covered by the V5 build.
+
+**Fixed**
+- "Rainbow" and other color-wheel effects no longer mis-drive the white channel on RGBW strips.
+- Restored the pre-16.0 look of several effects that had changed appearance after the 16.0 upgrade (also fixes the DJ Light intensity regression).
+- HUB75: restored missing pixel trails on some 2D effects (Black Hole, Lissajous, Spaceships).
+- Fixed a crash when creating a 2D setup larger than the actual number of LEDs.
+- Fixed LED glitches on long strips with ESP32-C3.
+- Nightlight: brightness now applies correctly, including small transition steps, and no longer resets when set to max brightness.
+- Color no longer jumps when you change it mid-transition; gamma is now applied correctly during realtime/live-data override.
+- Improved boot behaviour for boot presets, and "Reset segments" now respects "Make a segment for each output".
+- Fixed a ledmap parser reading past the end of the map, an analog-button reading fix, and a pixel-buffer refresh after changing matrix dimensions.
+- Better brownout detection and extended error codes aligned with WLED-MM.
+
+**Watching**
+- Discussion opened on switching from plain gamma to an sRGB transfer function for better low-brightness accuracy (#5707), and on improving the Nodes/Instances page (#5711) β no shipped outcome yet.
+- Several v16.0 field reports still open: multi-controller sync losing colour (#5705), UDP sync failing in AP mode (#5709), and OTA-update trouble on some boards (#5682, #5702).
+
+_Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`repos/wled/WLED/commits?sha=main`, first-line view; a few older-dated cherry-picks appear in-range and were excluded as non-June). Issues via `search/issues` for repo:wled/WLED created:2026-06-01..2026-06-30 (18 opened) and closed:2026-06-01..2026-06-30 (25 closed); only user-facing ones surfaced. No versioned release published in June (v16.0.0 was 2026-05-03), so no month split._
+
## May 2026
*Summarised from 72 first-parent commits on `main`, 2026-05-01 β¦ 2026-05-31. (Trunk after the **v16.0.0** release on May 3; v16 is codenamed "Kagayaki".)*
diff --git a/docs/install/README.md b/docs/install/README.md
index 0c0571f8..6fcc8ddd 100644
--- a/docs/install/README.md
+++ b/docs/install/README.md
@@ -62,7 +62,7 @@ labelled summary with a thumbnail:
| Collapsed | Expanded |
|---|---|
-|  |  |
+|  |  |
## Catalog schema (`deviceModels.json`)
diff --git a/docs/moonmodules/core/AudioModule.md b/docs/moonmodules/core/AudioModule.md
index 0fc50b42..88ad5ffb 100644
--- a/docs/moonmodules/core/AudioModule.md
+++ b/docs/moonmodules/core/AudioModule.md
@@ -1,6 +1,6 @@
# AudioModule
-Acquires an audio source and publishes an **AudioFrame** β an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](../light/effects/AudioSpectrumEffect.md) are the consumers.
+Acquires an audio source and publishes an **AudioFrame** β an overall sound **level**, a 16-band frequency **spectrum**, and the **dominant peak**. The frame is available to consumers every render tick, but its analysed values are *recomputed* only when a full sample block has accumulated (a 512-sample block at 22 kHz takes ~23 ms, longer than one tick), so a tick that doesn't complete a block re-publishes the previous `AudioFrame` unchanged rather than re-analysing. It is the producer half of the audio-reactive pipeline; [AudioVolumeEffect](../light/effects/effects.md) and [AudioSpectrumEffect](../light/effects/effects.md) are the consumers.
It is named for what it does, audio acquisition plus analysis, not for one source: today the source is a digital IΒ²S MEMS microphone (the only one wired), and the same analysis pipeline is built to serve other sources (line-in, USB audio) behind the platform read seam as they are added. The candidate source types β IΒ²S with an MCLK for line-in, PDM mics, analog line-in, and IΒ²C-configured codecs β are surveyed in the [Troy (troyhacks)](#prior-art) prior-art notes below. Most of the module is the analysis (DC-blocker, RMS level, windowed FFT, band mapping), which is source-independent.
@@ -25,7 +25,7 @@ The part is self-clocked from the bit clock; there is no master-clock (MCLK) pin
Each `loop()`: read a block of samples β DC-blocker high-pass β compute the level β window + FFT β map to bands. The high-pass conditions the raw block once, up front, so both the level and the spectrum see the same cleaned signal.
- **DC-blocker high-pass** (`AudioLevel.h::DcBlocker`, host-tested): a one-pole/one-zero IIR high-pass (`y[n] = x[n] β x[nβ1] + RΒ·y[nβ1]`, `R = 0.99`, β 40 Hz corner at 22 kHz) applied to the whole block before any analysis. It removes the MEMS mic's large constant DC bias *and* sub-bass rumble below ~40 Hz (handling/wind/structural) that would otherwise leak into the lowest band. Its state carries across blocks (it's a continuous filter, not per-block), and it resets when the channel re-inits. This is distinct from, and runs before, the level path's own block-mean subtraction below.
-- **Level** (`AudioLevel.h`, host-tested): subtract the block's DC mean (belt-and-braces after the high-pass), take the RMS, and map it through the log/dB window (`floor` / `gain`). It is the overall loudness, independent of the FFT: the VU value. (It uses a gentler floor than the bands so the meter keeps moving with volume rather than gating hard.)
+- **Level** (`AudioLevel.h`, host-tested): subtract the block's DC mean (belt-and-braces after the high-pass), take the RMS, and map it through the log/dB window (`floor` / `gain`). It is the overall loudness, independent of the FFT: the VU value. (It uses a gentler floor than the bands so the meter keeps moving with volume rather than gating hard.) The frame carries this **raw** value as `level` (recomputed each block, snaps to transients β for beat-reactive effects) plus `levelSmoothed`, an exponential moving average of it (lags and rounds off sudden changes β for calm/breathing effects). Consumers pick the one matching their look: NoiseMeter reads raw `level`; the AudioVolume / AudioSpectrum VU meters and FreqMatrix read `levelSmoothed`. (WLED's `volumeRaw` / `volume` pair.)
- **Spectrum** (`AudioBands.h`, host-tested): apply a [Hann window](https://en.wikipedia.org/wiki/Hann_function) (the standard general-purpose FFT window, tapers the block edges so a tone doesn't smear across bins), run the FFT (`platform::audioFft`), then group the magnitude bins into 16 log-spaced bands (a plain geometric / equal-ratio bin split) and pick the loudest bin as the dominant peak (argmax). The peak is held when no real signal is present so it doesn't wander in silence.
Only the IΒ²S read and the FFT kernel are platform code (`platform_esp32_i2s.cpp`: IDF's [`i2s_std`](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html) driver + [esp-dsp](https://github.com/espressif/esp-dsp)'s float `dsps_fft2r_fc32`); everything else is plain domain math that runs in CI on the desktop's reference DFT.
@@ -40,6 +40,7 @@ The DSP choices are the textbook defaults on purpose: a **Hann** window, **RMS**
- `gain`, sensitivity: higher = more (a narrower dB window, so a given sound fills more of the bar). Default 222.
- `level RMS`: read-only RMS sound level. The display shows the PEAK level over each 1-second window (the live value the LEDs use is recomputed ~43Γ/s; sampling it once a second would read 0 between beats even while the meter LEDs move).
- `peakHz`: read-only dominant frequency (updates each second).
+- `simulate`: a dropdown that replaces the mic signal with a **synthesized `AudioFrame`**, so audio-reactive effects light up on a board with no microphone (a preview/demo device) and so audio behaviour can be exercised deterministically without playing real sound. Options: **off** (mic only); **music (silence)** / **sweep (silence)** β synthesize *only* when the real mic is quiet, so a mic board falls back to the synth between songs; **music (always)** / **sweep (always)** β always synthesize, ignoring the mic. *music* is a plausible song (per-band sine oscillators, a swelling volume, a periodic beat pulse, a drifting peak frequency); *sweep* marches a single lit band bassβtreble, a frequency-response test pattern. Default **off**.
## Cross-domain wiring
diff --git a/docs/moonmodules/core/DevicesModule.md b/docs/moonmodules/core/DevicesModule.md
index 41a7f5b5..44db02a1 100644
--- a/docs/moonmodules/core/DevicesModule.md
+++ b/docs/moonmodules/core/DevicesModule.md
@@ -1,6 +1,6 @@
# DevicesModule
-
+
A **core**, domain-neutral module that discovers other devices on the LAN, identifies what each is, and presents them as a browsable list. It focuses on *all* devices on the network (including this one, marked as self), not on the host's own state β so its card looks the same on every projectMM instance, ESP32 or PC. Light-domain modules consume the device list; the discovery machinery itself stays domain-neutral.
@@ -34,6 +34,10 @@ Foreign ecosystems hook in as **plugins**, not hardcoded branches β the adapte
The plugin classification is pure and host-unit-tested (`unit_DeviceIdentify.cpp` feeds synthetic packets, incl. short/garbage β declined), with no network. The full pipeline is tested via `injectPacketForTest` (`unit_DevicesModule_discovery.cpp`) β and because `UdpSocket` works on desktop, the discovery path is host-testable with real datagrams, not just stubs.
+### Out-of-band devices (Hue bridge)
+
+A device not discovered by UDP presence β a Philips Hue bridge, found over HTTP by a [HueDriver](../light/drivers/HueDriver.md) β registers itself through `upsertHueBridge(ip, name, colourCount)`, reached via `active()` (the boot-instance static accessor, the `AudioModule::latestFrame()` seam shape). The bridge then lists like any device, with a `colour` field (its colour-light count, for sizing a layout). This keeps the module domain-neutral: the Hue HTTP/pairing lives entirely in the light-domain driver; the core only stores the resulting row. (`unit_DevicesModule_hue.cpp` pins the row + its persistence round trip.)
+
### Age-out
Each sighting stamps the device's `lastSeenMs`; `ageOut()` runs every tick. A live-confirmed device is kept for `kStaleMs` (**24 h**) after its last presence packet, so the list is a durable "devices I've seen" history; a **cached** row (restored from persistence, not yet re-heard this session) gets only a short `kCachedGraceMs` (**60 s**) probation, so a long-gone persisted device can't survive forever across reboots β a live packet promotes it to the 24 h window. A **timestamp**, not a counter. The self row never ages out (it tracks the current local IP). Storage is a fixed `devices_[kMaxDevices]` array β bounded, no heap.
@@ -44,11 +48,11 @@ Because the presence broadcast and the mDNS advertise are WLED-shaped, a project
**In WLED's own "Sync interfaces" instances list** β a real WLED lists every projectMM board it heard on UDP 65506. (The `undefined` columns are WLED-sync fields projectMM doesn't fill β the presence packet carries identity, not the full WLED sync state; listing is what we're after.)
-
+
**In the native WLED app** (iOS / Android) β discovered via the mDNS `_wled._tcp` advertise, validated via the `/json/info` shim, with live colour + a working brightness slider over the `/ws` WebSocket. See [HttpServerModule Β§ WLED-compatibility shim](HttpServerModule.md#wled-compatibility-shim) for the wire contract (reverse-engineered from the [WLED-Android](https://github.com/Moustachauve/WLED-Android) client).
-
+
## Transport boundary (discovery vs commands)
diff --git a/docs/moonmodules/core/FilesystemModule.md b/docs/moonmodules/core/FilesystemModule.md
index a8d37a6a..46015b6b 100644
--- a/docs/moonmodules/core/FilesystemModule.md
+++ b/docs/moonmodules/core/FilesystemModule.md
@@ -1,6 +1,6 @@
# FilesystemModule
-
+
Persists control values to flash so settings survive a reboot. Always loaded, runs first in the scheduler so its load hook fires before any other module's `setup()`.
@@ -50,6 +50,8 @@ A subtree's dirty flag is cleared only after its write succeeds; a failed write
The `lastSaved` read-only control shows how long ago the last write happened (`"never"`, `"5s ago"`, `"3m ago"`), refreshed each `loop1s()`.
+The `filesystem` progress control shows the config-partition usage (bytes used / total), refreshed each `loop1s()` from `platform::filesystemUsed()` / `filesystemTotal()`. It is bound only when the platform reports a real partition (a chip without a data partition, or desktop, reports 0 and the bar is omitted). This bar lives here, on the module that owns the filesystem β not on SystemModule.
+
## Conditional visibility (`hidden` flag)
Modules with conditional controls (e.g. NetworkModule's static-IP fields under `addressing=Static`) bind their full control set unconditionally and toggle a `hidden` flag per descriptor:
diff --git a/docs/moonmodules/core/FirmwareUpdateModule.md b/docs/moonmodules/core/FirmwareUpdateModule.md
index 491eba50..0d7e9838 100644
--- a/docs/moonmodules/core/FirmwareUpdateModule.md
+++ b/docs/moonmodules/core/FirmwareUpdateModule.md
@@ -1,6 +1,6 @@
# FirmwareUpdateModule
-
+
A thin status surface for OTA flashing. The flash itself is driven by `POST /api/firmware/url` in HttpServerModule, which hands the URL to `platform::http_fetch_to_ota` (a task that downloads via `esp_https_ota` and writes the next OTA partition). The task and this module communicate through shared file-scope globals; the module polls them in `loop1s()` and the existing WebSocket state push surfaces the change at 1 Hz.
diff --git a/docs/moonmodules/core/NetworkModule.md b/docs/moonmodules/core/NetworkModule.md
index 419fd7d9..afa62b45 100644
--- a/docs/moonmodules/core/NetworkModule.md
+++ b/docs/moonmodules/core/NetworkModule.md
@@ -1,6 +1,6 @@
# NetworkModule
-
+
Manages all device connectivity with automatic fallback: Ethernet β WiFi STA β WiFi AP. One MoonModule, one UI card β the user sees "Network", not three separate technologies.
diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md
index a9d00b06..28610e1c 100644
--- a/docs/moonmodules/core/SystemModule.md
+++ b/docs/moonmodules/core/SystemModule.md
@@ -1,6 +1,6 @@
# SystemModule
-
+
System-level diagnostics and device identity. Always loaded, always visible in the UI.
@@ -21,11 +21,9 @@ System-level diagnostics and device identity. Always loaded, always visible in t
**Static (set at boot):**
- `chip` (read-only) β chip model (ESP32, ESP32-S3, etc.)
- `sdk` (read-only) β ESP-IDF version string (or compiler on desktop)
-- `sdkDate` (read-only) β the date this firmware image was built, from the IDF app descriptor (e.g. `May 26 2026`); the compiler `__DATE__` on desktop
- `wifiCoproc` (read-only) β WiFi co-processor firmware status, shown only on boards whose radio is a separate chip (the ESP32-P4 with its on-board [ESP32-C6](https://www.espressif.com/en/products/socs/esp32-c6) over [esp_hosted](https://github.com/espressif/esp-hosted-mcu)). Reports the detected slave firmware version (`C6 fw 2.12.9`) when the link is up, or `not detected` when the C6 never completes its handshake / reports 0.0.0, which is the signature of absent or incompatible C6 slave firmware. Absent on native-radio targets (the platform returns an empty string and the control is not added).
- `flash` (read-only) β total flash chip size
- `psram` (read-only, progress) β used / total PSRAM (only if present)
-- `filesystem` (read-only, progress) β used / total filesystem
- `bootReason` (read-only) β human-readable reset reason from `platform::resetReason()` (e.g. `POWERON`, `SW`, `PANIC`, `INT_WDT`, `TASK_WDT`, `BROWNOUT`, `DEEPSLEEP`). Desktop always reports `OK`. The UI flags the reboot button with a red border (`data-crashed="true"`) when the value is one of PANIC / INT_WDT / TASK_WDT / BROWNOUT, indicating the prior boot ended unexpectedly.
On desktop these show "desktop" / "N/A" for hardware-specific fields.
diff --git a/docs/moonmodules/light/Drivers.md b/docs/moonmodules/light/Drivers.md
index ce91f3f2..95413415 100644
--- a/docs/moonmodules/light/Drivers.md
+++ b/docs/moonmodules/light/Drivers.md
@@ -1,6 +1,6 @@
# Drivers
-
+
Top-level container for one or more drivers. The consumer side of the pipeline β owns the shared output buffer (when memory allows) and performs blend+map from every layer's buffer into it each frame.
@@ -26,6 +26,7 @@ The Drivers container owns the shared output-correction state and exposes two co
|---|---|---|
| `brightness` | uint8 (0β255) | Global brightness. Scales every channel through a 256-entry LUT (`(v Γ brightness) / 255`). Changing it rebuilds only the LUT on the cheap `onUpdate` tier β no pipeline realloc, so the slider is fluent. Gamma / white-balance fold into this LUT later as a per-channel R/G/B split. |
| `lightPreset` | select | The physical wire format: channel order and whether the light is RGBW. Options: `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`, `RGBW`, `GRBW`. Defaults to `GRB` β the WS2812/SK6812 wire order, so a strip shows correct colours out of the box (PreviewDriver reads the RGB source buffer directly and is unaffected). RGBW presets make each driver emit 4 channels per light with white derived as `min(R,G,B)` from the (brightness-scaled) RGB. |
+| `palette` | select | The **global active colour palette** (`Rainbow`, `Party`, `Lava`, `Ocean`, β¦). Palette-driven effects read it via `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so changing this recolours every such effect live. The select index expands the chosen gradient into the active 16-entry palette on `onUpdate` (cheap, off the hot path). Drivers owns this as a global render parameter, alongside `brightness` and `lightPreset`. Palette model + names follow FastLED's, credited as prior art; implementation in `src/light/Palette.h`. |
The state lives on `Correction` (`src/light/drivers/Correction.h`): a brightness LUT, channel-order table, output channel count, derive-white flag. `Drivers::onUpdate` rebuilds it on a `brightness`/`lightPreset` change and hands each child a `const Correction*`. Every driver sees the same composited output; per-driver layer assignment (different drivers reading different layers) is a [backlog](../../backlog/README.md) item.
diff --git a/docs/moonmodules/light/Layers.md b/docs/moonmodules/light/Layers.md
index ef475279..57c61ed9 100644
--- a/docs/moonmodules/light/Layers.md
+++ b/docs/moonmodules/light/Layers.md
@@ -1,6 +1,6 @@
# Layers
-
+
Top-level container for one or more layers. Each layer renders independently into its own buffer; the Drivers container composes those buffers downstream.
diff --git a/docs/moonmodules/light/Layouts.md b/docs/moonmodules/light/Layouts.md
index a96629e5..bd31f33c 100644
--- a/docs/moonmodules/light/Layouts.md
+++ b/docs/moonmodules/light/Layouts.md
@@ -1,6 +1,6 @@
# Layouts
-
+
Top-level container for one or more layouts. Shared by every layer in the Layers container β defines the physical light topology of the installation.
diff --git a/docs/moonmodules/light/ModifierBase.md b/docs/moonmodules/light/ModifierBase.md
index 70e9e686..aa0148da 100644
--- a/docs/moonmodules/light/ModifierBase.md
+++ b/docs/moonmodules/light/ModifierBase.md
@@ -20,7 +20,7 @@ Because the build walks physical lights, fan-out (one logical cell driving N phy
## Affine modifiers and the matrix reference
-Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](modifiers/RotateModifier.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2Γ2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook β the fold interface hosts a matrix-backed modifier with no change.
+Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](modifiers/modifiers.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2Γ2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook β the fold interface hosts a matrix-backed modifier with no change.
## Prior art
diff --git a/docs/moonmodules/light/drivers/HueDriver.md b/docs/moonmodules/light/drivers/HueDriver.md
new file mode 100644
index 00000000..a46dd12b
--- /dev/null
+++ b/docs/moonmodules/light/drivers/HueDriver.md
@@ -0,0 +1,35 @@
+# HueDriver
+
+Overview and controls: [drivers.md Β§ HueDriver](drivers.md#hue). This page carries the reference detail a control list can't β what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing.
+
+
+
+## What makes Hue different (and why the driver is shaped this way)
+
+Hue is an HTTP hub, not a strip, and these properties drive the design:
+
+- **It's rate-limited** (~10 commands/s across the bridge; true real-time needs the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) β DTLS streaming at ~25 Hz, a separate future). So the driver paces itself: **`loop()` does at most one bridge PUT every `kPutIntervalMs`**, gated by a `platform::millis()` check (never work-every-tick), round-robined across the lights. A single bounded ~ms PUT can't stall a frame; the rest of `loop()` returns instantly. This is **smooth ambient colour**, the standard API's sweet spot β not fast strobing.
+- **Only colour-capable, reachable lights are driven.** The bridge reports each light's capabilities; the driver keeps only lights whose state has a `hue` field (an "Extended color light") *and* `reachable:true`, so every window pixel maps to a bulb that can actually show the effect's colour right now. A dimmable-only white, an on/off plug, or a powered-off bulb is skipped.
+- **Transitions are smoothed by the bridge.** Each PUT carries a `transitiontime` matched to how often that light is refreshed (lights Γ interval), so the bulb *glides* from its current colour to the next instead of snapping β the bridge's built-in fade, tuned to the cadence. (The Hue default of 400 ms is too long for this rate and smears into a frozen look.)
+- **The shared output Correction applies**, same as the physical LED / network drivers: the global brightness slider and a swapped colour-order preset reach the Hue lights too (each pixel runs through the brightness LUT + channel reorder before RGBβHSV). Brightness 0 β black β the light turns off.
+- **It needs an app key** (a "username"): the user presses the bridge's physical link button once, then the device claims a key. The driver runs this as a short bounded poll across a few 1 Hz ticks β never blocking the loop waiting for the press.
+- **It's plain HTTP, no TLS.** The Hue v1 API answers over `http:///api/...`, so there is no certificate handling on the device. Bench-confirmed against a BSB002 bridge (API 1.77).
+
+
+
+## Wire contract (Hue v1 API, plain HTTP)
+
+The driver talks to the bridge over `platform::httpRequest` (declared in `src/platform/platform.h` β a synchronous LAN HTTP GET/PUT/POST helper that reads the response straight into the caller's buffer):
+
+- **Pair** β `POST http:///api` with `{"devicetype":"projectMM#device"}`. Before the link button is pressed the bridge returns `link button not pressed`; after, `[{"success":{"username":""}}]` β the `` becomes `appKey`.
+- **List lights** β `GET http:///api//lights` β a JSON object keyed by light id (`{"1":{β¦},"2":{β¦}}`). A real bridge's response runs several KB; the driver scans it for colour-capable (`"hue"` in state) + reachable (`"reachable":true`) lights and maps window index β light id.
+- **List rooms** β `GET http:///api//groups` β a JSON object keyed by group id (`{"1":{"name":β¦,"lights":["1","2"],"type":"Room"},β¦}`). The driver scans it for `"type":"Room"` entries, reads each room's `name` and `lights` id array, and records which colour lights belong to each room (a bitmask over the colour-light list). This feeds the `room`/`light` dropdowns and the driven-set filter.
+- **Set a light** β `PUT http:///api//lights//state` with `{"on":true,"bri":0-254,"hue":0-65535,"sat":0-254,"transitiontime":N}` (or `{"on":false,β¦}` when the pixel is black). `hue`/`sat`/`bri` come from a textbook integer RGBβHSV of the pixel; `transitiontime` (deciseconds) is the cadence-matched fade.
+
+## Prior art
+
+The [Hue v1 CLIP API](https://developers.meethue.com/develop/hue-api/) (link-button pairing, `/lights//state`, `transitiontime`). The effect-as-output mapping (bulbs as pixels of the render buffer, driven through the window) is projectMM's own β the same shape its UDP and LED drivers use.
+
+## Source
+
+[HueDriver.h](../../../../src/light/drivers/HueDriver.h)
diff --git a/docs/moonmodules/light/drivers/LcdLedDriver.md b/docs/moonmodules/light/drivers/LcdLedDriver.md
index 38e29dba..849782a7 100644
--- a/docs/moonmodules/light/drivers/LcdLedDriver.md
+++ b/docs/moonmodules/light/drivers/LcdLedDriver.md
@@ -1,6 +1,6 @@
# LCD LED Driver
-Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **ESP32-S3** over the [LCD_CAM](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html) peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The S3's scale path β where the [RMT driver](RmtLedDriver.md) tops out at 4 channels on the S3, this drives 8 lanes for the wall time of one. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes.
+Overview and controls: [drivers.md Β§ LCD LED](drivers.md#lcdled). This page carries the reference detail a control list can't β 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring.
## Wire contract β 3 slots per bit
@@ -12,16 +12,6 @@ Because the whole frame is pre-encoded into one DMA buffer off the hot path and
Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`).
-## Controls
-
-- `pins` (text, default empty) β comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width and rejects partial sets, so all 8 GPIOs are claimed even when fewer strands are wired). Empty by default (the strand is user-soldered, so no pin is assumed β the driver idles until set); a known-good ESP32-S3 N16R8 Dev set is `1,2,4,5,6,7,8,9`, which clears the octal-PSRAM pins (26β37), USB (19/20) and strapping pins. Changing it re-creates the i80 bus **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)).
-- `ledsPerPin` (text, default empty) β lights per lane, matched by position; empty = even split. To drive fewer than 8 strands, give the unused lanes `0` (or list only the used lanes' counts summing to the grid size β the remainder lanes get 0 and idle LOW).
-- `clockPin` (pin, default 10) β the i80 bus WR line. The peripheral *requires* it on a real GPIO (the IDF i80 bus rejects `wr_gpio_num < 0`); WS2812 strands ignore the waveform. Peripheral-fixed (not user-strand wiring), so it keeps a sensible overridable default β point it at any otherwise-free GPIO if 10 is taken.
-- `dcPin` (pin, default 11) β the i80 data/command line, same story: required by the peripheral (`dc_gpio_num < 0` is rejected), unused by the LEDs, overridable default.
-- `loopbackTest` (bool) β one-shot signal self-test: jumper the **first** pin in `pins` to `loopbackRxPin`, tick the box; the driver transmits its **real frame** (full size, real DMA chain, repeated back to back like the render loop) with a known pattern in every row, captures the whole frame back with an RMT RX channel (the increment-1 rig reused β RMT receive is transmitter-agnostic) and verifies every bit. Result lands in the status field; on failure it names the first corrupted light.
-- `loopbackTxPin` (pin, default unset / β1) β optional **TX override** for the self-test: the loopback drives only lane 0 with the test pattern, so when this is set it transmits on this pin in place of lane 0 (`pins[0]`), the other 7 lanes unchanged β letting the test run on a dedicated jumper without re-typing `pins`. Falls back to lane 0 when unset. Test-only β normal output uses `pins`. Shown only while `loopbackTest` is on.
-- `loopbackRxPin` (pin, default unset / β1) β the RX pin for the self-test; set it when you wire the jumper (the bench used 12). Shown only while `loopbackTest` is on.
-
## Memory
One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used β the peripheral streams from internal SRAM): `longest lane Γ channels Γ 24 + latch pad` bytes, ~72 B per RGB light. A 1000-light installation across 8 lanes β 9 KB; the documented boundary is ~1500+ lights on a *single* lane (~110 KB), where a future streaming/PSRAM increment takes over. Allocation respects the platform heap reserve and degrades to a status error β never a crash.
diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md
index 47dc3ccf..4103bf61 100644
--- a/docs/moonmodules/light/drivers/NetworkSendDriver.md
+++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md
@@ -1,16 +1,8 @@
# Network Send Driver
-
+Overview and controls: [drivers.md Β§ Network Send](drivers.md#networksend). This page carries the reference detail a control list can't β the per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers.
-Streams the light buffer over UDP in one of three industry protocols, selected by a control: **[Art-Net](https://art-net.org.uk/downloads/art-net.pdf)**, **[E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf)** (the ANSI E1.31 streaming-ACN standard), or **[DDP](http://www.3waylabs.com/ddp/)** (Distributed Display Protocol). Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, chunks the corrected bytes per the selected protocol, and sends the whole frame as one burst at the configured rate. The single-node-multiple-protocols shape follows MoonLight's D_NetworkOut (architecture studied, not copied). Compatible with industry receivers β pixel controllers (Falcon, Advatek), xLights, LedFx, and ArtNet-controllable software.
-
-## Controls
-
-- `protocol` (select: ArtNet / E1.31 / DDP, default ArtNet) β the wire protocol; the destination port follows it automatically (6454 / 5568 / 4048). Changing it re-targets the socket **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) β switch output protocol on a running device mid-show.
-- `ip` (IPv4, default 255.255.255.255) β destination address. The default is the limited-broadcast address, so a fresh sender reaches every receiver on the LAN with no IP to type; set a unicast address to target one device. Changing it re-binds live. E1.31 multicast is deliberately not implemented (see Interop below).
-- `universe_start` (uint16_t, default 0) β first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it. This is the *protocol* offset (which universe the slice maps onto), distinct from the buffer `start` below.
-- `start` / `count` β the shared source-buffer window (every driver has it; see [Drivers Β§ Per-driver source window](../Drivers.md#per-driver-source-window-start--count)). This sink packs and sends exactly the lights `[start, start+count)` (count 0 = the whole buffer from `start`), so one sender covers just its slice β e.g. drive some lights over LEDs and the rest over ArtNet, or run two senders for different ranges.
-- `fps` (uint8_t, default 50, range 1-120) β frame rate limit. Without it the loop would re-send on every render tick; receivers expect a steady frame cadence.
+
## Chunking per protocol
diff --git a/docs/moonmodules/light/drivers/ParlioLedDriver.md b/docs/moonmodules/light/drivers/ParlioLedDriver.md
index bbe65d95..77ec1874 100644
--- a/docs/moonmodules/light/drivers/ParlioLedDriver.md
+++ b/docs/moonmodules/light/drivers/ParlioLedDriver.md
@@ -1,26 +1,6 @@
# Parlio LED Driver
-Parallel [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf) output on the **[ESP32-P4](https://www.espressif.com/en/products/socs/esp32-p4)** over the [Parlio (Parallel IO)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/parlio/index.html) TX peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer. The P4's scale path β the sibling of the [LCD driver](LcdLedDriver.md) on the S3. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) per light, and bit-transposes corrected bytes across the lanes.
-
-The P4 actually carries **all three** LED peripherals β [RMT](RmtLedDriver.md) (4 DMA-backed channels), [LCD_CAM i80](LcdLedDriver.md) (8 lanes), and Parlio (this driver) β and all three drivers auto-wire there off their SOC-capability gates. Parlio is the **preferred** parallel path on the P4 (its dedicated parallel-output engine), with RMT the easy single-strand option and LCD_CAM the other parallel route; the user picks per install by enabling the driver that matches their wiring.
-
-### Running all three at once β the P4 pin budget
-
-The three drivers are independent children of the `Drivers` container: each has its own `pins`, each reads the same logical buffer, and they're separate peripherals (RMT, LCD_CAM, and Parlio are distinct engines), so **they can all transmit simultaneously, each on its own GPIOs**. The combined output-pin ceiling, with the drivers' current per-chip caps:
-
-| Driver | Pins (current cap) | Peripheral max |
-|---|---|---|
-| RMT | 4 (the P4 has 4 TX channels) | 4 |
-| LCD_CAM i80 | 8 | 16 |
-| Parlio | 8 (any 1β8) | 16 |
-| **Total simultaneous** | **20** | up to 36 if the LCD/Parlio caps were raised |
-
-So **up to 20 parallel WS2812 strands** at once on the P4 today. The Waveshare P4-NANO physically exposes exactly 20 clear GPIOs (`20β27, 32β33, 39β48`, after Ethernet, the C6 SDIO, I2C and the strapping pins), so that board can in principle drive all 20 β but two honest limits apply beyond pin count: (1) **throughput is bounded by internal DMA RAM and the render tick, not pins** β the per-frame DMA buffers (~72 B/RGB light per parallel driver) and the encode time set the real ceiling on *long* strands, so 20 short strands is very different from 20 long ones; and (2) raising the LCD/Parlio caps to 16 (a constant change) only helps where a board breaks out that many free pins, which the P4-NANO does not. For most installs one parallel driver (8 lanes) is plenty; the multi-driver headroom is there for unusually wide, short-strand layouts.
-
-It is the [LCD driver](LcdLedDriver.md) shape with two simplifications, because Parlio is a simpler peripheral than the LCD_CAM i80 bus:
-
-- **No clock/dc pins.** The i80 bus needs two GPIOs (WR + DC) on real pins even though WS2812 ignores them (the IDF i80 layer rejects `wr/dc < 0`); Parlio generates the pixel clock itself (`clk_out_gpio_num = GPIO_NUM_NC`) and has no command/data phase, so there are none. (The LCD driver keeps an overridable default for its two; dropping them there would need a direct-LCD_CAM driver β backlogged.)
-- **No exactly-8-pins rule.** The i80 layer rejects a partial bus (every data line must be a real GPIO), so the LCD driver demands exactly 8 pins. Parlio takes the data GPIOs directly and runs on **1β8 lanes** β whatever `pins` names.
+Overview and controls: [drivers.md Β§ Parlio LED](drivers.md#parlioled). This page carries the reference detail a control list can't β the P4 pin budget (all three LED peripherals at once), the shared 3-slot wire contract, memory sizing, and cross-domain wiring.
## Wire contract β 3 slots per bit
@@ -32,14 +12,6 @@ Because the whole frame is pre-encoded into one DMA buffer off the hot path and
Identical semantics to the [RMT driver](RmtLedDriver.md#buffer-slicing-across-pins): consecutive slices of the source buffer in `pins` order, sizes from `ledsPerPin`, even-split remainder. The parsers are shared (`PinList.h`).
-## Controls
-
-- `pins` (text, default empty) β comma-separated data GPIOs, one lane each, **1 to 8** (no all-pins rule). Empty by default (the strand is user-soldered, so no pin is assumed β the driver idles until set). Choosing pins on the P4-NANO, **avoid**: STRAPPING pins **34β38** (boot-mode control β driving these can break boot, never use them for output), Ethernet RMII (28β31, 49β52), the ESP32-C6 SDIO (14β19, 54), and I2C (7β8). The clear GPIOs are **20β27, 32β33, 39β48**; a known-good bench set is `20,21,22,23,24,25,26,27`. Add pins for parallel strips. Changing it re-creates the Parlio TX unit **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). The loopback self-test transmits on the **first** pin.
-- `ledsPerPin` (text, default empty) β lights per lane, matched by position; empty = even split over the wired lanes (all lights on the first lane when one pin is set), remainder to the last lane. Same semantics as the RMT/LCD drivers.
-- `loopbackTest` (bool) β one-shot **whole-frame** signal self-test: TX on the first pin in `pins`, RX on `loopbackRxPin`. It builds the real frame (test pattern in every row on lane 0), transmits it back to back like the render loop through a private Parlio TX unit, captures the entire frame on the RX pin (RMT-RX with the P4's DMA backend β the [same `rmtWs2812RxCapture`](RmtLedDriver.md#loopback-self-test-on-device) the RMT/LCD rigs use, transmitter-agnostic), and bit-verifies every WS2812 bit. The verdict lands in the status field: `loopback PASS`, `loopback FAIL: bad bit N/M (light K)`, or `loopback: jumper not detected` (a plain-GPIO continuity pre-check runs first). The strip on lane 0 flickers once during the run; normal output resumes after.
-- `loopbackTxPin` (pin, default unset / β1) β optional **TX override** for the self-test: the loopback drives only lane 0 with the test pattern, so when this is set it transmits on this pin in place of lane 0 (`pins[0]`), the other lanes unchanged β letting the test run on a dedicated jumper without re-typing `pins`. Falls back to lane 0 when unset. Test-only β normal output uses `pins`. Shown only while `loopbackTest` is on.
-- `loopbackRxPin` (pin, default unset / β1) β the RX pin for the self-test; set it when you wire the jumper (the bench used 33, jumper the TX pin β 32, both strapping-safe). Shown only while `loopbackTest` is on.
-
## Memory
One internal-RAM DMA frame buffer owned by the platform (PSRAM is deliberately not used β Parlio streams from internal SRAM): `longest lane Γ channels Γ 24 + latch pad` bytes, ~72 B per RGB light, same sizing as the LCD driver. A 1000-light installation across 8 lanes β 9 KB; the ~1500+-lights-on-a-single-lane (~110 KB) boundary where a future streaming/PSRAM increment takes over is the same documented limit. Allocation respects the platform heap reserve and degrades to a status error β never a crash.
diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md
index f4626fac..afdf7052 100644
--- a/docs/moonmodules/light/drivers/PreviewDriver.md
+++ b/docs/moonmodules/light/drivers/PreviewDriver.md
@@ -1,12 +1,8 @@
# Preview Driver
-
+Overview and controls: [drivers.md Β§ Preview](drivers.md#preview). This page carries the reference detail a control list can't β the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample.
-Streams a true-shape 3D preview to the web UI over WebSocket. The preview is a **point list** β only the real lights, at their real positions β not a dense grid. So a sphere, ring, or arbitrary fixture map shows in its true shape, and the per-frame data is just the lights that exist (much less than a padded bounding box).
-
-## Controls
-
-- `fps` (uint8_t, default 24, range 1-60) β preview stream rate (independent of the render loop)
+
## Protocol
diff --git a/docs/moonmodules/light/drivers/RmtLedDriver.md b/docs/moonmodules/light/drivers/RmtLedDriver.md
index 62243b76..b38642c7 100644
--- a/docs/moonmodules/light/drivers/RmtLedDriver.md
+++ b/docs/moonmodules/light/drivers/RmtLedDriver.md
@@ -1,6 +1,8 @@
# RMT LED Driver
-Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral β one or more strands, one GPIO and one RMT TX channel per strand. Reads the Drivers container's buffer, applies the shared [Correction](Correction.md) (brightness / channel order / RGBW white) per light, and emits the WS2812 1-wire signal. Runs on any chip whose RMT peripheral has TX channels: classic ESP32 (8 channels), ESP32-S3 (4 channels), and ESP32-P4 (4 channels, DMA-backed). On desktop the RMT platform seam is a no-op and the driver is inert.
+Overview and controls: [drivers.md Β§ RMT LED](drivers.md#rmtled). This page carries the reference detail a control list can't β the WS2812B wire contract, buffer slicing, the on-device loopback self-test, and the LED-flicker troubleshooting playbook.
+
+Output driver for WS2812B-class addressable LEDs over the ESP32 **[RMT (Remote Control Transceiver)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/rmt.html)** peripheral β one GPIO and one RMT TX channel per strand. Reads the [Drivers](../Drivers.md) buffer, applies the shared [output correction](../Drivers.md#output-correction) per light, and emits the WS2812 1-wire signal.
## Wire contract β [WS2812B](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf)
@@ -21,15 +23,6 @@ The source buffer is split into **consecutive slices**, one per pin, in list ord
`loop()` encodes the whole frame once, then starts every pin's transmission (`platform::rmtWs2812Transmit`) before waiting on each (`platform::rmtWs2812Wait`) β the RMT channels clock out concurrently, so the render tick is charged roughly the **longest** strand, not the sum (~3 ms per 100 pixels on the longest slice), plus one shared reset gap. The transmission runs synchronously on the render task, so a large strand or a WiFi-interrupt-sensitive install can show timing artifacts.
-## Controls
-
-- `pins` (text, default empty) β comma-separated data / TX GPIO list, e.g. `18,17,16`. Empty by default (the strand is user-soldered, so no pin is assumed β the driver idles until set; the bench used `18`). One RMT TX channel per pin: up to 8 on classic ESP32, 4 on the S3 and P4 (exceeding the chip's limit, a bad token, or a duplicate pin puts an error in the status field and the driver idles). Changing it re-initialises the channels **live, no reboot** ([Β§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) β edit pins, counts, or colour order on a running device and the next frame uses them. The loopback self-test transmits on the **first** pin in the list.
-- `ledsPerPin` (text, default empty) β comma-separated lights-per-pin, e.g. `100,100,50`, matched to `pins` by position. It may be empty or shorter than `pins` (the unassigned remainder splits evenly across the leftover pins); see Buffer slicing above.
-- `loopbackTxPin` (pin, default unset / β1) β optional **TX override** for the self-test: when set, the loopback transmits on this pin instead of the first pin in `pins`, so the test can run on a dedicated jumper without re-typing the operational `pins`. Falls back to `pins[0]` when unset. Test-only β normal output always uses `pins`. Shown only while `loopbackTest` is on.
-- `loopbackRxPin` (pin, default unset / β1) β the RX pin for the loopback self-test; set it when you wire the jumper (the bench used 5). Jumper it to the TX pin (`loopbackTxPin` if set, else the **first** pin in `pins`) to run the test. Shown only while `loopbackTest` is on.
-- `loopbackTest` (bool) β a persistent on/off mode for the RMT TXβRX loopback self-test (see Self-test below). While it is on, the test re-runs whenever a relevant control changes (`pins`, `loopbackTxPin`, `loopbackRxPin`, `loopbackFrame`), so the pins can be set in any order and the result always reflects the current wiring; the verdict lands in the module's status field. Turning it off clears the verdict.
-- `loopbackFrame` (bool) β whole-frame variant of the self-test, shown only while `loopbackTest` is on. Instead of a 24-bit burst it transmits a real frame (the first pin's slice, or 64 lights) back to back and bit-verifies the entire capture. This is what catches frame-rate corruption and RF interference on the data line β a 24-bit burst can pass through a wire that mangles a sustained frame. On failure the status names the first corrupted bit and light.
-
## Cross-domain wiring
The driver is added as a child of the `Drivers` container at runtime via the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry) β not boot-wired, exactly like [NetworkSendDriver](NetworkSendDriver.md). RMT is the default LED driver for classic ESP32 and S3 board entries. The type is registered on every target; on a chip without RMT TX channels it is inert. Once added, it receives `setSourceBuffer` / `setCorrection` / `setLayer` from `Drivers::passBufferToDrivers` (which wires every child, boot- or runtime-added), and applies the same `const Correction*` ArtNet uses. The **symbol encode** (`encodeWs2812Symbols` in `RmtSymbol.h`) is domain code in `src/light/` so it is host-testable; the **peripheral** (`platform::rmtWs2812*` in `src/platform/esp32/platform_esp32_rmt.cpp`) is the only ESP-IDF-touching part. Per-chip channel and memory limits come from the IDF SOC capability macros, so the same code serves classic, S3 and P4.
diff --git a/docs/moonmodules/light/drivers/drivers.md b/docs/moonmodules/light/drivers/drivers.md
new file mode 100644
index 00000000..c7c78141
--- /dev/null
+++ b/docs/moonmodules/light/drivers/drivers.md
@@ -0,0 +1,111 @@
+# Drivers
+
+Every driver, one block each: what it does and what each control means β together. A driver reads its window of the [Drivers](../Drivers.md) container's shared buffer, applies the shared [output correction](../Drivers.md#output-correction) (brightness / channel order / RGBW white) per light, and sends the result out β over a wire protocol (WS2812 on RMT / LCD_CAM / Parlio), over the network (Art-Net / E1.31 / DDP), to a smart-light hub (Hue), or to the web UI (Preview). A driver is added as a child of the `Drivers` container per board through the catalog (`POST /api/modules`, a board's [`deviceModels.json`](../../../install/deviceModels.json) `modules` entry), so a device only carries the outputs its board actually has; `PreviewDriver` is the one boot-wired driver.
+
+Each block links to that driver's **detail page** for the deeper material a control list can't carry β wire contracts, buffer slicing, memory sizing, per-protocol chunking, the on-device loopback self-test, and troubleshooting. Every driver shares the `start` / `count` **source-window** controls ([Drivers Β§ Per-driver source window](../Drivers.md#per-driver-source-window-start--count)): the slice `[start, start+count)` of the buffer this driver sends (`count` 0 = to the end), so different drivers can cover different ranges of one buffer.
+
+**Jump to:** [LED output](#led-output-drivers) Β· [Network](#network-drivers) Β· [Smart light](#smart-light-drivers) Β· [Preview](#preview-drivers)
+
+## LED output drivers
+
+
+
+### RMT LED
+
+WS2812B-class addressable LEDs over the ESP32 **RMT** peripheral β one GPIO and one RMT TX channel per strand. The general single-/few-strand LED output; the default LED driver for classic ESP32 and S3 board entries. Runs on any chip whose RMT has TX channels (classic ESP32: 8, S3: 4, P4: 4 DMA-backed); inert on desktop.
+
+- `pins` β comma-separated data / TX GPIO list, e.g. `18,17,16` (one RMT TX channel per pin). Empty by default (idles until set). Changing it re-inits the channels live, no reboot.
+- `ledsPerPin` β comma-separated lights-per-pin, matched to `pins` by position; empty or shorter than `pins` splits the remainder evenly.
+- `loopbackTest` β persistent on/off mode for the RMT TXβRX loopback self-test (jumper the first `pins` entry to `loopbackRxPin`); verdict lands in the status field.
+- `loopbackFrame` β whole-frame variant of the self-test (transmits a real frame back-to-back, bit-verifies the whole capture β catches frame-rate / RF corruption a 24-bit burst misses). Shown only while `loopbackTest` is on.
+- `loopbackTxPin` β optional TX override for the self-test (transmit on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on.
+- `loopbackRxPin` β the RX pin for the self-test. Shown only while `loopbackTest` is on.
+
+Detail: [RmtLedDriver.md](RmtLedDriver.md) β WS2812B wire contract, buffer slicing, concurrent show, the loopback self-test, and the LED-flicker troubleshooting playbook.
+
+
+
+### LCD LED
+
+Parallel WS2812B on the **ESP32-S3** over the **LCD_CAM** peripheral: up to **8 strands clock out simultaneously**, one GPIO per strand, all fed by a single autonomous DMA transfer β the S3's scale path where RMT tops out at 4 channels.
+
+- `pins` β comma-separated data GPIOs, one lane each, **exactly 8** (the i80 peripheral configures every data line of the bus width). Empty by default (idles until set). Changing it re-creates the i80 bus live.
+- `ledsPerPin` β lights per lane, matched by position; empty = even split. Give unused lanes `0` to drive fewer than 8 strands.
+- `clockPin` (default 10) β the i80 bus WR line; required on a real GPIO by the peripheral, ignored by the LEDs, overridable.
+- `dcPin` (default 11) β the i80 data/command line; same story β required, unused by the LEDs, overridable.
+- `loopbackTest` β one-shot whole-frame signal self-test (jumper the first pin to `loopbackRxPin`). Result in the status field.
+- `loopbackTxPin` β optional TX override (drives lane 0 with the test pattern on this pin instead of `pins[0]`). Shown only while `loopbackTest` is on.
+- `loopbackRxPin` β the RX pin for the self-test. Shown only while `loopbackTest` is on.
+
+Detail: [LcdLedDriver.md](LcdLedDriver.md) β 3-slot-per-bit wire contract, buffer slicing, DMA memory sizing, and cross-domain wiring.
+
+
+
+### Parlio LED
+
+Parallel WS2812B on the **ESP32-P4** over the **Parlio (Parallel IO)** TX peripheral: up to **8 strands** clock out simultaneously via one autonomous DMA transfer β the P4's preferred parallel path (the sibling of the LCD driver on the S3). Runs on **1β8 lanes** (no exactly-8 rule), with no clock/dc pins (Parlio generates its own pixel clock).
+
+- `pins` β comma-separated data GPIOs, one lane each, **1 to 8**. Empty by default (idles until set). On the P4-NANO avoid the strapping (34β38), Ethernet, C6-SDIO and I2C pins; a known-good set is `20,21,22,23,24,25,26,27`. Changing it re-creates the Parlio TX unit live.
+- `ledsPerPin` β lights per lane, matched by position; empty = even split over the wired lanes.
+- `loopbackTest` β one-shot whole-frame signal self-test (TX on the first pin, RX on `loopbackRxPin`). Verdict in the status field.
+- `loopbackTxPin` β optional TX override (lane 0's test pattern goes to this pin instead of `pins[0]`). Shown only while `loopbackTest` is on.
+- `loopbackRxPin` β the RX pin for the self-test. Shown only while `loopbackTest` is on.
+
+Detail: [ParlioLedDriver.md](ParlioLedDriver.md) β the P4 pin budget (all three LED peripherals at once, up to 20 parallel strands), the shared 3-slot wire contract, memory sizing, and cross-domain wiring.
+
+## Network drivers
+
+
+
+### Network Send
+
+
+
+Streams the light buffer over UDP in one of three industry protocols β **Art-Net**, **E1.31 / sACN**, or **DDP** β selected by a control. Sends the whole frame as one burst per configured rate; compatible with pixel controllers (Falcon, Advatek), xLights, and LedFx.
+
+- `protocol` (Art-Net / E1.31 / DDP, default Art-Net) β the wire protocol; the destination port follows automatically (6454 / 5568 / 4048). Changing it re-targets the socket live.
+- `ip` (default 255.255.255.255) β destination address; the default limited-broadcast reaches every LAN receiver with no IP to type. Set a unicast address to target one device. (E1.31 multicast is deliberately not implemented β see the detail page.)
+- `universe_start` (default 0) β first universe for Art-Net and E1.31; DDP is byte-addressed and ignores it.
+- `fps` (default 50, range 1β120) β frame-rate limit (without it the loop would re-send every render tick; receivers expect a steady cadence).
+
+Detail: [NetworkSendDriver.md](NetworkSendDriver.md) β per-protocol chunking table, E1.31/Art-Net interop notes, the synchronous-send caveat, and the packet-layout headers.
+
+## Smart light drivers
+
+
+
+### Hue
+
+
+
+Drives **Philips Hue bulbs as pixels of an effect**: make a small grid, run any effect, add a HueDriver, and each colour bulb in the driver's window becomes one pixel, pushed to the bridge over the Hue HTTP API. Paces itself to the bridge's ~10 command/s rate limit β smooth ambient colour, not fast strobing. Only colour-capable, reachable bulbs are driven.
+
+- `bridgeIp` β the Hue bridge's LAN IPv4 (find it via the bridge app, the router, or `discovery.meethue.com`).
+- `appKey` β the Hue app key (username); filled automatically by `pair`, persisted as the driver's credential.
+- `pair` β a button: press it, then press the bridge's physical link button within ~30 s; the driver claims a key and learns the light list.
+- `room` / `light` β two dropdowns narrowing which colour lights are driven (both default to `All`); pick a room, then optionally one bulb.
+
+Detail: [HueDriver.md](HueDriver.md) β what makes Hue different (rate limit, bridge-smoothed transitions, pairing), the Hue v1 HTTP wire contract, and the Devices-module listing.
+
+## Preview drivers
+
+
+
+### Preview
+
+
+
+Streams a true-shape 3D preview to the web UI over WebSocket as a **point list** β only the real lights at their real positions, not a dense grid β so a sphere, ring, or arbitrary fixture map shows in its true shape. The one boot-wired driver.
+
+- `fps` (default 24, range 1β60) β preview stream rate (independent of the render loop).
+
+Detail: [PreviewDriver.md](PreviewDriver.md) β the binary WebSocket protocol (coordinate table + per-frame channels), sparse-layout handling, and the large-layout spatial downsample.
+
+## Source
+
+- [RmtLedDriver.h](../../../../src/light/drivers/RmtLedDriver.h)
+- [LcdLedDriver.h](../../../../src/light/drivers/LcdLedDriver.h)
+- [ParlioLedDriver.h](../../../../src/light/drivers/ParlioLedDriver.h)
+- [NetworkSendDriver.h](../../../../src/light/drivers/NetworkSendDriver.h)
+- [HueDriver.h](../../../../src/light/drivers/HueDriver.h)
+- [PreviewDriver.h](../../../../src/light/drivers/PreviewDriver.h)
diff --git a/docs/moonmodules/light/effects/AudioSpectrumEffect.md b/docs/moonmodules/light/effects/AudioSpectrumEffect.md
deleted file mode 100644
index 7da392b6..00000000
--- a/docs/moonmodules/light/effects/AudioSpectrumEffect.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# AudioSpectrumEffect
-
-The classic equalizer display: the microphone's **16 frequency bands** (bass β treble) spread across the grid's X axis, each column lighting from the bottom up in proportion to its band's magnitude β a bar graph that dances with the music.
-
-On a grid at least 3 rows tall, the **bottom row is an overall level/volume meter** (a horizontal VU bar lit left-to-right in proportion to `level`) and the spectrum bars sit in the rows above it. A shorter grid uses the full height for the spectrum.
-
-Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; no microphone or silence β all bands zero β dark.
-
-## Controls
-
-- `colorMode` β `height` (the default: each bar green at its base ramping to red at the top) or `per-band` (each column a distinct hue across the colour wheel, bass red β treble violet β the rainbow-analyser look).
-
-## Scaling to the grid
-
-The 16 bands map onto whatever the grid width is β column `x` shows band `x * 16 / width`:
-
-- a **16-wide** grid is one column per band,
-- a **32-wide** grid gives each band two columns,
-- an **8-wide** grid samples every other band,
-- a **1-row strip** (height 1) collapses the bars to per-column brightness.
-
-So the analyser fills the surface at any size, including a 0Γ0 grid (it simply draws nothing).
-
-## Source
-
-[AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h)
diff --git a/docs/moonmodules/light/effects/AudioVolumeEffect.md b/docs/moonmodules/light/effects/AudioVolumeEffect.md
deleted file mode 100644
index 7ba54c29..00000000
--- a/docs/moonmodules/light/effects/AudioVolumeEffect.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# AudioVolumeEffect
-
-A VU meter on the whole grid: every light pulses with the microphone's sound **level**, its colour ramping from calm green (quiet) toward hot red (loud). The simplest audio-reactive effect: one scalar drives one brightness.
-
-Reads the live frame from [AudioModule](../../core/AudioModule.md)`::latestFrame()`; with no microphone (or in silence) the level is zero and the grid stays dark, so the effect is safe on any target and grid size.
-
-## Controls
-
-- **brightness**: overall ceiling (1-255). Default 255.
-
-## Notes
-
-The colour is a level-driven greenβred ramp; modifiers and layouts give the flat VU surface its shape. Like every effect it writes only logical RGB; the driver's [Correction](../drivers/Correction.md) applies channel order and, for an RGBW preset, derives the white channel (`W = min(R, G, B)`) after brightness scaling.
-
-## Source
-
-[AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h)
diff --git a/docs/moonmodules/light/effects/CheckerboardEffect.md b/docs/moonmodules/light/effects/CheckerboardEffect.md
deleted file mode 100644
index bddbb9c4..00000000
--- a/docs/moonmodules/light/effects/CheckerboardEffect.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Checkerboard 2D Effect
-
-
-
-
-
-Animated checker pattern with two configurable hues.
-
-## Controls
-
-- `cell_size` (uint8_t, default 4, range 1-32) β cell width/height in grid units
-- `bpm` (uint8_t, default 60, range 1-255) β phase shift speed (cells appear to move)
-- `hue_a` (uint8_t, default 0) β colour of even cells
-- `hue_b` (uint8_t, default 128) β colour of odd cells
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β non-zero output, spatial variation. The file also covers other stateless effects via a shared macro.
-
-## Source
-
-[CheckerboardEffect.h](../../../../src/light/effects/CheckerboardEffect.h)
diff --git a/docs/moonmodules/light/effects/DistortionWavesEffect.md b/docs/moonmodules/light/effects/DistortionWavesEffect.md
deleted file mode 100644
index 6e36702e..00000000
--- a/docs/moonmodules/light/effects/DistortionWavesEffect.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# DistortionWavesEffect
-
-Two interfering sine waves whose sum drives the hue β a flowing, moirΓ©-like colour field. A horizontal and a vertical wave run at independent frequencies and slightly different time rates, so they beat against each other and the pattern drifts. 2D ([Layer](../Layer.md) extrudes it onto a 3D grid). Ported from WLED's "Distortion Waves".
-
-## Controls
-
-- `freq_x` β horizontal wave frequency (1β8, default 3).
-- `freq_y` β vertical wave frequency (1β8, default 3).
-- `speed` β animation speed (0β100, default 50; `0` = frozen).
-
-## Rendering
-
-For a light at (x, y): `hue = (sin8(xΒ·freq_x + t) + sin8(yΒ·freq_y + t_y)) / 2`, where `t` is the time phase and `t_y β 1.3Β·t` (WLED runs the vertical wave's time faster; approximated as `(tΒ·333) >> 8` to stay integer). The hue feeds `hsvToRgb(hue, 240, 255)`, keeping WLED's slightly-desaturated look. Integer-only: angles are `uint8_t` and the [`sin8`](../../core/Control.md) LUT replaces `sinf`.
-
-## Prior art
-
-- **MoonLight β E_WLED.h** β the WLED port of Distortion Waves.
-- **projectMM v1 / v2 β DistortionWaves** β same two-interfering-sines algorithm; those used float `sinf`, this is the integer-`sin8` equivalent.
-
-## Source
-
-[DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h)
diff --git a/docs/moonmodules/light/effects/FireEffect.md b/docs/moonmodules/light/effects/FireEffect.md
deleted file mode 100644
index 92aec26e..00000000
--- a/docs/moonmodules/light/effects/FireEffect.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Fire 2D Effect
-
-
-
-
-
-Classic demoscene fire simulation on the XY plane. Maintains a `width x height` heat field; sparks spawn at the bottom row, drift upward with cooling, and are mapped to a black-red-yellow-white palette.
-
-## Controls
-
-- `cooling` (uint8_t, default 55, range 10-200) β heat loss per frame (higher = shorter flames)
-- `sparking` (uint8_t, default 120, range 50-255) β probability of new sparks at the bottom row
-- `hue_shift` (uint8_t, default 0, range 0-255) β rotate the fire palette around the colour wheel (0 = classic fire, 96 = green ghost-fire, 160 = blue plasma-fire)
-
-## Rendering
-
-Per frame (in this order):
-
-1. **Cool** every cell by a small random amount.
-2. **Rise** β each row averages from the row below (4-tap neighbourhood), heat propagates from `y = h-1` up toward `y = 0`.
-3. **Sparks** β up to 4 random sparks at the bottom row each frame, gated by `sparking`.
-4. **Render** the heat field to RGB via the fire palette (or hue-rotated HSV when `hue_shift != 0`).
-
-Integer-only, no floats. Internal PRNG (LCG) avoids `rand()`.
-
-## Memory
-
-Allocates `width * height` bytes for the heat buffer in `onBuildState()` when `enabled` is true. Freed in `teardown()` and when disabled. Toggling `enabled` triggers a scheduler rebuild that (re)allocates.
-
-| Logical size | Heat buffer |
-|--------------|-------------|
-| 64x64 | 4 KB |
-| 128x128 | 16 KB |
-
-`dynamicBytes()` reports the live size.
-
-## Tests
-
-[Unit tests: FireEffect](../../../tests/unit-tests.md#fireeffect) β buffer becomes non-zero after several frames of sparking.
-
-## Prior art
-
-Standard demoscene fire (Lode's tutorials, FastLED's `Fire2012`). Adapted to the integer-only, no-Arduino style of this codebase.
-
-## Source
-
-[FireEffect.h](../../../../src/light/effects/FireEffect.h)
diff --git a/docs/moonmodules/light/effects/GameOfLifeEffect.md b/docs/moonmodules/light/effects/GameOfLifeEffect.md
deleted file mode 100644
index 38ed7595..00000000
--- a/docs/moonmodules/light/effects/GameOfLifeEffect.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Game of Life Effect
-
-
-
-
-
-Conway's Game of Life (B3/S23) on the XY plane. A D2 effect: it simulates the
-z=0 plane and `Layer::extrude` fills z on 3D layers.
-
-## Controls
-
-- `seed` β PRNG seed for the first initial state. Later re-seeds continue the
- same PRNG stream, so each revival is a fresh soup rather than a replay.
-- `wraparound` β when on, the grid edges wrap (a torus); when off, off-grid
- neighbours count as dead.
-- `hue` β base hue of living cells; a per-cell spatial offset is added so the
- colony shows a gradient rather than a flat colour.
-- `bpm` β generation rate. Roughly `bpm / 8` generations per second (bpm 8 β
- 1/s for watching gliders move, 255 β as fast as the frame rate allows). The
- step is time-gated, so the speed is independent of the device's tick rate.
-
-## Rendering
-
-Two `width Γ height` byte grids (cur/nxt) hold one cell each. The step reads
-cur, applies B3/S23 (birth on exactly 3 live neighbours, survival on 2 or 3),
-writes nxt, then swaps. Living cells render as
-`hsvToRgb(hue + x*3 + y*5, 200, 255)`; dead cells are black.
-
-**Keeping it lively.** A random Conway soup always decays toward sparse
-still-lifes plus a few blinkers β visually frozen, even though a plain
-"nothing changed" check never fires (the blinkers keep flipping). So the grid
-re-seeds when it goes **extinct**, **thins below ~3% density**, or **stops
-growing for ~32 generations** (the live count barely moving). That keeps fresh
-gliders and chaos coming. MoonLight does the richer version (pentomino
-injection + CRC cycle detection); this is the minimal equivalent.
-
-## Memory
-
-`2 Γ width Γ height` bytes, allocated in `onBuildState` (PSRAM-first via
-`platform::alloc`, like `FireEffect`'s heat grid) and reallocated when the
-layer's dimensions change. At 128Γ128 that is 32 KB. Freed in `teardown` and the
-destructor. Reported via `setDynamicBytes` so the per-effect heap figure is
-honest.
-
-## Extension seams
-
-The simulation step and the colouring are decoupled: the rule lives in one
-predicate (B3/S23 β birth on 3 neighbours, survival on 2 or 3) and the colour
-in one render line (`hsvToRgb`). A different birth/survival mask drops into the
-predicate without touching the rest; a palette lookup drops into the render
-line in place of `hsvToRgb`. Nothing else is coupled to either.
-
-## Tests
-
-[Unit tests: GameOfLifeEffect](../../../tests/unit-tests.md#gameoflifeeffect) β B3/S23 rule (blinker oscillates, block is a still-life, lone cell dies), wraparound, grid (re)allocation and free, 0Γ0 survival, bpm pacing, and the renders-every-frame regression.
-
-## Prior art
-
-- **MoonLight `E_MoonModules.h` GameOfLife** β the feature-rich origin
- (rulesets, palette colouring, blur, mutation, pentomino seeding, CRC stasis
- detection). We take the proven algorithm and re-seed idea, not the structure.
- ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h),
- Ewoud Wijma 2022 / Brandon Butler 2024).
-- **projectMM v1 β GameOfLifeEffect**
- ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/GameOfLifeEffect.h)) β
- used PSRAM grids and exposed `setCell` / `getCell` / `liveCount` test helpers
- for deterministic rule testing without rendering; mirrored here.
-
-## Source
-
-[GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h)
diff --git a/docs/moonmodules/light/effects/GlowParticlesEffect.md b/docs/moonmodules/light/effects/GlowParticlesEffect.md
deleted file mode 100644
index 8b606736..00000000
--- a/docs/moonmodules/light/effects/GlowParticlesEffect.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Glow Particles 2D Effect
-
-
-
-
-
-Soft-glowing particles rendered as a metaball field. Particles move with independent velocities and bounce off the edges; the per-pixel field summation produces chaotic organic blobs β like `MetaballsEffect` with more freedom of movement.
-
-## Controls
-
-- `count` (uint8_t, default 5, range 1-8) β number of glow sources
-- `speed` (uint8_t, default 60, range 1-255) β movement speed
-- `radius` (uint8_t, default 24, range 4-64) β influence radius (larger = more merging)
-- `hue_shift` (uint8_t, default 0, range 0-255) β global hue rotation
-
-Same metaball field as [MetaballsEffect](MetaballsEffect.md), but the sources move freely (12.4 fixed-point, edge-bouncing) instead of orbiting. Fixed particle array, no heap.
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (GlowParticlesEffect shares the same baseline assertions β non-zero output, spatial variation β alongside other effects).
-
-## Source
-
-[GlowParticlesEffect.h](../../../../src/light/effects/GlowParticlesEffect.h)
diff --git a/docs/moonmodules/light/effects/LavaLampEffect.md b/docs/moonmodules/light/effects/LavaLampEffect.md
deleted file mode 100644
index b078b933..00000000
--- a/docs/moonmodules/light/effects/LavaLampEffect.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Lava Lamp 2D Effect
-
-
-
-
-
-Three slow blobs whose summed field is mapped through a black β red β orange β yellow β white palette. Atmospheric, fluid look β like a real lava lamp rather than the bright HSV of `MetaballsEffect`.
-
-## Controls
-
-- `bpm` (uint8_t, default 8, range 1-64) β orbit speed in beats per minute
-- `radius` (uint8_t, default 36, range 8-80) β blob influence radius
-- `intensity` (uint8_t, default 200, range 64-255) β how strongly the field maps into the palette
-
-Three orbiting blobs share [MetaballsEffect](MetaballsEffect.md)'s field sum, but `intensity` maps it through a blackβredβorangeβyellowβwhite palette (flash table) for the atmospheric look instead of bright HSV. No heap.
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β LavaLampEffect is included in the shared baseline coverage: non-zero output, spatial variation.
-
-## Source
-
-[LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h)
diff --git a/docs/moonmodules/light/effects/LinesEffect.md b/docs/moonmodules/light/effects/LinesEffect.md
deleted file mode 100644
index 9290b792..00000000
--- a/docs/moonmodules/light/effects/LinesEffect.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# LinesEffect
-
-
-
-
-
-Sweeps one or more axis-aligned planes across the grid in sync at a given BPM. Each plane is a distinct colour: red (YZ, sweeps along X), green (XZ, sweeps along Y), blue (XY, sweeps along Z). Useful for verifying preview axis orientation β each colour names the axis it sweeps.
-
-Port of MoonLight's Lines effect.
-
-## Controls
-
-- **speed** β sweep rate in BPM (1β240). Default 30.
-- **axis** β which plane(s) to draw: `all`, `x (red)`, `y (green)`, `z (blue)`. Default `all`.
-
-## Notes
-
-On a 1D layout only the red plane is active (width > 1 check). On a 2D layout blue is suppressed (depth = 1). On a 3D layout all three sweep simultaneously.
-
-## Source
-
-[LinesEffect.h](../../../../src/light/effects/LinesEffect.h)
diff --git a/docs/moonmodules/light/effects/MetaballsEffect.md b/docs/moonmodules/light/effects/MetaballsEffect.md
deleted file mode 100644
index 660deea2..00000000
--- a/docs/moonmodules/light/effects/MetaballsEffect.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Metaballs 2D Effect
-
-
-
-
-
-Four "blobs" moving on the XY plane via integer sin/cos, with a metaball field summation per pixel. Visually similar to a lava lamp β blobs fluidly merge and separate.
-
-## Controls
-
-- `bpm` (uint8_t, default 30, range 1-255) β orbit speed in beats per minute
-- `radius` (uint8_t, default 28, range 4-64) β ball influence radius (larger = more merging)
-- `hue_shift` (uint8_t, default 0, range 0-255) β rotate the resulting hue
-
-Four `sin8`-driven balls orbit the XY plane (phase-accumulator, so `bpm` changes don't jump); a metaball field sum per pixel drives both brightness and hue. No floats, no heap.
-
-## Tests
-
-[Unit tests: MetaballsEffect](../../../tests/unit-tests.md#metaballseffect) β non-zero output, spatial variation.
-
-## Prior art
-
-Classic demoscene effect (1980s). Same integer field-summation technique as countless WLED ports.
-
-## Source
-
-[MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h)
diff --git a/docs/moonmodules/light/effects/NetworkReceiveEffect.md b/docs/moonmodules/light/effects/NetworkReceiveEffect.md
deleted file mode 100644
index dec06bd4..00000000
--- a/docs/moonmodules/light/effects/NetworkReceiveEffect.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Network Receive Effect
-
-Receives lights-over-UDP data β **[Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/), all at once** β and writes it into the layer buffer, behaving like any other effect: composable with modifiers, part of layer blending, selectable through the same UI. The receive side for industry senders (Resolume Arena, Madrix, xLights, LedFx, β¦) and the end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md).
-
-There is deliberately **no protocol control**: the effect binds the three well-known ports (6454 ArtNet, 5568 E1.31, 4048 DDP) simultaneously and validates each packet against its port's wire format β WLED's multi-port pattern. Whatever a sender speaks just works; the status field shows what is being received (`receiving DDP`, β¦).
-
-## Controls
-
-- `universe_start` (uint16_t, default 0) β first universe to accept (ArtNet/E1.31); a packet for universe `u` lands at byte offset `(u β universe_start) Γ channels_per_universe`. Universes below the start or beyond the buffer are ignored. E1.31 senders conventionally start at universe 1 β set both ends accordingly (see the sender's universe rule).
-- `channels_per_universe` (uint16_t, default 510) β bytes each universe maps to. 510 = whole RGB lights per universe (the xLights/Falcon convention and our own sender's split); set **512** for senders that pack pixels across universe boundaries (Madrix-style). Also clamps each universe's payload to its slot, so a 512-channel frame from a 510-packed source can't bleed its 2 padding bytes into the next universe.
-
-DDP skips the universe math entirely: its packets carry a byte offset and land directly (clamped to the buffer).
-
-## ArtNet discovery (Resolume node lists)
-
-Controllers find output nodes by broadcasting **ArtPoll**; this effect answers with **ArtPollReply** (our IP, MAC, names, bound universe), so the device appears automatically in Resolume's Advanced Output, Madrix and xLights node lists instead of needing manual IP entry. The reply goes unicast to the poller via the platform's `sendToAddr`.
-
-## Rendering
-
-Opens and binds the three sockets in `setup()` (a taken port is reported in the status field; the other sockets still drain). `loop()` polls non-blocking at the frame boundary: it drains each socket (bounded per tick, so a packet flood can't wedge the render loop), validates each packet with its protocol's shared parser, and copies payloads into a **staging buffer**; staging is copied to the layer buffer every tick.
-
-The staging buffer is load-bearing: the Layer clears its buffer at the start of every tick, so writing packets straight into it would strobe black between frames. Staging gives hold-last-frame semantics. Sequence fields (and DDP's push flag) are ignored: out-of-order packets are last-write-wins.
-
-## Wire contracts
-
-The byte layouts live in [ArtNetPacket.h](../../../../src/light/ArtNetPacket.h), [E131Packet.h](../../../../src/light/E131Packet.h) and [DdpPacket.h](../../../../src/light/DdpPacket.h), shared with the sender. The receiver is liberal: any ArtNet protocol version, any E1.31 priority/sequence (no multi-source arbitration), any DDP data type. E1.31 **multicast is not joined** (unicast only β platform IGMP support is a backlog item); point sACN senders at the device's IP.
-
-## Tests
-
-[Unit tests: NetworkReceiveEffect](../../../tests/unit-tests.md#networkreceiveeffect) β per-protocol buildβparse round-trips and reject cases, cross-protocol rejects, universe placement with `channels_per_universe` 510 and 512, DDP byte placement with hostile-offset clamping, ArtPoll/ArtPollReply layout, staging lifecycle, and a localhost round-trip driving all three protocol sockets at once.
-
-Live tier: `uv run scripts/scenario/run_network_live.py` ([MoonDeck.md Β§ run_network_live](../../../../scripts/MoonDeck.md#run_network_live)) seeds real boards via all three protocols per round.
-
-## Design notes
-
-- Receive as an effect (not a separate input mechanism): any external light source is just another MoonModule that writes into a layer buffer.
-- Processing is synchronous at the frame boundary β check for pending packets, never block ([architecture.md](../../../architecture.md) network-input rule).
-
-## Prior art
-
-### MoonLight β D_NetworkIn ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Drivers/D_NetworkIn.h))
-
-ArtNet/E1.31/DDP receive in one driver node (protocol selected by control; we autodetect by port instead).
-
-### WLED β realtime UDP input
-
-Multi-port listening with per-packet header validation, plus ArtPollReply for controller discovery β the pattern this effect follows.
-
-### projectMM v1 β ArtNetInModule ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/ArtNetInModule.h))
-
-v1 treated ArtNet receive as an effect within a layer, the same architectural choice.
-
-## Source
-
-[NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h)
diff --git a/docs/moonmodules/light/effects/NoiseEffect.md b/docs/moonmodules/light/effects/NoiseEffect.md
deleted file mode 100644
index 2b32921c..00000000
--- a/docs/moonmodules/light/effects/NoiseEffect.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Noise Effect
-
-
-
-
-
-Smooth animated noise. Samples a 2D field on flat (`depth == 1`) layouts and a true 3D field on volumetric (`depth > 1`) layouts, so a cube renders as a varied volume rather than stacked identical slices.
-
-## Controls
-
-- `scale` (slider, default 4, range 1-32) β spatial frequency (higher = finer detail)
-- `bpm` (slider, default 60, range 1-255) β animation speed in beats per minute
-
-## Design notes
-
-The effect picks the 2D (`depth == 1`) or 3D path per `loop()`. The noise value drives **hue, not brightness** β driving brightness would leave most lights near-black; full brightness keeps the field visible. Time is applied as a coordinate offset into the field (smooth drift, not a per-frame hash reseed), scaled by panel width so a 16-wide and 128-wide panel look equally fast at the same `bpm`; in 3D the z-axis scrolls at 1/5 the x-rate so the field flows rather than slides flat. `scale` defaults low (4) so the pattern reads on small grids; higher suits larger panels.
-
-## Tests
-
-[Unit tests: NoiseEffect](../../../tests/unit-tests.md#noiseeffect) β non-zero output, spatial variation, differs from rainbow.
-
-[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) β full pipeline with noise + multiply/mirror, performance bounds.
-
-## Prior art
-
-### MoonLight β E_MoonLight.h ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h))
-
-Multiple noise effects (Noise2D, Noise3D variants). Uses FastLED noise functions. Time via `millis()`.
-
-### projectMM v2 β Noise2DEffect ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/effects/Noise2DEffect.h))
-
-Same hash-based value noise as v1. Uses PixelEffectBase spine.
-
-### projectMM v1 β NoiseEffect2D ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/effects/NoiseEffect2D.h))
-
-Hash-based value noise with trilinear interpolation. Controls: scale (1-32), speed (0-255). Uses `timeMicros()` for animation. v1 ran scale 4 with a 0.1x multiplier (effective 0.4) β projectMM's default of 4 is informed by this.
-
-## Source
-
-[NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h)
diff --git a/docs/moonmodules/light/effects/ParticlesEffect.md b/docs/moonmodules/light/effects/ParticlesEffect.md
deleted file mode 100644
index c4d6cc0c..00000000
--- a/docs/moonmodules/light/effects/ParticlesEffect.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Particles 2D Effect
-
-
-
-
-
-A swarm of particles drifting on the XY plane with persistent trails. Each particle has fixed-point position, velocity, and hue. A private RGB trail buffer is faded each frame, particles are drawn on top, and the trail buffer is copied to the layer buffer (which the Layer clears every frame).
-
-## Controls
-
-- `count` (uint8_t, default 32, range 1-64) β number of active particles
-- `speed` (uint8_t, default 80, range 1-255) β velocity multiplier
-- `fade` (uint8_t, default 240, range 200-255) β trail persistence (255 = no fade, 200 = quick fade)
-- `hue_shift` (uint8_t, default 0, range 0-255) β rotate all particle hues
-
-## Rendering
-
-Per frame:
-
-1. **Fade** every byte in the trail buffer via `scale8(byte, fade)` (integer multiply, no float).
-2. **Update + draw** β for each active particle: advance position using 12.4 fixed-point math, bounce off edges, draw RGB into the trail buffer.
-3. **Copy** the trail buffer into the layer buffer.
-
-Maximum particles is `MAX_PARTICLES = 64` (a compile-time constant). Particle state is a fixed array β no heap allocation for the particle list itself.
-
-## Memory
-
-Allocates `width * height * channelsPerLight` bytes for the persistent trail buffer in `onBuildState()` when `enabled` is true. Freed in `teardown()` and when disabled.
-
-| Logical size | Trail buffer (RGB) |
-|--------------|--------------------|
-| 64x64 | 12 KB |
-| 128x128 | 48 KB |
-
-Particle list (`64 * 8` bytes) is part of `sizeof(ParticlesEffect)`, not `dynamicBytes()`. `dynamicBytes()` reports only the trail buffer.
-
-## Tests
-
-[Unit tests: ParticlesEffect](../../../tests/unit-tests.md#particleseffect) β buffer becomes non-zero after one frame (particles draw immediately).
-
-## Prior art
-
-Classic "snow"/"stars"/"fireflies" effect from many LED firmwares. The fade-and-draw approach (a.k.a. *afterimage*) matches FastLED's `fadeToBlackBy` idiom.
-
-## Source
-
-[ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h)
diff --git a/docs/moonmodules/light/effects/PlasmaEffect.md b/docs/moonmodules/light/effects/PlasmaEffect.md
deleted file mode 100644
index 489c50f2..00000000
--- a/docs/moonmodules/light/effects/PlasmaEffect.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Plasma Effect
-
-
-
-
-
-Animated plasma pattern from summed sine waves on orthogonal and diagonal axes. Default effect in the desktop and ESP32 pipeline. 2D on flat (`depth == 1`) layouts, 3D on volumetric (`depth > 1`) layouts so a cube renders as a varied volume.
-
-## Controls
-
-- `bpm` (uint8_t, default 30, range 1-255) β animation speed in beats per minute
-- `scale_x` (uint8_t, default 16, range 1-64) β horizontal wave length in grid cells (`step = 256 / scale_x`)
-- `scale_y` (uint8_t, default 16, range 1-64) β vertical wave length in grid cells
-- `hue_shift` (uint8_t, default 0, range 0-255) β rotates the entire color wheel
-
-## Design notes
-
-Sums orthogonal + diagonal `sin8` waves (256-byte LUT, no float, no heap), adding a fifth depth sine on `depth > 1`. `hue_shift` rotates the result before `hsvToRgb`. A phase accumulator (matching NoiseEffect) means a `bpm` change doesn't jump the animation. The default effect in both pipelines, paired with MultiplyModifier (see `src/main.cpp`).
-
-## Tests
-
-[Unit tests: PlasmaEffect](../../../tests/unit-tests.md#plasmaeffect) β non-zero output, spatial variation, differs from NoiseEffect.
-
-Default pipeline uses Plasma + MultiplyModifier (see `src/main.cpp`).
-
-## Prior art
-
-Classic demoscene plasma effect (sum of sines). Integer sin8 LUT approach matches FastLED-style tables. No direct v1/v2 module port β simpler than NoiseEffect (no hash/bilinear).
-
-## Source
-
-[PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h)
diff --git a/docs/moonmodules/light/effects/PlasmaPaletteEffect.md b/docs/moonmodules/light/effects/PlasmaPaletteEffect.md
deleted file mode 100644
index 87be51f4..00000000
--- a/docs/moonmodules/light/effects/PlasmaPaletteEffect.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Plasma Palette 2D Effect
-
-
-
-
-
-Same four-sine plasma field as `PlasmaEffect`, but colours come from a 256-entry fire-ocean RGB palette in flash instead of `hsvToRgb`.
-
-## Controls
-
-- `bpm` (uint8_t, default 30, range 1-255)
-- `scale_x` (uint8_t, default 16, range 1-64)
-- `scale_y` (uint8_t, default 16, range 1-64)
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (PlasmaPaletteEffect is one of the stateless effects covered) β non-zero output, spatial variation.
-
-## Source
-
-[PlasmaPaletteEffect.h](../../../../src/light/effects/PlasmaPaletteEffect.h)
diff --git a/docs/moonmodules/light/effects/RainbowEffect.md b/docs/moonmodules/light/effects/RainbowEffect.md
deleted file mode 100644
index 5cf87545..00000000
--- a/docs/moonmodules/light/effects/RainbowEffect.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Rainbow 2D Effect
-
-
-
-
-
-Diagonal rainbow pattern across a 2D grid, animated over time. Good default/test effect β always produces visible, colorful output.
-
-## Controls
-
-- `speed` (uint8_t, default 60, range 1-255) β animation speed in BPM (beats per minute). 60 = 1 full cycle per second.
-
-## Tests
-
-[Unit tests: RainbowEffect](../../../tests/unit-tests.md#rainboweffect) β non-zero output, valid RGB, spatial variation.
-
-[Scenario: scenario_Layer_base_pipeline](../../../tests/scenario-tests.md#scenario_layer_base_pipeline) β full pipeline with rainbow effect, performance bounds.
-
-## Design notes
-
-- Test effect. Dead simple β proves the pipeline works.
-- No palette, no variants. Rainbow is visually recognizable, which makes it easy to spot in tests.
-
-## Source
-
-[RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h)
diff --git a/docs/moonmodules/light/effects/RingsEffect.md b/docs/moonmodules/light/effects/RingsEffect.md
deleted file mode 100644
index 7f3d4449..00000000
--- a/docs/moonmodules/light/effects/RingsEffect.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Rings 2D Effect
-
-
-
-
-
-Expanding concentric rings from random centre points. Each ring grows outward and respawns once it has expanded past the visible area. Multiple rings overlap with additive blending.
-
-(This concentric-rings effect is "Rings"; the name "Ripples" belongs to the MoonLight sine-wave water-surface effect.)
-
-## Controls
-
-- `count` (uint8_t, default 4, range 1-8) β number of simultaneously active rings
-- `speed` (uint8_t, default 60, range 1-255) β expansion rate
-- `thickness` (uint8_t, default 3, range 1-16) β ring thickness in pixels
-- `hue_shift` (uint8_t, default 0, range 0-255) β global hue rotation
-
-An age-based fade makes old, wide rings disappear softly. Per-ring state (position + radius + hue) lives in a fixed array β no heap.
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β shared rendering/smoke coverage: non-zero output, spatial variation. (RingsEffect carries per-ring mutable state β position, radius, hue β with random respawn; that behaviour isn't unit-tested today.)
-
-## Source
-
-[RingsEffect.h](../../../../src/light/effects/RingsEffect.h)
diff --git a/docs/moonmodules/light/effects/RipplesEffect.md b/docs/moonmodules/light/effects/RipplesEffect.md
deleted file mode 100644
index 5ef2d96c..00000000
--- a/docs/moonmodules/light/effects/RipplesEffect.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Ripples 3D Effect
-
-
-
-
-
-3D dancing sine-wave ripples β a reimplementation of MoonLight's Ripples. For each `(x, z)` column on the floor plane, the distance from the centre sets a wave phase, and one pixel per column is lit at the height `y = floor(h/2 Β· (1 + sin(dist / interval + time)))`. The lit surface ripples like water filling the volume, with the hue cycling over time and position.
-
-Genuinely 3D (`Dim::D3`): it writes a height across the y-axis. On a flat 2D layout (depth 1) it degenerates to a single rippling y-row, which is honest for a flat grid.
-
-## Controls
-
-- `speed` (uint8_t, default 50, range 0-99) β animation speed; 0 = frozen, 99 = fast
-- `interval` (uint8_t, default 128, range 1-254) β wavefront spacing; low = tight rings, high = wide
-
-## Prior art
-
-Ported from [MoonLight](https://github.com/ewowi/MoonLight)'s Ripples (via projectMM-v1), studied and rewritten against this project's `EffectBase` β we read the approach and implemented our own, reusing `core/color.h`'s `hsvToRgb` rather than MoonLight's inlined HSV. The wavefront math (distance β phase β sine height, the `1.3Β·(255βinterval)/128Β·βh` spacing and `millis/(100βspeed)/6.4` time base) follows MoonLight's so the look matches.
-
-Float trig (`sinf`/`sqrtf`) in the loop is consistent with the existing wave effects (Plasma, LavaLamp); the hot-path integer-math preference is for per-light colour work, not the handful of transcendental ops a wavefront needs.
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) β shared rendering/smoke coverage: non-zero output, spatial variation, plus a 0Γ0Γ0 grid robustness check.
-
-## Source
-
-[RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h)
diff --git a/docs/moonmodules/light/effects/SineEffect.md b/docs/moonmodules/light/effects/SineEffect.md
deleted file mode 100644
index df37a634..00000000
--- a/docs/moonmodules/light/effects/SineEffect.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# SineEffect
-
-A 3D colour sine field: the red, green, and blue channels each follow a sine wave along one axis (x, y, z) with a 120Β° phase offset between channels, so the box glows through shifting colours that scroll over time. Every axis drives a channel, so it is a true 3D effect; on a 2D grid the z term is constant ([Layer](../Layer.md) extrudes a lower-dimensional effect across the unused axis).
-
-## Controls
-
-- `frequency` β spatial frequency, how many wave cycles span the box (1β20, default 1).
-- `amplitude` β peak brightness, DMX-aligned (0β255, default 255 = full).
-- `bpm` β scroll speed of the wave over time (1β255, default 30).
-
-## Rendering
-
-For a light at (x, y, z) the channel value is `sin8(coordΒ·frequency + t + phase) Β· amplitude / 255`, where `t` is the time phase and `phase` is 0 / 85 / 170 (the 0Β° / 120Β° / 240Β° channel offsets in `uint8_t` angle units, 256 = full turn). All integer: angles are bytes and the project's [`sin8`](../../core/Control.md) LUT replaces `sinf`, so there is no per-light float.
-
-## Prior art
-
-- **projectMM v1 / v2 β SineEffect** β same 3D sine algorithm. Those used float `sinf` and published a normalized brightness via a KvStore for inter-module communication; this version is integer-only (`sin8`) and carries no KvStore.
-
-## Source
-
-[SineEffect.h](../../../../src/light/effects/SineEffect.h)
diff --git a/docs/moonmodules/light/effects/SpiralEffect.md b/docs/moonmodules/light/effects/SpiralEffect.md
deleted file mode 100644
index 7c268169..00000000
--- a/docs/moonmodules/light/effects/SpiralEffect.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Spiral 2D Effect
-
-
-
-
-
-Rotating spiral from angle + distance. Uses shared `atan2_8` and `dist8` in `core/color.h`.
-
-## Controls
-
-- `bpm` (uint8_t, default 40, range 1-255) β rotation speed
-- `twist` (uint8_t, default 4, range 1-16) β tightness of spiral arms
-- `hue_shift` (uint8_t, default 0, range 0-255) β colour offset
-
-## Tests
-
-[Unit tests: CheckerboardEffect](../../../tests/unit-tests.md#checkerboardeffect) (SpiralEffect is one of the stateless effects covered) β non-zero output, spatial variation.
-
-## Source
-
-[SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h)
diff --git a/docs/moonmodules/light/effects/effects.md b/docs/moonmodules/light/effects/effects.md
new file mode 100644
index 00000000..94b99952
--- /dev/null
+++ b/docs/moonmodules/light/effects/effects.md
@@ -0,0 +1,655 @@
+# Effects
+
+Every effect, one block each: its preview, what it does, and what each control means β together. An effect writes per-pixel colour into its [Layer](../Layer.md)'s buffer each tick; [modifiers](../modifiers/modifiers.md) reshape the result and a [driver](../drivers/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](../Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio β see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](../Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).)
+
+**Jump to:** [MoonLight](#moonlight-effects) Β· [MoonModules](#moonmodules-effects) Β· [WLED](#wled-effects) Β· [FastLED](#fastled-effects) Β· [projectMM-native](#projectmm-native-effects)
+
+> Some WLED-origin effects show a preview gif from [WLED-Utils](https://github.com/scottrbailey/WLED-Utils) by scottrbailey (the canonical WLED effect gif set, cross-linked with credit); these show WLED's rendering. Effects with a local `../../../assets/β¦` gif show our own output.
+
+## MoonLight effects
+
+
+
+### DistortionWaves π« Β· 2D
+
+Two interfering sine waves beat against each other into a moirΓ© colour field.
+
+- `freq_x` / `freq_y` β horizontal/vertical wave frequency (1β8).
+- `speed` β animation rate (0 = frozen).
+
+Origin: WLED Β· by ldirko & blazoncek (WLED port) Β· [gallery](https://editor.soulmatelights.com/gallery/1089-distorsion-waves) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h)
+
+[Tests](../../../tests/unit-tests.md#distortionwaveseffect)
+
+
+
+### FixedRectangle π« Β· 3D
+
+A solid colour filling a positioned box within the grid, with an optional alternating-white checker on the box's pixels.
+
+- `red` / `green` / `blue` / `white` β the box colour.
+- `X position` / `Y position` / `Z position` β the box's origin corner.
+- `Rectangle width` / `Rectangle height` / `Rectangle depth` β the box extent on each axis.
+- `alternateWhite` β alternate box pixels to white in a checker pattern.
+
+Origin: MoonLight Β· by [limpkin](https://github.com/limpkin) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h)
+
+[Tests](../../../tests/unit-tests.md#fixedrectangleeffect)
+
+
+
+### FreqSaws π«π Β· 2D
+
+Audio-reactive sawtooth waves: each column maps to a frequency band whose magnitude drives a per-band oscillator speed, so louder bands sweep their sawtooth up the column faster, with three phase methods.
+
+- `fade` β background decay per frame.
+- `increaser` β how fast a band's speed ramps up with its magnitude.
+- `decreaser` β how fast a silent band's speed decays.
+- `bpmMax` β ceiling on a band's oscillation speed.
+- `invert` β flip alternate columns vertically.
+- `keepOn` β keep oscillating even when a band is silent.
+- `method` β phase model (`Chaos`, `Chaos fix`, `BandPhases`).
+
+Origin: MoonLight (audio) Β· by [@TroyHacks](https://github.com/troyhacks) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h)
+
+[Tests](../../../tests/unit-tests.md#freqsawseffect)
+
+
+
+### LavaLamp π«π¦
Β· 2D
+
+
+
+Three slow blobs through a blackβredβorangeβyellowβwhite ramp β atmospheric lava look.
+
+- `bpm` β blob drift speed.
+- `radius` β blob influence radius.
+- `intensity` β field gain into the blackβredβorangeβyellowβwhite ramp.
+
+Origin: projectMM original (metaball lava lamp) Β· source [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h)
+
+[Tests](../../../tests/unit-tests.md#spiraleffect)
+
+
+
+### Lines π« Β· β
+
+
+
+Sweeps axis-aligned planes in sync; red/green/blue name the X/Y/Z axis β a preview-orientation test pattern.
+
+- `speed` β sweep BPM.
+- `axis` β which plane sweeps (`all`, `x (red)`, `y (green)`, `z (blue)`).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [LinesEffect.h](../../../../src/light/effects/LinesEffect.h)
+
+
+
+### Metaballs π«π¦
Β· 2D
+
+
+
+`count` blobs orbit via integer sin/cos; metaball field per pixel β bright HSV merge/split.
+
+- `bpm` β orbit speed.
+- `radius` β blob influence radius.
+- `count` β number of orbiting balls (1β8).
+- `hue_shift` β rotate the palette index.
+
+Origin: projectMM original (metaballs) Β· source [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h)
+
+[Tests](../../../tests/unit-tests.md#metaballseffect)
+
+
+
+### Particles π«π¦
Β· 2D
+
+
+
+A swarm of drifting particles with persistent fading trails.
+
+- `count` β number of particles (1β255).
+- `speed` β drift velocity.
+- `fade` β trail persistence (higher = longer tails).
+- `hue_shift` β rotate every particle's hue.
+
+Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h)
+
+[Tests](../../../tests/unit-tests.md#particleseffect)
+
+
+
+### Plasma π«π¦
Β· 2D/3D
+
+
+
+Summed sine waves on orthogonal + diagonal axes; large rolling blobs (3D on volumetric layouts).
+
+- `bpm` β roll speed.
+- `scale_x` / `scale_y` β blob size on each axis (larger = bigger, calmer blobs, lower spatial frequency).
+- `hue_shift` β rotate the palette index.
+
+Origin: FastLED / WLED lineage (classic plasma) Β· source [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h)
+
+[Tests](../../../tests/unit-tests.md#plasmaeffect)
+
+
+
+### Praxis π« Β· 2D
+
+An algorithmic palette pattern driven by two beat oscillators (a macro and a micro mutator) whose frequencies and ranges reshape the hue field over time.
+
+- `macroMutatorFreq` / `macroMutatorMin` / `macroMutatorMax` β the coarse mutator's beat frequency and its oscillation range.
+- `microMutatorFreq` / `microMutatorMin` / `microMutatorMax` β the fine mutator's beat frequency and range.
+
+Origin: MoonLight Β· by MONSOONO / @Flavourdynamics Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h)
+
+[Tests](../../../tests/unit-tests.md#praxiseffect)
+
+
+
+### Rainbow π« Β· 2D
+
+
+
+Diagonal animated rainbow β always-visible default/test effect.
+
+- `speed` β animation BPM (one full hue cycle per beat).
+
+Origin: FastLED Β· Mark Kriegsman (rainbow) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_FastLED.h) Β· source [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h)
+
+[Tests](../../../tests/unit-tests.md#rainboweffect)
+
+
+
+### Random π« Β· 3D
+
+Lights one random light per frame in a random palette colour over a fading background β a sparse, palette-tinted sparkle.
+
+- `fade` β how fast prior sparkles fade to black.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RandomEffect.h](../../../../src/light/effects/RandomEffect.h)
+
+[Tests](../../../tests/unit-tests.md#randomeffect)
+
+
+
+### Rings π«π¦
Β· 2D
+
+
+
+Expanding concentric rings from random centres, additive overlap (calm defaults).
+
+- `count` β simultaneous rings (1β8 active).
+- `speed` β expansion rate.
+- `thickness` β ring band width.
+- `hue_shift` β rotate every ring's hue.
+
+Origin: projectMM original (concentric rings) Β· source [RingsEffect.h](../../../../src/light/effects/RingsEffect.h)
+
+[Tests](../../../tests/unit-tests.md#spiraleffect)
+
+
+
+### Ripples π«π¦π¦
Β· 3D
+
+
+
+Distance-from-centre sets a per-column wave phase; the lit surface ripples like water.
+
+- `speed` β wave animation rate (0 = frozen, 99 = fast).
+- `interval` β wavefront spacing (low = tight rings, high = wide).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h)
+
+[Tests](../../../tests/unit-tests.md#spiraleffect)
+
+
+
+### RubiksCube π«π§ Β· 3D
+
+A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its solution back one turn at a time, the six faces in their standard colours.
+
+- `turnsPerSecond` β how fast the cube turns.
+- `cubeSize` β the cube order (2Γ2 up to 8Γ8).
+- `randomTurning` β turn endlessly at random instead of scramble-then-solve.
+
+Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h)
+
+[Tests](../../../tests/unit-tests.md#rubikscubeeffect)
+
+
+
+### Solid π« Β· 3D
+
+A flat fill with five colour modes: a plain RGB(W) colour, the active palette spread across the lights, an RMS-averaged single palette colour, or the palette banded along the grid's rows or columns.
+
+- `red` / `green` / `blue` / `white` β the flat colour in `RGB(W)` mode (ignored in the palette modes).
+- `brightness` β scales the flat and palette-spread output.
+- `colorMode` β `RGB(W)`, `Palette` (spread across the lights), `Palette avg` (RMS mean of the palette), `Palette rows`, `Palette cols` (palette banded along that axis).
+- `minRGB` β in the band modes, drops palette entries whose every channel is below this floor.
+- `randomColors` β in the band modes, deterministically shuffles the surviving palette entries.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SolidEffect.h](../../../../src/light/effects/SolidEffect.h)
+
+[Tests](../../../tests/unit-tests.md#solideffect)
+
+
+
+### SphereMove π«π§ Β· 3D
+
+A hollow spherical shell that bounces through the 3D volume, its surface coloured from the palette, leaving no trail.
+
+- `speed` β how fast the sphere moves through the volume.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h)
+
+[Tests](../../../tests/unit-tests.md#spheremoveeffect)
+
+
+
+### Spiral π«π¦
Β· 2D
+
+
+
+Rotating spiral from angle + distance (`atan2_8`/`dist8`).
+
+- `bpm` β rotation speed.
+- `twist` β how tightly the arm winds (hue gain per unit of distance).
+- `hue_shift` β rotate the palette index.
+
+Origin: projectMM original (rotating spiral) Β· source [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h)
+
+[Tests](../../../tests/unit-tests.md#spiraleffect)
+
+
+
+### StarField π« Β· 2D
+
+A perspective starfield: stars approach the viewer from a vanishing point, brightening as they near, then respawn at depth.
+
+- `speed` β how fast stars approach (frame throttle).
+- `numStars` β how many stars are active.
+- `blur` β motion-trail fade per frame.
+- `usePalette` β colour the stars from the palette instead of white.
+
+Origin: MoonLight Β· by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h)
+
+[Tests](../../../tests/unit-tests.md#starfieldeffect)
+
+
+
+### StarSky π« Β· 3D
+
+
+
+Twinkling stars at random light positions, each fading in and out independently over a dark background.
+
+- `speed` β fade rate per frame (how fast each star brightens/dims).
+- `star_fill_ratio` β how many stars (as a fraction of the light count).
+- `usePalette` β colour the stars from the active palette instead of white.
+
+Origin: MoonLight Β· by [limpkin](https://github.com/limpkin) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h)
+
+[Tests](../../../tests/unit-tests.md#starskyeffect)
+
+
+
+### Text π« Β· 2D
+
+Renders a multi-line string in a bitmap font. Static by default (laid out top-left, each newline dropping one font-height, clipped where it runs off the grid); turn on `scroll` to march the whole block leftwards as a wrapping marquee. Text colour comes from the active palette.
+
+- `text` β the string to show; a **multi-line text area** (each line renders on its own row).
+- `scroll` β off (default) = static; on = horizontal marquee.
+- `font` β glyph size (`4x6` compact, `6x8` larger).
+- `speed` β marquee speed (only used when `scroll` is on).
+- `hue` β palette index for the text colour.
+
+Origin: projectMM original, on MoonLight's Scrolling Text Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [TextEffect.h](../../../../src/light/effects/TextEffect.h)
+
+[Tests](../../../tests/unit-tests.md#texteffect)
+
+## MoonModules effects
+
+
+
+### GameOfLife π«π Β· 2D/3D
+
+Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbour's palette colour on birth, optional greenβred age colouring, a dead-cell blur fading toward the background colour, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static.
+
+- `backgroundColorR` / `backgroundColorG` / `backgroundColorB` β the colour dead cells fade toward (0β255 each).
+- `ruleset` β the birth/survive rule (Conway, HighLife, InverseLife, Maze, Mazecentric, DrighLife, or Custom).
+- `customRuleString` β a custom `B#/S#` rule, read only when `ruleset` = Custom.
+- `GameSpeed (FPS)` β generation rate (0β100, 100 = uncapped).
+- `startingLifeDensity` β % of cells alive at start (10β90).
+- `mutationChance` β % chance a newborn gets a random colour (0β100).
+- `wrap` β toroidal edges (cells wrap around).
+- `disablePause` β skip the 1.5 s settle pause between boards.
+- `colorByAge` β greenβred aging instead of inheriting a neighbour's palette colour.
+- `infinite` β respawn on stasis (R-pentomino/glider) instead of resetting.
+- `blur` β dead-cell fade strength toward the background colour.
+
+Origin: MoonModules Β· by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) Β· [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h)
+
+[Tests](../../../tests/unit-tests.md#gameoflifeeffect)
+
+
+
+### GEQ π«ππ Β· 2D
+
+
+
+A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bottom, with optional smoothing between bars, per-bar palette colouring, and falling peak markers.
+
+- `fadeOut` β how fast bars fade each frame.
+- `ripple` β falling-peak marker decay.
+- `colorBars` β colour each bar from the palette by band instead of by row.
+- `smoothBars` β blend neighbouring bands for smoother bar heights.
+
+Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [GEQEffect.h](../../../../src/light/effects/GEQEffect.h)
+
+[Tests](../../../tests/unit-tests.md#geqeffect)
+
+
+
+### GEQ3D π«ππ Β· 2D
+
+A 3D-perspective graphic equaliser: audio bands rise as bars with faked depth, their side/top lines drawn toward a "projector" vanishing point (sweeping leftβright) and shortened by `depth`. Bands left of the projector are painted right-to-left, bands right of it left-to-right; per-face darkening (side/top/front) and optional `borders`.
+
+- `speed` β projector sweep rate (1β10, higher = faster).
+- `frontFill` β bar front-face fill strength (0β255).
+- `horizon` β vanishing-point row the projector sits on.
+- `depth` β how far the side/top perspective lines reach toward the projector.
+- `numBands` β bands shown (2β16, fewer = wider bars).
+- `borders` β outline each bar.
+
+Origin: MoonModules (audio) Β· by [@TroyHacks](https://github.com/troyhacks) (GPLv3) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h)
+
+[Tests](../../../tests/unit-tests.md#geq3deffect)
+
+
+
+### Noise2D π«ππ Β· 2D
+
+A smoothly drifting value-noise field: each pixel samples 3D noise (grid position Γ `scale`, time on the Z axis) and indexes the palette directly, giving an organic plasma wash that morphs over time.
+
+- `speed` β how fast the field morphs (time-flow rate).
+- `scale` β noise zoom (higher = finer, more detailed).
+
+Origin: WLED Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h)
+
+[Tests](../../../tests/unit-tests.md#noise2deffect)
+
+
+
+### PaintBrush π«ππ Β· 3D
+
+Audio-reactive brush strokes: lines whose 3D endpoints oscillate on the beat (`beatsin8`, audio-band timebase), each stroke shortened to a band-magnitude length so the moving tip sweeps a curve over the fading field.
+
+- `oscillatorOffset` β phase-spread between the oscillating endpoints (0β16).
+- `numLines` β parallel animated strokes (2β255).
+- `fadeRate` β background decay per frame (0β128, higher = shorter strokes).
+- `minLength` β a stroke draws only if longer than this, so quiet bands stay dark.
+- `color_chaos` β per-line random hue vs a per-band gradient.
+- `phase_chaos` β random per-frame phase jitter.
+
+Origin: MoonModules (audio) Β· by [@TroyHacks](https://github.com/troyhacks) (GPLv3) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) Β· source [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h)
+
+[Tests](../../../tests/unit-tests.md#paintbrusheffect)
+
+
+
+### Tetrix π«π Β· 2D
+
+Falling Tetris-style blocks: each column drops a brick that lands on the growing stack, fills the column, then clears and restarts.
+
+- `speed` β fall speed (0 = randomised per brick).
+- `width` β brick height (0 = randomised).
+- `oneColor` β one advancing palette colour for all bricks instead of random per-brick colours.
+
+Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h)
+
+[Tests](../../../tests/unit-tests.md#tetrixeffect)
+
+## WLED effects
+
+
+
+### Blurz ππ Β· 2D
+
+
+
+Audio-reactive blurred dots: one frequency band per frame lights a dot whose position maps to that band (or to the major-peak frequency), then the whole frame is blurred for soft trails.
+
+- `fadeRate` β background decay per frame.
+- `blur` β blur strength applied each frame.
+- `freqMap` β place the dot by the major-peak frequency instead of scanning bands.
+- `geqScanner` β scan the dot across the strip in a GEQ-like sweep.
+
+Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR), enhancements by [@softhack007](https://github.com/softhack007) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h)
+
+[Tests](../../../tests/unit-tests.md#blurzeffect)
+
+
+
+### BouncingBalls π Β· 2D
+
+
+
+A row of balls per column bounce under gravity, each losing energy on impact and relaunching when it stops, palette-coloured by ball index over a fading background.
+
+- `grav` β gravity strength (higher = faster fall, snappier bounce).
+- `numBalls` β balls per column (1β16).
+
+Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h)
+
+[Tests](../../../tests/unit-tests.md#bouncingballseffect)
+
+
+
+### FreqMatrix ππ Β· 1D
+
+
+
+A 1D scrolling frequency display: each frame shifts the strip and injects a new pixel at one end whose hue comes from the dominant frequency and whose brightness from the volume.
+
+- `speed` β scroll rate.
+- `fx` β sound-effect intensity (scales the injected brightness).
+- `lowBin` / `highBin` β the frequency window mapped across the hue range.
+- `sensitivity` β input gain (10β100).
+- `audioSpeed` β let the volume modulate the scroll speed.
+
+Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h)
+
+[Tests](../../../tests/unit-tests.md#freqmatrixeffect)
+
+
+
+### Lissajous π Β· 2D
+
+
+
+A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sweeps, palette-coloured along its length, with a fading trail.
+
+- `xFrequency` β the x-axis sweep frequency (sets the curve's lobe count).
+- `fadeRate` β trail fade per frame.
+- `speed` β how fast the curve's phase advances.
+
+Origin: WLED Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h)
+
+[Tests](../../../tests/unit-tests.md#lissajouseffect)
+
+
+
+### NoiseMeter ππ Β· 3D
+
+
+
+An audio VU meter rendered as a noise bar: the volume sets how many rows light from the bottom, each row coloured by drifting Perlin noise, filling the full width and depth.
+
+- `fadeRate` β trail decay per frame (200β254).
+- `width` β how strongly the volume drives the bar height.
+
+Origin: WLED (audio) Β· by Andrew Tuline (WLED-SR) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h)
+
+[Tests](../../../tests/unit-tests.md#noisemetereffect)
+
+
+
+### Wave π Β· 2D
+
+An oscilloscope waveform scrolls across the grid with a fading trail; six selectable shapes.
+
+- `bpm` β travel speed (phase advance per minute).
+- `fade` β trail fade per frame (0 = instant clear, 255 = long tail).
+- `type` β waveform shape (`Sawtooth`, `Triangle`, `Sine`, `Square`, `Sin3`, `Noise`).
+
+Origin: MoonLight Β· by Ewoud Wijma Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [WaveEffect.h](../../../../src/light/effects/WaveEffect.h)
+
+[Tests](../../../tests/unit-tests.md#waveeffect)
+
+## FastLED effects
+
+
+
+### Fire β‘οΈπ¦
Β· 2D
+
+
+
+Fire2012-style heat field β sparks at the base rise and cool through the active palette (heat = palette index, cold at the low end, hottest at the high end); spark count scales with width.
+
+- `cooling` β how fast heat dissipates as it rises (higher = shorter flames).
+- `sparking` β chance of a new spark at the base each frame (higher = livelier fire).
+
+The flame colour comes from the **active palette**. For the classic fire look pick the **Lava** palette (blackβredβorangeβyellowβwhite β the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green.
+
+Origin: FastLED / MoonLight Β· Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) Β· source [FireEffect.h](../../../../src/light/effects/FireEffect.h)
+
+[Tests](../../../tests/unit-tests.md#fireeffect)
+
+
+
+### Noise β‘οΈ Β· 2D/3D
+
+
+
+Smooth animated value noise; true 3D field on volumetric layouts.
+
+- `scale` β spatial frequency of the field (1β32, higher = finer detail).
+- `bpm` β scroll speed (8 noise cells per beat).
+
+Origin: FastLED Β· inoise field (Mark Kriegsman) Β· source [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h)
+
+[Tests](../../../tests/unit-tests.md#noiseeffect)
+
+## projectMM-native effects
+
+
+
+### AudioSpectrum π
+
+The 16 mic frequency bands spread across X, each column lit bottom-up by its magnitude.
+
+- `colorMode` β bar colouring: `height` (green base β red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look).
+
+Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) Β· source [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h)
+
+[Tests](../../../tests/unit-tests.md#audiomodule)
+
+
+
+### AudioVolume π
+
+A whole-grid VU meter: every light pulses with the mic level, colour indexing the palette by loudness.
+
+- `brightness` β overall brightness ceiling for the VU pulse (1β255).
+
+Origin: projectMM original (VU meter) Β· source [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h)
+
+[Tests](../../../tests/unit-tests.md#audiomodule)
+
+
+
+### DemoReel π¬ Β· 3D
+
+A demo reel: plays every other registered effect in turn, auto-advancing on a timer, so one Layer cycles the whole library hands-free β the showcase/test tool for everything. It hosts a single live effect at a time (created from the effect registry, rendered into this Layer) and swaps to the next when the interval elapses β new effects are picked up automatically. It can also pick a fresh palette each cycle and overlay the playing effect's name. The `status` line shows which effect is playing (e.g. `playing: Plasma (3/20)`). It never hosts itself, and it plays effects in sequence rather than compositing them (layering is the [Layer](../Layer.md) stack's job).
+
+- `interval` β seconds each effect plays before advancing (1β120).
+- `shuffle` β jump to a random next effect instead of registry order.
+- `randomPalette` β pick a random palette on each cycle (showcases the palette set); default on.
+- `showName` β overlay the playing effect's name in a small font; default on.
+
+Origin: FastLED Β· Mark Kriegsman's [DemoReel100](https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino); projectMM reel Β· source [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h)
+
+[Tests](../../../tests/unit-tests.md#demoreeleffect)
+
+
+
+### NetworkReceive π‘π
+
+Receives lights-over-UDP (Art-Net, E1.31/sACN, DDP) and writes it into the layer β the receive side for Resolume/Madrix/xLights/LedFx.
+
+- `universe_start` β the first incoming universe to map onto the layer (mirrors the sender).
+- `channels_per_universe` β bytes each universe maps to (510 = whole RGB lights per universe, the xLights/Falcon convention; 512 for Madrix-style senders that pack pixels across universe boundaries).
+
+Origin: projectMM original (E1.31 / Art-Net receive) Β· source [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h)
+
+[Tests](../../../tests/unit-tests.md#networkreceiveeffect)
+
+**Wire contract:** listens for [Art-Net](https://art-net.org.uk/downloads/art-net.pdf), [E1.31 / sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) simultaneously; `universe_start` + `channels_per_universe` map incoming universes onto the layer buffer. The end-to-end pair with [NetworkSendDriver](../drivers/NetworkSendDriver.md).
+
+
+
+### Sine π Β· 3D
+
+R/G/B each follow a sine along one axis at 120Β° phase offset β a glowing, scrolling colour box.
+
+- `frequency` β spatial frequency, waves across the box (1β20).
+- `amplitude` β peak brightness (0β255, 255 = full).
+- `bpm` β scroll speed.
+
+Origin: MoonLight (Sinus, AI-generated) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) Β· source [SineEffect.h](../../../../src/light/effects/SineEffect.h)
+
+[Tests](../../../tests/unit-tests.md#sineeffect)
+
+## Source
+
+- [AudioSpectrumEffect.h](../../../../src/light/effects/AudioSpectrumEffect.h)
+- [AudioVolumeEffect.h](../../../../src/light/effects/AudioVolumeEffect.h)
+- [BlurzEffect.h](../../../../src/light/effects/BlurzEffect.h)
+- [BouncingBallsEffect.h](../../../../src/light/effects/BouncingBallsEffect.h)
+- [DemoReelEffect.h](../../../../src/light/effects/DemoReelEffect.h)
+- [DistortionWavesEffect.h](../../../../src/light/effects/DistortionWavesEffect.h)
+- [FireEffect.h](../../../../src/light/effects/FireEffect.h)
+- [FixedRectangleEffect.h](../../../../src/light/effects/FixedRectangleEffect.h)
+- [FreqMatrixEffect.h](../../../../src/light/effects/FreqMatrixEffect.h)
+- [FreqSawsEffect.h](../../../../src/light/effects/FreqSawsEffect.h)
+- [GEQ3DEffect.h](../../../../src/light/effects/GEQ3DEffect.h)
+- [GEQEffect.h](../../../../src/light/effects/GEQEffect.h)
+- [GameOfLifeEffect.h](../../../../src/light/effects/GameOfLifeEffect.h)
+- [LavaLampEffect.h](../../../../src/light/effects/LavaLampEffect.h)
+- [LinesEffect.h](../../../../src/light/effects/LinesEffect.h)
+- [LissajousEffect.h](../../../../src/light/effects/LissajousEffect.h)
+- [MetaballsEffect.h](../../../../src/light/effects/MetaballsEffect.h)
+- [NetworkReceiveEffect.h](../../../../src/light/effects/NetworkReceiveEffect.h)
+- [Noise2DEffect.h](../../../../src/light/effects/Noise2DEffect.h)
+- [NoiseEffect.h](../../../../src/light/effects/NoiseEffect.h)
+- [NoiseMeterEffect.h](../../../../src/light/effects/NoiseMeterEffect.h)
+- [PaintBrushEffect.h](../../../../src/light/effects/PaintBrushEffect.h)
+- [ParticlesEffect.h](../../../../src/light/effects/ParticlesEffect.h)
+- [PlasmaEffect.h](../../../../src/light/effects/PlasmaEffect.h)
+- [PraxisEffect.h](../../../../src/light/effects/PraxisEffect.h)
+- [RainbowEffect.h](../../../../src/light/effects/RainbowEffect.h)
+- [RandomEffect.h](../../../../src/light/effects/RandomEffect.h)
+- [RingsEffect.h](../../../../src/light/effects/RingsEffect.h)
+- [RipplesEffect.h](../../../../src/light/effects/RipplesEffect.h)
+- [RubiksCubeEffect.h](../../../../src/light/effects/RubiksCubeEffect.h)
+- [SineEffect.h](../../../../src/light/effects/SineEffect.h)
+- [SolidEffect.h](../../../../src/light/effects/SolidEffect.h)
+- [SphereMoveEffect.h](../../../../src/light/effects/SphereMoveEffect.h)
+- [SpiralEffect.h](../../../../src/light/effects/SpiralEffect.h)
+- [StarFieldEffect.h](../../../../src/light/effects/StarFieldEffect.h)
+- [StarSkyEffect.h](../../../../src/light/effects/StarSkyEffect.h)
+- [TetrixEffect.h](../../../../src/light/effects/TetrixEffect.h)
+- [TextEffect.h](../../../../src/light/effects/TextEffect.h)
+- [WaveEffect.h](../../../../src/light/effects/WaveEffect.h)
diff --git a/docs/moonmodules/light/layouts/GridLayout.md b/docs/moonmodules/light/layouts/GridLayout.md
deleted file mode 100644
index 1abe202c..00000000
--- a/docs/moonmodules/light/layouts/GridLayout.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Grid Layout
-
-
-
-Arranges lights in a 3D grid, row-major (x fastest, then y, then z). Full-density β every position maps to a light. Controls: `width`, `height`, `depth`, `serpentine`.
-
-## Mapping
-
-A plain grid (`serpentine` off) emits driver index `i` at box cell `i`, so the Layer takes the **1:1 unshuffled memcpy fast path** β the mapping isn't *declared* identity, it's *measured*: the Layer walks the coords once and only skips the mapping table when the order is natural. `serpentine` wires odd rows in reverse (boustrophedon β the strip snakes back and forth), so driver index `i` no longer equals box cell `i`: the grid is dense but **shuffled**, which routes it through the boxβdriver mapping LUT exactly as a sparse layout does. A handy lever for exercising both the identity and non-identity mapping paths from one layout. The Layer buffer and driver buffer are separate when memory allows (for parallelism), shared when memory is tight. `defaultGridSize` (16) is owned here and also read by the composition roots to size the boot grid.
-
-## Tests
-
-[Unit tests: GridLayout](../../../tests/unit-tests.md#gridlayout) β row-major coordinate iteration, 3D grids, Layouts multi-layout offset.
-
-## Prior art
-
-### MoonLight β L_MoonLight.h Panel ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h))
-
-Panel layout with serpentine, multiple panel arrangements. Uses `addLight()` to define each position.
-
-### projectMM v1 β GridLayout ([source](https://github.com/ewowi/projectMM-v1/blob/54b50bc/src/modules/layouts/GridLayout.h))
-
-Width/height/depth/serpentine controls. Mapping rebuilt in onUpdate(), parent notified via onChildrenReady().
-
-### projectMM v2 β GridLayoutModule ([source](https://github.com/ewowi/projectMM-v2/blob/main/src/modules/lights/GridLayoutModule.h))
-
-Same controls. Uses LayoutModule base class.
-
-## Source
-
-[GridLayout.h](../../../../src/light/layouts/GridLayout.h)
diff --git a/docs/moonmodules/light/layouts/SphereLayout.md b/docs/moonmodules/light/layouts/SphereLayout.md
deleted file mode 100644
index 87ef07e6..00000000
--- a/docs/moonmodules/light/layouts/SphereLayout.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Sphere Layout
-
-Arranges lights on the **surface of a hollow sphere** β a one-light-thick shell, no interior lights. Lattice layout: every light sits at an integer `(x, y, z)` inside a `(2Β·radius+1)Β³` bounding box centred at `(radius, radius, radius)`.
-
-## Controls
-
-- `radius` (default 4, range 1β64) β surface radius in light-units. A lattice point is on the shell when its distance from the centre rounds to `radius` (it falls in the half-open band `[radiusβ0.5, radius+0.5)`). `radius = 1` is the smallest hollow sphere β 18 lights: the 6 axis-neighbours (dΒ²=1) plus the 12 edge-neighbours (dΒ²=2) of the centre, all of which round to distance 1.
-
-## Light count and mapping
-
-Light count is derived from `radius` (not set directly) β the lattice points landing in the shell band, growing roughly with surface area (`~4ΟΒ·radiusΒ²`). The iterator and `lightCount()` share one shell predicate, so the count always matches the emitted points. Distances compare in squared integer space (no `sqrt`, no per-light float), so the shell is exact and deterministic across platforms.
-
-A sphere is **not** 1:1 unshuffled β the shell points are sparse within the bounding box, so it supplies explicit coordinates via `forEachCoord` like every non-grid layout; Layer/Drivers wiring treats it identically to any other `LayoutBase`.
-
-## Tests
-
-[Unit tests: SphereLayout](../../../tests/unit-tests.md#spherelayout) β shell-only (no interior/centre point), symmetry, count matches the iterator, radius-1 base case. Add / replace / remove / multiple layouts are covered in [Layouts](../../../tests/unit-tests.md#layouts) and the layout-mutation scenario.
-
-## Prior art
-
-### MoonLight / projectMM v1βv2 β layout nodes ([MoonLight L_MoonLight.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h))
-
-Prior projects expose layouts that call an `addLight(x, y, z)` per position; SphereLayout follows the same "a layout enumerates its light coordinates" shape, computing the shell analytically rather than from a stored list.
-
-## Source
-
-[SphereLayout.h](../../../../src/light/layouts/SphereLayout.h)
diff --git a/docs/moonmodules/light/layouts/WheelLayout.md b/docs/moonmodules/light/layouts/WheelLayout.md
deleted file mode 100644
index 7b0db846..00000000
--- a/docs/moonmodules/light/layouts/WheelLayout.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# WheelLayout
-
-A bicycle-wheel arrangement: a number of straight spokes radiate from a centre hub, each carrying a row of LEDs spaced one unit apart from the centre outward. Spoke *k* points at angle *k / spokes* of a full turn; the LEDs along it sit at increasing radius.
-
-## Controls
-
-- `spokes` β number of spokes (2β64, default 8).
-- `ledsPerSpoke` β LEDs along each spoke (1β256, default 10).
-
-`lightCount()` = `spokes Γ ledsPerSpoke`.
-
-## Coordinate iterator
-
-`forEachCoord` emits `(index, x, y, 0)` for each LED, walking spoke by spoke. A spoke's angle is `spoke Β· 256 / spokes` in `uint8_t` turn units; the LED at radius *r* sits at `(maxR + rΒ·cos, maxR + rΒ·sin)`, where cos/sin come from the project's [`cos8`/`sin8`](../../core/Control.md) integer LUT (signed component `val β 128`, divided back by 128). The whole wheel is shifted by `+ledsPerSpoke` so every coordinate is β₯ 0 within a `(2Β·ledsPerSpoke)`-wide bounding box. Integer-only (same discipline as [SphereLayout](SphereLayout.md)) β no `double` cos/sin/round.
-
-## Prior art
-
-- **MoonLight β ring/spoke layouts** (L_MoonLight.h): Ring, Rings241, and other radial layouts.
-- **projectMM v2 β WheelLayoutModule** β spoke-based coordinate generation; that used `double` trig, this is the integer-LUT equivalent.
-
-## Source
-
-[WheelLayout.h](../../../../src/light/layouts/WheelLayout.h)
diff --git a/docs/moonmodules/light/layouts/layouts.md b/docs/moonmodules/light/layouts/layouts.md
new file mode 100644
index 00000000..387e477d
--- /dev/null
+++ b/docs/moonmodules/light/layouts/layouts.md
@@ -0,0 +1,214 @@
+# Layouts
+
+Every layout, one block each: what it does and what each control means β together. A layout maps light indices to physical `(x, y, z)` positions β it defines the *shape* an [effect](../effects/effects.md) draws onto and a [driver](../drivers/) sends out. The [Layouts](../Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](../Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).)
+
+## MoonLight layouts
+
+
+
+### Car Lights
+
+A pair of concentric-ring "headlight" clusters (nested rings of 1/8/12/16/24 LEDs) positioned to mimic a car's front lights β a fixed arrangement composed from [Ring](#ring) geometry.
+
+
+
+- `scale` β overall size scale (1β10).
+
+Origin: projectMM / custom fixture Β· source [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h)
+
+
+
+### Cube
+
+A 3D cube volume, `width`Γ`height`Γ`depth`, wired in a configurable axis order with optional per-axis serpentine β the 3D generalisation of Panel.
+
+- `width` / `height` / `depth` β cube extent per axis (1β128).
+- `wiringOrder` β the axis nesting order the strip follows.
+- `X++` / `Y++` / `Z++` β count up (vs down) along that axis.
+- `snakeX` / `snakeY` / `snakeZ` β serpentine (alternate rows/columns reverse) on that axis.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h)
+
+
+
+### Human-Sized Cube
+
+A hollow walk-in cube built from five LED-curtain faces (front, back, top, left, right), each a `width`Γ`height`Γ`depth` curtain β for large/room-scale cube installations.
+
+- `width` / `height` / `depth` β cube extent per axis (1β20).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h)
+
+
+
+### Panel
+
+A 2D matrix panel with full wiring control: choose the axis order, per-axis direction, and serpentine β the general matrix layout ([Grid](#grid) is the simple case).
+
+- `panelWidth` / `panelHeight` β panel size in lights (1β512).
+- `wiringOrder` β `XY` (rows) or `YX` (columns) nesting.
+- `X++` / `Y++` β count up vs down along that axis.
+- `snake` β serpentine wiring (alternate lines reverse).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h)
+
+
+
+### Panels
+
+Tiles an MΓN grid of full matrix panels into one large display: an outer walk over the panel grid plus an inner walk over each panel's lights, both independently wired β for multi-panel video walls.
+
+- `horizontalPanels` / `verticalPanels` β panel-grid size (1β32 each).
+- `wiringOrderP` / `X++P` / `Y++P` / `snakeP` β the panel-to-panel wiring (order, direction, serpentine).
+- `panelWidth` / `panelHeight` β each panel's size (1β512).
+- `wiringOrder` / `X++` / `Y++` / `snake` β the per-panel light wiring.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h)
+
+
+
+### Ring
+
+A single ring of LEDs evenly spaced around a circle β `nrOfLEDs` points, starting at `angleFirst`, spanning `rotation` degrees.
+
+- `nrOfLEDs` β LEDs around the ring (1β255).
+- `angleFirst` β starting angle in degrees.
+- `rotation` β arc spanned (360 = full circle).
+- `clockwise` β direction of travel.
+- `scale` β spacing/radius scale.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [RingLayout.h](../../../../src/light/layouts/RingLayout.h)
+
+
+
+### Rings 241
+
+The classic 241-LED concentric-ring disc: nested rings of 1, 8, 12, 16, 24, 32, 40, 48, 60 LEDs sharing a centre.
+
+- `scale` β overall radius scale (1β10).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h)
+
+
+
+### Single Column
+
+A vertical line of LEDs at a fixed X β the 1D column primitive.
+
+- `starting Y` β the column's start row.
+- `height` β LEDs in the column (1β1000).
+- `X position` β the column's x.
+- `reversed order` β wire top-to-bottom instead of bottom-to-top.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h)
+
+
+
+### Single Row
+
+A horizontal line of LEDs at a fixed Y β the 1D row primitive.
+
+- `starting X` β the row's start column.
+- `width` β LEDs in the row (1β1000).
+- `Y position` β the row's y.
+- `reversed order` β wire right-to-left instead of left-to-right.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h)
+
+
+
+### Spiral
+
+A conical spiral: `ledCount` LEDs winding up a cone from `bottomRadius` to a point over `height`.
+
+- `ledCount` β LEDs along the spiral (1β2048).
+- `bottomRadius` β radius at the base.
+- `height` β spiral height.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h)
+
+
+
+### Toronto Bar Gourds
+
+Maps a set of decorative "gourd" objects (a specific bar installation), each rendered at one of three granularities β one light per gourd, per side, or per LED.
+
+
+
+- `granularity` β `One Gourd One Light`, `One Side One Light`, or `One LED One Light`.
+- `nrOfLightsPerGourd` β LEDs per gourd in the coarsest mode (1β128).
+
+Origin: projectMM / custom fixture Β· source [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h)
+
+
+
+### Tubes
+
+Parallel vertical tubes: `nrOfTubes` columns of `ledsPerTube` LEDs, spaced `tubeDistance` apart.
+
+- `nrOfTubes` β number of tubes (1β64).
+- `ledsPerTube` β LEDs per tube (1β255).
+- `tubeDistance` β spacing between tubes.
+- `reversed` β reverse the wiring order.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h)
+
+## projectMM-native layouts
+
+
+
+### Grid
+
+A dense 3D grid, row-major (x fastest, then y, then z); every position maps to a light.
+
+- `width` / `height` / `depth` β grid extent on each axis in lights (1β512).
+- `serpentine` β boustrophedon-wire alternate rows (every other row runs in reverse, matching a snaked strip).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [GridLayout.h](../../../../src/light/layouts/GridLayout.h)
+
+[Tests](../../../tests/unit-tests.md#gridlayout)
+
+
+
+### Sphere
+
+Lights on the surface of a hollow sphere β a one-light-thick shell inside a `(2Β·radius+1)Β³` box, no interior lights.
+
+- `radius` β surface radius in light-units (1β64); the shell is every cell whose distance from the centre rounds to `radius`.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h)
+
+[Tests](../../../tests/unit-tests.md#spherelayout)
+
+
+
+### Wheel
+
+A bicycle-wheel: `spokes` straight rows radiate from a centre hub, each carrying `ledsPerSpoke` LEDs spaced one unit apart outward.
+
+- `spokes` β number of spokes radiating from the hub (2β64).
+- `ledsPerSpoke` β LEDs along each spoke, spaced one unit apart from the centre outward.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Layouts/L_MoonLight.h) Β· source [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h)
+
+[Tests](../../../tests/unit-tests.md#wheellayout)
+
+The [Layouts](../Layouts.md) container itself takes no controls β see its page for coordinate iteration, reordering, and rebuild propagation.
+
+## Source
+
+- [CarLightsLayout.h](../../../../src/light/layouts/CarLightsLayout.h)
+- [CubeLayout.h](../../../../src/light/layouts/CubeLayout.h)
+- [GridLayout.h](../../../../src/light/layouts/GridLayout.h)
+- [HumanSizedCubeLayout.h](../../../../src/light/layouts/HumanSizedCubeLayout.h)
+- [PanelLayout.h](../../../../src/light/layouts/PanelLayout.h)
+- [PanelsLayout.h](../../../../src/light/layouts/PanelsLayout.h)
+- [RingLayout.h](../../../../src/light/layouts/RingLayout.h)
+- [Rings241Layout.h](../../../../src/light/layouts/Rings241Layout.h)
+- [SingleColumnLayout.h](../../../../src/light/layouts/SingleColumnLayout.h)
+- [SingleRowLayout.h](../../../../src/light/layouts/SingleRowLayout.h)
+- [SphereLayout.h](../../../../src/light/layouts/SphereLayout.h)
+- [SpiralLayout.h](../../../../src/light/layouts/SpiralLayout.h)
+- [TorontoBarGourdsLayout.h](../../../../src/light/layouts/TorontoBarGourdsLayout.h)
+- [TubesLayout.h](../../../../src/light/layouts/TubesLayout.h)
+- [WheelLayout.h](../../../../src/light/layouts/WheelLayout.h)
diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md
deleted file mode 100644
index ee6395fb..00000000
--- a/docs/moonmodules/light/modifiers/CheckerboardModifier.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Checkerboard Modifier
-
-
-
-
-
-Static modifier. Masks the layer in a checkerboard pattern: lights in the "off" squares are dropped (they receive nothing), lights in the "on" squares pass through unchanged. Unlike Multiply, this doesn't remap or resize β it's a spatial on/off mask applied to whatever the effect drew.
-
-## Controls
-
-- `size` (Uint8, 1β64, default 2) β checker square edge, in lights
-- `invert` (Bool, default false) β flip which squares pass through
-
-## Effect on the pipeline
-
-- **Logical box unchanged** β a mask doesn't resize the box (no `modifyLogicalSize`); only which cells contribute changes.
-- **Pass or drop** β `modifyLogical` returns `true` to pass a light through unchanged, or `false` to drop it (an "off" square), so a dropped physical light has no logical source and stays dark.
-- **Square parity**: a light at `(x,y,z)` belongs to square `(x/size, y/size, z/size)`; the square is "on" when the sum of those indices is even (flipped by `invert`).
-
-## Cross-domain wiring
-
-A Layer folds all its enabled modifiers as a chain (Checkerboard-then-Multiply differs from Multiply-then-Checkerboard). The fold + reject contract is in [ModifierBase](../ModifierBase.md).
-
-## Tests
-
-[Unit tests: CheckerboardModifier](../../../tests/unit-tests.md#checkerboardmodifier) β identity dimensions, the drop pattern for both `invert` phases, `size` grouping into squares.
-
-[Scenario: scenario_modifier_swap](../../../tests/scenario-tests.md#scenario_modifier_swap) β replaces the Layer's modifier between Multiply and Checkerboard and verifies the pipeline stays live across each swap.
-
-## Prior art
-
-### MoonLight β M_MoonLight.h Checkerboard ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h))
-
-MoonLight's Checkerboard drops lights by setting `position.x = UINT16_MAX` (a sentinel the layout pass skips), with `size`, `invert`, and a `group` flag. We express the drop as `modifyLogical` returning `false` (no sentinel needed) and start with `size` + `invert`; `group` is deferred.
-
-## Source
-
-[CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h)
diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md
deleted file mode 100644
index 96b3d661..00000000
--- a/docs/moonmodules/light/modifiers/MultiplyModifier.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Multiply Modifier
-
-
-
-
-
-Static modifier. Tiles the logical image across the physical box `multiply` times per axis, optionally reflecting alternate tiles. With a multiplier of 2 and mirror enabled on an axis, that axis folds in half β the classic kaleidoscope mirror. Multiply subsumes the old MirrorModifier: a pure mirror is `multiply = 2, mirror = true` on the chosen axes.
-
-## Controls
-
-- `multiplyX` (Uint8, 1β64, default 2) β tiles along X (1 = no tiling)
-- `multiplyY` (Uint8, 1β64, default 2) β tiles along Y
-- `multiplyZ` (Uint8, 1β64, default 1) β tiles along Z
-- `mirrorX` (Bool, default true) β reflect alternate (odd) tiles along X
-- `mirrorY` (Bool, default true) β reflect alternate tiles along Y
-- `mirrorZ` (Bool, default true) β reflect alternate tiles along Z
-
-The defaults (`multiply 2/2/1`, `mirror all on`) reproduce the canonical mirror-XY pipeline: a 128Γ128 physical grid folds to a 64Γ64 logical buffer, each logical light fanning out to its four reflected quadrants. (`mirrorZ` on is a no-op on a 2D/depth-1 layout.)
-
-## Effect on the pipeline
-
-- **Logical box shrinks by the multiplier**: `logW = physW / multiplyX` (etc.). 128Γ128 with multiply 2/2 β 64Γ64 logical (the effect renders a quarter of the lights). The effective multiplier clamps to the axis extent β `multiplyZ` on a depth-1 layout clamps to 1 (no-op), never blanking the layer.
-- **Fan-out is the fold**: each physical light folds (`pos % logicalSize`) onto its logical cell, so the `multiplyXΒ·multiplyYΒ·multiplyZ` physical lights of the tiles all land on one logical light β the 1:N mapping emerges from the build with no fan-out list and no cap (see [ModifierBase Β§ Fan-out is free](../ModifierBase.md)).
-- **Tile vs fold**: with mirror **off** on an axis, tiles repeat (translate); with mirror **on**, odd-numbered tiles reflect within their tile (`size β 1 β pos`), so multiply 2 + mirror = a fold.
-- **Integer division**: a physical extent not divisible by the multiplier leaves uncovered cells at the high edge (they map to nothing) β the same edge behaviour the old mirror had on odd widths, without a shared centre line.
-
-## Cross-domain wiring
-
-A Layer folds all its enabled modifiers as a chain (order matters: multiply-then-checkerboard β checkerboard-then-multiply). The fold contract is in [ModifierBase](../ModifierBase.md).
-
-## Tests
-
-[Unit tests: MultiplyModifier](../../../tests/unit-tests.md#multiplymodifier) β logical dimensions, tile fan-out, per-axis mirror reflection, the pure-fold equivalence to the old Mirror, the multiplyZ-on-2D no-op, and the extent clamp.
-
-[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) β full pipeline with the multiply/mirror kaleidoscope, performance bounds.
-
-## Prior art
-
-### MoonLight β M_MoonLight.h Multiply ([source](https://github.com/ewowi/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h))
-
-MoonLight's Multiply node tiles via `position % modifierSize` and reflects odd tiles when its `mirror` flag is set (`position = size β 1 β position`). We expose **per-axis** mirror bools and per-axis multipliers instead of MoonLight's single multiplier coord + single mirror flag, so X/Y/Z fold and tile independently. MoonLight keeps Mirror, Multiply, Transpose, and Kaleidoscope as separate nodes; we fold mirror into Multiply since mirror is just multiply-2-with-reflection.
-
-## Source
-
-[MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h)
diff --git a/docs/moonmodules/light/modifiers/RandomMapModifier.md b/docs/moonmodules/light/modifiers/RandomMapModifier.md
deleted file mode 100644
index d1e426c0..00000000
--- a/docs/moonmodules/light/modifiers/RandomMapModifier.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# RandomMapModifier
-
-A **modifier** that randomly remaps every light to another light β a true 1:1 permutation (every light goes somewhere, no two lights land on the same place, none are dropped) β and reshuffles to a fresh random permutation on a `bpm` timer. The image scrambles into a new arrangement every beat; the *content* is untouched, only *where each pixel lands* changes.
-
-It is a **dynamic** modifier: where a static modifier (Multiply, Checkerboard) shapes the layer's mapping once, RandomMapModifier re-shapes it on a timer.
-
-## Controls
-
-- `bpm` β reshuffles per minute, `0`β`60`, default `6`. `6` β one new permutation every 10 seconds; `60` is the cap (one per second β faster would strobe, and the per-beat work is bounded for that reason). `0` **freezes** the current permutation: a fixed random remap that never changes.
-
-## How it works
-
-The permutation rides the layer's existing LUT. Like [CheckerboardModifier](CheckerboardModifier.md), it leaves the logical box the same size as the physical box (identity dimensions) and maps each logical light to exactly one physical light β here, the light at `perm[index]`, where `perm` is a [FisherβYates](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle)-shuffled array of every light index. On each beat the modifier reshuffles `perm` and asks its parent [Layer](../Layer.md) to rebuild the LUT β the same rebuild a control change triggers, so no new mechanism, just a timer-driven trigger. The shuffle uses an integer LCG seeded from a generation counter, so a given generation is reproducible (and unit-testable) while successive beats differ.
-
-The permutation buffer is sized to the light count and (re)allocated only when the grid resizes β never per frame. If that allocation fails, the modifier degrades to identity passthrough (no remap), the same way the LUT degrades; an empty (0Γ0Γ0) grid is a no-op. On a **sparse** layout the permutation is over grid-cell indices, so a real light can be remapped onto a cell with no LED (it goes dark); this is acceptable for the current version.
-
-## Cost
-
-Each beat re-runs the layer's LUT rebuild on the render thread β a transient one-frame cost, like a device scan, not a steady per-frame tax. The `bpm`β€60 cap bounds how often this happens. Steady-state tick/FPS between beats is unchanged.
-
-## Prior art
-
-MoonLight has no direct equivalent; the random-remap idea is common in pixel-mapper tools (e.g. shuffle/scramble transitions). The permutation-rides-the-LUT approach and the bpm-accumulator timer are projectMM's own, reusing the [CheckerboardModifier](CheckerboardModifier.md) 1:1 mapping shape and the effect bpm-timer pattern.
-
-## Source
-
-[RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h)
diff --git a/docs/moonmodules/light/modifiers/RegionModifier.md b/docs/moonmodules/light/modifiers/RegionModifier.md
deleted file mode 100644
index 09117cb6..00000000
--- a/docs/moonmodules/light/modifiers/RegionModifier.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Region Modifier
-
-Static modifier. Carves the layer down to a sub-rectangle of the physical bounding box: the effect renders only inside the region, everything outside is dark. The region is given as **percentages of the physical extent on each axis**, so it survives a physical resize β a `0..50` region stays the left half whether the panel is 64 or 128 wide. Default `0..100` on every axis is the full box (an identity carve).
-
-Region and Multiply are independent and **compose**: a layer can occupy a region *and* be tiled/mirrored within it (Region then Multiply), since a Layer folds its whole enabled modifier chain in order (see [ModifierBase](../ModifierBase.md)).
-
-## Controls
-
-- `startX` / `startY` / `startZ` (Int16, default 0) β region start, as a percentage of physical width / height / depth.
-- `endX` / `endY` / `endZ` (Int16, default 100) β region end, as a percentage of physical width / height / depth.
-
-`Int16`, not a 0β100 slider: the UI renders an unbounded int16 as a **β100..200 percentage slider** so the window can slide **off-screen** (negative start / >100 end). Values round-trip through `/api/state`, `/api/types`, and persistence.
-
-## Region math
-
-Per axis, **half-open** `[startPixel, endPixel)`, **un-clamped** to the box:
-
-- `startPixel = floor(start% / 100 Β· extent)` β may be negative.
-- `endPixel = ceil(end% / 100 Β· extent)`, **exclusive** β may exceed `extent`.
-- window size = `endPixel β startPixel` (floored to β₯ 1 on a non-empty axis).
-
-Half-open makes abutting windows **tile exactly**: a `0..50` and a `50..100` layer split a 128-wide axis into pixels `0..63` and `64..127` β no overlap, no gap. `start` floors and `end` ceils so a small panel never rounds to an empty window. Default `0..100` on a `W`-wide axis β the full width.
-
-### Off-screen windows (move, don't rescale)
-
-The window's **logical size is the full `start..end` span**, so the effect always renders at a fixed scale β moving `start` and `end` together slides the window without resizing it (like dragging an OS window). A physical light outside the window is dropped; window cells with no physical light under them (the off-screen part) stay dark. A window slid **entirely** off the box (e.g. `start=-100, end=0`) maps no lights β the layer goes dark, the way you move an effect completely out of view. Because the span is fixed, sweeping `start`/`end` translates an effect across and off the panel without distorting it.
-
-## Effect on the pipeline
-
-- **Logical box = the region size** β `modifyLogicalSize` shrinks the box to the carved rectangle, so the Layer's render buffer (and the status line `wΓhΓd`) shrinks to the region. The effect only ever renders the region.
-- **Fold + reject** β `modifyLogical` folds a physical light into region-local space (subtract the start offset) and returns `false` for any physical light outside the region, so everything beyond the region stays dark. A 1:1 fold, never fans out.
-- **Fast path**: the cheapest carve is *no modifier at all* β then `Layer::rebuildLUT` keeps its identity-memcpy / sparse fast path with zero carving cost. The default is to not add a RegionModifier; a full-region `0..100` one is correct but not the absolute cheapest, so full-coverage layers simply omit it.
-
-## Cross-domain wiring
-
-Region is a normal `ModifierBase` β carving is its `modifyLogicalSize` + `modifyLogical` fold, composed into the chain like any modifier. See [ModifierBase](../ModifierBase.md).
-
-## Tests
-
-[Unit tests: RegionModifier](../../../tests/unit-tests.md#regionmodifier) β the region math (full box, exact half, abutting-tile, small-panel rounding, β₯1-pixel floor, off-screen / fully-off / wider-than-box windows, degenerate axes) and the coordinate offset mapping. [Unit tests: Layer](../../../tests/unit-tests.md#layer) adds the integration case: a RegionModifier shrinks the Layer's logical box to the region and the LUT maps only region cells.
-
-## Prior art
-
-The crop / region node of any compositor (After Effects' crop, a shader scissor rect): restrict rendering to a rectangle, the rest is transparent. MoonLight has no single "region" modifier β its layers map through the same coordinate-transform mechanism, which is the lineage for expressing this as a modifier rather than a Layer control.
-
-## Source
-
-[RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h)
diff --git a/docs/moonmodules/light/modifiers/RotateModifier.md b/docs/moonmodules/light/modifiers/RotateModifier.md
deleted file mode 100644
index 2de85ffa..00000000
--- a/docs/moonmodules/light/modifiers/RotateModifier.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# RotateModifier
-
-A **dynamic modifier** that rotates the 2D image around its centre, turning continuously over time. The one modifier that overrides `modifyLive` (per-frame, no mapping rebuild) β so the rotation is smooth, and the Layer runs its live pass only because this modifier is present (a static-only chain pays nothing per frame; see [ModifierBase](../ModifierBase.md)). Also the codebase's **transform-matrix reference**.
-
-## Controls
-
-- `speed` β rotation speed (1β255, default 1). `loop()` advances the angle on the timer; `modifyLive` applies it on the next frame (no rebuild).
-
-## How it works
-
-`loop()` advances the angle on the `speed` timer; rotation is applied each frame in the Layer's live pass (`modifyLive`), not baked into the mapping β so a `speed` change is a cheap live edit, no rebuild. A source that rotates outside the box leaves that destination dark. 2D only: the z axis passes through. The integer 2Γ2-matrix backward map is in the header.
-
-## Prior art
-
-- **MoonLight β M_MoonLight.h Rotate / PinWheel** β a per-light `modifyXYZ()` coordinate transform. Our `modifyLive` is the same per-frame hook; we carry an explicit rotation matrix.
-
-## Source
-
-[RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h)
diff --git a/docs/moonmodules/light/modifiers/modifiers.md b/docs/moonmodules/light/modifiers/modifiers.md
new file mode 100644
index 00000000..961eb0ff
--- /dev/null
+++ b/docs/moonmodules/light/modifiers/modifiers.md
@@ -0,0 +1,161 @@
+# Modifiers
+
+Every modifier, one block each: its preview, what it does, and what each control means β together. A modifier sits between an [effect](../effects/effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose β a [Layer](../Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](../ModifierBase.md) for the static-vs-dynamic split. Each block's emoji are its `tags()` (see the [tag emoji legend](../../../architecture.md#tag-emoji-legend)); **Kind** is static (baked into the mapping at rebuild) or dynamic (per-frame remap). Modifiers are grouped into sections, and each block carries that modifier's preview, behaviour, and control descriptions together. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../../backlog/folder-structure-proposal.md).)
+
+## MoonLight modifiers
+
+
+
+### Block π« Β· static
+
+Expands a 1D effect into concentric **square rings** (Chebyshev distance from the centre): the effect's linear position becomes the ring index, so a gradient effect draws nested squares.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h)
+
+[Tests](../../../tests/unit-tests.md#blockmodifier)
+
+
+
+### Checkerboard π« Β· static
+
+
+
+Masks the layer in a checkerboard: "off" squares are dropped, "on" squares pass through unchanged.
+
+- `size` β checker square edge in lights (β₯1).
+- `invert` β flip which squares pass through vs are masked.
+
+Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h)
+
+[Tests](../../../tests/unit-tests.md#checkerboardmodifier)
+
+
+
+### Circle π« Β· static
+
+Expands a 1D effect into concentric **circular rings** (Euclidean distance from the centre): the effect's linear position becomes the radius, so a gradient effect draws nested circles. The circular counterpart to [Block](#block).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h)
+
+[Tests](../../../tests/unit-tests.md#circlemodifier)
+
+
+
+### Mirror π« Β· static
+
+Folds the far half of the box back onto the near half per axis, mirroring the image across the box centre (top-left quadrant reflected into the others in 2D, near octant into all eight in 3D).
+
+- `mirrorX` / `mirrorY` / `mirrorZ` β mirror across the centre on that axis (each default on; enabling an axis the layout doesn't use is a no-op).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h)
+
+[Tests](../../../tests/unit-tests.md#mirrormodifier)
+
+
+
+### Multiply π« Β· static
+
+
+
+Tiles the logical image across the box `multiply` times per axis, optionally mirroring alternate tiles (a pure mirror is `multiply = 2, mirror = true`).
+
+- `multiplyX` / `multiplyY` / `multiplyZ` β tile count per axis (1β64; 1 = no tiling).
+- `mirrorX` / `mirrorY` / `mirrorZ` β reflect alternate tiles on that axis (with a count of 2, folds the axis in half β the kaleidoscope mirror).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h)
+
+[Tests](../../../tests/unit-tests.md#multiplymodifier)
+
+
+
+### Pinwheel π« Β· static
+
+Remaps the grid into radial **petals** around the centre β the angle to each pixel picks its petal, with an optional swirl (angle sheared by radius), symmetry, and z-twist. Turns a linear or 2D effect into a rotating flower/spokes pattern.
+
+- `petals` β number of petals radiating from the centre.
+- `swirl` β shear the angle by radius (β127..127; a spiral; negative reverses).
+- `reverse` β reverse the petal order.
+- `symmetry` β fold the petals into a factor-of-360 symmetry.
+- `zTwist` β twist the petals along z (3D).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h)
+
+[Tests](../../../tests/unit-tests.md#pinwheelmodifier)
+
+
+
+### RippleXZ π« Β· static
+
+Collapses an axis to a single plane so a higher-dimensional effect ripples along the remaining axes β used to drive a 1Dβ2D/3D ripple.
+
+- `shrink` β collapse the selected axis (on = collapse).
+- `towardsX` / `towardsZ` β which axis collapses to a single line.
+
+Origin: MoonLight Β· by @Troy (WLEDMM Art-Net) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h)
+
+[Tests](../../../tests/unit-tests.md#ripplexzmodifier)
+
+
+
+### Transpose π« Β· static
+
+Swaps a pair of box axes (and every coordinate through them), then optionally inverts each axis β rotate/flip the image without redrawing the effect.
+
+- `XY` / `XZ` / `YZ` β swap that pair of axes.
+- `inverse X` / `inverse Y` / `inverse Z` β flip that axis after the swap.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h)
+
+[Tests](../../../tests/unit-tests.md#transposemodifier)
+
+## projectMM-native modifiers
+
+
+
+### RandomMap Β· dynamic
+
+Remaps every light to another via a true 1:1 permutation, reshuffling to a fresh permutation on a `bpm` timer β the arrangement scrambles each beat, the content is untouched.
+
+- `bpm` β reshuffles per minute (0β60; 6 β a fresh permutation every 10 s; 0 = frozen).
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h)
+
+[Tests](../../../tests/unit-tests.md#randommapmodifier)
+
+
+
+### Region Β· static
+
+Carves the layer to a sub-rectangle given as percentages of the physical extent (so it survives a resize); outside the region is dark.
+
+- `startX` / `startY` / `startZ` and `endX` / `endY` / `endZ` β the sub-rectangle bounds as **percentages** of each axis's physical extent (0 = start of axis, 100 = end), so the region survives a resize; values may go negative or past 100 to push the window off-screen.
+
+Origin: MoonLight Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h)
+
+[Tests](../../../tests/unit-tests.md#regionmodifier)
+
+
+
+### Rotate Β· dynamic
+
+Rotates the 2D image around its centre, turning continuously over time (the codebase's transform-matrix reference).
+
+- `speed` β rotation speed (1β255; turns faster as it rises).
+
+Origin: MoonLight Β· by WildCats08 / [@Brandon502](https://github.com/Brandon502) Β· via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h) Β· source [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h)
+
+[Tests](../../../tests/unit-tests.md#rotatemodifier)
+
+## Source
+
+- [BlockModifier.h](../../../../src/light/modifiers/BlockModifier.h)
+- [CheckerboardModifier.h](../../../../src/light/modifiers/CheckerboardModifier.h)
+- [CircleModifier.h](../../../../src/light/modifiers/CircleModifier.h)
+- [MirrorModifier.h](../../../../src/light/modifiers/MirrorModifier.h)
+- [MultiplyModifier.h](../../../../src/light/modifiers/MultiplyModifier.h)
+- [PinwheelModifier.h](../../../../src/light/modifiers/PinwheelModifier.h)
+- [RandomMapModifier.h](../../../../src/light/modifiers/RandomMapModifier.h)
+- [RegionModifier.h](../../../../src/light/modifiers/RegionModifier.h)
+- [RippleXZModifier.h](../../../../src/light/modifiers/RippleXZModifier.h)
+- [RotateModifier.h](../../../../src/light/modifiers/RotateModifier.h)
+- [TransposeModifier.h](../../../../src/light/modifiers/TransposeModifier.h)
diff --git a/docs/moonmodules/light/moonlive/MoonLiveEffect.md b/docs/moonmodules/light/moonlive/MoonLiveEffect.md
index fdff70db..2d932383 100644
--- a/docs/moonmodules/light/moonlive/MoonLiveEffect.md
+++ b/docs/moonmodules/light/moonlive/MoonLiveEffect.md
@@ -14,7 +14,7 @@ The functions are **not built into the compiler** β `setRGB`, `fill`, `random1
## Controls
-- `source` β the script text (default `fill(0, 0, 255);` β solid blue). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot).
+- `source` β the script text (default: random pixels β `setRGB(random16(256), random16(256), random16(256), random16(256));`, one random light in a random colour each tick). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot).
- **Scripted controls** β a script declares a tunable variable with a range annotation, and the engine surfaces it as a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick:
```c
diff --git a/docs/performance.md b/docs/performance.md
index ba1a956c..ed9f079a 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -4,6 +4,8 @@ projectMM's per-step **performance contracts** live in the scenario JSONs β ea
This document holds what scenarios can't carry: structural sizes (`sizeof`), build-variant deltas, and the WiFi/Ethernet physics that explain *why* a contract comes out where it does.
+**Render-loop model.** The Layer's buffer **persists** frame-to-frame β `Layer::loop()` does not clear it (the FastLED/WLED/MoonLight convention; see [architecture.md Β§ Buffer persistence](architecture.md#buffer-persistence--the-layer-does-not-clear-each-frame)). This removed the per-frame full-buffer `memset` that a clear-every-frame model pays, and replaced N per-effect `draw::fade` passes with a single **collected fade** (`Layer::fadeToBlackBy` MINs the requested amounts and applies one buffer pass per frame) β so a layer with several fading effects now pays one fade pass, not N. Net hot-path effect on the tick numbers below is small (the clear/fade are one linear pass over the buffer, dwarfed by per-light effect compute and the output driver), but the *model* is what the scenario `observed` blocks were re-measured against on this cycle.
+
---
## Desktop (64-bit)
diff --git a/docs/testing.md b/docs/testing.md
index 3cb322a4..cae3c299 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -389,7 +389,7 @@ uv run scripts/scenario/run_live_scenario.py --compare-baseline # check
MoonDeck's Live tab wraps the same workflow: the Network bar at the top selects the LAN, Discover/Refresh populates the device list, the Live Scenarios card runs the selected scenario against every checked device.
-
+
All scenarios use relative FPS bounds (`min_pct`) so they pass on any device β desktop at 10K FPS or ESP32 at 17 FPS. Settle time is 3 seconds to let the pipeline stabilise after rebuilds.
diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md
index d98db6fb..8530d721 100644
--- a/docs/tests/scenario-tests.md
+++ b/docs/tests/scenario-tests.md
@@ -23,9 +23,9 @@ Baseline: the render pipeline runs with no audio module present.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 32,258-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,625-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-12 β 2026-06-25
+- `pc-macos`: observed 2026-06-12 β 2026-07-01
#### `measure-audio-added` (measure) π
@@ -41,9 +41,9 @@ Pipeline still renders with the (idle, unconfigured) mic added.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 34,483-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,873-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-12 β 2026-06-25
+- `pc-macos`: observed 2026-06-12 β 2026-07-01
#### `measure-pins-configured` (measure) π
@@ -61,9 +61,9 @@ All three mic pins set via the sequential install-fan-out order: pipeline still
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 32,258-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,873-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-25
+- `pc-macos`: observed 2026-06-13 β 2026-07-01
#### `measure-consumer-live` (measure) π
@@ -79,9 +79,9 @@ Pipeline renders with the producer + consumer both wired.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 30,303-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 13,889-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-12 β 2026-06-25
+- `pc-macos`: observed 2026-06-12 β 2026-07-01
#### `measure-after-mic-removed` (measure) π
@@ -97,9 +97,9 @@ Mic gone, consumer remains: pipeline keeps rendering on silent audio (buffer non
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 31,250-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 13,514-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-12 β 2026-06-25
+- `pc-macos`: observed 2026-06-12 β 2026-07-01
#### `measure-back-to-baseline` (measure) π
@@ -115,9 +115,9 @@ Both audio modules gone: back to the pipeline-only baseline, still rendering.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 40,000-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,873-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-12 β 2026-06-25
+- `pc-macos`: observed 2026-06-12 β 2026-07-01
## DevicesModule
@@ -259,10 +259,10 @@ Add NetworkSendDriver and run the bounded FPS measurement (expected to stay at >
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β₯ 20,000 / 7,576-β | unlimited / unlimited | β / unlimited |
+| `pc-macos` | β₯ 20,000 / 4,115-β | unlimited / unlimited | β / unlimited |
| `pc-windows` | β / 7,874-8,475 | β / unlimited | β / unlimited |
-- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-06-05
+- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-07-01
- `pc-windows`: observed 2026-06-07
### scenario_Layer_memory_1to1
@@ -312,9 +312,9 @@ Add a third modifier (Checkerboard mask) on top of the chain β a 3-deep fold.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 142,857-200,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 18,182-200,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-26
+- `pc-macos`: observed 2026-06-26 β 2026-07-01
#### `remove-middle` (remove_module) π
@@ -324,9 +324,9 @@ Remove the middle modifier (Multiply) β the chain re-folds with Region then Ch
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 45,455-55,556 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 6,536-55,556 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-26
+- `pc-macos`: observed 2026-06-26 β 2026-07-01
#### `add-live-rotate` (add_module) π
@@ -336,9 +336,9 @@ Add a DYNAMIC Rotate on top of the static chain β its modifyLive runs the per-
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 25,641-28,571 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 5,128-31,250 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-26
+- `pc-macos`: observed 2026-06-26 β 2026-07-01
### scenario_modifier_swap
@@ -368,13 +368,13 @@ Multiply modifier active β pipeline live, LUT folds the grid.
| `esp32-eth` | β / 1,580-7,752 | β / 172KB-225KB | β / 76KB-108KB |
| `esp32p4-eth` | β / 5,587-6,061 | β / 33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 1,773-2,571 | β / 8350KB | β / 92KB |
-| `pc-macos` | β / 50,000-166,667 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 25,000-166,667 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-25
- `esp32-eth`: observed 2026-06-07 β 2026-06-08
- `esp32p4-eth`: observed 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-25
-- `pc-macos`: observed 2026-06-07 β 2026-06-21
+- `pc-macos`: observed 2026-06-07 β 2026-06-30
#### `checkerboard` (measure) π
@@ -391,13 +391,13 @@ Checkerboard modifier active β masks half the lights; pipeline stays live (dri
| `esp32-eth` | β / 769-990 | β / 170KB-225KB | β / 76KB-108KB |
| `esp32p4-eth` | β / 2,747-2,762 | β / 33242KB | β / 376KB |
| `esp32s3-n16r8` | β / 924-943 | β / 8349KB | β / 92KB |
-| `pc-macos` | β / 15,873-58,824 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 4,184-58,824 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-25
- `esp32-eth`: observed 2026-06-07 β 2026-06-08
- `esp32p4-eth`: observed 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-25
-- `pc-macos`: observed 2026-06-07 β 2026-06-25
+- `pc-macos`: observed 2026-06-07 β 2026-07-01
#### `multiply-2` (measure) π
@@ -414,23 +414,23 @@ Back to Multiply β replace round-trips cleanly, pipeline live again.
| `esp32-eth` | β / 1,587-2,278 | β / 169KB-225KB | β / 76KB-108KB |
| `esp32p4-eth` | β / 6,329-6,410 | β / 33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 2,146-2,604 | β / 8349KB-8350KB | β / 92KB |
-| `pc-macos` | β / 45,455-166,667 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 10,101-166,667 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-25
- `esp32-eth`: observed 2026-06-07 β 2026-06-08
- `esp32p4-eth`: observed 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-25
-- `pc-macos`: observed 2026-06-07 β 2026-06-25
+- `pc-macos`: observed 2026-06-07 β 2026-07-01
### scenario_perf_full
-`test/scenarios/light/scenario_perf_full.json` β Comprehensive incremental performance check (the SLOW, on-device companion to scenario_perf_light). Mutate mode + canvas-preparing: clear_children whatever the device already had (pre-wired apparatus like PreviewDriver/Board survives β clear_children only drops user-editable children), rebuild a known minimal tree, then add one subsystem at a time β audio, device discovery, a modifier, then EVERY output driver this board has (each optional + capped to 64 output LEDs so its per-frame cost is comparable, not its transmit-all-16K time), then a network driver β measuring the tick/heap delta after each so each subsystem's cost is isolated. Then sweep the grid 16Β²β32Β²β64Β²β128Β² (16K) for both a LIGHT effect (Checkerboard) and a HEAVY one (Noise) to bracket the compute range across sizes. LED drivers are platform-gated (RMT on classic/S3, LCD on S3, Parlio on P4; none on desktop) so each driver step is optional:true and skipped where absent β the all-drivers comparison is assembled across boards (S3 gives RMT vs LCD, P4 gives RMT vs Parlio). Subsumes the old scenario_Layer_buildup (incremental module cost), scenario_GridLayout_grid_sizes (grid sweep), and scenario_AllEffects_grid_sizes (per-effect size sweep, here reduced to a light/heavy bracket). Runs minutes on a device; not a per-commit gate.
+`test/scenarios/light/scenario_perf_full.json` β Comprehensive incremental performance check (the SLOW, on-device companion to scenario_perf_light). Mutate mode + canvas-preparing: clear_children whatever the device already had (pre-wired apparatus like PreviewDriver/Board survives β clear_children only drops user-editable children), rebuild a known minimal tree, then add one subsystem at a time β audio, device discovery, a modifier, then EVERY output driver this board has (each optional + capped to 64 output LEDs so its per-frame cost is comparable, not its transmit-all-16K time), then a network driver β measuring the tick/heap delta after each so each subsystem's cost is isolated. Then sweep the grid 16Β²β32Β²β64Β²β128Β² (16K) for both a LIGHT effect (Spiral) and a HEAVY one (Noise) to bracket the compute range across sizes. LED drivers are platform-gated (RMT on classic/S3, LCD on S3, Parlio on P4; none on desktop) so each driver step is optional:true and skipped where absent β the all-drivers comparison is assembled across boards (S3 gives RMT vs LCD, P4 gives RMT vs Parlio). Subsumes the old scenario_Layer_buildup (incremental module cost), scenario_GridLayout_grid_sizes (grid sweep), and scenario_AllEffects_grid_sizes (per-effect size sweep, here reduced to a light/heavy bracket). Runs minutes on a device; not a per-commit gate.
-**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, NetworkSendDriver, RmtLedDriver, LcdLedDriver, ParlioLedDriver, MultiplyModifier, CheckerboardEffect, NoiseEffect
+**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, NetworkSendDriver, RmtLedDriver, LcdLedDriver, ParlioLedDriver, MultiplyModifier, SpiralEffect, NoiseEffect
#### `measure-minimal` (measure) π
-Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/discovery still on as the device ships. The floor for the subsystem-cost diffs below.
+Bare minimum at 16Β²: Grid + Layer + Spiral, no output driver, audio/discovery still on as the device ships. The floor for the subsystem-cost diffs below.
**Setup** (preceding non-measured steps):
- `clear-layers` (clear_children) β Start clean: drop whatever effects/modifiers/layouts/drivers the device had (pre-wired Preview survives).
@@ -447,12 +447,12 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco
| `esp32` | β / 7,692-8,929 | β / 134KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 14,925-17,544 | β / 33226KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 5,376-9,009 | β / 8340KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 83,333-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-no-audio` (measure) π
@@ -466,12 +466,12 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco
| `esp32` | β / 8,621-9,901 | β / 134KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 18,182-18,868 | β / 33228KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 8,065-9,901 | β / 8338KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 125,000-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-quiet` (measure) π
@@ -487,12 +487,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 7,246-9,901 | β / 131KB-146KB | β / 108KB |
| `esp32p4-eth` | β / 17,544-18,519 | β / 33226KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 7,752-9,901 | β / 8337KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 142,857-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-modifier` (measure) π
@@ -506,12 +506,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 2,786-3,610 | β / 130KB-145KB | β / 108KB |
| `esp32p4-eth` | β / 8,772-10,638 | β / 33224KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 3,413-4,237 | β / 8336KB-8350KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 333,333-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-preview` (measure) π
@@ -526,12 +526,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 8,696-9,524 | β / 123KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 15,873-18,182 | β / 33228KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 8,065-9,434 | β / 8335KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 200,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 83,333-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-network` (measure) π
@@ -545,12 +545,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 6,098-7,194 | β / 131KB-145KB | β / 108KB |
| `esp32p4-eth` | β / 14,493-17,544 | β / 33226KB-33244KB | β / 376KB |
| `esp32s3-n16r8` | β / 6,452-8,065 | β / 8334KB-8351KB | β / 84KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 142,857-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-26
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-rmt` (measure) π
@@ -565,12 +565,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 6,579-9,174 | β / 106KB-122KB | β / 84KB-108KB |
| `esp32p4-eth` | β / 15,873-17,857 | β / 33200KB-33221KB | β / 376KB |
| `esp32s3-n16r8` | β / 7,194-9,346 | β / 8307KB-8328KB | β / 84KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 32,258-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-26
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-lcd` (measure) π
@@ -585,12 +585,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 8,403-9,901 | β / 126KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 15,873-17,857 | β / 33225KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 7,042-9,259 | β / 8333KB-8352KB | β / 88KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 76,923-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-parlio` (measure) π
@@ -605,19 +605,19 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 8,475-9,901 | β / 135KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 15,873-17,857 | β / 33225KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 7,692-9,434 | β / 8338KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 90,909-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-light-16` (measure) π
**Setup** (preceding non-measured steps):
- `remove-parlio-driver` (remove_module)
- `add-preview-for-sweep` (add_module) β Re-add PreviewDriver as the output for the grid sweep (the per-driver adds above each removed their driver; Preview is the cheap, every-board output for a pure-render size curve).
-- `light-16-w` (set_control) β Grid sweep, LIGHT effect (Checkerboard is already FX).
+- `light-16-w` (set_control) β Grid sweep, LIGHT effect (Spiral is already FX).
- `light-16-h` (set_control)
**Performance** (contract / observed) β tick stored, FPS shown:
@@ -627,12 +627,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 6,711-9,804 | β / 134KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 15,385-18,868 | β / 33226KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 8,403-9,901 | β / 8336KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 100,000-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-light-32` (measure) π
@@ -647,12 +647,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 2,801-3,367 | β / 134KB-144KB | β / 108KB |
| `esp32p4-eth` | β / 7,246-7,576 | β / 33225KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 3,049-3,597 | β / 8331KB-8350KB | β / 92KB-112KB |
-| `pc-macos` | β / 333,333-1,000,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 35,714-1,000,000 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-light-64` (measure) π
@@ -667,12 +667,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 870-928 | β / 125KB-135KB | β / 108KB |
| `esp32p4-eth` | β / 2,008-2,232 | β / 33218KB-33234KB | β / 376KB |
| `esp32s3-n16r8` | β / 894-1,011 | β / 8312KB-8341KB | β / 88KB-112KB |
-| `pc-macos` | β / 12,658-250,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 9,091-333,333 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-light-128` (measure) π
@@ -687,12 +687,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 224-238 | β / 89KB-99KB | β / 62KB |
| `esp32p4-eth` | β / 515-573 | β / 33182KB-33198KB | β / 376KB |
| `esp32s3-n16r8` | β / 114-134 | β / 8291KB-8305KB | β / 92KB-112KB |
-| `pc-macos` | β / 5,348-62,500 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 2,165-62,500 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-heavy-16` (measure) π
@@ -708,12 +708,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 990-1,224 | β / 136KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 2,865-3,367 | β / 33229KB-33245KB | β / 376KB |
| `esp32s3-n16r8` | β / 1,100-1,361 | β / 8342KB-8352KB | β / 92KB-112KB |
-| `pc-macos` | β / 62,500-333,333 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 47,619-333,333 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-26
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-06-27
#### `measure-heavy-32` (measure) π
@@ -728,12 +728,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 306-314 | β / 134KB-144KB | β / 108KB |
| `esp32p4-eth` | β / 799-898 | β / 33227KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 290-356 | β / 8339KB-8350KB | β / 92KB-112KB |
-| `pc-macos` | β / 15,152-71,429 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 11,765-71,429 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-06-27
#### `measure-heavy-64` (measure) π
@@ -748,12 +748,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 73.8-79.4 | β / 125KB-135KB | β / 108KB |
| `esp32p4-eth` | β / 196-229 | β / 33218KB-33234KB | β / 376KB |
| `esp32s3-n16r8` | β / 85.2-90.3 | β / 8330KB-8341KB | β / 92KB-112KB |
-| `pc-macos` | β / 2,924-16,129 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 2,119-16,129 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-21
+- `pc-macos`: observed 2026-06-17 β 2026-06-27
#### `measure-heavy-128` (measure) π
@@ -768,12 +768,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 16.0-19.0 | β / 89KB-99KB | β / 62KB |
| `esp32p4-eth` | β / 53.7-57.4 | β / 33182KB-33198KB | β / 376KB |
| `esp32s3-n16r8` | β / 19.2-20.8 | β / 8293KB-8305KB | β / 92KB-112KB |
-| `pc-macos` | β / 1,094-3,247 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 584-3,676 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-mod-16` (measure) π
@@ -789,12 +789,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 2,020-2,222 | β / 135KB-145KB | β / 108KB |
| `esp32p4-eth` | β / 5,263-6,494 | β / 33224KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 2,193-2,618 | β / 8340KB-8350KB | β / 92KB-112KB |
-| `pc-macos` | β / 250,000-1,000,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 200,000-1,000,000 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-mod-32` (measure) π
@@ -829,12 +829,12 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 144-149 | β / 111KB-122KB | β / 96KB-100KB |
| `esp32p4-eth` | β / 438-486 | β / 33194KB-33210KB | β / 376KB |
| `esp32s3-n16r8` | β / 119-162 | β / 8307KB-8317KB | β / 92KB-112KB |
-| `pc-macos` | β / 23,256-71,429 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 9,434-71,429 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-26
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-mod-128` (measure) π
@@ -849,22 +849,22 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t
| `esp32` | β / 29.8-35.1 | β / 36KB-47KB | β / 24KB-26KB |
| `esp32p4-eth` | β / 86.3-102 | β / 33089KB-33105KB | β / 376KB |
| `esp32s3-n16r8` | β / 16.8-35.6 | β / 8202KB-8212KB | β / 92KB-112KB |
-| `pc-macos` | β / 5,128-16,129 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 3,378-16,393 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
### scenario_perf_light
-`test/scenarios/light/scenario_perf_light.json` β Fast incremental performance check: start from the bare minimum render pipeline and add one thing at a time, measuring the tick/heap delta each step, so a regression shows up as a per-step jump. The LIGHT companion to scenario_perf_full β it stays small (β€64Β²) and driver-free so it runs in seconds. Mutate mode + canvas-preparing: the steps clear_children whatever Layouts/Layers/Drivers the device already had (the pre-wired apparatus like PreviewDriver/Board survives β clear_children only drops user-editable children) and rebuild a known tree, so it runs from any starting state and always measures the same minimal pipeline. Order: (1) minimal = Grid(16Β²)+Layer+a LIGHT effect (Checkerboard, the cheapest), no modifier/driver/audio/discovery; (2) +MultiplyModifier (adds the mapping LUT β the heavy memory path); (3) +PreviewDriver; (4) swap to a HEAVY effect (Noise) to bracket the compute range; (5) grid 16Β²β32Β²β64Β² to show the size scaling. Full 128Β²/16K sweep, real LED/network drivers, audio+discovery cost: see scenario_perf_full.
+`test/scenarios/light/scenario_perf_light.json` β Fast incremental performance check: start from the bare minimum render pipeline and add one thing at a time, measuring the tick/heap delta each step, so a regression shows up as a per-step jump. The LIGHT companion to scenario_perf_full β it stays small (β€64Β²) and driver-free so it runs in seconds. Mutate mode + canvas-preparing: the steps clear_children whatever Layouts/Layers/Drivers the device already had (the pre-wired apparatus like PreviewDriver/Board survives β clear_children only drops user-editable children) and rebuild a known tree, so it runs from any starting state and always measures the same minimal pipeline. Order: (1) minimal = Grid(16Β²)+Layer+a LIGHT effect (Spiral, a light effect), no modifier/driver/audio/discovery; (2) +MultiplyModifier (adds the mapping LUT β the heavy memory path); (3) +PreviewDriver; (4) swap to a HEAVY effect (Noise) to bracket the compute range; (5) grid 16Β²β32Β²β64Β² to show the size scaling. Full 128Β²/16K sweep, real LED/network drivers, audio+discovery cost: see scenario_perf_full.
-**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, CheckerboardEffect, NoiseEffect, MultiplyModifier
+**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Drivers, PreviewDriver, SpiralEffect, NoiseEffect, MultiplyModifier
#### `measure-minimal` (measure) π
-Bare minimum: Grid(16Β²) + Layer + Checkerboard (light effect). No modifier, no driver. The render floor everything else is measured against.
+Bare minimum: Grid(16Β²) + Layer + Spiral (light effect). No modifier, no driver. The render floor everything else is measured against.
**Setup** (preceding non-measured steps):
- `disable-audio` (set_control) β Quiet I2S sampling so it can't pollute the tick (optional β device only).
@@ -883,12 +883,12 @@ Bare minimum: Grid(16Β²) + Layer + Checkerboard (light effect). No modifier, no
| `esp32` | β / 6,173-8,850 | β / 125KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 13,699-18,519 | β / 33228KB-33246KB | β / 376KB |
| `esp32s3-n16r8` | β / 5,814-8,850 | β / 8316KB-8347KB | β / 80KB-104KB |
-| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 125,000-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-24
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-with-modifier` (measure) π
@@ -904,12 +904,12 @@ Cost of the modifier + LUT over the minimal pipeline. Heap delta vs measure-mini
| `esp32` | β / 3,077-9,709 | β / 131KB-147KB | β / 108KB |
| `esp32p4-eth` | β / 8,621-10,309 | β / 33226KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 3,195-4,032 | β / 8330KB-8345KB | β / 92KB-100KB |
-| `pc-macos` | β / β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 500,000-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-with-preview` (measure) π
@@ -922,12 +922,12 @@ PreviewDriver is the pre-wired apparatus β it survives clear_children and is a
| `esp32` | β / 3,067-9,804 | β / 132KB-146KB | β / 108KB |
| `esp32p4-eth` | β / 10,417-10,753 | β / 33226KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 3,802-4,274 | β / 8330KB-8345KB | β / 84KB-100KB |
-| `pc-macos` | β / β | β / unlimited | β / unlimited |
+| `pc-macos` | β / 333,333-β | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-heavy-16` (measure) π
@@ -941,12 +941,12 @@ PreviewDriver is the pre-wired apparatus β it survives clear_children and is a
| `esp32` | β / 1,142-3,268 | β / 131KB-146KB | β / 108KB |
| `esp32p4-eth` | β / 5,556-6,494 | β / 33224KB-33243KB | β / 376KB |
| `esp32s3-n16r8` | β / 2,299-2,506 | β / 8332KB-8342KB | β / 88KB-100KB |
-| `pc-macos` | β / 333,333-1,000,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 200,000-1,000,000 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-heavy-32` (measure) π
@@ -961,12 +961,12 @@ PreviewDriver is the pre-wired apparatus β it survives clear_children and is a
| `esp32` | β / 265-826 | β / 130KB-144KB | β / 108KB |
| `esp32p4-eth` | β / 1,603-1,880 | β / 33221KB-33237KB | β / 376KB |
| `esp32s3-n16r8` | β / 562-715 | β / 8328KB-8333KB | β / 84KB-104KB |
-| `pc-macos` | β / 90,909-333,333 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 26,316-333,333 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-25
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
#### `measure-heavy-64` (measure) π
@@ -981,20 +981,20 @@ PreviewDriver is the pre-wired apparatus β it survives clear_children and is a
| `esp32` | β / 77.1-227 | β / 111KB-135KB | β / 88KB-108KB |
| `esp32p4-eth` | β / 411-491 | β / 33195KB-33210KB | β / 376KB |
| `esp32s3-n16r8` | β / 129-162 | β / 8302KB-8317KB | β / 92KB-108KB |
-| `pc-macos` | β / 20,000-71,429 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 11,111-71,429 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-17 β 2026-06-25
- `esp32p4-eth`: observed 2026-06-17 β 2026-06-25
- `esp32s3-n16r8`: observed 2026-06-17 β 2026-06-25
-- `pc-macos`: observed 2026-06-17 β 2026-06-26
+- `pc-macos`: observed 2026-06-17 β 2026-07-01
## Layers
### scenario_Layers_composition
-`test/scenarios/light/scenario_Layers_composition.json` β Multi-layer composition end-to-end: LayoutsβGrid, TWO Layers under one Layers container (bottom Checkerboard, top Rainbow), DriversβNetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output.
+`test/scenarios/light/scenario_Layers_composition.json` β Multi-layer composition end-to-end: LayoutsβGrid, TWO Layers under one Layers container (bottom Spiral, top Rainbow), DriversβNetworkSendDriver. Proves the Drivers composite loop builds, allocates its output buffer, blends both enabled layers and feeds the result to the driver without crashing, and gates the bounded FPS so the N-pass composite cost is tracked. The exact alpha/additive blend math and the disable-drops-to-single-layer path are pinned by the unit tests (unit_BlendMap, unit_Layers_container); construct-mode set_control can't apply controls (built post-scheduler), so this scenario uses each Layer's default blend (alpha, full opacity) and asserts wired liveness + tick, not per-byte blend output.
-**Mode**: `construct` Β· **Also touches**: Layer, GridLayout, RainbowEffect, CheckerboardEffect, Drivers, NetworkSendDriver
+**Mode**: `construct` Β· **Also touches**: Layer, GridLayout, RainbowEffect, SpiralEffect, Drivers, NetworkSendDriver
#### `add-artnet` (add_module) π
@@ -1005,9 +1005,9 @@ Add NetworkSendDriver and run the bounded FPS measurement over the two-layer com
- `add-grid` (add_module) β 128x128 GridLayout under Layouts (above host clock resolution so the composite tick is measurable).
- `add-layers-group` (add_module) β Top-level Layers container β the multi-layer composition host.
- `add-bottom-layer` (add_module) β Bottom Layer (composited first β clears + overwrites the output buffer). RGB.
-- `add-bottom-effect` (add_module) β A Checkerboard base as the bottom layer's effect.
+- `add-bottom-effect` (add_module) β A Spiral base as the bottom layer's effect.
- `add-top-layer` (add_module) β Top Layer (composited second β blends onto the bottom with its default blend). RGB.
-- `add-top-effect` (add_module) β Rainbow as the top layer's effect β composited over the Checkerboard base.
+- `add-top-effect` (add_module) β Rainbow as the top layer's effect β composited over the Spiral base.
- `add-driver-group` (add_module) β Top-level Drivers container wired to the Layers container (composites all enabled layers into its output buffer).
**Bounds**:
@@ -1018,9 +1018,9 @@ Add NetworkSendDriver and run the bounded FPS measurement over the two-layer com
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 6,135-18,519 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 1,149-19,231 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-25
+- `pc-macos`: observed 2026-06-25 β 2026-07-01
## Layouts
@@ -1042,11 +1042,11 @@ Baseline: a single 64x64 grid layout drives the pipeline.
| Board | FPS | heap | block |
|---|---|---|---|
| `esp32-eth` | β / 41,667 | β / 224KB | β / 108KB |
-| `pc-macos` | β / 25,000-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,873-125,000 | β / unlimited | β / unlimited |
| `pc-windows` | β / 32,258-37,037 | β / unlimited | β / unlimited |
- `esp32-eth`: observed 2026-06-08
-- `pc-macos`: observed 2026-06-05 β 2026-06-25
+- `pc-macos`: observed 2026-06-05 β 2026-07-01
- `pc-windows`: observed 2026-06-07
#### `measure-two-layouts` (measure) π
@@ -1064,11 +1064,11 @@ Pipeline still renders with two layouts wired (buffer non-null, fps measurable).
| Board | FPS | heap | block |
|---|---|---|---|
| `esp32-eth` | β / 37,037 | β / 223KB | β / 108KB |
-| `pc-macos` | β / 11,905-111,111 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 3,953-111,111 | β / unlimited | β / unlimited |
| `pc-windows` | β / 16,393-23,810 | β / unlimited | β / unlimited |
- `esp32-eth`: observed 2026-06-08
-- `pc-macos`: observed 2026-06-05 β 2026-06-25
+- `pc-macos`: observed 2026-06-05 β 2026-06-27
- `pc-windows`: observed 2026-06-07
#### `measure-after-replace` (measure) π
@@ -1086,11 +1086,11 @@ Pipeline still renders after replacing a grid with a sphere (different layout ty
| Board | FPS | heap | block |
|---|---|---|---|
| `esp32-eth` | β / 38,462 | β / 223KB | β / 108KB |
-| `pc-macos` | β / 3,690-100,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 1,957-100,000 | β / unlimited | β / unlimited |
| `pc-windows` | β / 5,848-9,009 | β / unlimited | β / unlimited |
- `esp32-eth`: observed 2026-06-08
-- `pc-macos`: observed 2026-06-05 β 2026-06-25
+- `pc-macos`: observed 2026-06-05 β 2026-06-27
- `pc-windows`: observed 2026-06-07
#### `measure-after-remove` (measure) π
@@ -1108,15 +1108,120 @@ Pipeline renders with the single remaining grid, same as the baseline.
| Board | FPS | heap | block |
|---|---|---|---|
| `esp32-eth` | β / 41,667 | β / 224KB | β / 108KB |
-| `pc-macos` | β / 16,949-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 6,623-125,000 | β / unlimited | β / unlimited |
| `pc-windows` | β / 33,333-38,462 | β / unlimited | β / unlimited |
- `esp32-eth`: observed 2026-06-08
-- `pc-macos`: observed 2026-06-05
+- `pc-macos`: observed 2026-06-05 β 2026-07-01
- `pc-windows`: observed 2026-06-07
## MoonLiveEffect
+### scenario_MoonLiveEffect_controls
+
+`test/scenarios/light/scenario_MoonLiveEffect_controls.json` β Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a control (`uint8_t speed = 7; // @control 0..15`) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate.
+
+**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Layers, Layer, Drivers, NetworkSendDriver
+
+#### `add-control-script` (add_module) π
+
+Add a MoonLiveEffect whose source declares a `speed` control and uses it. The control appears bound to the arena slot (seeded to its default 7); the wired effect renders one pixel.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 250,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `set-source-with-control` (set_control) π
+
+Edit the source to the control script. A source edit recompiles (controlChangeTriggersBuildState gates on `source`); the engine derives the `speed` control and the binding surfaces it.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `change-control-live` (set_control) π
+
+Change the `speed` control value (a slider move). This must NOT recompile β controlChangeTriggersBuildState returns false for a scripted control; the arena byte updates and the next render tick reads it. Tick stays cheap (a recompile would spike it).
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `edit-source-two-controls` (set_control) π
+
+Edit the source to add a second control. The recompile re-derives the control set; the stable-address grow-only arena keeps `speed`'s live value while seeding the new slot.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `edit-source-shrink-to-one-control` (set_control) π
+
+Edit the source back to a single control. The control set shrinks 2 -> 1: `speed` stays bound (its live value kept), the removed `hue`'s value is dropped, and the value change path is exercised without an unexpected recompile crash.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `edit-source-broken` (set_control) π
+
+Push a broken script. Compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status β no crash.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 125,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `edit-source-recover` (set_control) π
+
+Recover with a valid control script β the effect compiles and renders again.
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 1,000,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
+#### `re-add-control-effect` (add_module) π
+
+Re-add a fresh effect after the remove β exec memory + control arena re-acquired clean (it renders its default fill on add). Control re-acquisition itself is proven by the add-control-script step at the top: a freshly-added effect compiling a control source surfaces + seeds its control; construct-mode set_control can't apply a dynamically-added scripted control as the final asserted render, so the gate here is the bare re-add's liveness.
+
+**Setup** (preceding non-measured steps):
+- `remove-control-effect` (remove_module) β Remove the effect β the engine releases its exec block AND its control arena (teardown).
+
+**Performance** (contract / observed) β tick stored, FPS shown:
+
+| Board | FPS | heap | block |
+|---|---|---|---|
+| `pc-macos` | β / 500,000-β | β / unlimited | β / unlimited |
+
+- `pc-macos`: observed 2026-06-28 β 2026-07-01
+
### scenario_MoonLiveEffect_livescript
`test/scenarios/light/scenario_MoonLiveEffect_livescript.json` β Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end β the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill colour recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate.
@@ -1125,7 +1230,7 @@ Pipeline renders with the single remaining grid, same as the baseline.
#### `add-moonlive` (add_module) π
-Add a MoonLiveEffect to the Layer. Its default source `fill(0, 0, 255);` compiles on-device to native code; measure that the wired effect renders.
+Add a MoonLiveEffect to the Layer. Its default source (random pixels) compiles on-device to native code; measure that the wired effect renders.
**Performance** (contract / observed) β tick stored, FPS shown:
@@ -1303,14 +1408,14 @@ Disable mirrorX. Modifier control triggers a pipeline rebuild β measures the r
| `esp32-eth` | β / 10.4 | β / 132KB | β / 48KB-50KB |
| `esp32-eth-wifi` | β₯ 10.0 / 12.0 | β₯ 103KB / 94KB | β / 48KB |
| `esp32p4-eth` | β / 5,952-6,135 | β / 33238KB | β / 376KB |
-| `pc-macos` | β₯ 5,000 / 3,636-9,259 | unlimited / unlimited | β / unlimited |
+| `pc-macos` | β₯ 5,000 / 2,857-9,259 | unlimited / unlimited | β / unlimited |
| `pc-windows` | β / 2,024-2,392 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-02
- `esp32-eth`: observed 2026-06-02
- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02
- `esp32p4-eth`: observed 2026-06-17
-- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-06-14
+- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-07-01
- `pc-windows`: observed 2026-06-07
#### `disable-mirrorY` (set_control) π
@@ -1350,14 +1455,14 @@ Re-enable mirrorY and measure β the heavy LUT path must recover (FPS within 50
| `esp32-eth` | β / 10.5-10.6 | β / 132KB | β / 48KB-50KB |
| `esp32-eth-wifi` | β₯ 10.0 / 12.1 | β₯ 103KB / 94KB | β / 48KB |
| `esp32p4-eth` | β / 5,319-6,098 | β / 33238KB | β / 376KB |
-| `pc-macos` | β₯ 8,333 / 3,390-10,417 | unlimited / unlimited | β / unlimited |
+| `pc-macos` | β₯ 8,333 / 3,356-10,417 | unlimited / unlimited | β / unlimited |
| `pc-windows` | β / 4,065-4,854 | β / unlimited | β / unlimited |
- `esp32`: observed 2026-06-02
- `esp32-eth`: observed 2026-06-02
- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02
- `esp32p4-eth`: observed 2026-06-17
-- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-06-16
+- `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β 2026-06-30
- `pc-windows`: observed 2026-06-07
## MultiplyModifier
@@ -1571,9 +1676,9 @@ Baseline: the pipeline renders with one driver (Preview) wired.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 29,412-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 13,699-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-24
+- `pc-macos`: observed 2026-06-13 β 2026-06-30
#### `measure-two-drivers` (measure) π
@@ -1589,9 +1694,9 @@ Pipeline renders with both drivers wired.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 17,857-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 10,204-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-22
+- `pc-macos`: observed 2026-06-13 β 2026-07-01
#### `measure-three-drivers` (measure) π
@@ -1607,9 +1712,9 @@ Pipeline renders with three drivers wired.
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 38,462-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 13,333-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-25
+- `pc-macos`: observed 2026-06-13 β 2026-07-01
#### `measure-after-first-remove` (measure) π
@@ -1625,9 +1730,9 @@ One ArtNet gone, Preview + ArtNet2 remain: pipeline keeps rendering (buffer non-
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 30,303-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,152-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-24
+- `pc-macos`: observed 2026-06-13 β 2026-07-01
#### `measure-back-to-one-driver` (measure) π
@@ -1643,6 +1748,6 @@ Both added drivers gone, back to the single Preview baseline, still rendering
| Board | FPS | heap | block |
|---|---|---|---|
-| `pc-macos` | β / 38,462-125,000 | β / unlimited | β / unlimited |
+| `pc-macos` | β / 15,873-125,000 | β / unlimited | β / unlimited |
-- `pc-macos`: observed 2026-06-13 β 2026-06-24
+- `pc-macos`: observed 2026-06-13 β 2026-07-01
diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md
index 5f96101c..1a350af4 100644
--- a/docs/tests/unit-tests.md
+++ b/docs/tests/unit-tests.md
@@ -38,7 +38,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _AudioModule: a fresh, unconfigured module is idle (pins default unset)_
- _AudioModule: setup/teardown is repeatable with no residual state_
- _AudioModule: teardown clears the active mic (latestFrame falls back to silence)_
-- _AudioModule: last setup() wins, any add/remove order stays coherent_
+- _AudioModule: two mics β first wins, survivor re-elects, any order stays coherent_
## BlendMap
@@ -73,29 +73,14 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Calling free() twice is harmless; pointer and count remain zeroed.
- allocate() refuses zero-count or zero-channels (returns false, no allocation, buffer left empty so a caller that ignores the bool doesn't get a partial state).
-## CheckerboardEffect
-
-`test/unit/light/unit_CheckerboardEffect.cpp`
-*Also touches: SpiralEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect.*
-
-- Checkerboard paints at least one non-zero byte on a 16Γ16 grid (effect actually renders).
-- With cell_size=4, adjacent cells render different colours (the checker pattern is real, not uniform).
-- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead β same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders).
-- Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies).
-- RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders).
-- At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict).
-- RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront β assert it renders something and varies across the surface.
-- Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) β scan the whole buffer, corner-pair would be too strict.
-
## CheckerboardModifier
`test/unit/light/unit_CheckerboardModifier.cpp`
-- Identity dimensions β a mask doesn't resize the logical box.
-- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity.
-- invert flips which parity passes β the cell that was dropped now passes and vice versa.
-- size>1 groups cells into squares: with size=2, the 2Γ2 block at the origin is all one square (parity of 0/2=0), so all four pass; the next block over drops.
-- Never fans out β at most one destination.
+- A mask leaves the logical box unchanged.
+- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. Passing cells keep their coord.
+- invert flips which parity passes.
+- size>1 groups cells into squares: with size=2, the 2Γ2 block at the origin is all one square (parity 0), so all four pass; the next block over drops.
## Color
@@ -145,26 +130,52 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Brightness scaling runs before white derivation so W = min of the *scaled* RGB values.
- rebuild() can switch the output channel count between RGB (3) and RGBW (4) on the fly.
-## DevicesModule
+## DemoReelEffect
+
+`test/unit/light/unit_DemoReelEffect.cpp`
+
+- The reel enumerates the effect registry, hosts one effect at a time, renders it, and advances through the whole list without crashing β the create/teardown/delete churn every tick is the robustness path this pins. Registering two real effects + the reel gives it something to cycle.
+
+## DevicePlugin
`test/unit/core/unit_DeviceIdentify.cpp`
+*Also touches: DevicesModule.*
-- _classifyDevice: projectMM from /api/state modules array_
-- _classifyDevice: WLED from /json/info brand_
-- _classifyDevice: a live non-projectMM/non-WLED host is generic_
-- _classifyDevice: a truncated projectMM body still classifies (modules is early)_
-- _extractDeviceName: projectMM reads the deviceName control's value, not the type_
-- _extractDeviceName: WLED reads the top-level name field_
-- _extractDeviceName: generic / garbage / null bodies yield empty_
-- _extractDeviceName: respects the output buffer size (no overflow)_
-- _devTypeStr maps every type_
+- _MmPlugin claims a presence packet carrying the projectMM marker_
+- _MmPlugin declines a plain WLED packet (no projectMM marker)_
+- _WledPlugin claims a plain WLED packet as WLED_
+- _WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)_
+- _Plugins decline a short / garbage datagram, never read out of bounds_
+- _WledPlugin tolerates an empty name (the module supplies the IP fallback)_
+
+## DevicesModule
`test/unit/core/unit_DevicesModule_ageout.cpp`
-- _DevicesModule: a still-fresh device survives just under kStaleMs (24h)_
-- _DevicesModule: a device drops once past kStaleMs (24h)_
+- A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h β else a long-gone persisted device would survive forever across reboots (its clock resets to "boot" each restore). It drops once past kCachedGraceMs.
+- _DevicesModule: a cached device drops once past the probation window_
+- A live-confirmed device (a presence packet cleared its `cached` flag) gets the full 24 h.
+- _DevicesModule: a live-confirmed device drops once past kStaleMs (24h)_
+- A projectMM peer also answers as a plain WLED (its presence packet without our marker), so a later WLED-classified sighting must NOT relabel a restored projectMM row. This drives the downgrade-prevention in upsertDevice through the public path: restore the row as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM.
- _DevicesModule: restore tolerates an empty / malformed cache_
+`test/unit/core/unit_DevicesModule_discovery.cpp`
+*Also touches: DevicePlugin.*
+
+- _DevicesModule: a plain WLED packet lists a WLED device with its name_
+- _DevicesModule: a projectMM-marked packet lists a projectMM device_
+- _DevicesModule: a short / garbage datagram is ignored, never listed_
+- The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep their OWN name + type β no cross-contamination between packets.
+- A peer RENAME must propagate: a later packet from the same IP with a new name updates the row in place β the live-update requirement (the name rides the presence packet).
+- A projectMM device stays projectMM even when a later plain-WLED packet arrives from the same address β the type only RAISES toward projectMM, never downgrades. (A projectMM peer could be seen via an unmarked packet too; that must not relabel it WLED.)
+
+`test/unit/core/unit_DevicesModule_hue.cpp`
+
+- _DevicesModule: a Hue bridge is listed with its colour count_
+- _DevicesModule: upsertHueBridge is idempotent, updates count in place_
+- _DevicesModule: a persisted Hue bridge restores as a Hue row with its count_
+- _DevicesModule: a corrupt persisted colour clamps to the valid range, row still restores_
+
## DistortionWavesEffect
`test/unit/light/unit_DistortionWavesEffect.cpp`
@@ -180,6 +191,13 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Disabled child drivers don't tick: toggling `enabled` flips whether that driver's loop() runs.
+`test/unit/light/unit_Drivers_firstOutputRgb.cpp`
+
+- _Drivers::firstOutputRgb reads pixel 0 of the driven buffer_
+- _Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)_
+- _Drivers::firstOutputRgb returns false when there is no driven buffer_
+- _MoonModule::firstOutputRgb defaults to false (no output module)_
+
## FilesystemModule
`test/unit/core/unit_FilesystemModule_persistence.cpp`
@@ -191,7 +209,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Companion to the wiredByCode case above: when the JSON describes a different type at the position where a code-wired child lives, the position-replacement must NOT kill the code-wired child. Stop reconciliation at that index instead and let the next save re-write the file with the actual tree shape. When the saved JSON wants a different type at the position where a code-wired child lives, reconciliation stops at that index instead of destroying the wired child.
- Round-trip persistence with children: write a Layer subtree that contains both controls and child modules with controls of their own, then read the file back as text and verify it parses as valid JSON. Regresses the missing-comma bug between each child's "N.type" field and that child's first control (e.g. "0.type":"X""0.foo":1 instead of "0.type":"X","0.foo":1). Saving a Layer with multiple children produces valid JSON β comma separators between child `N.type` and the child's first control field are present.
- Singleton survives probe lifecycle: /api/types factory-creates a probe of every registered type (including FilesystemModule) to capture defaults, then deletes it. The probe's destructor must NOT clear the singleton β otherwise every save path (noteDirty, debounced loop1s, flushPending on reboot) silently no-ops for the rest of the device's life. The fix is to register the singleton in setScheduler(), not in the constructor. This test catches that singleton-clear regression. /api/types factory-creates a temporary FilesystemModule probe; its destruction must NOT clear the static singleton (otherwise every later save silently no-ops).
-- Int16 controls preserve their saved value across a filesystem load β the load path does not clamp them to `c.min`/`c.max` (which are `uint8_t` and so default to 0,0, a range that can't represent an int16). Without this a 128Γ128 grid would reload as 0Γ0Γ0 and the pipeline would allocate no buffers; the test pins the round-trip so an Int16 value survives unclamped.
+- Regression: Int16 controls (GridLayout's width/height/depth, Layer's start/end) round-tripped through the filesystem load path were clamped to c.min/c.max, which default to 0,0 because ControlDescriptor.min/max are uint8_t and can't represent an int16 range. Every Int16 control loaded as 0 β so a 128Γ128 grid became 0Γ0Γ0 after restart and the whole pipeline allocated no buffers. Int16 controls (GridLayout width/height, RegionModifier start/end) preserve their saved value across load β no zero-clamping from uint8 min/max bounds.
## FireEffect
@@ -206,21 +224,17 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
`test/unit/core/unit_FirmwareUpdateModule.cpp`
- The `firmware` control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). The firmware card owns firmware identity (version/build/firmware) + the partition usage.
+- OTA phase is surfaced through the shared status slot (MoonModule::setStatus()), not a control. publishStatus() runs in setup()/loop1s() and maps the platform OTA status string to a severity: "idle" clears the banner, an "error: " prefix is Severity::Error, anything else is neutral Severity::Status.
## GameOfLifeEffect
`test/unit/light/unit_GameOfLifeEffect.cpp`
-- Two cell grids of width Γ height bytes each.
-- Disabling releases both grids (dynamicBytes drops to 0) via the parent lifecycle.
-- A blinker (horizontal 3-in-a-row) oscillates with period 2 under B3/S23: it becomes a vertical 3-in-a-row, then back. Pins both birth (B3) and survival (S23) on a known pattern.
-- A 2Γ2 block is a still-life: every live cell has 3 neighbours (S3), no dead cell has exactly 3 (no B3), so stepOnce leaves it unchanged.
-- A lone cell dies (underpopulation: 0 neighbours, not S2/S3) β extinction.
-- Wraparound: a blinker on the right edge stays a valid 3-cell pattern because neighbours wrap, rather than losing cells to a hard edge.
-- Reallocation on dimension change: grids resize, byte count tracks new wΓh.
-- Must not crash on a zero-size grid (no allocation, loop is a no-op).
-- bpm time-gates the generation rate: a low bpm advances fewer generations per unit time than a high bpm over the same elapsed window. Drives time via the desktop millis() test seam (Layer reads platform::millis in loop()).
-- Regression: the Layer clears the buffer before every effect frame, so the grid must be re-painted on EVERY frame, not just on the (rarer) beats where a generation advances. A bpm gate that skipped the paint left non-step frames black β visible as "a flash now and then" at low bpm. Drive several frames at a slow bpm (most are non-step) and require the buffer stays lit on all of them.
+- The B#/S# parser turns a rule string into birth/survive neighbour sets. Conway = B3/S23.
+- A 2Γ2 block is a Conway still life: every live cell has 3 neighbours (survives), and the surrounding dead cells never have exactly 3 (no births). It must be identical after a step.
+- Regression: a 3D grid gives a cell up to 26 neighbours (3Γ3Γ3 minus self), but the B/S rule tables are sized 9 (single-digit Conway notation, 0..8). A dense 3D neighbourhood must not read those tables out of bounds β a count β₯9 is in no single-digit ruleset, so the cell dies / stays dead. This fills a 3Γ3Γ3 cube (the centre has all 26 neighbours alive) and just steps: the test passing under ASan/bounds-checking is the OOB-read pin; behaviourally the over-crowded centre dies (26 β S) and the dense interior doesn't survive.
+- A horizontal 3-cell blinker oscillates to vertical after one step (period-2 oscillator). This is the canonical "the rules actually run" check: birth on 3, death of the ends (1 neighbour each).
+- A lone cell (0 neighbours) dies β the dead-by-isolation rule, and a sanity check that an empty grid stays empty (no spontaneous births at count 0 under Conway).
## GridLayout
@@ -244,6 +258,19 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _apply-core: applyOp dispatches each op type and tolerates bad input_
- A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is enforced THROUGH the apply-core β so the APPLY_OP `set` the installer pushes over serial is guarded exactly like an HTTP write, with no per-transport special-casing. This is the point of moving validation onto the control: one backend check, every path.
+## HueDriver
+
+`test/unit/light/unit_HueDriver.cpp`
+
+- _HueDriver: a coloured pixel becomes an on/bri/hue/sat state body_
+- _HueDriver: a black pixel becomes on:false_
+- _HueDriver: RGBβHSV maps the primaries to the right Hue wheel positions_
+- _HueDriver: unchanged colour is not resent, a changed one is_
+- _HueDriver: parseLights keeps only colour-capable, reachable lights_
+- Room + light selection filters which colour lights the driver actually drives. Both dropdowns default to "All" (index 0): then every colour light is driven (unchanged behaviour). Selecting a room narrows the driven set to that room's colour lights; selecting a light drives just that one.
+- The single status line (folding what were the separate hueStatus / colourLights controls) shows the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not.
+- fetchLights sizes its read buffer by growing while the body looks truncated. The signal is "does the body end in '}'": a too-small buffer cuts the JSON mid-content. (Regression: an earlier check tested strlen==cap-1, which never fires because httpRequest strips headers first, so a >2 KB bridge response was parsed truncated and lights silently disappeared.)
+
## ImprovFrame
`test/unit/core/unit_ImprovFrame.cpp`
@@ -291,27 +318,43 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _overflow safety: too many nodes fails cleanly_
- _overflow safety: nesting deeper than kMaxDepth fails cleanly_
- _input longer than the text buffer fails cleanly_
+- parseString must DECODE the JSON string escapes our own writer emits (JsonSink/writeJsonString) β \" \\ \n \r \t \b \f β so reader and writer are symmetric. A multi-line value (a script with `\n`) must arrive as a real newline, not a literal backslash-n.
## Layer
`test/unit/light/unit_Layer_extrude.cpp`
-*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, FireEffect, ParticlesEffect.*
+*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect.*
- A D2 effect (Rainbow) on a 3D layer writes z=0 once; Layer::extrude copies that slice across every z>0 β slices are byte-identical.
-- A D1 effect writes row y=0,z=0; extrude duplicates that row across every y and every z-slice.
+- A D1 effect writes the x=0 column; extrude duplicates it across every x and every z-slice.
- NoiseEffect declared D3 still produces a valid image on a depth=1 layer (it honours the runtime depth instead of hardcoding z).
- PlasmaEffect (D3) on a 2D layer same contract: valid 2D image, no buffer overrun.
- NoiseEffect (D3) on a 1D layer (height=depth=1) writes a valid strip and never overflows.
- PlasmaEffect (D3) on a 1D layer same contract: valid 1D strip, no overflow.
-- CheckerboardEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract).
+- SpiralEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract).
- FireEffect (D2, stateful β heat buffer sized to wΓh) extrudes cleanly across z on a 3D layer.
- ParticlesEffect (D2, stateful β trail sized to wΓhΓcpl) extrudes cleanly across z on a 3D layer.
+`test/unit/light/unit_Layer_live_modifier.cpp`
+*Also touches: RotateModifier, ModifierBase.*
+
+- With a Rotate present, the live pass rotates the gradient each frame as the angle advances β so two frames at different times differ. A static GradientEffect alone would produce identical frames, so any difference is the live remap.
+- PAY-FOR-WHAT-YOU-USE: a Layer with no live modifier must NOT run the live pass β the static gradient is byte-identical across frames regardless of the clock.
+- A DISABLED Rotate must not run the live pass either (the gate keys off ENABLED live modifiers). Same static gradient β identical frames.
+- COALESCED REBUILD: two beat-driven modifiers (RandomMap) on one Layer both ask for a rebuild on a beat; Layer::loop() must rebuild ONCE (not re-enter onBuildState per modifier) and the Layer must stay valid β the composed mapping changes, no crash.
+
+`test/unit/light/unit_Layer_modifier_chain.cpp`
+*Also touches: ModifierBase.*
+
+- Region (left half) THEN Multiply (2Γ mirror): the logical box folds twice. On a 16-wide axis: Region 0..50 β 8, then Multiply 2 β 4. Both modifiers apply β the second is no longer dead weight.
+- Order matters: Region-then-Multiply differs from Multiply-then-Region. Region's percentage applies to whatever box it sees, so the composed logical size differs.
+- A DISABLED middle modifier is skipped β the chain folds only the enabled ones.
+
`test/unit/light/unit_Layer_phase_animation.cpp`
-*Also touches: MetaballsEffect, CheckerboardEffect, LavaLampEffect, SpiralEffect.*
+*Also touches: MetaballsEffect, SpiralEffect, LavaLampEffect, SpiralEffect.*
- Metaballs visibly changes over 100ms even when per-tick dt is sub-millisecond (no phase-accumulator truncation).
-- Checkerboard advances at desktop speed (cells flip across 100ms).
+- SpiralEffect advances at desktop speed (the spiral rotates across 100ms).
- LavaLamp animates across 100ms (blobs move).
- Spiral animates across 100ms (rotation visible).
- Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen.
@@ -323,23 +366,24 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box.
- Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount).
- REGRESSION: a high fan-out Multiply (8Γ8Γ4 = 256) on a 128Γ128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount Γ maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 Γ 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range β the symptom that black-screened the device.
+- Region carving: a RegionModifier shrinks the Layer's LOGICAL box to the region (so the effect renders only there), and the LUT maps each region cell to its box cell at the start offset β every destination in range, none outside the region. The driver buffer still holds all physical lights; cells outside the region simply get no logical source (dark). Default 0/100 = full box (the no-carve fast path) is covered by unit_RegionModifier; here we carve a quarter.
`test/unit/light/unit_Layer_zero_grid.cpp`
-*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RingsEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.*
+*Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, MetaballsEffect, RingsEffect, RipplesEffect, LavaLampEffect, FireEffect, ParticlesEffect, GameOfLifeEffect, GEQ3DEffect, PaintBrushEffect.*
- Rainbow on 0,0,0 grid: no crash.
- Noise on 0,0,0 grid: no crash.
- Plasma on 0,0,0 grid: no crash.
-- Checkerboard on 0,0,0 grid: no crash.
- Spiral on 0,0,0 grid: no crash.
- Metaballs on 0,0,0 grid: no crash.
-- PlasmaPalette on 0,0,0 grid: no crash.
- Rings on 0,0,0 grid: no crash.
- Ripples on 0,0,0 grid: no crash.
-- GlowParticles on 0,0,0 grid: no crash.
- LavaLamp on 0,0,0 grid: no crash.
- Fire on 0,0,0 grid: no heat buffer allocated, no crash.
- Particles on 0,0,0 grid: no trail buffer allocated, no crash.
+- GameOfLife on 0,0,0 grid: no heap alloc for 0 cells, no crash.
+- GEQ3D / PaintBrush on 0,0,0 grid: audio effects, no crash with no buffer.
+- _PaintBrushEffect on 0,0,0 grid_
## Layers
@@ -352,6 +396,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Disabling the top layer drops cleanly to the single (bottom) layer β no crash, the driver now sees the bottom layer's content. Pins the robustness path.
- Drivers' composition/output-buffer allocation contract (architecture.md Β§ Adaptive allocation). The driver output buffer exists ONLY when the pipeline must blend into physical space; otherwise the lone layer's buffer is handed to drivers directly (zero-copy). dynamicBytes() reflects outputBuffer_.bytes(), so it's 0 β no buffer. Pins all three cases in one place: 1. one identity (no-LUT) layer β NO output buffer (zero-copy) 2. two enabled layers β output buffer (must composite) 3. one layer WITH a LUT β output buffer (must map logicalβphysical)
- activeLayer() returns the first enabled child, or the only child if all are disabled (so dimensions stay queryable during boot/toggle-off).
+- firstEnabledLayer() is the output-selection counterpart to activeLayer(): it never falls back to a disabled layer, so it returns nullptr exactly when nothing renders.
- If the container holds only non-Layer children, activeLayer() returns nullptr (the role-guard skips, never miscasts).
## Layouts
@@ -427,6 +472,47 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- typeName/typeRole with an out-of-range index returns nullptr / Generic safely (never UB).
- The factory grows its registry capacity dynamically β registering 10+ extra types past the initial size still works and every name stays discoverable.
+## MoonLive
+
+`test/unit/core/unit_moonlive_compiler.cpp`
+
+- _compileSource: fill(r,g,b) fills every light_
+- _compileSource: setRGB(index, r,g,b) writes one pixel_
+- REMARK #1: every argument is an expression β random16 in ANY slot.
+- REMARK #2: a literal / random16 bound may be a uint16 (0..65535), not capped at 255.
+- _compileSource: out-of-range index is bounds-rejected at runtime_
+- _compileSource rejects malformed programs with a diagnostic, never crashes_
+- _MoonLive.compile(source) on a bad script leaves the engine !ok with an error_
+- VREG REUSE: a chain of calls must fit the small device register file. Each argument temp dies once its call consumes it and is recycled, so peak register pressure stays low no matter how many calls a statement nests β setRGB with all four arguments a random16 still compiles.
+- DOMAIN-NEUTRAL: the core compiler owns no function names. With an EMPTY table it knows nothing β `setRGB`/`fill`/`random16` are all "unknown function". The LED vocabulary lives only in the host's table; a different host registers different names. (Remark #3.)
+- _MoonLive recompiling swaps the program live (fill <-> setRGB)_
+- STAGE 1 CONTROLS β parse layer: a `uint8_t name = def; // @control min..max` declaration surfaces a DeclaredControl, and a declared name used in a statement resolves to it.
+- _compileSource: malformed control declarations fail with a diagnostic, never crash_
+
+`test/unit/core/unit_moonlive_fill.cpp`
+
+- _MoonLive emitFill produces a non-empty routine_
+- _MoonLive emitFill rejects a too-small buffer (degrades, no overrun)_
+- _MoonLive emitFill/emitAnimatedFill reject a null output buffer (no crash)_
+- _MoonLive compiles and fills a buffer with the chosen colour_
+- _MoonLive run on zero lights writes nothing (robust to empty)_
+- The native routines write channels +0/+1/+2 per light, so a layer with fewer than 3 channels per light can't hold RGB β run() must leave it untouched, not overrun it.
+- _MoonLive recompile swaps the colour; free returns to !ok_
+- _MoonLive animated fill derives colour from the per-frame t_
+- _platform allocExec returns usable executable memory, freeExec releases it_
+- _MoonLive controls: declaredControls + controlSlot seeded from the default_
+- _MoonLive controls: arena address is STABLE across a recompile and the slot value survives_
+- _MoonLive controls: free() releases the arena (no stale slot after teardown)_
+
+`test/unit/core/unit_moonlive_ir.cpp`
+
+- _MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)_
+- _MoonLive compiled fill is robust: zero lights writes nothing_
+- _MoonLive compileSource degrades on a too-small code buffer_
+- _MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected_
+- _MoonLive control: a declared control reads the arena live (no recompile on value change)_
+- _MoonLive control survives a host call (kArg4 live across random16)_
+
## MoonModule
`test/unit/core/unit_MoonModule.cpp`
@@ -481,19 +567,16 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
`test/unit/light/unit_MultiplyModifier.cpp`
-- Reports D3 β handles all three axes. Pins the ModifierBase default too.
-- Defaults (multiply 2/2/1, mirror true/true/false) reproduce the canonical mirror-XY pipeline: a 128Γ128 physical grid β 64Γ64 logical (each axis folds).
-- multiplyZ tiles the Z axis too: 128Γ128Γ4 with multiply 2/2/2 β 64Γ64Γ2.
-- PURE-FOLD EQUIVALENCE: with the defaults (mult 2, mirror XY), the corner logical pixel (0,0) fans out to all four physical corners β byte-identical to the old MirrorModifier corner test. This is the canonical-pipeline guarantee.
-- PURE-FOLD EQUIVALENCE: an interior pixel folds to the same two columns the old mirrorX-only produced β original + horizontal reflection.
-- No multiplication on any axis (all multipliers 1) β identity pass-through.
-- Tiling WITHOUT mirror repeats (does not reflect) β multiply 2 on X, mirror off: logical x=0 lands at physical x=0 (tile 0) and x=64 (tile 1, identity offset), NOT x=127. This is the difference from a fold.
-- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so logD stays 1 and the layer isn't blanked. Before the clamp, multiplyZ=4 made logD = 1/4 = 0 β empty layer.
-- A multiplier larger than the axis extent clamps to the extent (can't tile more times than there are pixels).
-- maxMultiplier is the product of the raw controls (the fan-out upper bound).
-- REGRESSION: maxMultiplier() must NOT wrap when all axes are maxed. The product 64Γ64Γ16 = 65536 overflows nrOfLightsType (uint16 on no-PSRAM) and would wrap to 0 β feeding the uint64 maxDest math in Layer::rebuildLUT an already-wrapped (possibly 0) multiplier β empty LUT β black display. It must saturate to the type max instead. (Single-axis tests above stay under the wrap; this one crosses it.) On uint32 (PSRAM) the product fits and isn't saturated β assert only the non-wrap, non-zero invariant that holds on both widths.
-- REGRESSION: an 8Γ8 multiply must emit all 64 tile positions, not be truncated to 8. The Layer's scratch buffer is sized to ModifierBase::kMaxFanout (64); a smaller buffer (the original physicals[8]) silently dropped 56 of the 64 tiles, so a 128Γ128 grid showed only 8 tiles instead of the full 8Γ8 = 64.
-- Fan-out never exceeds maxOut even if asked for more than the buffer holds.
+- _MultiplyModifier advertises D3 dimensions_
+- Defaults (multiply 2/2/1) β a 128Γ128 physical grid folds to a 64Γ64 logical box.
+- _MultiplyModifier logical size on Z_
+- FAN-OUT (fold direction): with the defaults (mult 2, mirror XY), all four physical CORNERS fold onto the single logical pixel (0,0) β the inverse of the old "logical (0,0) β 4 physical corners". This is the kaleidoscope fold made concrete.
+- mirrorX only: two physical columns fold to the same logical column (original + its horizontal reflection). The logical box is 64 wide.
+- All multipliers 1 β identity: the box is unchanged and every coord folds to itself.
+- Tiling WITHOUT mirror repeats (does not reflect): physical x=64 (tile 1) folds to logical x=0, same as physical x=0 β both tiles map identically, no reflection.
+- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so depth stays 1 and the layer isn't blanked.
+- A multiplier larger than the axis extent clamps to the extent.
+- REGRESSION (π): a non-divisible extent leaves a leftover edge strip that the tiles don't cover β those pixels must be DROPPED, not wrapped back into a tile (which would duplicate the edge). 5-wide, multiply 2 β tile width 2, covers pixels 0..3; pixel 4 is the leftover and has no tile.
## NetworkModule
@@ -573,6 +656,17 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- With depth > 1, adjacent and distant z-slices each render differently (3D noise, not a stack of identical 2D slices).
- Same z-slice variation requirement holds for Plasma β each depth plane renders differently.
+## Palette
+
+`test/unit/light/unit_Palette.cpp`
+
+- _Palette: gradient endpoints land on the first/last stop colours_
+- _Palette: a mid-gradient sample interpolates between stops_
+- _Palette: colorFromPalette index 0 reads entry 0; brightness scales_
+- _Palette: the index wraps at 255β0 (no out-of-range read)_
+- _Palette: a degenerate (empty) gradient is all black, never out-of-bounds_
+- _Palettes::active swaps the global palette on setActive_
+
## ParlioLedDriver
`test/unit/light/unit_ParlioLedDriver.cpp`
@@ -632,7 +726,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
`test/unit/light/unit_RainbowEffect.cpp`
- A single frame on a 4Γ4 grid leaves the buffer non-zero (rainbow always paints somewhere).
-- Pixel (0,0) is at full saturation and value (one channel exactly 255) β confirms hsvToRgb wiring.
+- Pixel (0,0) carries a lit palette colour β confirms the effect writes a real RGB there.
- Distant pixels carry different hues (the rainbow gradient is spatial, not uniform).
## RandomMapModifier
@@ -640,16 +734,30 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
`test/unit/light/unit_RandomMapModifier.cpp`
*Also touches: Layer.*
-- A remap doesn't resize the logical box.
-- _RandomMapModifier maxMultiplier is 1_
-- The core property: the mapping is a true bijection over [0, w*h*d) β every destination index appears exactly once (no gaps, no duplicates).
-- A fresh modifier with the same generation produces the same permutation (deterministic seed β reproducible, which is what makes it testable).
-- Reshuffling (a beat) changes the mapping, and the result is still a bijection.
-- Robustness: an empty (0Γ0Γ0) grid must not crash β maxOut 0 yields no destination.
-- A resize (different box count) rebuilds the permutation to the new size, still a bijection.
+- A remap leaves the logical box unchanged.
+- The core property: a true bijection over [0, w*h*d) β every destination index appears exactly once (no gaps, no duplicates).
+- Deterministic seed β reproducible permutation (what makes it testable).
+- Reshuffling (a beat) changes the mapping, still a bijection.
+- Robustness: an empty (0Γ0Γ0) box must not crash β it folds to a no-op.
+- A resize (different box count) rebuilds the permutation to the new size.
- _RandomMapModifier loop() reshuffles on a beat (bpm 60 β 1/s)_
- _RandomMapModifier loop() with bpm 0 never reshuffles (frozen)_
+## RegionModifier
+
+`test/unit/light/unit_RegionModifier.cpp`
+
+- Default region (0/100 on every axis) is the full box: identity size, no rejection.
+- Half of an axis, half-open: end=50 on 128 β region width 64, not 65.
+- Two abutting regions tile a 128-wide axis with no overlap and no gap.
+- A physical coord inside the region folds to region-local (subtract the start pixel); a coord outside is rejected.
+- Rounding rule on a small panel: start floors, end ceils to an exclusive pixel. start 33 / end 66 on a 4-wide axis β floor(1.32)=1 .. ceil(2.64)=3 β pixels 1,2.
+- A region that rounds to nothing still gets a 1-pixel floor.
+- OFF-SCREEN: a window slid half off the left edge keeps its FULL size (the effect renders at a fixed scale); only the visible half maps to physical lights. startX=-50 on 64 β window [β32, 32), span 64. Physical x 0..31 land at window-local 32..63 (the right half of the window β the visible part); the left half of the window (0..31) has no physical light, so it's dark. The effect isn't rescaled.
+- A window entirely off the box maps NO lights β the layer goes dark on that axis, which is how an effect is moved completely out of view. The box still has a valid size (the effect renders), nothing just reaches the screen.
+- A window stretched WIDER than the box (start<0 and end>100) renders the full span; the box shows the middle slice. startX=-50,endX=150 on 64 β window [β32, 96), span 128.
+- Degenerate axes don't crash: a 1-wide axis stays 1, a 0-extent axis yields 0.
+
## RmtLedDriver
`test/unit/light/unit_RmtLedDriver_lifecycle.cpp`
@@ -685,6 +793,10 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _RmtLedDriver idles with a status error on a bad pin list_
- _RmtLedDriver with the empty default pins idles cleanly (no pin assumed)_
- _RmtLedDriver re-slices when the source buffer changes_
+- _RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer_
+- _RmtLedDriver window: count 0 means the rest of the buffer from start_
+- _RmtLedDriver window: a size-1 window at 0 is the onboard-LED case_
+- _RmtLedDriver window: a start past the buffer end yields an empty slice_
- loop() is a safe no-op across single-pin, multi-pin and zero-grid configs.
`test/unit/light/unit_RmtLedEncoder.cpp`
@@ -699,11 +811,10 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
`test/unit/light/unit_RotateModifier.cpp`
-- _RotateModifier logicalDimensions are identity_
-- _RotateModifier maxMultiplier is 1_
-- At the initial angle (0) the rotation is identity β every light maps to itself.
-- Every emitted destination is in range (no out-of-bounds index), and the count is always 0 or 1 (a remap never fans out).
-- _RotateModifier tolerates an empty grid_
+- _RotateModifier advertises a live (per-frame) modifier_
+- At the initial angle (0) the rotation matrix is the identity β every cell samples itself.
+- z passes through (2D rotation) β a 3D coord's z is untouched.
+- An empty box doesn't divide-by-zero or wrap: the remap is a no-op-ish transform that the Layer's live pass then treats as out-of-box (dark), never a crash.
## Scheduler
@@ -746,6 +857,18 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights.
- Default radius is a sensible small sphere (not 0, not huge).
+## SpiralEffect
+
+`test/unit/light/unit_effects_render.cpp`
+*Also touches: RingsEffect, RipplesEffect, LavaLampEffect.*
+
+- LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead β same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders).
+- Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies).
+- RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders).
+- At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict).
+- RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront β assert it renders something and varies across the surface.
+- Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) β scan the whole buffer, corner-pair would be too strict.
+
## SystemModule
`test/unit/core/unit_SystemModule.cpp`
@@ -769,6 +892,35 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _sanitizeHostname trims leading and trailing dashes / invalid runs_
- _sanitizeHostname yields empty for all-invalid input (caller falls back)_
+## TextEffect
+
+`test/unit/light/unit_TextEffect.cpp`
+
+- Static text renders glyph pixels top-left. On a grid tall/wide enough for one line of the 6x8 font, a non-empty string lights some pixels; an empty string lights none.
+- A multi-line string wraps: the second line renders on a lower row (font-height down), so a two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6).
+- Scroll mode advances the text over time and never crashes; on a degenerate grid it's a safe no-op.
+
+## Uncategorized
+
+`test/unit/light/unit_Layer_persistence.cpp`
+
+- _Layer: buffer persists across frames (no per-frame clear)_
+- _Layer: fadeToBlackBy decays the persisted buffer once per frame_
+- _Layer: multiple fade requests combine with MIN (gentlest wins, longest trail)_
+- _Layer: collected fade resets after it is consumed_
+- _Layer: onBuildState clears the buffer (a rebuild wipes stale pixels)_
+
+## WaveEffect
+
+`test/unit/light/unit_WaveEffect.cpp`
+
+- _WaveEffect: sawtooth ramps 0βtop across the phase_
+- _WaveEffect: triangle peaks in the middle and returns_
+- _WaveEffect: sine sits mid at the zero crossings_
+- _WaveEffect: square is low then high_
+- _WaveEffect: every type stays within the grid bounds_
+- _WaveEffect: a zero-height grid never reads out of bounds_
+
## WheelLayout
`test/unit/light/unit_WheelLayout.cpp`
@@ -778,6 +930,69 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run
- _WheelLayout coordinates are non-negative (centre-shifted into address space)_
- _WheelLayout different spoke counts give different layouts_
+## WledPacket
+
+`test/unit/core/unit_WledPacket.cpp`
+
+- _WledPacket::build produces a valid WLED header (token/id/size)_
+- _WledPacket::readName round-trips the device name_
+- _WledPacket marker is set only when stamped, and stays WLED-valid_
+- _WledPacket::isValid rejects short / wrong-magic / null input_
+- _WledPacket::readName truncates a long name to the buffer, never overruns_
+
+## crc
+
+`test/unit/core/unit_crc.cpp`
+
+- CRC-16/CCITT-FALSE has a well-known check value: "123456789" β 0x29B1. Pinning it proves the polynomial/init/reflection match the standard variant (so a fingerprint computed here matches any other CCITT-FALSE implementation).
+- A change-detector: different content β (almost always) different CRC; identical content β same.
+- Empty span returns the init value (no bytes processed).
+
+## draw
+
+`test/unit/light/unit_draw.cpp`
+
+- mm::draw::pixel() writes inside the grid and silently clips outside it (no out-of-bounds write).
+- A 1D line (a row): every pixel from a.x to b.x inclusive is lit.
+- A 2D diagonal: endpoints are lit and the line is contiguous (one pixel per step on the main diagonal of a square).
+- A 3D line: drives all three axes, endpoints lit, no out-of-bounds on a small cube.
+- A line running off the grid clips: it draws the on-grid part and stops, no crash.
+- The `shorten` parameter pulls the far endpoint back toward `a` by shorten/255 (with WLEDMM *2 rounding), so an effect can sweep a partial segment. For aβb = (0,0)β(8,0): shorten 255 draws the whole line (tip at 8), 128 β half (tip at (16*128/255+1)/2 = 4), 1 = just the start pixel (tip 0), 0 = nothing. This pins the rounding of the shorten branch.
+- draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same behaviour as FastLED blur1d / MoonLight blurRows), and is symmetric around a centred bright pixel.
+- blur runs separably on every axis with extent>1: a 2D blur spreads a centre pixel to all four orthogonal neighbours; a 3D blur reaches the z neighbours too. And it never writes out of bounds.
+- A glyph blits in the correct orientation β neither X-mirrored (a 'b' as a 'd') nor Y-flipped. 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter upright and un-mirrored.
+
+## light_types
+
+`test/unit/light/unit_Coord3D.cpp`
+
+- _Coord3D arithmetic is per-axis_
+- _Coord3D modulo and divide fold per axis_
+- _Coord3D % and / guard a zero or degenerate axis_
+- _Coord3D equality_
+
+## math8
+
+`test/unit/core/unit_math8.cpp`
+
+- sin8: a 256-entry sine LUT centred on 128, peaking near 255 and 0 a quarter and three-quarters of the way round. cos8 is sin8 shifted a quarter turn.
+- triwave8: linear up 0β255 then down 255β0, peaking at the midpoint.
+- qadd8/qsub8 clamp at the 0..255 ends instead of wrapping.
+- nscale8 is the recognisable spelling of scale8 (n/256 channel scale), so nscale8(x,255)==x.
+- beat8: a sawtooth completing `bpm` cycles per minute. At t=0 it's 0; halfway through a beat ~128.
+- beatsin8: a sine oscillating in [low,high] at bpm. Stays in range across the cycle and actually moves (not stuck at one value).
+- Random8: a seeded PRNG β same seed gives the same sequence (determinism), and below(n) stays under n. Two different seeds diverge.
+- atan2_8 / dist8: the geometry helpers moved here from color.h still behave.
+- map8 rescales 0..255 onto [lo,hi] inclusively β the top of the input must REACH hi (FastLED's map8 == map(in,0,255,lo,hi)). Regression: an earlier scale8-based form left hi unreachable, so a one-step span (a bar height of 1) collapsed to 0 β the bug GEQ3D's height mapping hit.
+
+## noise
+
+`test/unit/core/unit_noise.cpp`
+
+- Determinism: the same coordinate always gives the same value (a pure function of position), so a field is reproducible frame to frame and across the 1D/2D/3D entry points at z/y = 0.
+- Smoothness: neighbouring positions WITHIN a cell (sub-256 steps) differ only a little β that's what makes it value noise rather than a raw hash (which would jump randomly every step).
+- Range: output is a full byte; over a swept field it uses a wide span (not stuck near one value).
+
## platform
`test/unit/core/unit_platform_clock.cpp`
diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md
index 600f5365..96e62fe8 100644
--- a/scripts/MoonDeck.md
+++ b/scripts/MoonDeck.md
@@ -21,7 +21,7 @@ Below: the UI behaviours common to every card, described once, then one section
## PC Tab
-
+
### build_desktop
@@ -57,9 +57,9 @@ While the app is running, MoonDeck shows the button as **Stop** (a 5-second poll
### preview_installer
-
-
-
+
+
+
Locally preview the web installer page at without tagging a release. Stages `docs/install/index.html` + `src/ui/install-picker.js` into `build/install-preview/` and serves them via Python's `http.server` on port 8000.
@@ -117,7 +117,7 @@ The MoonDeck button writes the file, prints a `MOONDECK_VIEW: /api/history-repor
### screenshot_modules
-Capture UI screenshots of every module that has controls and save them to `docs/assets/screenshots/`.
+Capture UI screenshots of every module that has controls and save them to `docs/assets/`.
```bash
uv run scripts/docs/install_playwright.py # one-time (or use Install Playwright button in MoonDeck)
@@ -152,16 +152,16 @@ uv run scripts/docs/update_module_docs.py # update all
uv run scripts/docs/update_module_docs.py --dry-run # preview without writing
```
-For each `.md` file, if `docs/assets/screenshots/.png` exists and the file doesn't already contain a screenshot reference, inserts the image after the first heading. If a matching `.gif` also exists, inserts the GIF reference on the next line. Safe to re-run β skips files that already have all references.
+For each `.md` file, if `docs/assets//.png` exists and the file doesn't already contain a screenshot reference, inserts the image after the first heading. If a matching `.gif` also exists, inserts the GIF reference on the next line. Safe to re-run β skips files that already have all references.
Also inserts MoonDeck tab screenshots and the installer screenshot into `scripts/MoonDeck.md` and `README.md` at fixed anchor points (defined in the `EXTRA_SHOTS` list in the script).
-Reports unreferenced screenshots β any PNG or GIF in `docs/assets/screenshots/` not mentioned anywhere in `docs/` or `scripts/`.
+Reports unreferenced screenshots β any PNG or GIF in `docs/assets/` not mentioned anywhere in `docs/` or `scripts/`.
## Live Tab
-
+
### live_scenario
@@ -218,7 +218,7 @@ When the verdict is `CHOPPY`/`DEAD`, the *cause* (which close path fired on the
## ESP32 Tab
-
+
The tab is laid out top-to-bottom along the firmware workflow. Each dropdown sits between the script groups that consume it, so picking a dropdown is the natural prelude to the buttons below it.
diff --git a/scripts/check/check_specs.py b/scripts/check/check_specs.py
index a0fbe30b..1e98077a 100644
--- a/scripts/check/check_specs.py
+++ b/scripts/check/check_specs.py
@@ -50,11 +50,43 @@ def find_moonmodules():
modules.append(h_file)
return modules
+# Effects, modifiers, layouts, and drivers each document themselves as one compact-row page per type
+# rather than a file per module (docs consolidation β see the folder-structure decision). A module
+# whose type name ends in one of these suffixes is documented on the matching shared page; everything
+# 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
+# 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 = {
+ "Effect": SPECS / "light" / "effects" / "effects.md",
+ "Modifier": SPECS / "light" / "modifiers" / "modifiers.md",
+ "Layout": SPECS / "light" / "layouts" / "layouts.md",
+ "Driver": SPECS / "light" / "drivers" / "drivers.md",
+}
+
+
def find_spec(module_path):
- """Find the matching spec .md file for a source .h file."""
+ """Find the matching spec .md for a source .h.
+
+ A module whose type name ends in Effect/Modifier/Layout is documented on the shared
+ per-type page (its row must carry every control name β checked by check_spec_freshness).
+ Everything else uses the per-module page (stem match), as before.
+ """
name = module_path.stem # e.g. "NoiseEffect"
- # Search all spec directories
+ # MoonLive is a live-script module under light/moonlive/, not a normal effect β it keeps its
+ # own page despite the *Effect suffix. Only the per-type folders consolidate.
+ in_moonlive = "moonlive" in module_path.parts
+ if not in_moonlive:
+ for suffix, page in CONSOLIDATED_PAGES.items():
+ if name.endswith(suffix):
+ if page.exists():
+ return page
+ break # consolidated page missing β fall through to the per-module stem search
+
+ # Per-module page: match by filename stem anywhere under docs/moonmodules/.
for md in SPECS.rglob("*.md"):
if md.stem == name:
return md
diff --git a/scripts/docs/screenshot_modules.py b/scripts/docs/screenshot_modules.py
index 309a2a56..b15d5e68 100644
--- a/scripts/docs/screenshot_modules.py
+++ b/scripts/docs/screenshot_modules.py
@@ -12,14 +12,13 @@
Also captures MoonDeck tab screenshots and the web installer page, and
inserts them into the appropriate docs files.
-Saves to:
- docs/assets/screenshots/.png β module card screenshot
- docs/assets/screenshots/.gif β preview animation (effects/modifiers)
- docs/assets/screenshots/ui_overview.png β projectMM full-page screenshot
- docs/assets/screenshots/moondeck_pc.png β MoonDeck PC tab
- docs/assets/screenshots/moondeck_esp32.png β MoonDeck ESP32 tab
- docs/assets/screenshots/moondeck_live.png β MoonDeck Live tab
- docs/assets/screenshots/installer.png β web installer page
+Saves to (by domain/type, mirroring src β see docs/backlog/folder-structure-proposal.md):
+ docs/assets/light/effects/.png/.gif β effect card + preview
+ docs/assets/light/{modifiers,layouts,drivers}/ β other light modules
+ docs/assets/core/.png β core modules
+ docs/assets/ui/ui_overview.png β projectMM full-page screenshot
+ docs/assets/ui/moondeck_{pc,esp32,live}.png β MoonDeck tabs
+ docs/assets/ui/installer.png β web installer page
Usage:
# one effect, raw (no modifier), good preview size, with its GIF, overwriting:
@@ -71,7 +70,23 @@
from playwright.sync_api import sync_playwright, Page
ROOT = Path(__file__).resolve().parent.parent.parent
-OUT_DIR = ROOT / "docs" / "assets" / "screenshots"
+ASSETS = ROOT / "docs" / "assets"
+UI_DIR = ASSETS / "ui" # tooling / installer / full-page shots (not per-module)
+
+# Map a module to its asset subfolder (domain/type), mirroring src. Module screenshots live in
+# docs/assets/{core, light/{effects,modifiers,layouts,drivers}}/ β see folder-structure-proposal.
+def asset_dir_for(type_name: str) -> Path:
+ if type_name.endswith("Effect"):
+ return ASSETS / "light" / "effects"
+ if type_name.endswith("Modifier"):
+ return ASSETS / "light" / "modifiers"
+ if type_name.endswith("Layout"):
+ return ASSETS / "light" / "layouts"
+ if type_name.endswith("Driver"):
+ return ASSETS / "light" / "drivers"
+ if type_name in ("Layouts", "Layers", "Drivers"):
+ return ASSETS / "light"
+ return ASSETS / "core" # SystemModule, FilesystemModule, DevicesModule, β¦ and the rest
# ---------------------------------------------------------------------------
# Module catalogue
@@ -125,7 +140,7 @@
# ---------------------------------------------------------------------------
# Extra shots: MoonDeck tabs + web installer
# Each entry: (filename, url, wait_selector, doc_files, anchor_text)
-# filename: saved as docs/assets/screenshots/
+# filename: saved as docs/assets/ui/
# url: full URL to load
# wait_selector: CSS selector to wait for before screenshotting (or "")
# doc_files: list of repo-relative paths to insert image into
@@ -701,7 +716,9 @@ def _sweep_orphans(modules: list) -> None:
container_names = find_container_nav_names(args.host)
core_names = find_core_module_names(args.host)
- OUT_DIR.mkdir(parents=True, exist_ok=True)
+ for _d in (UI_DIR, ASSETS/'core', ASSETS/'light', ASSETS/'light'/'effects',
+ ASSETS/'light'/'modifiers', ASSETS/'light'/'layouts', ASSETS/'light'/'drivers'):
+ _d.mkdir(parents=True, exist_ok=True)
captured, gif_captured, skipped, failed = [], [], [], []
@@ -716,7 +733,7 @@ def _sweep_orphans(modules: list) -> None:
try:
# --- Full-page UI overview screenshot --- (needs projectMM)
if not args.extras_only:
- overview_path = OUT_DIR / "ui_overview.png"
+ overview_path = UI_DIR / "ui_overview.png"
filter_allows = (not filt or filt in "ui_overview")
if filter_allows:
if not overview_path.exists() or args.force:
@@ -742,7 +759,7 @@ def _sweep_orphans(modules: list) -> None:
for filename, url, wait_sel, _doc_files, _anchor in EXTRA_SHOTS:
if filt and filt not in filename.lower():
continue
- out_path = OUT_DIR / filename
+ out_path = UI_DIR / filename
if out_path.exists() and not args.force:
print(f" skip {filename} (already captured)")
skipped.append((filename, "already exists"))
@@ -768,7 +785,7 @@ def _sweep_orphans(modules: list) -> None:
cname = container_names.get(container_type, "")
if not cname:
continue
- out_path = OUT_DIR / f"{container_type}.png"
+ out_path = asset_dir_for(container_type) / f"{container_type}.png"
if out_path.exists() and not args.force:
print(f" skip {container_type} (already captured)")
skipped.append((container_type, "already exists"))
@@ -790,7 +807,7 @@ def _sweep_orphans(modules: list) -> None:
print(f" skip {type_name} (not in state β ESP32-only?)")
skipped.append((type_name, "not in state"))
continue
- out_path = OUT_DIR / f"{type_name}.png"
+ out_path = asset_dir_for(type_name) / f"{type_name}.png"
if out_path.exists() and not args.force:
print(f" skip {type_name} (already captured)")
skipped.append((type_name, "already exists"))
@@ -807,8 +824,8 @@ def _sweep_orphans(modules: list) -> None:
for type_name, parent_type, extra_props, want_gif in MODULES:
if filt and filt not in type_name.lower():
continue
- out_path = OUT_DIR / f"{type_name}.png"
- gif_path = OUT_DIR / f"{type_name}.gif"
+ out_path = asset_dir_for(type_name) / f"{type_name}.png"
+ gif_path = asset_dir_for(type_name) / f"{type_name}.gif"
need_png = not out_path.exists() or args.force
need_gif = want_gif and args.gif and (not gif_path.exists() or args.force)
@@ -851,7 +868,7 @@ def _sweep_orphans(modules: list) -> None:
print("gif-failed ", end="", flush=True)
failed.append((type_name, "gif failed"))
- print(f"β {OUT_DIR.relative_to(ROOT)}/")
+ print(f"β {asset_dir_for(type_name).relative_to(ROOT)}/")
delete_module(args.host, actual_name)
added_ids.remove(actual_name)
time.sleep(0.5)
@@ -911,7 +928,7 @@ def _sweep(modules: list) -> None:
print(" Add module screenshots: uv run scripts/docs/update_module_docs.py")
if "ui_overview" in captured:
print(" Add UI overview to docs/architecture.md # Web UI section:")
- print(" ")
+ print(" ")
return 0 if not failed else 1
diff --git a/scripts/docs/update_module_docs.py b/scripts/docs/update_module_docs.py
index 9ef7ee6d..34db0d43 100644
--- a/scripts/docs/update_module_docs.py
+++ b/scripts/docs/update_module_docs.py
@@ -6,15 +6,15 @@
"""Insert screenshot references into docs/moonmodules/**/*.md files.
For each .md file, if a matching screenshot exists at
-docs/assets/screenshots/.png and the file doesn't already
+docs/assets//.png and the file doesn't already
contain a screenshot reference, insert one line after the first heading:
- 
+ 
The relative path is computed from the .md file's location so links
work both on GitHub and in the MoonDeck /api/docs/ renderer.
-Also reports any screenshots (PNG or GIF) in docs/assets/screenshots/ that
+Also reports any screenshots (PNG or GIF) under docs/assets/ that
are not referenced anywhere in docs/.
Usage:
@@ -28,14 +28,29 @@
ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_DIR = ROOT / "docs" / "moonmodules"
-SCREENSHOTS_DIR = ROOT / "docs" / "assets" / "screenshots"
-
-SCREENSHOT_RE = re.compile(r'!\[.*?\]\(.*?assets/screenshots/.*?\)')
-GIF_RE = re.compile(r'!\[.*?\]\(.*?assets/screenshots/.*?\.gif.*?\)')
+ASSETS = ROOT / "docs" / "assets"
+UI_DIR = ASSETS / "ui" # tooling/installer/full-page shots (not per-module)
+
+# A module's asset subfolder (domain/type), mirroring src β see folder-structure-proposal.md.
+def asset_dir_for(type_name: str):
+ if type_name.endswith("Effect"):
+ return ASSETS / "light" / "effects"
+ if type_name.endswith("Modifier"):
+ return ASSETS / "light" / "modifiers"
+ if type_name.endswith("Layout"):
+ return ASSETS / "light" / "layouts"
+ if type_name.endswith("Driver"):
+ return ASSETS / "light" / "drivers"
+ if type_name in ("Layouts", "Layers", "Drivers"):
+ return ASSETS / "light"
+ return ASSETS / "core"
+
+SCREENSHOT_RE = re.compile(r'!\[.*?\]\(.*?assets/.*?\.(?:png|jpe?g)\)')
+GIF_RE = re.compile(r'!\[.*?\]\(.*?assets/.*?\.gif.*?\)')
# Extra screenshots with fixed placement in specific doc files.
# Each entry: (filename, doc_rel_path, anchor_text)
-# filename: file in docs/assets/screenshots/
+# filename: file in docs/assets/ui/
# doc_rel_path: repo-relative path to the doc file
# anchor_text: heading/line after which to insert (exact prefix match)
EXTRA_SHOTS = [
@@ -74,7 +89,7 @@ def insert_extra_shot(doc_path: Path, filename: str, anchor: str,
# Relative path from the doc file's directory to the screenshot.
depth = len(doc_path.parent.relative_to(ROOT).parts)
- rel = "../" * depth + f"docs/assets/screenshots/{filename}"
+ rel = "../" * depth + f"docs/assets/ui/{filename}"
label = filename.replace(".png", "").replace("_", " ").title()
img_line = f"\n\n\n"
@@ -169,7 +184,7 @@ def main() -> int:
# Fix spacing around image blocks: ensure one blank line after the last
# image in a block, and collapse any triple+ blank lines to one blank.
- img_line_re = re.compile(r'^!\[.*?\]\(.*?assets/screenshots/.*?\)\n', re.MULTILINE)
+ img_line_re = re.compile(r'^!\[.*?\]\(.*?assets/.*?\)\n', re.MULTILINE)
for md_file in sorted(DOCS_DIR.rglob("*.md")):
original = md_file.read_text(encoding="utf-8")
lines = original.splitlines(keepends=True)
@@ -190,8 +205,8 @@ def main() -> int:
for md_file in sorted(DOCS_DIR.rglob("*.md")):
type_name = type_name_from_md(md_file)
- png = SCREENSHOTS_DIR / f"{type_name}.png"
- gif = SCREENSHOTS_DIR / f"{type_name}.gif"
+ png = asset_dir_for(type_name) / f"{type_name}.png"
+ gif = asset_dir_for(type_name) / f"{type_name}.gif"
if not png.exists():
skipped_no_screenshot.append(md_file.relative_to(ROOT))
@@ -209,7 +224,7 @@ def main() -> int:
# --- Extra shots: insert MoonDeck tab + installer images into fixed doc files ---
extra_updated = []
for filename, doc_rel, anchor in EXTRA_SHOTS:
- screenshot = SCREENSHOTS_DIR / filename
+ screenshot = UI_DIR / filename
if not screenshot.exists():
continue
doc_path = ROOT / doc_rel
@@ -242,7 +257,7 @@ def main() -> int:
all_docs_text += (ROOT / "README.md").read_text(encoding="utf-8")
unreferenced = []
- for f in sorted(SCREENSHOTS_DIR.glob("*")):
+ for f in sorted(ASSETS.rglob("*")):
if f.suffix.lower() not in (".png", ".gif"):
continue
if f.name not in all_docs_text:
diff --git a/scripts/moondeck.py b/scripts/moondeck.py
index 0d744e80..046b745d 100644
--- a/scripts/moondeck.py
+++ b/scripts/moondeck.py
@@ -1534,7 +1534,11 @@ def _serve_doc_asset(self):
The renderer resolves relative image src values to ROOT-relative paths
before building the URL, so this handler only needs a simple join."""
import mimetypes
- rel = self.path[len("/api/doc-asset/"):]
+ from urllib.parse import unquote
+ # URL-decode the path: a doc image with a space in its name is written `Hue%20driver.png` in
+ # the markdown, so the request path carries `%20`; without decoding, the file lookup would seek
+ # a literal "%20" in the name and 404.
+ rel = unquote(self.path[len("/api/doc-asset/"):])
# Resolve against ROOT and ensure no escape.
try:
asset_path = (ROOT / rel).resolve()
@@ -1633,6 +1637,58 @@ def _link_tag(m):
s = re.sub(r'(?\1', s)
return s
+ def _render_cell(c: str) -> str:
+ """Render one table cell. The compact module pages (effects/modifiers/layouts)
+ use raw
previews and row anchors inside cells β
+ two tags GitHub/VS Code honor but the default escape path would turn to literal
+ text. Pass those two through (resolving an
src to /api/doc-asset/ like the
+ markdown-image path does); escape + inline-render the rest."""
+ def _img_attrs(tag: str) -> dict:
+ """Pull src/width/alt/style from an
tag regardless of attribute ORDER β each
+ is matched by its own name="value" search, so an author may write them in any sequence
+ (the previous fixed srcβwidthβaltβstyle regex silently dropped out-of-order attrs)."""
+ def attr(name):
+ m = re.search(rf'\b{name}="([^"]*)"', tag)
+ return m.group(1) if m else None
+ return {k: attr(k) for k in ("src", "width", "alt", "style")}
+ def _img(a):
+ src_ = a.get("src") or ""
+ width = a.get("width")
+ style = a.get("style")
+ alt_ = a.get("alt")
+ if not src_.startswith(("http://", "https://", "/")):
+ abs_src = (md_path.parent / src_).resolve()
+ try:
+ src_ = str(abs_src.relative_to(ROOT.resolve()))
+ except ValueError:
+ pass
+ src_ = f"/api/doc-asset/{src_}"
+ # Escape every attribute value that reaches the HTML β src/width/style as well as alt β
+ # so a doc-page value with a quote or angle bracket can't break the tag or inject markup.
+ wattr = f' width="{html_mod.escape(width)}"' if width else ""
+ altattr = f' alt="{html_mod.escape(alt_)}"' if alt_ else ""
+ # Preserve an author-set width style (the cross-renderer size lever) and append our
+ # margin so the preview isn't flush against the cell edges.
+ style = (style + ";" if style else "") + "margin:4px 0"
+ return f'
'
+ # No raw HTML β ordinary escaped+inline cell (the common case).
+ if "
line
+ # breaks in a "card" cell), escape the rest, render markdown, then restore them.
+ tokens = []
+ def _stash(html: str) -> str:
+ tokens.append(html)
+ return f"\x00{len(tokens)-1}\x00"
+ # Match the
envelope, then extract attributes by name (order-independent).
+ c = re.sub(r'
]*>', lambda m: _stash(_img(_img_attrs(m.group(0)))), c)
+ c = re.sub(r'', lambda m: _stash(f''), c)
+ c = re.sub(r'
', lambda _: _stash("
"), c)
+ out = render_inline(html_mod.escape(c))
+ for i, html in enumerate(tokens):
+ out = out.replace(f"\x00{i}\x00", html)
+ return out
+
def close_list_if_open():
nonlocal in_list
if in_list:
@@ -1706,7 +1762,7 @@ def _heading_slug(text: str) -> tuple[str, str]:
# CSS handle the first-row styling.
cell_tag = "td"
cell_html = "".join(
- f"<{cell_tag}>{render_inline(html_mod.escape(c))}{cell_tag}>"
+ f"<{cell_tag}>{_render_cell(c)}{cell_tag}>"
for c in cells
)
lines.append(f"{cell_html}
")
@@ -1762,10 +1818,36 @@ def _heading_slug(text: str) -> tuple[str, str]:
continue
close_list_if_open()
+ stripped_check = raw_line.strip()
+
+ # A standalone
line (the per-module doc pages put the preview gif on its own
+ # line above the description). Resolve a relative src to /api/doc-asset/ and keep the
+ # width, like the table-cell path does β the allowlist below doesn't cover
.
+ if re.fullmatch(r'
]*>(?:\s*)?', stripped_check):
+ # Extract each attribute by name, order-independent (an author may write src/alt/width
+ # in any sequence β a fixed-order regex would silently drop the out-of-order ones).
+ def _attr(name):
+ m = re.search(rf'\b{name}="([^"]*)"', stripped_check)
+ return m.group(1) if m else None
+ src_ = _attr("src") or ""
+ if not src_.startswith(("http://", "https://", "/")):
+ abs_src = (md_path.parent / src_).resolve()
+ try:
+ src_ = f"/api/doc-asset/{abs_src.relative_to(ROOT.resolve())}"
+ except ValueError:
+ pass
+ w_ = _attr("width")
+ # Escape every attribute value before it reaches the HTML (like _render_cell._img does)
+ # so a doc-page src/width/alt with a quote or bracket can't break the tag or inject markup.
+ wattr = f' width="{html_mod.escape(w_)}"' if w_ else ""
+ alt_ = _attr("alt")
+ aattr = f' alt="{html_mod.escape(alt_)}"' if alt_ is not None else ""
+ lines.append(f'
')
+ continue
+
# Pass-through for a fixed allowlist of structural HTML tags used
# by history_report.py's combined graph+commits output. Narrowed
# to known safe tags so arbitrary doc content can't inject scripts.
- stripped_check = raw_line.strip()
if (stripped_check.startswith("<")
and stripped_check.endswith(">")
and _allowed_html_re.match(stripped_check)):
diff --git a/src/core/AudioFrame.h b/src/core/AudioFrame.h
index 5aa3cf10..cd4a1031 100644
--- a/src/core/AudioFrame.h
+++ b/src/core/AudioFrame.h
@@ -16,11 +16,18 @@ namespace mm {
// math straight off them (the hot-path rule); the float FFT magnitudes never
// leave the module.
struct AudioFrame {
- uint16_t level = 0; // overall sound level (RMS), 0..255-ish β the VU value
- uint16_t peakHz = 0; // dominant frequency this frame, in Hz (0 = none)
- uint16_t peakMag = 0; // magnitude of that peak (gates the peakHz update)
- uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255
- // (bass = bands[0], treble = bands[15])
+ uint16_t level = 0; // RAW overall sound level (RMS), 0..255-ish β the instantaneous VU
+ // value, recomputed each audio block with NO smoothing. Snaps to a
+ // transient (a drum hit spikes immediately) β use this for punchy,
+ // beat-reactive effects. (WLED calls this `volumeRaw`.)
+ uint16_t levelSmoothed = 0; // SMOOTHED level: an exponential moving average of `level`, so it
+ // lags and rounds off sudden changes and "breathes" with the music
+ // instead of twitching. Use this for calm/glowing effects that
+ // should swell, not flash. (WLED calls this `volume`/`volumeSmth`.)
+ uint16_t peakHz = 0; // dominant frequency this frame, in Hz (0 = none)
+ uint16_t peakMag = 0; // magnitude of that peak (gates the peakHz update)
+ uint8_t bands[16] = {}; // 16 log-spaced frequency-band magnitudes, 0..255
+ // (bass = bands[0], treble = bands[15])
};
} // namespace mm
diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h
index 602761a0..7d3b87cc 100644
--- a/src/core/AudioModule.h
+++ b/src/core/AudioModule.h
@@ -28,6 +28,7 @@
#include "core/AudioFrame.h"
#include "core/AudioLevel.h"
#include "core/AudioBands.h"
+#include "core/math8.h" // beatsin8 / sin8 β the simulated-audio oscillators
#include "platform/platform.h"
#include
@@ -73,6 +74,16 @@ class AudioModule : public MoonModule {
// ambient room dark, lower for a quiet room.
uint8_t gain = 222; // sensitivity β HIGHER = more (a narrower dB window
// so a given sound fills more of the bar).
+ // Simulated audio: fill the frame with a synthesized signal so audio-reactive effects are demoable
+ // (and testable) without a mic or music. Two patterns:
+ // `music` β a plausible song: multi-sine bands + a swelling volume + a periodic beat + a
+ // sweeping peak. Nice for demos (bars dance, VU breathes, peaks move).
+ // `sweep` β a single band lit, marching bassβtreble on a timer, with the peak frequency and a
+ // steady volume tracking it. Deterministic β the clean test pattern to check that each
+ // effect responds across the whole spectrum.
+ // A real mic always wins: when `mode` is a fill-in mode it only runs while there's no real signal.
+ uint8_t simulate = 0; // 0 = off, 1 = music (fill silence), 2 = sweep (fill silence),
+ // 3 = music (always), 4 = sweep (always)
static constexpr uint16_t kSampleRates[] = {8000, 16000, 22050, 44100};
static constexpr uint8_t kSampleRateCount = 4;
@@ -87,6 +98,9 @@ class AudioModule : public MoonModule {
controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount);
controls_.addUint8("floor", floor, 0, 255);
controls_.addUint8("gain", gain, 1, 255);
+ static constexpr const char* kSimulateOptions[] = {
+ "off", "music (silence)", "sweep (silence)", "music (always)", "sweep (always)"};
+ controls_.addSelect("simulate", simulate, kSimulateOptions, 5);
// Read-only live read-outs (formatted in loop1s). Derived every second,
// nothing to persist, so ReadOnly (the display-only type) not a flipped
// Text β same idiom as SystemModule's uptime/fps.
@@ -107,10 +121,13 @@ class AudioModule : public MoonModule {
}
void onBuildState() override { reinit(); MoonModule::onBuildState(); }
- void setup() override { active_ = this; reinit(); }
+ void setup() override {
+ if (active_ == nullptr) active_ = this; // first live mic wins; a 2nd mic is captured but not read
+ reinit();
+ }
void teardown() override {
deinit();
- if (active_ == this) active_ = nullptr; // effects fall back to silence
+ if (active_ == this) active_ = nullptr; // vacate; a surviving module re-elects itself in loop()
}
// The latest analysed frame β what effects read. Always valid (zeroed until
@@ -122,17 +139,38 @@ class AudioModule : public MoonModule {
// and an effect can be added/removed via the UI at any time, so it can't rely
// on a boot-time setter β it asks here. Returns the live mic's frame while one
// exists, else a static all-silent frame, so an effect added before/without a
- // mic still reads valid silence instead of null. The active instance registers
- // itself in setup() and clears the pointer in teardown(), so add/remove in any
- // order leaves a coherent answer (the robustness rule).
+ // mic still reads valid silence instead of null. The FIRST live module claims the seat in setup(),
+ // vacates it in teardown(), and any running module re-claims an empty seat in loop() β so a device
+ // with two mics reads the first consistently, and removing the active one lets a survivor take over.
+ // Add/remove in any order leaves a coherent answer (the robustness rule).
static const AudioFrame* latestFrame() {
static const AudioFrame kSilence{};
return active_ ? &active_->frame_ : &kSilence;
}
void loop() override {
- if constexpr (!platform::hasI2sMic) return; // inert off I2S targets
- if (!inited_) return; // bad init β idle (zero frame)
+ // Self-elect as the active mic if the seat is empty. setup() gives it to the first live module
+ // and teardown() vacates it, but removing the active module while a second one is still running
+ // would otherwise leave the seat empty (effects go silent). A running module re-claiming an
+ // empty seat here keeps latestFrame() pointing at a live frame for ANY add/remove order β the
+ // survivor takes over on its next tick (robustness).
+ //
+ if (active_ == nullptr) active_ = this;
+
+ // Simulated audio (see the `simulate` control): 0=off, 1=music-on-silence, 2=sweep-on-silence,
+ // 3=music-always, 4=sweep-always. Real mic input always wins in the "on-silence" modes β the
+ // mic path below resets realQuietMs_ whenever a block carries signal, and synthesizeFrame()
+ // no-ops while that timer says the mic is live. Off I2S (desktop / mic-less) or on a bad init
+ // there's no mic, so the sim is the only possible source: run it and return.
+ const bool simSweep = (simulate == 2 || simulate == 4);
+ const bool simAlways = (simulate >= 3);
+ if constexpr (!platform::hasI2sMic) {
+ if (simulate != 0) synthesizeFrame(simSweep); // the only possible source off I2S
+ return;
+ } else {
+ if (!inited_) { if (simulate != 0) synthesizeFrame(simSweep); return; }
+ if (simAlways) { synthesizeFrame(simSweep); return; } // forced: skip the mic entirely
+ }
// Drain whatever the DMA holds this tick (non-blocking) into the free tail
// of the block accumulator. A full kBlock takes ~23 ms to arrive (longer
@@ -155,6 +193,13 @@ class AudioModule : public MoonModule {
// per-band display.
computeLevel(samples_, kBlock, static_cast(floor / 2), gain, frame_);
+ // Smoothed level: a one-pole exponential moving average of the raw `level`, so effects that
+ // want a calm, breathing VU (rather than the raw value's snap-to-transient) read a value that
+ // lags and rounds off sudden changes. 3/4 old + 1/4 new is the textbook light smoothing β
+ // fast enough to follow the music, slow enough to hide per-block jitter. Integer-only, one
+ // block behind, off the per-light path. (WLED's `volume`/`volumeSmth` to our raw `level`.)
+ frame_.levelSmoothed = static_cast((frame_.levelSmoothed * 3 + frame_.level) / 4);
+
// Spectrum: window -> FFT -> 16 log bands, same floor/gain mapping.
uint16_t peakHz = 0, peakMag = 0;
applyWindow(samples_, kBlock, windowed_);
@@ -172,6 +217,59 @@ class AudioModule : public MoonModule {
// live every render tick) move with the music. The window peak is the representative reading.
// Display-only β the live frame_.level the effects/LEDs use is untouched.
if (frame_.level > levelPeak_) levelPeak_ = frame_.level;
+
+ // Auto fill-in (simulate 1/2): if the mic has been quiet for a bit, synthesize so the effects
+ // still have something to react to. `level` this block re-arms the "real audio" grace period;
+ // once it lapses, the sim takes over and yields again the instant real sound returns.
+ if (simulate == 1 || simulate == 2) {
+ if (frame_.level > kSimRealThreshold) realBlocks_ = kSimRealGraceBlocks;
+ else if (realBlocks_ > 0) realBlocks_--;
+ if (realBlocks_ == 0) synthesizeFrame(simulate == 2);
+ }
+ }
+
+ // Fill frame_ with a synthesized signal. sweep=false β a plausible "song" (each band its own
+ // oscillator, a swelling volume, a periodic beat, a drifting peak); sweep=true β one band lit
+ // marching bassβtreble on a timer (the deterministic test pattern). All integer LUT math (sin8),
+ // once per tick, off the per-light path. Also runs the same levelSmoothed EMA the mic path does.
+ void synthesizeFrame(bool sweep) {
+ const uint32_t t = platform::millis();
+ if (sweep) {
+ // One band lit at a time, stepping bassβtreble every ~250 ms and wrapping. The lit band
+ // ramps up-and-down (triangle) so it's not a hard on/off, and the peak frequency + volume
+ // track the swept band so frequency-mapped and volume effects follow it too.
+ const uint8_t pos = static_cast((t / 250u) % 16u);
+ const uint8_t env = triwave8(static_cast((t % 250u) * 255u / 250u)); // 0..255 within a step
+ for (uint8_t b = 0; b < 16; b++) frame_.bands[b] = (b == pos) ? env : 0;
+ frame_.level = env;
+ frame_.peakHz = static_cast(80 + pos * 700); // bassβ~10.6 kHz across the 16 steps
+ frame_.peakMag = env;
+ } else {
+ // Musical "song": each band an independent sine at its own rate/phase (bass slow, treble
+ // fast), a slow overall volume swell, and a periodic beat pulse that briefly lifts the low
+ // bands + volume so beat-reactive effects fire. A drifting peak sweeps the dominant tone.
+ const uint8_t beat = (t % 600u < 90u) ? static_cast(triwave8(static_cast((t % 600u) * 255u / 90u))) : 0;
+ uint16_t sum = 0;
+ for (uint8_t b = 0; b < 16; b++) {
+ // Per-band oscillator: rate rises with b (treble flickers faster), phase spread by b.
+ const uint8_t rate = static_cast(1 + b); // BPM-ish multiplier
+ const uint8_t osc = sin8(static_cast(t * rate / 8u + b * 24u));
+ uint16_t v = static_cast((osc * 3u) / 4u); // 0..191 base
+ if (b < 4) v = static_cast(v + beat / 2u); // beat lifts the bass
+ frame_.bands[b] = static_cast(v > 255 ? 255 : v);
+ sum = static_cast(sum + frame_.bands[b]);
+ }
+ const uint8_t swell = sin8(static_cast(t / 24u)); // slow volume breath
+ uint16_t lvl = static_cast(swell / 2u + sum / 32u + beat / 2u);
+ frame_.level = lvl > 255 ? 255 : lvl;
+ // Peak drifts across the spectrum so freq-mapped effects move.
+ frame_.peakHz = static_cast(80 + sin8(static_cast(t / 40u)) * 40u);
+ frame_.peakMag = frame_.level;
+ }
+ frame_.levelSmoothed = static_cast((frame_.levelSmoothed * 3 + frame_.level) / 4);
+ // Feed the same 1 s peak-hold the mic path uses, so the "level RMS" display tracks the
+ // synthesized level instead of reading a stale 0 (the display is peak-over-window, not live).
+ if (frame_.level > levelPeak_) levelPeak_ = static_cast(frame_.level);
}
void loop1s() override {
@@ -203,6 +301,12 @@ class AudioModule : public MoonModule {
char peakStr_[12] = {};
uint8_t levelPeak_ = 0; // peak frame_.level across the current 1 s display window (UI only)
+ // Auto fill-in (simulate 1/2): treat the mic as "live" for a grace window after any block above
+ // the threshold, so brief gaps between beats don't flip to the sim. ~2 s of blocks (β86/blockΒ·23ms).
+ static constexpr uint16_t kSimRealThreshold = 4; // a block level above this counts as real sound
+ static constexpr uint16_t kSimRealGraceBlocks = 86; // ~2 s at ~23 ms/block before the sim takes over
+ uint16_t realBlocks_ = 0; // grace countdown: >0 = mic was recently live, hold off the sim
+
static constexpr const char* kInitFailMsg = "mic init failed β check pins / rate";
void reinit() {
diff --git a/src/core/Control.cpp b/src/core/Control.cpp
index 3a77774a..333af7db 100644
--- a/src/core/Control.cpp
+++ b/src/core/Control.cpp
@@ -33,6 +33,7 @@ const char* controlTypeName(ControlType t) {
case ControlType::ReadOnly: return "display";
case ControlType::ReadOnlyInt: return "display-int";
case ControlType::Select: return "select";
+ case ControlType::Palette: return "palette";
case ControlType::Progress: return "progress";
case ControlType::IPv4: return "ipv4";
case ControlType::List: return "list";
@@ -102,8 +103,9 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) {
sink.appendf("%d", *static_cast(c.ptr));
return;
case ControlType::Select:
- // The selected index β the option strings go in the metadata
- // block (writeControlMetadata) where the UI also wants them.
+ case ControlType::Palette:
+ // The selected index β the option strings / swatch colours go in the
+ // metadata block (writeControlMetadata) where the UI also wants them.
sink.appendf("%u", *static_cast(c.ptr));
return;
case ControlType::Progress:
@@ -169,6 +171,14 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) {
sink.append("]");
return;
}
+ case ControlType::Palette: {
+ // The light domain supplies the option objects ({name, colors}) via the function
+ // pointer in `aux` β core stays palette-agnostic. Falls back to an empty array.
+ sink.append(",\"options\":[");
+ if (c.aux) reinterpret_cast(c.aux)(sink);
+ sink.append("]");
+ return;
+ }
case ControlType::Progress:
// `bytes` (in min, see addProgress): 1 β KB label, 0 β plain count.
sink.appendf(",\"total\":%lu,\"bytes\":%s", static_cast(c.aux),
@@ -296,7 +306,8 @@ ApplyResult applyControlValue(const ControlDescriptor& c,
mm::json::parseString(json, key, static_cast(c.ptr), maxLen);
return ApplyResult::Ok;
}
- case ControlType::Select: {
+ case ControlType::Select:
+ case ControlType::Palette: {
int v = mm::json::parseInt(json, key);
const int hi = c.max > 0 ? c.max - 1 : 0;
if (policy == ApplyPolicy::Strict && (v < 0 || v > hi)) {
diff --git a/src/core/Control.h b/src/core/Control.h
index a9524e15..59687a9b 100644
--- a/src/core/Control.h
+++ b/src/core/Control.h
@@ -126,16 +126,26 @@ enum class ControlType : uint8_t {
// "control holds a void* into module-owned data" shape every addX()
// uses, one level up. (Data-over-objects: no per-row object graph,
// no allocation on rebuild β see docs/architecture.md hot-path.)
- Button // a momentary action, not a stored value. The UI renders a button;
+ Button, // a momentary action, not a stored value. The UI renders a button;
// a click POSTs a value and the module's onUpdate() runs the action.
// No backing storage (ptr unused) and non-persistable β distinct
// from Bool, which is an on/off STATE that renders as a toggle and a
// toggle is the wrong affordance for "do this now" (e.g. rescan).
+ Palette // a colour-palette dropdown (ptr β uint8_t index). Like Select, but
+ // each option carries its gradient *colours* (16 hex stops) so the UI
+ // renders a gradient swatch per option, not just a name. The light
+ // domain supplies the names + swatches via the Palette type; the wire
+ // shape (options:[{name,colors}]) is serialized in writeControlMetadata.
};
// Forward-declared (defined below the enum) so the descriptor can hold a pointer.
class JsonSink;
+// A ControlType::Palette control's options come from the light domain (it owns the palette set
+// and the swatch colours). The descriptor's `aux` holds a pointer to this function; core calls it
+// to emit the `"options":[{"name":β¦,"colors":β¦}, β¦]` array β core stays palette-agnostic.
+using PaletteOptionsFn = void (*)(JsonSink& sink);
+
// Backing for a ControlType::List control. The module that owns the data (e.g.
// DevicesModule over its device array) implements this; the control descriptor's
// `ptr` points at the implementation. Serialization (writeControlValue) walks
@@ -294,6 +304,13 @@ class ControlList {
ControlType::ReadOnlyInt, 0, 0};
}
+ // A colour-palette dropdown: like a Select (ptr β uint8_t index, max = optionCount), but the
+ // options carry swatch colours. `optionsFn` (light-domain) emits the {name,colors} objects.
+ void addPalette(const char* name, uint8_t& var, PaletteOptionsFn optionsFn, uint8_t optionCount) {
+ grow();
+ controls_[count_++] = {&var, name, reinterpret_cast(optionsFn), ControlType::Palette, 0, optionCount};
+ }
+
void addSelect(const char* name, uint8_t& var, const char* const* options, uint8_t optionCount) {
grow();
controls_[count_++] = {&var, name, reinterpret_cast(options), ControlType::Select, 0, optionCount};
diff --git a/src/core/DeviceIdentify.h b/src/core/DeviceIdentify.h
index 3eacf3d4..6a774a9f 100644
--- a/src/core/DeviceIdentify.h
+++ b/src/core/DeviceIdentify.h
@@ -12,12 +12,13 @@
namespace mm {
// What a discovered device is.
-enum class DevType : uint8_t { Generic = 0, ProjectMM = 1, Wled = 2 };
+enum class DevType : uint8_t { Generic = 0, ProjectMM = 1, Wled = 2, Hue = 3 };
inline const char* devTypeStr(DevType t) {
switch (t) {
case DevType::ProjectMM: return "projectMM";
case DevType::Wled: return "WLED";
+ case DevType::Hue: return "Hue bridge";
case DevType::Generic: return "generic";
}
return "generic";
diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h
index fdb87c5b..7db9ee94 100644
--- a/src/core/DevicesModule.h
+++ b/src/core/DevicesModule.h
@@ -51,6 +51,7 @@ class DevicesModule : public MoonModule, public ListSource {
sink.append("{\"name\":");
sink.writeJsonString(d.name[0] ? d.name : ip);
sink.appendf(",\"ip\":\"%s\",\"type\":\"%s\"", ip, devTypeStr(d.type));
+ if (d.type == DevType::Hue) sink.appendf(",\"colour\":%u", d.colourCount);
if (d.self) sink.append(",\"self\":true");
sink.append("}");
}
@@ -64,6 +65,7 @@ class DevicesModule : public MoonModule, public ListSource {
sink.writeJsonString(d.name[0] ? d.name : ip);
sink.appendf(",\"ip\":\"%s\",\"url\":\"http://%s/\",\"type\":\"%s\"",
ip, ip, devTypeStr(d.type));
+ if (d.type == DevType::Hue) sink.appendf(",\"colour\":%u", d.colourCount);
if (d.self) {
sink.append(",\"self\":true"); // self is always "now" β no meaningful age
} else if (d.cached) {
@@ -101,9 +103,15 @@ class DevicesModule : public MoonModule, public ListSource {
Device& d = devices_[deviceCount_++];
std::memcpy(d.ip, octets, 4);
std::snprintf(d.name, sizeof(d.name), "%s", name);
- d.type = (std::strcmp(typeStr, "projectMM") == 0) ? DevType::ProjectMM
- : (std::strcmp(typeStr, "WLED") == 0) ? DevType::Wled
- : DevType::Generic;
+ d.type = (std::strcmp(typeStr, "projectMM") == 0) ? DevType::ProjectMM
+ : (std::strcmp(typeStr, "WLED") == 0) ? DevType::Wled
+ : (std::strcmp(typeStr, "Hue bridge") == 0) ? DevType::Hue
+ : DevType::Generic;
+ // Clamp the persisted count to the valid range (0..127, the HueDriver's
+ // colourCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
+ // value when narrowed. 0 for non-bridge rows (the key is absent β readInt = 0).
+ const long colour = mm::json::readInt(mm::json::member(doc, el, "colour"));
+ d.colourCount = static_cast(colour < 0 ? 0 : (colour > 127 ? 127 : colour));
d.self = false;
d.cached = true; // restored, not re-heard live β UI shows "cached", not a time
// Stamp "now" so the cached entry gets its kCachedGraceMs PROBATION window
@@ -123,8 +131,44 @@ class DevicesModule : public MoonModule, public ListSource {
controls_.addList("devices", *this); // this module is the ListSource
}
+ // The boot DevicesModule (exactly one exists). A foreign-bridge driver in the light domain
+ // (HueDriver) registers a discovered bridge through this without a compile-time dependency
+ // on DevicesModule's address β the same static-seam shape as AudioModule::latestFrame().
+ static DevicesModule* active() { return active_; }
+
+ // Register a Hue bridge a HueDriver has connected to. Unlike upsertDevice (driven by a UDP
+ // presence packet), a bridge is discovered out-of-band β the driver already holds its IP +
+ // app key β so this is the explicit entry point for that. Idempotent: updates the name +
+ // colour count of the existing row, or inserts one. `colour` is how many of the bridge's
+ // lights are colour-capable, the figure for sizing a layout. Persisted like any device row.
+ void upsertHueBridge(const uint8_t ip[4], const char* name, uint8_t colour) {
+ Device* d = findByIp(ip);
+ bool persistChanged = false;
+ if (!d) {
+ if (deviceCount_ >= kMaxDevices) return; // bounded; silently cap
+ d = &devices_[deviceCount_++];
+ std::memcpy(d->ip, ip, 4);
+ persistChanged = true;
+ }
+ if (d->type != DevType::Hue) { d->type = DevType::Hue; persistChanged = true; }
+ if (name && name[0] && std::strcmp(d->name, name) != 0) {
+ std::snprintf(d->name, sizeof(d->name), "%s", name);
+ persistChanged = true;
+ }
+ if (!d->name[0]) { formatDottedQuad(d->name, ip); persistChanged = true; }
+ if (d->colourCount != colour) { d->colourCount = colour; persistChanged = true; }
+ d->lastSeenMs = platform::millis(); // transient β keeps the bridge from ageing out
+ // A cached row coming (back) online is a status change even with no persisted field edit β
+ // refresh on that transition too, else a re-announced bridge stays greyed-out in the UI.
+ const bool wasCached = d->cached;
+ d->cached = false;
+ if (persistChanged) sortByName(); // re-sort only on a real persisted change
+ if (persistChanged || wasCached) refreshStatus();
+ }
+
void setup() override {
MoonModule::setup();
+ active_ = this;
// The last-known device list is restored automatically before setup() by the
// persistence overlay (the `devices` List control round-trips as JSON). So the
// UI shows it INSTANTLY on boot β no waiting for an announcement to re-arrive.
@@ -192,8 +236,13 @@ class DevicesModule : public MoonModule, public ListSource {
// session. Cleared on the first live sighting.
uint32_t lastSeenMs = 0; // platform::millis() at the most recent mDNS sighting.
// Age-out drops a non-self device unheard for kStaleMs.
+ uint8_t colourCount = 0; // Hue bridge only: how many of its lights are colour-capable
+ // (the figure for sizing a layout). 0 for non-bridge rows.
};
+ // The boot instance, for active() β the foreign-bridge static seam (mirrors AudioModule).
+ static inline DevicesModule* active_ = nullptr;
+
static constexpr uint8_t kMaxDevices = 32; // a LAN's worth; bounded, no heap
// Broadcast our presence every this-many loop1s ticks (β seconds). Slow + light, like
// WLED's ~30 s beacon; a new device appears within this window. A departed device
diff --git a/src/core/FilesystemModule.cpp b/src/core/FilesystemModule.cpp
index 039f9953..05fb68b0 100644
--- a/src/core/FilesystemModule.cpp
+++ b/src/core/FilesystemModule.cpp
@@ -36,10 +36,22 @@ void FilesystemModule::setup() {
void FilesystemModule::onBuildControls() {
controls_.addReadOnly("lastSaved", lastSaveStr_, sizeof(lastSaveStr_));
+ // Filesystem-partition usage bar (bytes used / total). Lives here β on the module that owns the
+ // filesystem β not on SystemModule. Read the total once; loop1s refreshes the used value. Bound
+ // only when the platform reports a real partition (desktop / a chip without a data partition
+ // reports 0, so the bar is omitted rather than showing 0/0).
+ totalFsVal_ = static_cast(platform::filesystemTotal());
+ fsUsedVal_ = static_cast(platform::filesystemUsed());
+ if (totalFsVal_ > 0) {
+ controls_.addProgress("filesystem", fsUsedVal_, totalFsVal_);
+ }
MoonModule::onBuildControls();
}
void FilesystemModule::loop1s() {
+ // Refresh the usage bar first β cheap, and it should track saves even before the mount/scheduler
+ // guards below (the total is fixed; only the used value moves as files are written).
+ if (totalFsVal_ > 0) fsUsedVal_ = static_cast(platform::filesystemUsed());
if (!mounted_ || !scheduler_) return;
updateLastSavedStr();
if (!dirtyPending_) return;
diff --git a/src/core/FilesystemModule.h b/src/core/FilesystemModule.h
index 8b59cfaf..80fcbf9c 100644
--- a/src/core/FilesystemModule.h
+++ b/src/core/FilesystemModule.h
@@ -83,6 +83,8 @@ class FilesystemModule : public MoonModule {
uint32_t lastDirtyMs_ = 0;
uint32_t lastSaveMs_ = 0;
char lastSaveStr_[24] = "never"; // "lastSaved" read-only control value
+ uint32_t fsUsedVal_ = 0; // "filesystem" progress: bytes used, refreshed in loop1s
+ uint32_t totalFsVal_ = 0; // "filesystem" progress: partition total, read once in onBuildControls
// Shared load/save buffer β load runs once at boot (phase 2), save runs in loop1s after
// the 2s debounce. Mutually exclusive, so one buffer is enough. Kept off the task stack
// since 2KB plus recursive applyNode/writeNode frames is uncomfortably close to the ESP32
diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h
index fc50e2bf..3292b8b4 100644
--- a/src/core/SystemModule.h
+++ b/src/core/SystemModule.h
@@ -46,8 +46,8 @@ class SystemModule : public MoonModule {
// on the next WebSocket push.
std::snprintf(chipInfo_, sizeof(chipInfo_), "%s", platform::chipModel());
std::snprintf(sdkInfo_, sizeof(sdkInfo_), "%s", platform::sdkVersion());
- std::snprintf(sdkDateInfo_, sizeof(sdkDateInfo_), "%s", platform::sdkDate());
- // version / build / firmware (firmware identity) moved to FirmwareUpdateModule.
+ // version / build / firmware (firmware identity, incl. the build date) moved to
+ // FirmwareUpdateModule β SystemModule keeps only the IDF version string (`sdk`).
std::snprintf(bootReasonStr_, sizeof(bootReasonStr_), "%s", platform::resetReason());
if constexpr (platform::hasWifiCoprocessor) {
std::snprintf(coprocStr_, sizeof(coprocStr_), "%s", platform::coprocessorWifi());
@@ -70,7 +70,6 @@ class SystemModule : public MoonModule {
totalInternalVal_ = static_cast(platform::totalInternalHeap());
totalHeapVal_ = static_cast(platform::totalHeap());
chipFlashVal_ = static_cast(platform::flashChipSize());
- totalFsVal_ = static_cast(platform::filesystemTotal());
// Device name on top
controls_.addText("deviceName", deviceName_, sizeof(deviceName_));
@@ -107,19 +106,16 @@ class SystemModule : public MoonModule {
}
controls_.addReadOnly("maxBlock", maxBlockStr_, sizeof(maxBlockStr_));
- // Flash/filesystem. (version / build / firmware / firmwarePartition moved to
- // FirmwareUpdateModule β the firmware card owns firmware identity + partition usage.)
+ // Flash. (version / build / firmware / firmwarePartition moved to FirmwareUpdateModule β the
+ // firmware card owns firmware identity + partition usage; the filesystem-usage bar moved to
+ // FilesystemModule β the module that owns the filesystem.)
if (chipFlashVal_ > 0) {
controls_.addReadOnly("flash", flashStr_, sizeof(flashStr_));
}
- if (totalFsVal_ > 0) {
- controls_.addProgress("filesystem", fsUsedVal_, totalFsVal_);
- }
// Static info
controls_.addReadOnly("chip", chipInfo_, sizeof(chipInfo_));
controls_.addReadOnly("sdk", sdkInfo_, sizeof(sdkInfo_));
- controls_.addReadOnly("sdkDate", sdkDateInfo_, sizeof(sdkDateInfo_));
controls_.addReadOnly("bootReason", bootReasonStr_, sizeof(bootReasonStr_));
// WiFi co-processor (P4 + on-board C6) firmware read-out. Gated at compile
// time on hasWifiCoprocessor, so the whole control β and the snprintf/query
@@ -176,8 +172,6 @@ class SystemModule : public MoonModule {
uint32_t totalPsram = totalHeapVal_ > totalInternalVal_ ? totalHeapVal_ - totalInternalVal_ : 0;
psramUsedVal_ = totalPsram > freePsram ? totalPsram - freePsram : 0;
- fsUsedVal_ = static_cast(platform::filesystemUsed());
-
// maxInternalAllocBlock β NOT maxAllocBlock. The internal-RAM block
// is the scarce-resource KPI; the all-memory variant reports ~8 MB
// on PSRAM-equipped boards (S3/S2) and tells the user nothing.
@@ -242,19 +236,16 @@ class SystemModule : public MoonModule {
char maxBlockStr_[12] = {};
uint32_t heapUsedVal_ = 0;
uint32_t psramUsedVal_ = 0;
- uint32_t fsUsedVal_ = 0;
// Static (set in setup)
char chipInfo_[16] = {};
char sdkInfo_[24] = {};
- char sdkDateInfo_[16] = {}; // IDF app-descriptor compile date, e.g. "May 26 2026"
char bootReasonStr_[16] = {};
char coprocStr_[24] = {}; // WiFi co-processor status, e.g. "C6 fw 2.12.9" / "not detected"
uint32_t totalInternalVal_ = 0;
uint32_t totalHeapVal_ = 0;
char flashStr_[12] = {};
uint32_t chipFlashVal_ = 0; // total chip flash
- uint32_t totalFsVal_ = 0;
};
} // namespace mm
diff --git a/src/core/color.h b/src/core/color.h
index 4eb095e2..874b37d0 100644
--- a/src/core/color.h
+++ b/src/core/color.h
@@ -33,55 +33,11 @@ constexpr RGB hsvToRgb(uint8_t h, uint8_t s, uint8_t v) {
}
}
-// scale8: (val * scale) / 256, with +1 correction so scale8(x, 255) == x
+// scale8: (val * scale) / 256, with +1 correction so scale8(x, 255) == x. The fundamental
+// channel-scale op (brightness, blend), kept here with the colour type it scales. Integer
+// trig (sin8/cos8/atan2_8/dist8) and the rest of the 8-bit math library live in math8.h.
constexpr uint8_t scale8(uint8_t val, uint8_t scale) {
return static_cast(((static_cast(val) * static_cast(scale)) + 1 + ((static_cast(val) * static_cast(scale)) >> 8)) >> 8);
}
-// 256-entry sine LUT: sin(2*pi*i/256)*127+128, stored in flash.
-// Integer-only sin/cos for effects. Use sin8 for sine, cos8 = sin8(i + 64).
-inline constexpr uint8_t sin8_lut[256] = {
- 128,131,134,137,140,144,147,150,153,156,159,162,165,168,171,174,
- 177,179,182,185,188,191,193,196,199,201,204,206,209,211,213,216,
- 218,220,222,224,226,228,230,232,234,235,237,239,240,241,243,244,
- 245,246,248,249,250,250,251,252,253,253,254,254,254,255,255,255,
- 255,255,255,255,254,254,254,253,253,252,251,250,250,249,248,246,
- 245,244,243,241,240,239,237,235,234,232,230,228,226,224,222,220,
- 218,216,213,211,209,206,204,201,199,196,193,191,188,185,182,179,
- 177,174,171,168,165,162,159,156,153,150,147,144,140,137,134,131,
- 128,125,122,119,116,112,109,106,103,100,97,94,91,88,85,82,
- 79,77,74,71,68,65,63,60,57,55,52,50,47,45,43,40,
- 38,36,34,32,30,28,26,24,22,21,19,17,16,15,13,12,
- 11,10,8,7,6,6,5,4,3,3,2,2,2,1,1,1,
- 1,1,1,1,2,2,2,3,3,4,5,6,6,7,8,10,
- 11,12,13,15,16,17,19,21,22,24,26,28,30,32,34,36,
- 38,40,43,45,47,50,52,55,57,60,63,65,68,71,74,77,
- 79,82,85,88,91,94,97,100,103,106,109,112,116,119,122,125
-};
-
-constexpr uint8_t sin8(uint8_t i) { return sin8_lut[i]; }
-constexpr uint8_t cos8(uint8_t i) { return sin8_lut[static_cast(i + 64)]; }
-
-// Fast octant atan2: returns 0-255 for full circle (y, x) in -32768..32767
-constexpr uint8_t atan2_8(int16_t y, int16_t x) {
- uint8_t r = 0;
- if (y < 0) { y = static_cast(-y); r = 0x80; }
- if (x < 0) { x = static_cast(-x); r = static_cast(r | 0x40); }
- uint8_t offset = (x > y) ? 0 : 32;
- if (x < y) {
- int16_t t = y;
- y = x;
- x = t;
- }
- uint8_t b = (x == 0) ? 0 : static_cast((static_cast(y) * 64) / static_cast(x));
- return static_cast(r + offset + b);
-}
-
-// Octagonal distance approximation (no sqrt)
-constexpr uint8_t dist8(int16_t dx, int16_t dy) {
- int16_t ax = dx < 0 ? static_cast(-dx) : dx;
- int16_t ay = dy < 0 ? static_cast(-dy) : dy;
- return static_cast(ax > ay ? ax + (ay >> 1) : ay + (ax >> 1));
-}
-
} // namespace mm
diff --git a/src/core/crc.h b/src/core/crc.h
new file mode 100644
index 00000000..a107613c
--- /dev/null
+++ b/src/core/crc.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include
+#include
+
+// CRC-16/CCITT-FALSE: a 16-bit checksum over a byte span. Used as a cheap, well-distributed
+// "fingerprint" of a block of state β e.g. Game of Life hashes its grid each generation to
+// detect that the pattern has gone static or fallen into a short oscillation (the same CRC
+// recurring) so it can respawn instead of looping forever. The textbook polynomial 0x1021,
+// init 0xFFFF, no reflection β the recognisable CCITT-FALSE variant.
+//
+// Not a security hash (a CRC is trivially collidable); it's a fast change-detector, which is all
+// the stasis check needs. Integer-only, no table (the bit-serial form is tiny and the call sites
+// run off the hot path β once per generation, not per pixel).
+
+namespace mm {
+
+constexpr uint16_t crc16(const uint8_t* data, size_t len, uint16_t crc = 0xFFFFu) {
+ for (size_t i = 0; i < len; i++) {
+ crc = static_cast(crc ^ (static_cast(data[i]) << 8));
+ for (uint8_t bit = 0; bit < 8; bit++) {
+ crc = (crc & 0x8000u) ? static_cast((crc << 1) ^ 0x1021u)
+ : static_cast(crc << 1);
+ }
+ }
+ return crc;
+}
+
+} // namespace mm
diff --git a/src/core/math8.h b/src/core/math8.h
new file mode 100644
index 00000000..b9854e1f
--- /dev/null
+++ b/src/core/math8.h
@@ -0,0 +1,160 @@
+#pragma once
+
+#include "core/color.h" // scale8 (nscale8 builds on it), RGB not needed here
+
+#include
+
+// 8-bit fixed-point math for LED effects: integer trig, beat/timing, saturating
+// arithmetic, and a fast PRNG. The recognisable "lib8tion" surface β same names an
+// embedded/LED developer knows β written fresh against projectMM's architecture.
+//
+// Prior art: FastLED's lib8tion (Mark Kriegsman). We carry the ideas + names
+// (sin8/beatsin8/qadd8/nscale8/random8) and the textbook algorithms; the code is ours.
+//
+// All integer, LUT-backed where it pays, no float, no heap β safe in the render loop.
+// Time-dependent helpers (beat8/beatsin8) take the current time in ms as a parameter so
+// this stays platform-agnostic (the caller passes elapsed()/platform::millis(), keeping
+// the time source at the domain edge, not buried in core).
+
+namespace mm {
+
+// --- Integer trig (256-step circle) -----------------------------------------
+// 256-entry sine LUT: sin(2*pi*i/256)*127+128, in flash. sin8 for sine, cos8 = sin8(i+64).
+inline constexpr uint8_t sin8_lut[256] = {
+ 128,131,134,137,140,144,147,150,153,156,159,162,165,168,171,174,
+ 177,179,182,185,188,191,193,196,199,201,204,206,209,211,213,216,
+ 218,220,222,224,226,228,230,232,234,235,237,239,240,241,243,244,
+ 245,246,248,249,250,250,251,252,253,253,254,254,254,255,255,255,
+ 255,255,255,255,254,254,254,253,253,252,251,250,250,249,248,246,
+ 245,244,243,241,240,239,237,235,234,232,230,228,226,224,222,220,
+ 218,216,213,211,209,206,204,201,199,196,193,191,188,185,182,179,
+ 177,174,171,168,165,162,159,156,153,150,147,144,140,137,134,131,
+ 128,125,122,119,116,112,109,106,103,100,97,94,91,88,85,82,
+ 79,77,74,71,68,65,63,60,57,55,52,50,47,45,43,40,
+ 38,36,34,32,30,28,26,24,22,21,19,17,16,15,13,12,
+ 11,10,8,7,6,6,5,4,3,3,2,2,2,1,1,1,
+ 1,1,1,1,2,2,2,3,3,4,5,6,6,7,8,10,
+ 11,12,13,15,16,17,19,21,22,24,26,28,30,32,34,36,
+ 38,40,43,45,47,50,52,55,57,60,63,65,68,71,74,77,
+ 79,82,85,88,91,94,97,100,103,106,109,112,116,119,122,125
+};
+
+constexpr uint8_t sin8(uint8_t i) { return sin8_lut[i]; }
+constexpr uint8_t cos8(uint8_t i) { return sin8_lut[static_cast(i + 64)]; }
+
+// Triangle wave: 0β255 over the first half of the cycle, 255β0 over the second (the
+// textbook fold of a ramp). A cheaper, sharper alternative to sin8 for some effects.
+constexpr uint8_t triwave8(uint8_t i) {
+ return i < 128 ? static_cast(i * 2)
+ : static_cast((255 - i) * 2);
+}
+
+// Fast octant atan2: angle of (x, y) as 0..255 around the full circle (x, y in int16 range).
+constexpr uint8_t atan2_8(int16_t y, int16_t x) {
+ uint8_t r = 0;
+ if (y < 0) { y = static_cast(-y); r = 0x80; }
+ if (x < 0) { x = static_cast(-x); r = static_cast(r | 0x40); }
+ uint8_t offset = (x > y) ? 0 : 32;
+ if (x < y) { int16_t t = y; y = x; x = t; }
+ uint8_t b = (x == 0) ? 0 : static_cast((static_cast(y) * 64) / static_cast(x));
+ return static_cast(r + offset + b);
+}
+
+// Octagonal distance approximation: |Ξ| without a sqrt (max + half-min β the cheap 8-bit norm).
+constexpr uint8_t dist8(int16_t dx, int16_t dy) {
+ int16_t ax = dx < 0 ? static_cast(-dx) : dx;
+ int16_t ay = dy < 0 ? static_cast(-dy) : dy;
+ return static_cast(ax > ay ? ax + (ay >> 1) : ay + (ax >> 1));
+}
+
+// --- Saturating + scaling arithmetic ----------------------------------------
+// qadd8/qsub8: add/subtract clamping at the 0..255 ends instead of wrapping β so a bright
+// pixel + more stays white rather than rolling over to black (the LED-blend staple).
+constexpr uint8_t qadd8(uint8_t a, uint8_t b) {
+ uint16_t t = static_cast(a) + b;
+ return t > 255 ? 255 : static_cast(t);
+}
+constexpr uint8_t qsub8(uint8_t a, uint8_t b) {
+ return a > b ? static_cast(a - b) : 0;
+}
+
+// nscale8: scale a byte by a 0..255 fraction (n/256). Same as scale8 β the `nscale8` name
+// is the recognisable in-place-channel-scale spelling; one definition, two names.
+constexpr uint8_t nscale8(uint8_t val, uint8_t scale) { return scale8(val, scale); }
+
+// map8: rescale a 0..255 input onto the range [rangeStart, rangeEnd]. FastLED documents this as
+// map8(in, lo, hi) == map(in, 0, 255, lo, hi), i.e. the input top (255) reaches `hi` exactly. The
+// earlier `lo + scale8(in, hi-lo)` form used scale8's /256, which made `hi` unreachable and
+// collapsed a one-step span (a bar height of 1 could never be reached) β the bug this fixes. Div by
+// 255 (not >>8) so in=255 lands on hi; span held in uint16 so hi=255,lo=0 doesn't wrap.
+// Used to turn an audio band (0..255) into a bar height, a line length, etc.
+constexpr uint8_t map8(uint8_t in, uint8_t rangeStart, uint8_t rangeEnd) {
+ const uint16_t span = static_cast(rangeEnd - rangeStart);
+ return static_cast(rangeStart + (static_cast(in) * span) / 255u);
+}
+
+// --- Timing / beat ----------------------------------------------------------
+// The time source is passed in (ms since boot, e.g. platform::millis()/elapsed()) so core stays
+// platform-agnostic. `timebase` shifts the phase origin: an effect that wants its oscillation to
+// start (or jump) at a chosen moment passes that moment as the timebase, so the beat is measured
+// from there. This matches FastLED's beat8(bpm, timebase) / beatsin8(bpm,low,high,timebase,phase).
+
+// beat8: a 0..255 sawtooth completing `bpm` cycles per minute, measured from `timebase`.
+constexpr uint8_t beat8(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 * 256u) / period);
+}
+
+// beatsin8: a sine oscillating in [low,high] at `bpm`. `timebase` shifts the phase origin (in ms);
+// `phase` adds a fixed 0..255 offset to the beat (a constant lead/lag). FastLED's signature
+// (bpm, low, high, timebase, phase) β `ms` (the current time) is threaded as the first non-FastLED
+// arg so the function stays time-source-agnostic.
+constexpr uint8_t beatsin8(uint8_t bpm, uint32_t ms, uint8_t low = 0, uint8_t high = 255,
+ uint32_t timebase = 0, uint8_t phase = 0) {
+ const uint8_t beat = static_cast(beat8(bpm, ms, timebase) + phase);
+ const uint8_t s = sin8(beat); // 0..255 sine
+ const uint8_t range = static_cast(high - low);
+ return static_cast(low + scale8(s, range));
+}
+
+// beatsin16: 16-bit range version, for positions across a wide grid.
+constexpr uint16_t beatsin16(uint8_t bpm, uint32_t ms, uint16_t low = 0, uint16_t high = 65535,
+ uint32_t timebase = 0, uint8_t phase = 0) {
+ const uint8_t beat = static_cast(beat8(bpm, ms, timebase) + phase);
+ const uint8_t s = sin8(beat); // 0..255 sine
+ const uint16_t range = static_cast(high - low);
+ return static_cast(low + ((static_cast(s) * range) >> 8));
+}
+
+// --- Fast PRNG --------------------------------------------------------------
+// A small seedable xorshift β hot-path-cheap and deterministic (NOT std::rand, which is
+// slow, non-reentrant, and unseedable per-effect). Each effect owns a Random8 so its
+// sequence is reproducible and independent. Same shape as FastLED's random8/16.
+class Random8 {
+public:
+ constexpr explicit Random8(uint32_t seed = 0x1234ABCDu) : state_(seed ? seed : 1u) {}
+ constexpr void seed(uint32_t s) { state_ = s ? s : 1u; }
+ constexpr uint8_t next8() { return static_cast(advance() >> 24); }
+ constexpr uint16_t next16() { return static_cast(advance() >> 16); }
+ // 0..bound-1 (bound>0): scale the 16-bit draw to avoid modulo bias on small bounds.
+ constexpr uint8_t below(uint8_t bound) {
+ return bound ? static_cast((static_cast(next16()) * bound) >> 16) : 0;
+ }
+ // min..max-1 (FastLED's random8(min,max)): an offset draw over the half-open range.
+ constexpr uint8_t below(uint8_t min, uint8_t max) {
+ return max > min ? static_cast(min + below(static_cast(max - min))) : min;
+ }
+private:
+ constexpr uint32_t advance() {
+ state_ ^= state_ << 13;
+ state_ ^= state_ >> 17;
+ state_ ^= state_ << 5;
+ return state_;
+ }
+ uint32_t state_;
+};
+
+} // namespace mm
diff --git a/src/core/noise.h b/src/core/noise.h
new file mode 100644
index 00000000..480442da
--- /dev/null
+++ b/src/core/noise.h
@@ -0,0 +1,85 @@
+#pragma once
+
+#include
+
+// Value noise: a smooth, deterministic pseudo-random field, the staple "organic motion"
+// source for LED effects. inoise8 returns a 0..255 value that varies smoothly across space,
+// so neighbouring coordinates give similar values (unlike a raw hash). Sample it across a
+// grid for clouds/plasma/fire-like fields; scroll a coordinate (or pass a time offset) to
+// animate. 1D, 2D and 3D variants share one hash + a smoothstep interpolation.
+//
+// Prior art: FastLED's inoise8 (Perlin/value noise; Mark Kriegsman / Ken Perlin's method).
+// Same recognisable name + 0..255 contract; the hash + smoothstep + lerp here are ours,
+// promoted from NoiseEffect's own implementation so every effect shares one field generator.
+//
+// Coordinates are 16.0 fixed scaled however the caller likes β the high byte selects the
+// noise CELL, the low byte the interpolation position within it. So a larger coordinate step
+// per pixel = finer noise (more cells across the grid); a smaller step = broader, smoother.
+
+namespace mm {
+namespace noise {
+
+// Integer hash β 0..255. Three coords (z=0 for 1D/2D) feed one well-mixed avalanche.
+constexpr uint8_t hash(uint32_t x, uint32_t y, uint32_t z) {
+ uint32_t h = x * 1619u + y * 31337u + z * 6271u;
+ h = (h >> 13) ^ h;
+ h = h * (h * h * 60493u + 19990303u) + 1376312589u;
+ return static_cast((h >> 16) & 0xFF);
+}
+
+// Smoothstep 3tΒ²β2tΒ³ on 0..255 β turns the linear cell fraction into an eased one so the
+// field has no hard creases at cell boundaries (the difference between value noise and a
+// blocky grid).
+constexpr uint8_t smoothstep(uint8_t t) {
+ uint16_t t2 = static_cast(t) * t / 255;
+ uint16_t t3 = static_cast(t2) * t / 255;
+ return static_cast((3 * t2 - 2 * t3) & 0xFF);
+}
+
+// Linear interpolate aβb by t/255.
+constexpr uint8_t lerp8(uint8_t a, uint8_t b, uint8_t t) {
+ int16_t delta = static_cast(b) - static_cast(a);
+ return static_cast(static_cast(a) + delta * t / 255);
+}
+
+} // namespace noise
+
+// 1D value noise: x is a 16.0 fixed coordinate (high byte = cell, low byte = position).
+constexpr uint8_t inoise8(uint32_t x) {
+ const uint32_t ix = x >> 8;
+ const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF));
+ return noise::lerp8(noise::hash(ix, 0, 0), noise::hash(ix + 1, 0, 0), fx);
+}
+
+// 2D value noise with bilinear interpolation over the 4 cell corners.
+constexpr uint8_t inoise8(uint32_t x, uint32_t y) {
+ const uint32_t ix = x >> 8, iy = y >> 8;
+ const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF));
+ const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF));
+ const uint8_t v00 = noise::hash(ix, iy, 0);
+ const uint8_t v10 = noise::hash(ix + 1, iy, 0);
+ const uint8_t v01 = noise::hash(ix, iy + 1, 0);
+ const uint8_t v11 = noise::hash(ix + 1, iy + 1, 0);
+ return noise::lerp8(noise::lerp8(v00, v10, fx), noise::lerp8(v01, v11, fx), fy);
+}
+
+// 3D value noise with trilinear interpolation over the 8 cube corners.
+constexpr uint8_t inoise8(uint32_t x, uint32_t y, uint32_t z) {
+ const uint32_t ix = x >> 8, iy = y >> 8, iz = z >> 8;
+ const uint8_t fx = noise::smoothstep(static_cast(x & 0xFF));
+ const uint8_t fy = noise::smoothstep(static_cast(y & 0xFF));
+ const uint8_t fz = noise::smoothstep(static_cast(z & 0xFF));
+ const uint8_t v000 = noise::hash(ix, iy, iz);
+ const uint8_t v100 = noise::hash(ix + 1, iy, iz);
+ const uint8_t v010 = noise::hash(ix, iy + 1, iz);
+ const uint8_t v110 = noise::hash(ix + 1, iy + 1, iz);
+ const uint8_t v001 = noise::hash(ix, iy, iz + 1);
+ const uint8_t v101 = noise::hash(ix + 1, iy, iz + 1);
+ const uint8_t v011 = noise::hash(ix, iy + 1, iz + 1);
+ const uint8_t v111 = noise::hash(ix + 1, iy + 1, iz + 1);
+ const uint8_t z0 = noise::lerp8(noise::lerp8(v000, v100, fx), noise::lerp8(v010, v110, fx), fy);
+ const uint8_t z1 = noise::lerp8(noise::lerp8(v001, v101, fx), noise::lerp8(v011, v111, fx), fy);
+ return noise::lerp8(z0, z1, fz);
+}
+
+} // namespace mm
diff --git a/src/light/Palette.h b/src/light/Palette.h
new file mode 100644
index 00000000..c3b928df
--- /dev/null
+++ b/src/light/Palette.h
@@ -0,0 +1,256 @@
+#pragma once
+
+#include "core/color.h" // RGB, scale8, hsvToRgb
+#include "core/JsonSink.h" // paletteOptions emits the dropdown {name,colors} objects
+
+#include
+#include
+
+namespace mm {
+
+// A colour palette: the active palette is 16 evenly-spaced RGB entries (the CRGBPalette16 model),
+// and colorFromPalette() reads a 0-255 wheel index by interpolating between the two bracketing
+// entries. The gradient definitions (a {pos,R,G,B,β¦} stop list) live in flash and expand into the
+// 16 entries on selection, off the hot path; the per-light lookup is then a single scale8 blend.
+//
+// Prior art: FastLED's gradient palettes (CRGBPalette16 / ColorFromPalette), the convention WLED +
+// MoonLight share β the recognisable names + model are carried; this implementation is our own, on
+// our RGB/scale8. The gradient *data* in kBuiltinPalettes is from MoonLight's palettes.h (a public
+// palette set), reformatted; see docs/backlog/moonlight-palettes-data.md.
+struct Palette {
+ static constexpr uint8_t kEntries = 16;
+ RGB entry[kEntries] = {};
+
+ // Build the 16 entries from a gradient-stop list: {pos0,R,G,B, pos1,R,G,B, β¦} with pos 0..255,
+ // ascending, ending at 255. Each of the 16 evenly-spaced sample positions is the linear blend
+ // of the two stops it falls between. Off the hot path (called on selection).
+ void fromGradient(const uint8_t* stops, size_t count) {
+ const size_t nStops = count / 4;
+ if (nStops == 0) { for (auto& e : entry) e = {0, 0, 0}; return; }
+ for (uint8_t i = 0; i < kEntries; i++) {
+ // The sample position for entry i, spread 0..255 across the 16 entries.
+ const uint8_t pos = static_cast((static_cast(i) * 255) / (kEntries - 1));
+ entry[i] = sampleGradient(stops, nStops, pos);
+ }
+ }
+
+private:
+ // The colour at `pos` (0..255) on the gradient: find the bracketing stops and lerp.
+ static RGB sampleGradient(const uint8_t* stops, size_t nStops, uint8_t pos) {
+ // Before the first stop (a gradient whose first stop sits above 0): clamp to it, so the
+ // `pos - p0` below can't underflow.
+ if (pos <= stops[0]) return {stops[1], stops[2], stops[3]};
+ // Walk to the last stop whose position <= pos.
+ size_t s = 0;
+ while (s + 1 < nStops && stops[(s + 1) * 4] <= pos) s++;
+ const uint8_t* lo = stops + s * 4;
+ if (s + 1 >= nStops) return {lo[1], lo[2], lo[3]}; // at/after the last stop
+ const uint8_t* hi = stops + (s + 1) * 4;
+ const uint8_t p0 = lo[0], p1 = hi[0];
+ if (p1 == p0) return {lo[1], lo[2], lo[3]};
+ // Fraction of the way from lo to hi, as 0..255 for scale8.
+ const uint8_t frac = static_cast((static_cast(pos - p0) * 255) / (p1 - p0));
+ return lerpRGB({lo[1], lo[2], lo[3]}, {hi[1], hi[2], hi[3]}, frac);
+ }
+
+public:
+ // Linear blend aβb by frac (0 = a, 255 = b). Integer-only.
+ static RGB lerpRGB(const RGB& a, const RGB& b, uint8_t frac) {
+ const uint8_t inv = static_cast(255 - frac);
+ return { static_cast(scale8(a.r, inv) + scale8(b.r, frac)),
+ static_cast(scale8(a.g, inv) + scale8(b.g, frac)),
+ static_cast(scale8(a.b, inv) + scale8(b.b, frac)) };
+ }
+};
+
+// The per-light lookup: `index` is a 0..255 wheel position (wraps), mapped across the 16 entries;
+// blend the two bracketing entries, then scale by `brightness`. Hot-path-cheap (two scale8 + a
+// blend). This is the single seam every palette-driven effect calls, so the palette source is
+// swappable behind one signature without touching effects.
+inline RGB colorFromPalette(const Palette& p, uint8_t index, uint8_t brightness = 255) {
+ // Position across 16 entries: the high nibble selects the entry, the low byte the blend.
+ const uint8_t hi = static_cast(index >> 4); // 0..15 β bracket start
+ const uint8_t frac = static_cast((index & 0x0F) * 17); // 0..255 within the bracket
+ const RGB& a = p.entry[hi];
+ const RGB& b = p.entry[(hi + 1) & 0x0F]; // wrap 15β0
+ RGB c = Palette::lerpRGB(a, b, frac);
+ if (brightness != 255) {
+ c.r = scale8(c.r, brightness);
+ c.g = scale8(c.g, brightness);
+ c.b = scale8(c.b, brightness);
+ }
+ return c;
+}
+
+// Cross-fade two colours: `amt`/255 of the way from `a` to `b` (amt 0 = a, 255 = b). The textbook
+// RGB lerp, the staple for compositing/transitions. Prior art: FastLED's blend (colorutils).
+inline RGB blend(RGB a, RGB b, uint8_t amt) { return Palette::lerpRGB(a, b, amt); }
+
+// Dim a colour toward black by `amt`/255 (amt 0 = unchanged, 255 = black) β the per-frame fade
+// that gives effects a decaying trail. Prior art: FastLED's fadeToBlackBy.
+inline void fadeToBlackBy(RGB& c, uint8_t amt) {
+ const uint8_t keep = static_cast(255 - amt);
+ c.r = scale8(c.r, keep);
+ c.g = scale8(c.g, keep);
+ c.b = scale8(c.b, keep);
+}
+
+// --- Built-in palettes -------------------------------------------------------------------------
+// Gradient-stop definitions in flash ({pos,R,G,B,β¦}). The full MoonLight set: the gradient *data*
+// is from MoonLight's palettes.h (a public palette set, the WLED/SoundReactive gradient lineage),
+// reformatted into our {pos,R,G,B} stop layout (source: MoonLight's Modules/palettes.h). The
+// handful of procedurally-generated FastLED/MoonLight entries (Rainbow, Party, β¦) MoonLight builds
+// from code rather than a gradient array; we generate the equivalents the same way (rainbow via
+// hsvToRgb, the rest from representative stops) so the named set a MoonLight user knows is present.
+namespace palettes {
+
+// Gradient definitions β verbatim {pos,R,G,B,β¦} from MoonLight's palettes.h, names kept recognisable.
+inline constexpr uint8_t kParty[] = {0,85,0,171, 42,150,0,107, 85,201,0,42, 128,212,32,0, 170,191,98,0, 213,128,160,0, 255,85,212,0}; // FastLED party-colors stops
+inline constexpr uint8_t kForest[] = {0,0,100,0, 64,34,139,34, 128,0,128,0, 192,107,142,35, 255,0,100,0};
+inline constexpr uint8_t kLava[] = {0,0,0,0, 46,18,0,0, 96,113,0,0, 108,142,3,1, 119,175,17,1, 146,213,44,2, 174,255,82,4, 188,255,115,4, 202,255,156,4, 218,255,203,4, 234,255,255,4, 244,255,255,71, 255,255,255,255};
+inline constexpr uint8_t kOceanBreeze[] = {0,1,6,7, 89,1,99,111, 153,144,209,255, 255,0,73,82};
+inline constexpr uint8_t kFierceIce[] = {0,0,0,0, 59,0,9,45, 119,0,38,255, 149,3,100,255, 180,23,199,255, 217,100,235,255, 255,255,255,255};
+inline constexpr uint8_t kSunset[] = {0,120,0,0, 22,179,22,0, 51,255,104,0, 85,167,22,18, 135,100,0,103, 198,16,0,130, 255,0,0,160};
+inline constexpr uint8_t kSunset2[] = {0,10,62,123, 36,56,130,103, 87,153,225,85, 100,199,217,68, 107,255,207,54, 115,247,152,57, 120,239,107,61, 128,247,152,57, 180,255,207,54, 223,255,227,48, 255,255,248,42};
+inline constexpr uint8_t kOrangeTeal[] = {0,0,150,92, 55,0,150,92, 200,255,72,0, 255,255,72,0};
+inline constexpr uint8_t kAurora[] = {0,1,5,45, 64,0,200,23, 128,0,255,0, 170,0,243,45, 200,0,135,7, 255,1,5,45};
+inline constexpr uint8_t kAurora2[] = {0,17,177,13, 64,121,242,5, 128,25,173,121, 192,250,77,127, 255,171,101,221};
+inline constexpr uint8_t kAtlantica[] = {0,0,28,112, 50,32,96,255, 100,0,243,45, 150,12,95,82, 200,25,190,95, 255,40,170,80};
+inline constexpr uint8_t kAnalogous[] = {0,3,0,255, 63,23,0,255, 127,67,0,255, 191,142,0,45, 255,255,0,0};
+inline constexpr uint8_t kAprilNight[] = {0,1,5,45, 10,1,5,45, 25,5,169,175, 40,1,5,45, 61,1,5,45, 76,45,175,31, 91,1,5,45, 112,1,5,45, 127,249,150,5, 143,1,5,45, 162,1,5,45, 178,255,92,0, 193,1,5,45, 214,1,5,45, 229,223,45,72, 244,1,5,45, 255,1,5,45};
+inline constexpr uint8_t kAquaFlash[] = {0,0,0,0, 66,57,227,233, 96,255,255,8, 124,255,255,255, 153,255,255,8, 188,57,227,233, 255,0,0,0};
+inline constexpr uint8_t kAutumn[] = {0,26,1,1, 51,67,4,1, 84,118,14,1, 104,137,152,52, 112,113,65,1, 122,133,149,59, 124,137,152,52, 135,113,65,1, 142,139,154,46, 163,113,13,1, 204,55,3,1, 249,17,1,1, 255,17,1,1};
+inline constexpr uint8_t kBeech[] = {0,255,252,214, 12,255,252,214, 22,255,252,214, 26,190,191,115, 28,137,141,52, 28,112,255,205, 50,51,246,214, 71,17,235,226, 93,2,193,199, 120,0,156,174, 133,1,101,115, 136,1,59,71, 136,7,131,170, 208,1,90,151, 255,0,56,133};
+inline constexpr uint8_t kBlinkRed[] = {0,1,1,1, 43,4,1,11, 76,10,1,3, 109,161,4,29, 127,255,86,123, 165,125,16,160, 204,35,13,223, 255,18,2,18};
+inline constexpr uint8_t kC9[] = {0,184,4,0, 60,184,4,0, 65,144,44,2, 125,144,44,2, 130,4,96,2, 190,4,96,2, 195,7,7,88, 255,7,7,88};
+inline constexpr uint8_t kC9_2[] = {0,6,126,2, 45,6,126,2, 45,4,30,114, 90,4,30,114, 90,255,5,0, 135,255,5,0, 135,196,57,2, 180,196,57,2, 180,137,85,2, 255,137,85,2};
+inline constexpr uint8_t kC9New[] = {0,255,5,0, 60,255,5,0, 60,196,57,2, 120,196,57,2, 120,6,126,2, 180,6,126,2, 180,4,30,114, 255,4,30,114};
+inline constexpr uint8_t kCandy[] = {0,229,227,1, 15,227,101,3, 142,40,1,80, 198,17,1,79, 255,0,0,45};
+inline constexpr uint8_t kCandy2[] = {0,39,33,34, 25,4,6,15, 48,49,29,22, 73,224,173,1, 89,177,35,5, 130,4,6,15, 163,255,114,6, 186,224,173,1, 211,39,33,34, 255,1,1,1};
+inline constexpr uint8_t kColorfull[] = {0,10,85,5, 25,29,109,18, 60,59,138,42, 93,83,99,52, 106,110,66,64, 109,123,49,65, 113,139,35,66, 116,192,117,98, 124,255,255,137, 168,100,180,155, 255,22,121,174};
+inline constexpr uint8_t kDeparture[] = {0,8,3,0, 42,23,7,0, 63,75,38,6, 84,169,99,38, 106,213,169,119, 116,255,255,255, 138,135,255,138, 148,22,255,24, 170,0,255,0, 191,0,136,0, 212,0,55,0, 255,0,55,0};
+inline constexpr uint8_t kDrywet[] = {0,47,30,2, 42,213,147,24, 84,103,219,52, 127,3,219,207, 170,1,48,214, 212,1,1,111, 255,1,7,33};
+inline constexpr uint8_t kFairyReaf[] = {0,184,1,128, 160,1,193,182, 219,153,227,190, 255,255,255,255};
+inline constexpr uint8_t kGrintage[] = {0,2,1,1, 53,18,1,0, 104,69,29,1, 153,167,135,10, 255,46,56,4}; // es_vintage_57
+inline constexpr uint8_t kHult[] = {0,247,176,247, 48,255,136,255, 89,220,29,226, 160,7,82,178, 216,1,124,109, 255,1,124,109};
+inline constexpr uint8_t kHult64[] = {0,1,124,109, 66,1,93,79, 104,52,65,1, 130,115,127,1, 150,52,65,1, 201,1,86,72, 239,0,55,45, 255,0,55,45};
+inline constexpr uint8_t kJul[] = {0,194,1,1, 94,1,29,18, 132,57,131,28, 255,113,1,1};
+inline constexpr uint8_t kLandscape[] = {0,0,0,0, 37,2,25,1, 76,15,115,5, 127,79,213,1, 128,126,211,47, 130,188,209,247, 153,144,182,205, 204,59,117,250, 255,1,37,192};
+inline constexpr uint8_t kLightPink[] = {0,19,2,39, 25,26,4,45, 51,33,6,52, 76,68,62,125, 102,118,187,240, 109,163,215,247, 114,217,244,255, 122,159,149,221, 149,113,78,188, 183,128,57,155, 255,146,40,123}; // Pink_Purple
+inline constexpr uint8_t kLiteLight[] = {0,0,0,0, 9,1,1,1, 40,5,5,6, 66,5,5,6, 101,10,1,12, 255,0,0,0};
+inline constexpr uint8_t kMagenta[] = {0,0,0,0, 42,0,0,45, 84,0,0,255, 127,42,0,255, 170,255,0,255, 212,255,55,255, 255,255,255,255}; // BlacK_Blue_Magenta_White
+inline constexpr uint8_t kMagred[] = {0,0,0,0, 63,42,0,45, 127,255,0,255, 191,255,0,45, 255,255,0,0}; // BlacK_Magenta_Red
+inline constexpr uint8_t kOrangery[] = {0,255,95,23, 30,255,82,0, 60,223,13,8, 90,144,44,2, 120,255,110,17, 150,255,69,0, 180,158,13,11, 210,241,82,17, 255,213,37,4};
+inline constexpr uint8_t kPastel[] = {0,0,0,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,255,0}; // Blue_Cyan_Yellow
+inline constexpr uint8_t kPinkCandy[] = {0,255,255,255, 45,7,12,255, 112,227,1,127, 112,227,1,127, 140,255,255,255, 155,227,1,127, 196,45,1,99, 255,255,255,255};
+inline constexpr uint8_t kRedBlue[] = {0,0,0,0, 42,42,0,0, 84,255,0,0, 127,255,0,45, 170,255,0,255, 212,255,55,45, 255,255,255,0}; // BlacK_Red_Magenta_Yellow
+inline constexpr uint8_t kRedFlash[] = {0,0,0,0, 99,227,1,1, 130,249,199,95, 155,227,1,1, 255,0,0,0};
+inline constexpr uint8_t kRedReaf[] = {0,3,13,43, 104,78,141,240, 188,255,0,0, 255,28,1,1};
+inline constexpr uint8_t kRedShift[] = {0,31,1,27, 45,34,1,16, 99,137,5,9, 132,213,128,10, 175,199,22,1, 201,199,9,6, 255,1,0,1};
+inline constexpr uint8_t kRedTide[] = {0,247,5,0, 28,255,67,1, 43,234,88,11, 58,234,176,51, 84,229,28,1, 114,113,12,1, 140,255,225,44, 168,113,12,1, 196,244,209,88, 216,255,28,1, 255,53,1,1};
+inline constexpr uint8_t kRetroClown[] = {0,227,101,3, 117,194,18,19, 255,92,8,192};
+inline constexpr uint8_t kRewhi[] = {0,188,135,1, 255,46,7,1}; // retro2_16
+inline constexpr uint8_t kRivendell[] = {0,1,14,5, 101,16,36,14, 165,56,68,30, 242,150,156,99, 255,150,156,99};
+inline constexpr uint8_t kSakura[] = {0,196,19,10, 65,255,69,45, 130,223,45,72, 195,255,82,103, 255,223,13,17};
+inline constexpr uint8_t kSemiBlue[] = {0,0,0,0, 12,1,1,3, 53,8,1,22, 80,4,6,89, 119,2,25,216, 145,7,10,99, 186,15,2,31, 233,2,1,5, 255,0,0,0};
+inline constexpr uint8_t kSherbet[] = {0,255,33,4, 43,255,68,25, 86,255,7,25, 127,255,82,103, 170,255,255,242, 209,42,255,22, 255,87,255,65}; // rainbowsherbet
+inline constexpr uint8_t kSplash[] = {0,126,11,255, 127,197,1,22, 175,210,157,172, 221,157,3,112, 255,157,3,112}; // es_pinksplash_08
+inline constexpr uint8_t kTemperature[] = {0,1,27,105, 14,1,40,127, 28,1,70,168, 42,1,92,197, 56,1,119,221, 70,3,130,151, 84,23,156,149, 99,67,182,112, 113,121,201,52, 127,142,203,11, 141,224,223,1, 155,252,187,2, 170,247,147,1, 184,237,87,1, 198,229,43,1, 226,171,2,2, 240,80,3,3, 255,80,3,3};
+inline constexpr uint8_t kTertiary[] = {0,0,1,255, 63,3,68,45, 127,23,255,0, 191,100,68,1, 255,255,1,4};
+inline constexpr uint8_t kTiamat[] = {0,1,2,14, 33,2,5,35, 100,13,135,92, 120,43,255,193, 140,247,7,249, 160,193,17,208, 180,39,255,154, 200,4,213,236, 220,39,252,135, 240,193,213,253, 255,255,249,255};
+inline constexpr uint8_t kToxyReaf[] = {0,1,221,53, 255,73,3,178};
+inline constexpr uint8_t kVintage[] = {0,4,1,1, 51,16,0,1, 76,97,104,3, 101,255,131,19, 127,67,9,4, 153,16,0,1, 229,4,1,1, 255,4,1,1}; // es_vintage_01
+inline constexpr uint8_t kYelbluHot[] = {0,4,2,9, 58,16,0,47, 122,24,0,16, 158,144,9,1, 183,179,45,1, 219,220,114,2, 255,234,237,1};
+inline constexpr uint8_t kYelblu[] = {0,0,0,0, 42,0,0,45, 84,0,0,255, 127,42,0,255, 170,255,0,255, 212,255,55,255, 255,255,255,255}; // GMT_drywet-adjacent yelblu lineage
+inline constexpr uint8_t kYelmag[] = {0,4,1,70, 31,55,1,30, 63,255,4,7, 95,59,2,29, 127,11,3,50, 159,39,8,60, 191,112,19,40, 223,78,11,39, 255,29,8,59}; // rgi_15
+inline constexpr uint8_t kYellowout[] = {0,0,1,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,0,0};
+
+// A built-in is a gradient ({stops,len}) or the special "rainbow" (generated via hsvToRgb).
+struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; };
+
+#define MM_PAL(name, arr) {name, arr, sizeof(arr), false}
+inline constexpr Builtin kBuiltins[] = {
+ {"Rainbow", nullptr, 0, true},
+ MM_PAL("Party", kParty), MM_PAL("Lava", kLava),
+ MM_PAL("Ocean", kOceanBreeze), MM_PAL("Forest", kForest),
+ MM_PAL("Fierce Ice", kFierceIce), MM_PAL("Sunset", kSunset),
+ MM_PAL("Sunset 2", kSunset2), MM_PAL("Orange & Teal",kOrangeTeal),
+ MM_PAL("Aurora", kAurora), MM_PAL("Aurora 2", kAurora2),
+ MM_PAL("Atlantica", kAtlantica), MM_PAL("Analogous", kAnalogous),
+ MM_PAL("April Night", kAprilNight), MM_PAL("Aqua Flash", kAquaFlash),
+ MM_PAL("Autumn", kAutumn), MM_PAL("Beech", kBeech),
+ MM_PAL("Blink Red", kBlinkRed), MM_PAL("C9", kC9),
+ MM_PAL("C9 2", kC9_2), MM_PAL("C9 New", kC9New),
+ MM_PAL("Candy", kCandy), MM_PAL("Candy2", kCandy2),
+ MM_PAL("Colorfull", kColorfull), MM_PAL("Departure", kDeparture),
+ MM_PAL("Drywet", kDrywet), MM_PAL("Fairy Reaf", kFairyReaf),
+ MM_PAL("Grintage", kGrintage), MM_PAL("Hult", kHult),
+ MM_PAL("Hult 64", kHult64), MM_PAL("Jul", kJul),
+ MM_PAL("Landscape", kLandscape), MM_PAL("Light Pink", kLightPink),
+ MM_PAL("Lite Light", kLiteLight), MM_PAL("Magenta", kMagenta),
+ MM_PAL("Magred", kMagred), MM_PAL("Orangery", kOrangery),
+ MM_PAL("Pastel", kPastel), MM_PAL("Pink Candy", kPinkCandy),
+ MM_PAL("Red & Blue", kRedBlue), MM_PAL("Red Flash", kRedFlash),
+ MM_PAL("Red Reaf", kRedReaf), MM_PAL("Red Shift", kRedShift),
+ MM_PAL("Red Tide", kRedTide), MM_PAL("Retro Clown", kRetroClown),
+ MM_PAL("Rewhi", kRewhi), MM_PAL("Rivendell", kRivendell),
+ MM_PAL("Sakura", kSakura), MM_PAL("Semi Blue", kSemiBlue),
+ MM_PAL("Sherbet", kSherbet), MM_PAL("Splash", kSplash),
+ MM_PAL("Temperature", kTemperature), MM_PAL("Tertiary", kTertiary),
+ MM_PAL("Tiamat", kTiamat), MM_PAL("Toxy Reaf", kToxyReaf),
+ MM_PAL("Vintage", kVintage), MM_PAL("Yelblu Hot", kYelbluHot),
+ MM_PAL("Yelblu", kYelblu), MM_PAL("Yelmag", kYelmag),
+ MM_PAL("Yellowout", kYellowout),
+};
+#undef MM_PAL
+inline constexpr uint8_t kCount = sizeof(kBuiltins) / sizeof(kBuiltins[0]);
+
+} // namespace palettes
+
+// The global active palette effects read β the AudioModule::latestFrame() static-seam pattern.
+// Drivers owns the `palette` select control and calls setActive() on change; effects just call
+// colorFromPalette(*Palettes::active(), idx).
+class Palettes {
+public:
+ static const Palette* active() { return &active_; }
+
+ // Expand built-in `index` into the active palette (off the hot path β on selection).
+ static void setActive(uint8_t index) {
+ active_ = fromBuiltin(index);
+ }
+
+ // Build the 16-entry palette for built-in `index` (rainbow generated via hsvToRgb, the rest
+ // expanded from their gradient stops). Shared by setActive() and the default + the swatches.
+ static Palette fromBuiltin(uint8_t index) {
+ if (index >= palettes::kCount) index = 0;
+ const auto& b = palettes::kBuiltins[index];
+ Palette p;
+ if (b.rainbow) {
+ for (uint8_t i = 0; i < Palette::kEntries; i++)
+ p.entry[i] = hsvToRgb(static_cast((static_cast(i) * 256) / Palette::kEntries), 255, 255);
+ } else {
+ p.fromGradient(b.stops, b.len);
+ }
+ return p;
+ }
+
+private:
+ // Default to a full rainbow (index 0): always colourful, so an effect renders visible output
+ // before any palette is selected. setActive() (Drivers setup) overrides from the saved index.
+ static inline Palette active_ = fromBuiltin(0);
+};
+
+// Emit the palette dropdown's options for a ControlType::Palette control (the PaletteOptionsFn):
+// one {"name":β¦,"colors":"rrggbb rrggbb β¦"} object per built-in, the colours being the 16 entries
+// as space-separated hex so the UI renders each option as a gradient swatch.
+inline void paletteOptions(JsonSink& sink) {
+ for (uint8_t i = 0; i < palettes::kCount; i++) {
+ const Palette p = Palettes::fromBuiltin(i);
+ sink.appendf("%s{\"name\":\"%s\",\"colors\":\"", i > 0 ? "," : "", palettes::kBuiltins[i].name);
+ for (uint8_t e = 0; e < Palette::kEntries; e++)
+ sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b);
+ sink.append("\"}");
+ }
+}
+
+} // namespace mm
diff --git a/src/light/draw.h b/src/light/draw.h
new file mode 100644
index 00000000..246bc81d
--- /dev/null
+++ b/src/light/draw.h
@@ -0,0 +1,264 @@
+#pragma once
+
+#include "light/light_types.h" // Coord3D, lengthType
+#include "light/layers/Buffer.h" // Buffer (flat light array)
+#include "core/color.h" // RGB, scale8
+#include "core/math8.h" // qadd8 β saturating add for blur's seep accumulation
+#include "light/Palette.h" // blend(RGB,RGB,amt) β for blendPixel
+#include "light/fonts.h" // fonts::Font β bitmap glyph tables for draw::text
+
+// Geometry draw primitives for effects/modifiers: set a pixel, draw a line β bounds-clipped,
+// integer-only, working 1Dβ3D against the flat light Buffer. The "core absorbs the hard part"
+// rule applied to drawing: the Bresenham + clipping lives here once, so an effect calls
+// drawLine() instead of re-rolling it. Light-domain (it touches the light Buffer), not core.
+//
+// Prior art: the line algorithm is Bresenham (1962) generalised to 3D (the textbook DDA-error
+// form). FastLED keeps draw in its 2D/matrix add-ons, not core β same split here.
+//
+// The Buffer is a flat array of `count` lights Γ `cpl` channels; the grid SHAPE (w,h,d) lives on
+// the Layer/Layout, so the caller passes `dims` (the Coord3D extent). Index order matches the
+// engine: off = (zΒ·hΒ·w + yΒ·w + x)Β·cpl. A pixel outside [0,w)Γ[0,h)Γ[0,d) is silently clipped, so
+// a line that runs off the grid just stops drawing β no out-of-bounds write (the robustness rule).
+
+namespace mm {
+namespace draw {
+
+// One pixel, clipped to the grid. Writes R/G/B where channels fit (cpl may be 1..N); extra
+// channels (e.g. a W in RGBW) are left as-is β the driver derives white, same as effects do.
+inline void pixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) {
+ if (p.x < 0 || p.y < 0 || p.z < 0 || p.x >= dims.x || p.y >= dims.y || p.z >= dims.z) return;
+ const uint8_t cpl = buf.channelsPerLight();
+ const size_t off = (static_cast(p.z) * dims.y * dims.x
+ + static_cast(p.y) * dims.x + p.x) * cpl;
+ if (off + (cpl < 3 ? cpl : 3) > buf.bytes()) return; // defends a dims/buffer mismatch
+ uint8_t* d = buf.data();
+ if (cpl >= 1) d[off + 0] = c.r;
+ if (cpl >= 2) d[off + 1] = c.g;
+ if (cpl >= 3) d[off + 2] = c.b;
+}
+
+// A straight line aβb, clipped to the grid. 3D Bresenham: step along the dominant axis and carry
+// an integer error term per other axis (the textbook generalisation of the 2D line). Works for
+// 1D (a row), 2D (a plane), and 3D (a volume) without special-casing β a degenerate axis just
+// never steps. Endpoints are inclusive.
+//
+// `shorten` (0..255, default 255 = full line) draws only the first shorten/255 of the way from a
+// toward b β the far endpoint is pulled back toward `a`. 255 = whole line, 128 β half, 1 = the
+// start pixel, 0 = nothing. This is the perspective/length lever (MoonLight's `depth` param):
+// effects animate the drawn tip by varying `shorten`, so a fixed pair of endpoints traces a
+// sweeping partial segment over successive frames. (WLEDMM's *2-rounding shorten, generalised 3D.)
+inline void line(Buffer& buf, Coord3D dims, Coord3D a, Coord3D b, RGB c, uint8_t shorten = 255) {
+ if (shorten == 0) return;
+ if (shorten < 255) {
+ // Pull b back toward a by shorten/255, with *2 rounding like WLEDMM.
+ const int bx = ((2 * int(b.x) - 2 * int(a.x)) * int(shorten)) / 255 + 2 * int(a.x);
+ const int by = ((2 * int(b.y) - 2 * int(a.y)) * int(shorten)) / 255 + 2 * int(a.y);
+ const int bz = ((2 * int(b.z) - 2 * int(a.z)) * int(shorten)) / 255 + 2 * int(a.z);
+ b = {static_cast((bx + 1) / 2),
+ static_cast((by + 1) / 2),
+ static_cast((bz + 1) / 2)};
+ }
+ Coord3D p = a;
+ const lengthType dx = b.x > a.x ? static_cast(b.x - a.x) : static_cast(a.x - b.x);
+ const lengthType dy = b.y > a.y ? static_cast(b.y - a.y) : static_cast(a.y - b.y);
+ const lengthType dz = b.z > a.z ? static_cast(b.z - a.z) : static_cast(a.z - b.z);
+ const lengthType sx = b.x >= a.x ? 1 : -1;
+ const lengthType sy = b.y >= a.y ? 1 : -1;
+ const lengthType sz = b.z >= a.z ? 1 : -1;
+
+ // Drive the loop off the longest axis; accumulate error toward the other two.
+ if (dx >= dy && dx >= dz) {
+ lengthType ey = static_cast(dx / 2), ez = ey;
+ for (;; p.x = static_cast(p.x + sx)) {
+ pixel(buf, dims, p, c);
+ if (p.x == b.x) break;
+ if ((ey = static_cast(ey - dy)) < 0) { ey = static_cast(ey + dx); p.y = static_cast(p.y + sy); }
+ if ((ez = static_cast(ez - dz)) < 0) { ez = static_cast(ez + dx); p.z = static_cast(p.z + sz); }
+ }
+ } else if (dy >= dz) {
+ lengthType ex = static_cast(dy / 2), ez = ex;
+ for (;; p.y = static_cast(p.y + sy)) {
+ pixel(buf, dims, p, c);
+ if (p.y == b.y) break;
+ if ((ex = static_cast(ex - dx)) < 0) { ex = static_cast(ex + dy); p.x = static_cast(p.x + sx); }
+ if ((ez = static_cast(ez - dz)) < 0) { ez = static_cast(ez + dy); p.z = static_cast(p.z + sz); }
+ }
+ } else {
+ lengthType ex = static_cast(dz / 2), ey = ex;
+ for (;; p.z = static_cast(p.z + sz)) {
+ pixel(buf, dims, p, c);
+ if (p.z == b.z) break;
+ if ((ex = static_cast(ex - dx)) < 0) { ex = static_cast(ex + dz); p.x = static_cast(p.x + sx); }
+ if ((ey = static_cast(ey - dy)) < 0) { ey = static_cast(ey + dz); p.y = static_cast(p.y + sy); }
+ }
+ }
+}
+
+// --- Buffer read/modify helpers --------------------------------------------
+// The offset of a pixel in the flat buffer, or buf.bytes() if out of bounds (caller checks).
+inline size_t offsetOf(const Buffer& buf, Coord3D dims, Coord3D p) {
+ if (p.x < 0 || p.y < 0 || p.z < 0 || p.x >= dims.x || p.y >= dims.y || p.z >= dims.z) return buf.bytes();
+ return (static_cast(p.z) * dims.y * dims.x + static_cast(p.y) * dims.x + p.x)
+ * buf.channelsPerLight();
+}
+
+// Read the RGB at a pixel (black if out of bounds / fewer than 3 channels).
+inline RGB get(const Buffer& buf, Coord3D dims, Coord3D p) {
+ const size_t off = offsetOf(buf, dims, p);
+ if (off + 2 >= buf.bytes()) return {0, 0, 0};
+ const uint8_t* d = buf.data();
+ return {d[off + 0], d[off + 1], d[off + 2]};
+}
+
+// Blend a colour into a pixel by amt/255 (amt 0 = leave as-is, 255 = replace). The in-place
+// read-modify-write that GoL's dead-cell fade-to-background and age-toward-red use
+// (MoonLight's blendColor). Clipped like pixel().
+inline void blendPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c, uint8_t amt) {
+ const size_t off = offsetOf(buf, dims, p);
+ if (off + 2 >= buf.bytes()) return;
+ uint8_t* d = buf.data();
+ const RGB cur{d[off + 0], d[off + 1], d[off + 2]};
+ const RGB out = blend(cur, c, amt);
+ d[off + 0] = out.r; d[off + 1] = out.g; d[off + 2] = out.b;
+}
+
+// Add a colour into a pixel, saturating (a bright pixel can't wrap to dark) β WLED's addRGB / additive
+// setPixelColor. Used to re-stamp a light on top of a blur so its centre stays bright. Clipped like pixel().
+inline void addPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) {
+ const size_t off = offsetOf(buf, dims, p);
+ if (off + 2 >= buf.bytes()) return;
+ uint8_t* d = buf.data();
+ d[off + 0] = qadd8(d[off + 0], c.r);
+ d[off + 1] = qadd8(d[off + 1], c.g);
+ d[off + 2] = qadd8(d[off + 2], c.b);
+}
+
+// Fade the whole buffer toward black by amt/255 β one pass over the bytes. This is the primitive the
+// Layer's once-per-frame collected fade (Layer::fadeToBlackBy) applies; effects request a fade through
+// the Layer (which MINs the amount across effects and calls this once) rather than calling it directly.
+inline void fade(Buffer& buf, uint8_t amt) {
+ const uint8_t keep = static_cast(255 - amt);
+ uint8_t* d = buf.data();
+ const size_t n = buf.bytes();
+ for (size_t i = 0; i < n; i++) d[i] = scale8(d[i], keep);
+}
+
+// Box blur, working 1Dβ3D against the flat Buffer β one unified primitive, not a blur1d/blur2d/blur3d
+// trio (the *common patterns first* / "primitives are 3D-aware" rule, same as draw::line). It runs a
+// separable seep pass along each axis whose extent is >1: a 1ΓN 1D layer blurs along y (its only
+// axis with extent>1); 2D along x then y; 3D along x, y, z. `amt` (0 = none, 255 = max) is split
+// keep=255-amt / seep=amt>>1 per pixel.
+//
+// Algorithm: the canonical FastLED blur1d single-forward-pass with carryover β each pixel keeps
+// `keep` of itself, seeps `seep` forward to the next pixel and `seep` back to the previous one, so
+// one O(N) pass per axis approximates a symmetric box blur. Behaviour is identical to MoonLight's
+// blur1d/blurRows/blurColumns (verified against VirtualLayer.cpp); the speed comes from doing it on
+// the raw bytes β a stride walk with three uint8 carried in registers, no per-pixel getRGB/setRGB/
+// Coord3D construction (the overhead that makes a generic-layer blur an FPS killer). Prior art:
+// FastLED's blur1d (Mark Kriegsman), the recognisable carryover-seep; our byte-level implementation.
+//
+// `stride` is the byte step between adjacent pixels ALONG the blurred axis (cpl for x, wΒ·cpl for y,
+// wΒ·hΒ·cpl for z); `lineCount`/`lineStride` walk the starts of each parallel line. RGB only (first 3
+// channels); a W channel is untouched. Saturating adds (qadd8) so a bright pixel can't wrap to dark.
+inline void blurAxis(uint8_t* d, size_t cpl, size_t len, size_t stride,
+ size_t lineCount, size_t lineStride, uint8_t amt) {
+ if (len < 2 || cpl < 3) return; // nothing to seep along a 1-pixel (or sub-RGB) axis
+ const uint8_t keep = static_cast(255 - amt);
+ const uint8_t seep = static_cast(amt >> 1);
+ for (size_t l = 0; l < lineCount; l++) {
+ uint8_t* base = d + l * lineStride;
+ uint8_t cr = 0, cg = 0, cb = 0; // carryover (the seep flowing forward), starts black
+ size_t off = 0, prev = 0;
+ for (size_t i = 0; i < len; i++, off += stride) {
+ uint8_t* px = base + off;
+ const uint8_t pr = scale8(px[0], seep), pg = scale8(px[1], seep), pb = scale8(px[2], seep);
+ px[0] = qadd8(scale8(px[0], keep), cr); // keep self + receive prev pixel's forward seep
+ px[1] = qadd8(scale8(px[1], keep), cg);
+ px[2] = qadd8(scale8(px[2], keep), cb);
+ if (i) { // seep back into the previous pixel (deferred add)
+ uint8_t* pv = base + prev;
+ pv[0] = qadd8(pv[0], pr); pv[1] = qadd8(pv[1], pg); pv[2] = qadd8(pv[2], pb);
+ }
+ cr = pr; cg = pg; cb = pb; prev = off;
+ }
+ uint8_t* last = base + prev; // the final forward seep lands on the last pixel
+ last[0] = qadd8(last[0], cr); last[1] = qadd8(last[1], cg); last[2] = qadd8(last[2], cb);
+ }
+}
+
+// Blur the whole buffer by `amt`, separably along every axis with extent >1 (x, then y, then z β
+// MoonLight's blur2d order, extended to z). One call covers 1D/2D/3D. Off the per-pixel-effect path.
+inline void blur(Buffer& buf, Coord3D dims, uint8_t amt) {
+ if (amt == 0) return;
+ uint8_t* d = buf.data();
+ const size_t cpl = buf.channelsPerLight();
+ const size_t w = dims.x > 0 ? static_cast(dims.x) : 0;
+ const size_t h = dims.y > 0 ? static_cast(dims.y) : 0;
+ const size_t z = dims.z > 0 ? static_cast(dims.z) : 0;
+ if (w == 0 || h == 0 || z == 0) return;
+ if (static_cast(w * h * z) * cpl > buf.bytes()) return; // dims/buffer mismatch guard
+ // x-pass: each (y,z) line is `w` pixels, stride cpl; lines start every wΒ·cpl bytes, hΒ·z of them.
+ blurAxis(d, cpl, w, cpl, h * z, w * cpl, amt);
+ // y-pass: each (x,z) line is `h` pixels, stride wΒ·cpl. Lines: for each z, the w columns β start
+ // offsets are zΒ·(hΒ·wΒ·cpl) + xΒ·cpl. Walk them as one run of (wΒ·z) lines stepping by cpl, but the
+ // z blocks aren't contiguous in column-start, so loop z outside.
+ for (size_t zz = 0; zz < z; zz++)
+ blurAxis(d + zz * h * w * cpl, cpl, h, w * cpl, w, cpl, amt);
+ // z-pass (3D only): each (x,y) line is `z` pixels, stride wΒ·hΒ·cpl; wΒ·h lines stepping by cpl.
+ if (z > 1) blurAxis(d, cpl, z, w * h * cpl, w * h, cpl, amt);
+}
+
+// Fill the whole buffer with one colour (MoonLight's fill_solid).
+inline void fill(Buffer& buf, RGB c) {
+ const uint8_t cpl = buf.channelsPerLight();
+ if (cpl == 0) return; // a 0-channel buffer has no colour to write; guards off += 0 spinning
+ uint8_t* d = buf.data();
+ const size_t n = buf.bytes();
+ for (size_t off = 0; off + cpl <= n; off += cpl) {
+ if (cpl >= 1) d[off + 0] = c.r;
+ if (cpl >= 2) d[off + 1] = c.g;
+ if (cpl >= 3) d[off + 2] = c.b;
+ }
+}
+
+// Blit one glyph of `font` at grid position (x, y). Each of the font's `height` rows is one byte;
+// bit set β pixel on, columns MSB-first across the glyph's `width` (bit (7) is the left column, down
+// to bit (8-width)). Only printable ASCII 32..126 is drawn; anything else is skipped (a gap). Pixels
+// are clipped to the grid (draw::pixel). Prior art: the WLED/MoonLight console-font blitter shape.
+inline void glyph(Buffer& buf, Coord3D dims, const fonts::Font& font, char ch, lengthType x, lengthType y, RGB c) {
+ if (ch < 32 || ch > 126) return;
+ const uint8_t idx = static_cast(ch - 32);
+ const uint8_t* rows = font.rows + static_cast(idx) * font.height;
+ for (uint8_t ry = 0; ry < font.height; ry++) {
+ const uint8_t bits = rows[ry];
+ // Columns are MSB-first: the LEFTMOST glyph column (rx=0) is bit 7, the next bit 6, β¦ so
+ // read column rx from bit (7 - rx). (Reading (rx + 8-width) instead mirrors each glyph
+ // left-to-right β a 'b' renders as a 'd'.)
+ for (uint8_t rx = 0; rx < font.width; rx++)
+ if ((bits >> (7 - rx)) & 0x01)
+ pixel(buf, dims, {static_cast(x + rx), static_cast(y + ry), 0}, c);
+ }
+}
+
+// Draw a NUL-terminated string starting at (x, y), advancing `font.width` per character. `\n` starts
+// a new line one `font.height` down and resets x to the start column (multi-line layout). Off-grid
+// glyphs clip. Returns the total pixel width drawn on the first line (for scroll bookkeeping).
+inline lengthType text(Buffer& buf, Coord3D dims, const fonts::Font& font, const char* str,
+ lengthType x, lengthType y, RGB c) {
+ if (!str) return 0;
+ lengthType cx = x, cy = y;
+ lengthType firstLineWidth = 0; // frozen at the first '\n' so a multi-line string still reports line 1
+ bool onFirstLine = true;
+ for (const char* p = str; *p; p++) {
+ if (*p == '\n') {
+ if (onFirstLine) { firstLineWidth = static_cast(cx - x); onFirstLine = false; }
+ cx = x; cy = static_cast(cy + font.height); continue;
+ }
+ glyph(buf, dims, font, *p, cx, cy, c);
+ cx = static_cast(cx + font.width);
+ }
+ return onFirstLine ? static_cast(cx - x) : firstLineWidth;
+}
+
+} // namespace draw
+} // namespace mm
diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h
index 5c17e028..14ac5b7e 100644
--- a/src/light/drivers/Drivers.h
+++ b/src/light/drivers/Drivers.h
@@ -6,6 +6,7 @@
#include "light/layers/Layers.h"
#include "light/layers/BlendMap.h"
#include "light/drivers/Correction.h"
+#include "light/Palette.h" // the global active palette + its select control
#include "platform/platform.h"
#include // std::strcmp in onUpdate
@@ -159,6 +160,7 @@ class Drivers : public MoonModule {
// PreviewDriver reads the RGB source buffer directly, so the simulator is
// unaffected. RGB-ordered outputs (some ArtNet/network sinks) flip it back.
uint8_t lightPreset = 2; // index into kLightPresetOptions; 2 = GRB
+ uint8_t palette = 0; // index into mm::palettes::kBuiltins; the global active palette effects read
// Two ways to wire the source Layer:
// - setLayers(Layers*): bind the container; layer_ is re-resolved from
@@ -179,6 +181,7 @@ class Drivers : public MoonModule {
void onBuildControls() override {
controls_.addUint8("brightness", brightness, 0, 255);
controls_.addSelect("lightPreset", lightPreset, kLightPresetOptions, kLightPresetCount);
+ controls_.addPalette("palette", palette, mm::paletteOptions, mm::palettes::kCount);
MoonModule::onBuildControls(); // cascade to driver children
}
@@ -186,6 +189,10 @@ class Drivers : public MoonModule {
// pipeline realloc. This is what keeps the brightness slider fluent: controlChangeTriggersBuildState
// stays false for Drivers, so handleSetControl skips scheduler_->buildState().
void onUpdate(const char* controlName) override {
+ if (std::strcmp(controlName, "palette") == 0) {
+ Palettes::setActive(palette); // rebuild the active 16-entry lookup (cheap, off the hot path)
+ return;
+ }
if (std::strcmp(controlName, "brightness") == 0 ||
std::strcmp(controlName, "lightPreset") == 0) {
correction_.rebuild(brightness, static_cast(lightPreset));
@@ -201,6 +208,7 @@ class Drivers : public MoonModule {
void setup() override {
correction_.rebuild(brightness, static_cast(lightPreset));
+ Palettes::setActive(palette); // seed the global active palette from the persisted index
MoonModule::setup();
passBufferToDrivers();
}
diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h
new file mode 100644
index 00000000..7b7f9823
--- /dev/null
+++ b/src/light/drivers/HueDriver.h
@@ -0,0 +1,718 @@
+#pragma once
+
+#include "light/drivers/Drivers.h"
+#include "core/JsonUtil.h" // parse the bridge's JSON responses
+#include "core/FilesystemModule.h" // noteDirty β persist the app key after pairing
+#include "core/DevicesModule.h" // DevicesModule::active() β list the bridge as a device
+#include "platform/platform.h"
+
+#include
+#include
+#include
+
+namespace mm {
+
+// Philips Hue lights as a projectMM OUTPUT β a driver, not a listed device. The bulbs are
+// pixels of an effect: make a small grid (e.g. 4Γ1Γ1), run any effect, and this driver reads
+// its window of the shared buffer and pushes each light's colour to the bridge. Same shape as
+// NetworkSendDriver (read a window, send it out), but over the Hue v1 HTTP API instead of UDP.
+//
+// What makes Hue different from a strip and shapes the design:
+// - It's HTTP, not a wire protocol: GET /api/