Backlog — mixed (core + light)¶
Forward-looking items whose work genuinely spans both the core and light domains — a core mechanism interacting with a light driver/effect/modifier, where assigning it to one side would misrepresent it. Core-only items are in backlog-core.md, light-only in backlog-light.md. Index + overview: README.md.
Cross-domain¶
LightsControl: not building it (decided 2026-09-01)¶
The idea was one module owning global light state (master on/off, brightness, palette, bpm) and
acting as the integration point for external controllers, modelled on MoonLight's
ModuleLightsControl. It is not being built, and the two reasons are worth keeping:
The generic ControlModule is already the hub. It carries the surface (switches, encoders, faders, pads) that every transport writes: OSC, MQTT, the WLED bridge, the web UI, and now the physical inputs. A second hub beside it would be the split brain the OSC plan forbids.
A global light param belongs to the module that uses it. palette lives on Drivers because
that is where it is consumed (effects read it through Palettes::active()), and the same holds for
brightness and on. Moving them into a hub separates a value from its consumer and buys nothing:
encapsulation, not centralisation.
So the surface reaches INTO those modules rather than owning their state, which is already how it
works: fader1 targets Drivers.brightness, switch1 targets Drivers.on, and encoder1 targets
Drivers.palette. Those bindings are hardcoded today and become user-assignable as a UI plus
persistence job (see power-functions-analysis-top-down.md,
per-control assignment).
What an encoder bound to a select needs: the option NAME under the knob, not the index, so
turning through palettes reads "Rainbow" rather than "37". One rule in the generic UI (an encoder
bound to a select renders its target's option label) rather than per-module code, which is how the
rest of the UI already works. A uint8_t encoder covers 256 options, comfortably past the palette
count, and whether it wraps or clamps at the ends follows the bound control's type: a palette ring
wraps, a brightness does not.
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.
Partly shipped since this was written. ControlModule owns the preset system: a pad grid of slots, save and apply, and capture controls that choose which subtrees a save includes. So the home question is settled (the surface, not a hub), and what remains from the description above is preset looping on a timer, rotating between a first and last slot, plus Home Assistant registration for external recall. Persistence already round-trips control values, so the mechanism is there; the loop timer is the missing piece. Complements the presets UI note (control-value bundles).
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.
This is NOT a no-PSRAM blocker — 16K Noise + Multiply has run on a classic ESP32 (no PSRAM, 320KB internal) before at 10–20 FPS (WiFi vs Ethernet), sending frames out over ArtNet to a display, not physical LED drivers. It works there because classic's nrOfLightsType is uint16_t (half the LUT size) and the modifier shrinks the logical render grid. So the action is re-verify the working classic setup when a classic board is connected (find the config — grid, mirror, ArtNet target — that reproduces the historical 10–20 FPS), not "fix an impossibility." Worth investigating only if that re-verification shows the LUT memory has regressed since: the destinations array is the obvious lever (it stores a nrOfLightsType per physical destination; a 2× kaleidoscope is 1:1 in count so the LUT need not store fan-out > the physical count — confirm it isn't over-allocating to maxMultiplier() when the effective fan-out is 1). Capture the classic numbers into performance.md's multi-board table first.
NoiseEffect simplex cost on ESP32 (investigation)¶
With mirror XY at 128×128, NoiseEffect renders the 64×64 logical quadrant in ~11 ms/tick on the Olimex (measured) — the simplex math dominates, since the Xtensa LX6 has no FPU and float math is software-emulated. (RainbowEffect on the same pipeline is much cheaper.) This is correct, non-degraded behaviour; it's only worth revisiting if a deployment needs Noise faster than ~11 ms at this grid.
Worth investigating if so:
- Q16 fixed-point simplex instead of float (kills the software-float emulation cost).
- Lower-precision hash — current simplex uses a 256-entry permutation lookup; a smaller / SIMD-friendly hash may be faster on Xtensa.
- Strided sampling + interpolation — render at 32×32, bilinear up to 64×64. Visual quality cost; needs A/B comparison.
- Inline / unroll the inner per-pixel loop to keep the simplex state in registers.
None of these are obviously free, and a fixed-point port may shift the visual signature. Defer until there's a real use case — on the no-PSRAM Olimex at large grids the tick is dominated by the synchronous ArtNet send (~35 ms), not Noise, so the effect is rarely the bottleneck there.
S3 render-only data point (2026-06-17, scenario_perf_full): on the PSRAM S3 with no output driver, Noise is the dominant cost at every grid and there's no ArtNet floor to hide it: 16²→738µs, 32²→2,831µs, 64²→11,235µs, 128²(16K)→50,555µs (~20 FPS) — clean ~linear-in-pixels (67×), so no fragmentation/realloc pathology, just raw simplex compute. The light effect (Checkerboard) on the same sweep is 6–11× faster (16K→7,949µs, ~128 FPS). So on a PSRAM board the heavy effect IS the 16K bottleneck (where on the Olimex the network send was). This is the strongest case for the fixed-point/strided-sampling ideas above, since a PSRAM board can run 16K grids that the network-bound Olimex never reaches. The S3 has a real FPU (LX7), so the win is less about software-float emulation and more about per-pixel simplex work; profile before committing.