diff --git a/CLAUDE.md b/CLAUDE.md index 3ad3c378..d55f4dff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ Published at [moonmodules.org/projectMM](https://moonmodules.org/projectMM/); so - [performance.md](https://moonmodules.org/projectMM/performance.html) — per-module timing/memory per platform - [MIGRATING.md](https://moonmodules.org/projectMM/MIGRATING.html) — breaking-change log - [backlog/](https://moonmodules.org/projectMM/backlog/index.html) — forward-looking to-build lists (core / light / mixed) -- [adr/](https://moonmodules.org/projectMM/adr/index.html) — immutable architecture decision records (Nygard format) +- [adr/](https://moonmodules.org/projectMM/adr/index.html) — immutable architecture decision records (Nygard format); immutable except the status line: superseded/amended ADRs get a dated pointer to their successor - [history/](https://moonmodules.org/projectMM/history/index.html) — lessons, prior-project inventories, friend-repo digests - [moonmodules/](https://github.com/MoonModules/projectMM/tree/main/docs/moonmodules) — module catalog pages + generated technical pages diff --git a/docs/adr/README.md b/docs/adr/README.md index f385407f..01b2e310 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,7 +2,7 @@ An [ADR](https://github.com/joelparkerhenderson/architecture-decision-record) captures one significant architectural decision: the context that forced a choice, the option taken, and the consequences that followed. Format is [Michael Nygard's classic](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html): **Title, Status, Context, Decision, Consequences**. -These records are **immutable**. A decision that changes is not edited in place, a new ADR supersedes it and both link, so the reasoning trail stays honest. This is the difference from the [lessons log](../history/lessons.md): lessons are debugging war-stories, pruned as they are absorbed; ADRs are decisions, kept as an append-only record. The forward-looking counterpart, what we set out to build, is the [plan archive](../history/plans/README.md). +These records are **immutable except the status line**: a decision that changes is not edited in place — a new ADR supersedes it, the old one's status gains a dated pointer to its successor (`Superseded by ADR-NNNN, YYYY-MM-DD`, or a dated `Amended:` note), and both link, so the reasoning trail stays honest while every reader lands on a signpost to current truth. This is the difference from the [lessons log](../history/lessons.md): lessons are debugging war-stories, pruned as they are absorbed; ADRs are decisions, kept as an append-only record. The forward-looking counterpart, what we set out to build, is the [plan archive](../history/plans/README.md). Agents do not read this directory automatically, only when a decision's rationale is in question (the same rule as `history/` and `backlog/`). diff --git a/docs/architecture.md b/docs/architecture.md index 13065aaf..164c8265 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ The system is two layers, separated as much as practical: When mixing is needed (for performance or simplicity), it must be an explicit decision: consciously choosing minimalism over separation, not accidentally blurring the boundary. Use domain-neutral naming in those cases ("producer buffer" not "LED buffer", "output driver" not "LED driver" in core interfaces) to keep the door open for future separation. -**Core primitives, not one-offs.** Core earns growth only by adding a recognizable, reusable primitive many modules lean on (a streaming write, a positional read, a bounded arena, a recursive JSON reader); a core change that only one caller needs is the smell. When a complex system will need a capability, build the cleanest complete version rather than a crippled subset that pushes hacks outward (a JSON reader that can't read arrays is not "minimal"). And concrete first, abstract later: build one working feature end-to-end before extracting the shared abstraction. +**Core primitives, not one-offs.** Core earns growth only by adding a recognizable, reusable primitive many modules lean on (a streaming write, a positional read, a bounded arena, a recursive JSON reader); a core change that only one caller needs is the smell. When a complex system will need a capability, build the cleanest complete version rather than a crippled subset that pushes hacks outward (a JSON reader that can't read arrays is not "minimal"). # Core @@ -429,7 +429,17 @@ The `dim` int is also emitted in `/api/types` so the UI derives the dimensional ### Robustness rules -**Effects must run at every grid size.** Modifiers can shrink the logical grid to any size including 0×0×0 (e.g. every layout child is disabled). An effect's `tick()` must produce a correct result for any `(width, height, depth)`: no crashes, no divide-by-zero, no out-of-bounds writes. On a zero grid the loop is a clean no-op. Effects either gate at the top (`if (w <= 0 || h <= 0) return;`) or write their loops so an empty range is naturally a no-op (`for (y = 0; y < h; ...)`). +**Effects run at every non-empty grid shape.** Modifiers can reshape the logical grid to any size, so an effect's `tick()` produces a correct result for any `(width, height, depth)` of at least one light — a 1×1, a strip, a tall column, a cube. The empty case is the Layer's: `Layer::tick()` skips the effect pass entirely when an extent is 0 or the buffer holds no lights, so that check lives in one place for all effects rather than at the top of each. + +**The Layer decides whether a frame runs; the effect decides what it paints.** The modifier pass still runs when the effect pass is skipped: a beat-driven modifier advances its per-frame state through the empty interval, so the chain is in the right phase when the grid returns. An effect owns the checks about *itself*, and returns early for: + +- **Its own resources**: `if (!heat_) return;` — a ScratchBuffer it allocated. +- **Its own controls and timing**: `if (speed == 0) return;`, a rate limiter, a divide-by-zero guard on a control value. +- **Producer input**: `if (!f) return;` — no audio frame to react to. + +The test: *would the Layer know to skip this?* If yes (an empty grid, a disabled module), it belongs to the Layer. If no (this effect's buffer, this effect's control), it belongs to the effect. + +**Effects render at every channel count.** An effect writes per channel, the way `draw::pixel` does (`if (write >= 1) …r; if (write >= 2) …g;`), so a light carries as much of the color as it has channels — RGB on three, R+G on two, R on one. Channels the effect doesn't set belong to the driver. Every light has at least one channel: `Layer::setChannelsPerLight` enforces that at the setter. **Effects must animate at every tick rate.** Per-tick phase math computed as `dt * bpm * K / 60000` truncates to 0 on devices where `dt < 234/bpm` ms: desktop ticks every 0–1 ms, so even bpm=60 freezes. The fix is to keep the raw `dt * bpm` numerator in the phase accumulator and divide only at the read site: @@ -440,6 +450,12 @@ uint8_t t = static_cast((phase_num_ * 256) / 60000); See NoiseEffect / MetaballsEffect for the canonical pattern. Animation speed must depend only on `bpm` and wallclock, not on tick rate or grid size. +**Everything that changes over time is driven by elapsed time, never by the frame count.** The rule above is one half of it — a phase that truncates to zero and freezes. The other half is the mirror image and just as wrong: state advanced by a fixed amount *per frame* runs at whatever speed the hardware happens to render. The same gravity setting is an explosion on a desktop at 5,000 fps and a drift on an ESP32 at 470. This applies to every per-frame quantity, not just phase: a force, a velocity, a trail fade, a decay, a drop rate, a simulation step. The user sets a speed; the hardware must not get a vote. + +**A faster device renders the same motion more smoothly, not more motion.** The tempting fix — quantise to a fixed 60 Hz and skip the frames in between — is wrong here, because it discards exactly the smoothness the extra frames were rendered for. Instead scale the work by the fraction of a reference frame that actually elapsed, so a device rendering ten times as fast takes ten steps a tenth the size: the same trajectory at ten times the resolution. `particles::FrameTime` is the shared implementation (8.8 fixed point, 256 = one reference frame, whose rate is the constructor's `referenceHz` — 60 by default). It carries the undivided numerator and divides late, for the same reason `BeatPhase` does: one unit is a fraction of a millisecond, so a remainder held in whole milliseconds cannot represent it and the truncated time — which differs by render rate — becomes a framerate dependency of its own. + +The check is mechanical: **run the effect at two very different framerates over the same span of simulated time and compare.** If the result differs, something is counting frames. + **An effect renders a pattern; it does not transform geometry.** When migrating or adding an effect, strip out anything that is really a *modifier* — mirroring, tiling, rotation, scrolling/offset, a kaleidoscope fold, masking, any remap of *where* pixels land — and add it as a separate [modifier](#modifiers) instead. WLED (and other sources we port from) routinely fold these into the effect's own loop (a "mirror" checkbox, a "2D" rotation, a built-in pinwheel), because WLED has no modifier concept; we do. Keeping them out of the effect is what lets any effect compose with any modifier (the same RotateModifier rotates Fire, Noise, or a network-received frame) instead of every effect re-implementing its own half-baked mirror. The test: an effect's `tick()` should only *write colors into the logical buffer for its own coordinates*; if it's reading or rewriting positions to move/fold/duplicate the image, that behaviour belongs in a modifier. (This is the light-domain face of *Complexity lives in core; domain modules stay simple* — geometry transforms are the modifier's job, shared once, not duplicated into every effect.) ## MoonLive: the live-script engine diff --git a/docs/assets/core/ControlModule.png b/docs/assets/core/ControlModule.png new file mode 100644 index 00000000..4360d4fa Binary files /dev/null and b/docs/assets/core/ControlModule.png differ diff --git a/docs/assets/extra.css b/docs/assets/extra.css index fa8a3c02..fc0282b9 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -124,3 +124,27 @@ color: var(--md-accent-fg-color); /* the declared name: accent + bold */ font-weight: 700; } + +/* Power-functions tables: the first column holds a function NAME, which must never + break mid-token (`draw::fill` wrapping to "draw::fil / l" is unreadable — the reader + is scanning for an identifier, not prose). Material sizes table columns by content + and lets long inline code wrap anywhere: right for prose, wrong for a symbol. + Applied via an explicit {.mm-pf} class on each table (attr_list), so no other table + on the site is reshaped. */ +.md-typeset .mm-pf table, +.md-typeset table.mm-pf { + table-layout: fixed; + width: 100%; +} +.md-typeset .mm-pf table th:nth-child(1), .md-typeset .mm-pf table td:nth-child(1), +.md-typeset table.mm-pf th:nth-child(1), .md-typeset table.mm-pf td:nth-child(1) { width: 17%; } +.md-typeset .mm-pf table th:nth-child(2), .md-typeset .mm-pf table td:nth-child(2), +.md-typeset table.mm-pf th:nth-child(2), .md-typeset table.mm-pf td:nth-child(2) { width: 35%; } +.md-typeset .mm-pf table th:nth-child(3), .md-typeset .mm-pf table td:nth-child(3), +.md-typeset table.mm-pf th:nth-child(3), .md-typeset table.mm-pf td:nth-child(3) { width: 38%; } +.md-typeset .mm-pf table th:nth-child(4), .md-typeset .mm-pf table td:nth-child(4), +.md-typeset table.mm-pf th:nth-child(4), .md-typeset table.mm-pf td:nth-child(4) { width: 10%; } +/* The identifier column: keep each symbol whole rather than breaking it mid-token. */ +.md-typeset .mm-pf table td:nth-child(1) code, +.md-typeset table.mm-pf td:nth-child(1) code { white-space: nowrap; } +.md-typeset .mm-pf table td, .md-typeset table.mm-pf td { vertical-align: top; } diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 167c128a..ae67d754 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -18,7 +18,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co - **Windows code-signing** — drops the SmartScreen warning on first run of `projectMM.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. - **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. - **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. -- **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API (per *Concrete first, abstract later*), not speculatively now. +- **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. - **ESP32-P4 DHCP hostname not shown by the router (recheck later)** — the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) — the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. ### DevicesModule — interop plugins + the command half (discovery shipped) @@ -168,6 +168,14 @@ Related: this is the render/output-buffer face of the same non-PSRAM fragmentati ## Architecture +### Filesystem-change notification (live preset refresh) — undesigned + +ControlModule rebuilds its preset list by rescanning `/.config/presets`, and that rescan runs at startup and after every save, rename, delete and reorder. So a preset file **uploaded or deleted through the File Manager** appears only once the module next rescans (a reboot, or any preset action on the surface), not the instant the file lands. Documented as the actual behaviour in [control.md](../moonmodules/core/control.md). + +The fix is a **core-neutral filesystem-change notification**: FileManagerModule (or the `platform::fs*` write paths) signals "this path changed", and a module with a folder it cares about re-reads. Deliberately not built yet — it is a new core seam serving one caller today, which is the shape [architecture.md § Core primitives, not one-offs](../architecture.md#core-and-light-domain) warns about. **Build trigger**: a second consumer appears (a scripted-effect folder for MoonLive is the likely one, since live scripts uploaded as files have exactly the same staleness), or the manual-refresh step proves annoying in real use. + +Whatever the design, it stays domain-neutral (a path + a change kind, no preset/light vocabulary in core) and off the hot path — the notification marks a flag, the rescan happens on the owning module's next tick, never inside the writer. (CodeRabbit flagged the staleness; deferred here rather than growing the seam for one caller.) + ### WiFi runtime disable — open design question (undesigned) Today the eth-only build profile compiles WiFi out (`MM_NO_WIFI`). Turning WiFi off *at runtime* instead is undesigned: whether the gate should key off detected hardware presence, an explicit control, or a deviceModel-catalog field isn't decided. The eth-only build covers the need until a concrete case forces the choice. (Moved from architecture.md § What we leave undesigned; it's a deferred design decision, not a settled 🚧 one.) diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index f84147b3..de11e598 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -268,4 +268,20 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on - **Moving-head preview = peer interpreter.** When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture — the same light-preset model physical drivers use, interpreted to screen. This is *why* the increments named the abstraction "interpret the preset" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. - **Sparse light-preset editor.** A LightPresets row currently shows one role Select per channel across the whole `channels` width — including the unmapped `—` gaps a wide moving head has between its functions. For a fixture you usually only care about the few channels you drive (rgb, pan, tilt). The refinement: show only the *mapped* channels + an "add channel" affordance (pick a role → fills the first gap or grows the fixture), over the unchanged dense `roles[]` storage. A first attempt shipped and was reverted for edit bugs; redo it cleanly (the dense editor is the reliable interim). Prior art: GDTF / QLC+ fixture profiles (a fixture is a sparse `{channel → function}` map, not a dense per-channel array). +- **`worley` cellular noise, when an effect needs it** (deferred 2026-08-07, PO). The one power function the plan names that stayed unbuilt on purpose. Worley (cellular / Voronoi) noise measures the distance to the nearest of a set of scattered feature points, which is what produces the look nothing else does — cracked mud, scales, stained glass, a caustic. The fields we have cannot fake it: `fbm` and `warp` are smooth by construction, so their creases never form cells. + + **Why it waits.** An industry-standardness audit (Fable, 2026-08-07) put it explicitly on the "do NOT add yet" list: a practitioner does not consider it table stakes, and every other field function in the library earned its place by having a caller. Building it now would make it the only entry whose justification is "the canon has one". The trigger to build it is an effect that wants cells — at which point it lands with that effect, the way `hashInt` landed with Dissolve and `sampleWrap` with Echo. + + **What it costs when it comes:** the standard cheap form is a 3×3 neighbourhood scan around the sample's cell, hashing each neighbour's feature point and keeping the nearest distance — nine `hashInt` calls and nine distance tests per pixel, so meaningfully dearer than one noise sample. `dist16` and `hashInt` already exist, so the function itself is short; the cost question is what it does to a per-pixel budget on a large fixture. + +- **16-bit noise: tuning still open** (2026-08-08). The tier ships (`inoise16` 1/2/3D, `fbm16`), and two defects found by review are fixed: `lerp16`'s product is 64-bit (the 32-bit form was signed overflow, undefined behaviour, on roughly a quarter of samples), and `fbm16` sums its octaves at full width instead of shifting each down by 8 — which had made the output 8-bit wearing a 16-bit type (195 distinct values over 20,000 samples; now 15,118). + + **What is still open.** It is VALUE noise, like the 8-bit tier: cell corners are hashed and interpolated. Gradient (Perlin) noise hashes a gradient per corner and takes a dot product, which removes the faint axis-aligned grid value noise leaves — visible on a large smooth field, which is the case this tier exists for. Also unbuilt: a 3D `fbm16`, and `warp16` / `turbulence16` to match the 8-bit compositions. The trigger is an effect that wants a large smooth field; it lands with that effect and its measurements, the way the 8-bit compositions landed with theirs. + +- **Clear the grid on an effect's FIRST frame** (2026-08-08). Fourteen effects still show the previous effect's picture on the frame right after a switch: BouncingBalls, Fireworks, FreqMatrix, FreqSaws, GEQ, GEQ3D, GameOfLife, Lissajous, NoiseMeter, PaintBrush, Random and three more. Most predate the power-function branch. + + **What the user sees.** Switching to one of these leaves the old frame underneath for a moment — an audio effect with no signal yet, or a simulation still seeding, paints nothing and inherits whatever was there. `Layer::tick` deliberately does not clear (ADR-0003: an effect may fade its own last frame for trails), so owning the background is each effect's job. + + **Why it waits.** It is fourteen effects' worth of change across audio-reactive and simulation families, each needing its own judgement about whether to clear, fade, or seed differently — not a mechanical sweep. `unit_Effects_gridsweep.cpp` already measures it (`afterFirst`) and asserts only the settled frame, so the number is visible without blocking. + (The shared lane-driver scaffolding extraction — when a 3rd parallel backend lands — is tracked separately under [§ Extract shared lane-driver scaffolding](#extract-shared-lane-driver-scaffolding-when-the-3rd-parallel-backend-lands-deferred) above.) diff --git a/docs/backlog/livescripts-analysis-bottom-up.md b/docs/backlog/livescripts-analysis-bottom-up.md index 257dfe03..44a97c70 100644 --- a/docs/backlog/livescripts-analysis-bottom-up.md +++ b/docs/backlog/livescripts-analysis-bottom-up.md @@ -11,6 +11,7 @@ - **The front-end is portable; the back-end is not.** Tokenizer + parser + AST (`NodeToken`) are CPU-agnostic; only the *visitor → opcode* tier and the *load-and-execute* tier are ISA-bound. But today they're **deeply interleaved** — visitor methods emit Xtensa strings inline, there is **no intermediate representation (IR)** between AST and machine code. A clean redesign's load-bearing decision is whether to introduce that IR seam so one front-end feeds many back-ends (the LLVM shape, scaled down). - **The "compatible with MoonModule" requirement is the projectMM-specific value-add.** ESPLiveScript binds to the host via `addExternalFunction(name, ret, sig, fnptr)` / `addExternalVariable(name, type, _, ptr)` (`asm_external.h`) — a flat C-pointer registry. projectMM needs scripts to read/write **controls**, consume the **producer/consumer data structures** (a `Buffer`, an `AudioFrame`), and slot into the **module tree** as a scripted effect/layout/modifier/driver/peripheral. That binding layer — script ⇄ MoonModule — is ours to design; no surveyed engine has it. - **Memory + sync are already partly modelled in ESPLiveScript** and align with projectMM's constraints: compiled code lands in IRAM/PSRAM by target (`execute.h:10-15` gates PSRAM stack on S3/P4), a **save/load compiled-binary path** exists (`savebinary`/`executebinary` examples → compile once, ship the binary, skip re-compile on boot), and a `sync()` primitive coordinates concurrent script tasks. These are the right *ideas*; the redesign carries them forward against our `platform::` seam and `Scheduler`. +- **⚠️ Superseded upstream (noted 2026-08-06): hpwit has rewritten it as [ESPLiveScript2](https://github.com/hpwit/new-parser).** A from-scratch C++ reimplementation whose stated goal is precisely the gap this analysis identified below — a compiler you can *verify*: the whole toolchain builds and runs as a host program, and its tests execute the actual compiled Xtensa bytes under QEMU against v1's own example corpus. The rewrite landed in the first days of August 2026 (the repo was dormant May 2025 → August 2026), so this document's reading of v1 stands as written but is no longer a reading of hpwit's *current* work. **Before Stage 2 acts on any v1 conclusion, re-read v2** — the portability finding in particular (is codegen still Xtensa-only, or did the rewrite introduce the IR seam we concluded was missing?). Digest: [hpwit-new-parser.md](../history/hpwit-new-parser.md). - **Code-quality reality (for the redesign).** Header-only, ~18K lines across 11 headers, **pervasive global state** (`string signature; Token __t;` and dozens of file-scope compiler counters), no IR, no unit tests, a 4,100-line `Parser` and a 5,824-line `NodeToken`. It works and it's fast, but it is **not** a base to extend in place — it's the reference to learn from and rewrite against our architecture (exactly the *Industry standards, our own code* method we used for LED drivers). - **Recommendation: build our own native engine, Xtensa-first, behind an IR seam — start small, start beautiful, no dead-ends.** Take the ESPLiveScript *approach* (native machine-code execution, near-100% speed — the standout, never-done-before-in-this-space when bound to a module system) and add the one thing our multi-target goal needs that a single-ISA engine doesn't: put an **IR seam** between a platform-independent front-end (tokenizer→parser→AST) and the code generator. **Ship one backend first — Xtensa (classic ESP32 + S3)** — exactly where ESPLiveScript already proves native speed; that's the small, beautiful, blazingly-fast first deliverable. The IR seam is the **no-dead-end guarantee**: adding RISC-V (P4), ARM (Teensy), or x86/ARM64 (desktop) later is "write another backend behind the same IR," never "go back to the drawing board." ESPLiveScript's real dead-end isn't *Xtensa-first* — it's *Xtensa-welded-in, no IR*; we start at the same fast place but with the seam it lacks. **WASM/WAMR is the named fallback, per target**: a target without a native backend yet can run the portable path through the same IR, so we're never blocked — but the *flagship* experience is native. (Detail + why-this-over-WASM-wholesale in § Recommendation.) - **Safety the same way — climb the tiers, don't pay upfront.** A user-facing script editor means a bad script must degrade, not brick. Start with the **cheap** safety (array **bounds-checking** = a compare-branch per indexed access, low single-digit %, and removable in a trusted/fast mode; **watchdog / instruction budget** to kill a runaway loop = near-free, the task WDT already does most of it) — these catch the common bad-script cases at low cost (the kind the `fix-warnings` null-deref was). The **expensive** tier — a true memory sandbox where a script *cannot* touch memory outside its arena — is exactly what WASM gives for free and native can't cheaply; leave it as a tier we *can* climb via the IR→WASM fallback if field experience demands it, not a wall we hit. So safety is staged, not a foregone full-sandbox cost. diff --git a/docs/backlog/power-functions-analysis-bottom-up.md b/docs/backlog/power-functions-analysis-bottom-up.md new file mode 100644 index 00000000..7ccd930f --- /dev/null +++ b/docs/backlog/power-functions-analysis-bottom-up.md @@ -0,0 +1,192 @@ +# Power functions — bottom-up analysis + +> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** This is a Stage-1 bottom-up survey of *power functions*: the shared primitives (drawing, fields, physics, color, time) that LED effects are really made of, which MoonLive should expose as built-ins so scripts stay compact. It inventories three sources read on **2026-08-05**: (a) our own 39 compiled effects and the helpers they share, (b) WLED / the WLED Particle System / FastLED as prior art, (c) the industry-standard algorithm canon with originators. The **top-down** companion ([power-functions-analysis-top-down.md](power-functions-analysis-top-down.md)) turns the catalog into the implementation spec. Modelled on [livescripts-analysis-bottom-up.md](livescripts-analysis-bottom-up.md). Source citations are `file:line` against this repo, or repo-relative paths for external sources; usage counts come from reading every effect header and grepping the cloned externals. + +## TL;DR + +- **The gap is stark and measurable.** Our compiled effects draw on ~40 shared helpers (`draw::` primitives, the `sin8`/`beat8` family, 1D/2D/3D value noise, palettes, `Random8`, fonts). MoonLive scripts can call exactly **three functions** — `setRGB(i,r,g,b)`, `fill(r,g,b)`, `random16(n)` — by flat index only: no x/y/z, no dimensions, no time ([MoonLiveBuiltins_light.h:27-36](../../src/light/moonlive/MoonLiveBuiltins_light.h)). Every power function this document catalogs is something a script cannot express today. +- **Our own effects prove the demand.** Even *with* the shared library, the 39 effects hand-roll: the same `depthDim()` helper 16×, a BPM phase accumulator 9×, an integer `map()` 6×, five particle systems in five different representations, four different distance approximations, a private `plot()` that re-implements `draw::pixel`, and a byte-identical sine-blob oscillator in two effects. Each repeat is a power function asking to exist (§ What our effects hand-roll). +- **The WLED Particle System is found, and it is the single richest prior-art source for "gravity and inertia".** `wled00/FXparticleSystem.cpp/.h`, author **Damian Schneider (DedeHai)**, licensed **EUPL v1.2**, merged into mainline WLED via [PR #4506](https://github.com/wled/WLED/pull/4506) on 2025-02-17 (the 0.16 line); 32 effects are built on it. WLED-MM carries a diverged 2025 variant. Its design vocabulary — integer sub-pixel positions (1 pixel = 64 units), 3.4-fixed-point force accumulators, binned impulse collisions, a 2×2 bilinear splat with inverse-gamma weights — is the measured, ESP32-proven shape of an LED physics engine (§ WLED-PS). +- **One organizing principle covers almost everything.** Read at function level, WLED's ~200 effects and our 39 decompose the same way: **time-phase generators (beat/sin/noise/random) → palette lookup → additive sub-pixel compositing → decay (fade/blur)**. The particle system is exactly that pipeline made stateful. A power-function set that serves those four stages plus a physics kernel covers the overwhelming majority of known effects. +- **"Gravity and inertia" has a textbook answer — and an industry name: `particles`.** Semi-implicit (symplectic) Euler — `v += a; x += v` in fixed point — is what game engines, the demoscene, and WLED-PS all use: two adds per axis, stable at large timesteps (Fiedler, gafferongames.com; Hairer et al.). Restitution bounce is `v = -(v·e)>>8`; drag is `v *= (256-k)/256`; the "smooth follow" every audio meter wants is a one-pole filter `x += (target-x)>>n` (the critically-damped-smoothing family, *Game Programming Gems 4*). None of it needs float (§ Physics). +- **The shader question has a precise, honest answer.** Per-pixel budget at 240 MHz: ~15,600 cycles/pixel on 16×16@60 (anything goes) but **~293 cycles/pixel at 128×128@50** — one noise sample + one palette map + one blend, nothing more. PixelBlaze (Ben Hencke) proves the *ergonomics* of per-pixel scripting (normalized 0..1 coordinates, `time(n)` sawtooths, hsv out) but its interpreted VM measures ~48k pixel-evals/s on ESP32 — an order of magnitude short of large matrices. **No shipping project runs real GLSL on an MCU**; GPU shading exists only on Pi/desktop. Conclusion: power functions are **compiled fixed-point kernels that scripts compose** — per-frame calls into native code, not per-pixel interpretation. That is exactly the PO's "one set, same everywhere" with the desktop ceiling preserved: desktop may *accelerate* the same functions (or interpret richer per-pixel expressions on top), but the contract is the portable kernel set (§ Shaders). +- **Modern additions the classic canon lacks.** Two primitives from the shader world earn a place on CPU: **2D signed distance functions** (circle/box/segment + smooth-min; Quilez) — anti-aliased shapes, outlines, glow and metaball-morphing from a few fixed-point ops — and **cosine gradient palettes** (12 constants = a whole palette, bakeable to a LUT). Plus the **Wu sub-pixel splat**, which WLED treats as the difference between 8-bit-console and modern motion on a coarse matrix. +- **Fixed-point is settled policy, and the conventions already exist in-repo.** Coding standards mandate integer-first ([coding-standards § numeric types](../coding-standards.md)); effects document the working idioms: uint8 angle (256 = full turn), palette index mod-256, noise coords 16.0 fixed, the uint64 BPM phase numerator divided late, 12.4 particle positions. Power functions adopt these, not float. The known trap to design around: naive signed right-shift rounds asymmetrically (−1>>1 = −1) — WLED-PS documents the sign-corrected form. +- **Prior art is cataloged, credited, and not ported.** Standing rule ([no-WLED-MM-derivation](../../CLAUDE.md)) plus license reality: WLED and the PS are EUPL v1.2. This document takes *concepts, measurements and API shapes*; implementations come fresh from the textbook sources named per primitive (Bresenham 1965, Wu 1991, Blinn 1982/1996, Reynolds 1987, Penner 2002, Kriegsman's fire2012, Elias's ripple, Quilez's articles). +- **Recommendation for the top-down doc: a ~34-function core in nine families** (§ The candidate set), dimension-generic per the PO decision, each function: one canonical algorithm, integer form, one home in core or light. The three MoonLive-side constraints that must be lifted for scripts to use any of this: the 16-entry builtin table, the one-arg-in/one-out host-call ABI, and a grammar with no variables, loops, or coordinate/time symbols ([MoonLiveBuiltins.h:40-54](../../src/core/moonlive/MoonLiveBuiltins.h), [MoonLiveCompiler.h:11-15](../../src/core/moonlive/MoonLiveCompiler.h)). +- **Out of scope for Stage 1.** API naming and exact signatures; which functions land in `draw::` vs a new namespace; the MoonLive grammar redesign; benchmarks on hardware; scheduling of the three build stages. All Stage 2 (top-down). + +## Why this document exists + +The goal is that **power functions carry the weight, so the code around them stays small** — an effect writer expresses the idea, not the machinery. Code around the calls is expected and welcome; what should disappear is re-solving the same sub-problems. The two contexts differ only in how much surrounding code is reasonable: a **compiled effect is unlimited** (any effect-local logic that makes it better), while a **MoonLive script is more limited by intent** — not a hard line count, but a script that grows to 100-200+ lines is a signal the mechanics it needs belong in a power function rather than in the script. + +Today the script side offers three functions against a flat index, so almost nothing is expressible whatever the length. Meanwhile the compiled effects each re-solve the same sub-problems privately. **Power functions** are the common denominators — implemented once, natively, and exposed three ways, in the product owner's stated order: + +1. **Use them in existing effects** — all current effects are *demo* effects, and all can be rewritten to the new standard. The default bar is **runs exactly the same**; divergence (e.g. replacing float with fixed point) is a case-by-case call, with large fixtures as the guard: an effect must stay smooth at 12K+ lights, which is what the 16-bit variants and sub-pixel splat exist for. Rewriting also **extracts hidden modifiers** (see the effects-vs-modifiers decision below). +2. **Create new example effects** — effects written *only* in power functions, proving coverage. +3. **Expose them to MoonLive** — the same natives become script builtins, so a script composes what compiled effects compose. + +Product owner decisions taken for this analysis (2026-08-05): + +- **Dimension-generic from day one.** Every power function is defined for 1D/2D/3D where meaningful, the way effects' `dim()` and `draw::blur` (one call, every axis with extent >1) already work. No 2D-first API that fits strips and volumes badly. +- **One set, same everywhere — without capping desktop.** The contract is identical on every target (fixed-point CPU kernels). A platform may implement the *same function* faster (desktop SIMD, GPU); advanced desktop-side capability on top of the contract is allowed, but is not part of it. +- **Current effects are demo effects; all are rewrite candidates.** Per default a rewrite is pixel-identical; modifying one (float → fixed point, cleanup) is decided per effect, judged on large fixtures where smoothness is hardest. +- **Effects and modifiers stay distinct concepts.** An effect must not carry *hidden modifiers* — mirroring, coordinate transforms, symmetry folding baked into the effect body get extracted into real modifiers during the rewrite. (The inventory found one: FreqSaws' `invert` mirrors even columns in-effect, while mirroring otherwise correctly lives in [MirrorModifier](../../src/light/modifiers/MirrorModifier.h).) Consequence for the candidate set: power functions serve *both* module kinds, and transform-shaped entries (`toPolar`, `kaleido`) are modifier material first. +- **Stefan Petrick's style is a supported target.** Petrick is a friend of projectMM; his Animartrix idiom — polar coordinates, layered/warped noise, palette mapping composed per pixel — must be writable with our power-function set. His engine is float-per-pixel and FPU-bound (Teensy/S3-class); our expression of the same idiom is the fields family (polar LUT + `fbm` + `warp` + palette) on the portable contract, with per-target acceleration free to close the gap on FPU-strong targets. +- **One consistent codebase.** The power-function set is written as one architecture in one style — one coordinate model, one fixed-point vocabulary, one naming convention — not a mix of idioms accumulated per family. Consistency is itself a requirement the top-down designs for. +- **The physics family is named `particles` — the industry term** (Reeves, SIGGRAPH 1983; Unity ParticleSystem, Unreal Niagara, WLED-PS). "Gravity and inertia" are not a separate concept: inertia is the integrated state, and the forces carry their standard names (`gravity`, `force`, `drag`, `bounce`, `attract`, emitters). The non-particle scalar physics (`smoothFollow`) stays in time-and-motion under its own standard name. +- **A particle-system effect replaces its non-PS twin** — no parallel variants. WLED's precedent: PS Fire replaced Fire 2012, PS Pinball replaced Bouncing Balls (~12 KB flash saved). The five in-repo particle-shaped effects converge onto the one kernel; this is a named case of the pixel-identical-by-default rule's divergence clause (an analytic float trajectory folded onto the Euler kernel is not bit-identical — judged on the bench). +- **The contract is 16-bit; 8-bit is internal only.** One `sin` (0..65535 = full turn), one `beatsin`, 16-bit easing and noise in the API — because every effect must support big displays, and 8-bit outputs position to 256 levels, which visibly steps on a 12K-light wall. There is no auto-switching "sin816": the angle domain IS the API, so the contract picks one. Implementation stays cheap — the existing 256-entry LUT plus linear interpolation yields smooth 16-bit output (the FastLED `sin16` shape; WLED 0.16 moved wholesale to `sin16_t`). 8-bit survives only where the domain is inherently 8-bit (palette index, hue — mod-256 by design), as a fast path the API never exposes. + +## What we already have (and the MoonLive gap) + +### The shared library effects use + +| Home | Contents | +|---|---| +| [draw.h](../../src/light/draw.h) | `pixel` (clipped), `line` (3D Bresenham + `shorten`), `get`, `blendPixel`, `addPixel` (saturating), `fade`, `blur` (separable, every axis, 1D/2D/3D in one call), `fill`, `glyph`/`text` (two built-in fonts), `offsetOf` | +| [math8.h](../../src/core/math8.h) | `sin8`/`cos8` (256-entry LUT), `triwave8`, `atan2_8`, `dist8` (octagonal, no sqrt), `qadd8`/`qsub8`/`nscale8`, `map8`, `beat8`/`beatsin8`/`beatsin16` (ms passed explicitly), `Random8` (xorshift) | +| [noise.h](../../src/core/noise.h) | `inoise8` in 1D/2D/3D — value noise, 16.0 fixed coordinates | +| [color.h](../../src/core/color.h) / [Palette.h](../../src/light/Palette.h) | `RGB`, `hsvToRgb`, `scale8` (with the `/255` rounding), `colorFromPalette` (the hot-path seam), `blend`, `fadeToBlackBy`, 63 built-in gradients | +| [Layer.h](../../src/light/layers/Layer.h) | `width()/height()/depth()`, `elapsed()`, the collected once-per-frame `fadeToBlackBy`, persistent frame buffer (FastLED/WLED convention), `extrude(Dim)` | + +Notably absent even for compiled effects: circle, rect/bar, scroll/shift, polar/rotate, gradient fill, easing, any physics, `sin16`, `scale16`. + +### What MoonLive scripts can reach + +Three builtins — `setRGB`, `fill`, `random16` ([MoonLiveBuiltins_light.h:27-36](../../src/light/moonlive/MoonLiveBuiltins_light.h)). Structural constraints recorded for the top-down: + +- `BuiltinTable` capacity `kMax = 16` ([MoonLiveBuiltins.h:54](../../src/core/moonlive/MoonLiveBuiltins.h)) — the candidate set below needs ~30-40 entries. +- `HostCallFn = uint32_t(*)(uint32_t)` — one arg in, one out ([MoonLiveBuiltins.h:40](../../src/core/moonlive/MoonLiveBuiltins.h)); `drawLine(x0,y0,x1,y1,c)` is not expressible. Multi-arg host calls (or packed-arg convention) are a prerequisite. +- Grammar is `call ";"` only — no variables, operators in source, loops, conditionals, and no `x/y/index/time` symbols ([MoonLiveCompiler.h:11-15](../../src/core/moonlive/MoonLiveCompiler.h)). The runtime already *receives* `t` (elapsed ms) and dims ([MoonLive.h:54](../../src/core/moonlive/MoonLive.h)) but nothing exposes them to script code. +- What already works and carries forward: `@control` script-declared controls surfacing as real UI controls, and the bounds-guarded inline ops (`StoreElem`, `FillElems`). + +## What our effects hand-roll (the demand evidence) + +From reading all 39 effect headers. Each row is a power-function candidate with its in-repo demand: + +| Pattern | Count | Examples | Power function it implies | +|---|---|---|---| +| `depthDim()` copy-paste (`depth()>0 ? depth() : 1`) | 16 effects | [LissajousEffect.h:78](../../src/light/effects/LissajousEffect.h), [TetrixEffect.h:162](../../src/light/effects/TetrixEffect.h) | dims as a first-class value (safe extents) | +| `Coord3D dims{...}` + `Buffer& buf` preamble | 22 effects | [BouncingBallsEffect.h:55](../../src/light/effects/BouncingBallsEffect.h) | a draw context carrying buffer+dims | +| BPM phase accumulator (`phase_ += dt*bpm`, divide late, uint64) | 9 effects + 3 members each | [PlasmaEffect.h:38-45](../../src/light/effects/PlasmaEffect.h), [NoiseEffect.h:39-43](../../src/light/effects/NoiseEffect.h) | `beatPhase(bpm)` — the stateful, sub-ms-safe time base | +| Integer `map()` with zero-span guard | 6 effects | [GEQEffect.h:150](../../src/light/effects/GEQEffect.h) | `map16`/`map32` beside the existing `map8` | +| Raw flat-index writes bypassing `draw::pixel` | 14 effects | [LinesEffect.h:91-98](../../src/light/effects/LinesEffect.h) (a local `setRGB` lambda) | flat-index + row-pointer fast paths as *library* fast paths | +| Private scratch plane, fade, blit | 3 (+13 with ScratchBuffer state) | [ParticlesEffect.h:52-84](../../src/light/effects/ParticlesEffect.h), [WaveEffect.h:72-103](../../src/light/effects/WaveEffect.h) | trails/decay owned by the library | +| Palette lookup `colorFromPalette(*Palettes::active(), …)` | 27 effects | [FireEffect.h:96](../../src/light/effects/FireEffect.h) | already a power function — carry to scripts | +| `Random8 rng_` + `rand8()` adapter + constrained-random forms | 12 effects | [StarSkyEffect.h:132-140](../../src/light/effects/StarSkyEffect.h) | bounded random (`randomBelow`, `randomRange`, grid-safe) | +| Sine oscillator vs uint8 angle (3 hand-rolled shapes; one byte-identical in 2 effects) | 8 effects | [LavaLampEffect.h:57](../../src/light/effects/LavaLampEffect.h) ≡ [MetaballsEffect.h:58](../../src/light/effects/MetaballsEffect.h) | oscillator family incl. `sin16`, wave shapes | +| Radial/polar/distance — 4 different implementations | 5 effects | `dist8` vs squared-field vs `sqrtf` vs hand-rolled `isqrt` ([PaintBrushEffect.h:132](../../src/light/effects/PaintBrushEffect.h)) | one distance/polar family (`isqrt`, `dist16`, polar LUT) | +| Particle state — five different representations | 5 effects | 12.4 fixed ([ParticlesEffect.h:88](../../src/light/effects/ParticlesEffect.h)), float analytic ([BouncingBallsEffect.h:85](../../src/light/effects/BouncingBallsEffect.h)), SoA aging, perspective float, state machine | **the particle kernel** (§ Physics) | +| Bar/column fill from a value | 4 effects | [GEQEffect.h:108-129](../../src/light/effects/GEQEffect.h) | `drawBar`/`fillRect` | +| Buffer scroll via read-back | 1 effect (N-step shift) | [FreqMatrixEffect.h:123-126](../../src/light/effects/FreqMatrixEffect.h) | `scroll(axis, delta, wrap)` | +| Off-by-one-safe extent mapping (each site carries a bug comment) | 4+ effects | [LinesEffect.h:100-108](../../src/light/effects/LinesEffect.h) | mapping helpers own the fencepost, once | + +Absent from our effects entirely (so: candidates justified by prior art, not in-repo demand): easing curves, springs/inertia, kaleidoscope-in-effect (lives in modifiers), circle/rect primitives, ripple-as-propagating-field, collisions between particles. + +## Prior art 1 — WLED and the WLED Particle System + +### Where everything lives (the PO asked) + +| What | Where | Author | +|---|---|---| +| Effect library (~200 effects) | `wled/WLED` → `wled00/FX.cpp` (11,224 lines), Segment model in `FX.h` | Aircoookie + community; many effects credit Andrew Tuline (WLED-SR) | +| Shared helpers | `wled00/FX_fcn.cpp` (1D), `FX_2Dfcn.cpp` (2D), `colors.cpp`, `util.cpp`, `wled_math.cpp` | WLED 2D functionality originated in the WLED-SR repo, original author **ewowi (Ewoud Wijma)**; migrated into `wled/wled` for v14 by ewowi + blazoncek, then developed further partly in `wled/wled` and partly in WLED-MM | +| **Particle System** | `wled00/FXparticleSystem.cpp` (1,945 lines) + `.h` (422) | **Damian Schneider (DedeHai)**, 2013–2024, **EUPL v1.2** | +| WLED-MM variant | `MoonModules/WLED` branch `mdev`, same two files, diverged 2025 (CRGB framebuffer, `renderonly` fire flag, no mass-ratio collisions) | DedeHai; MoonModules carry | + +PS integration history: [PR #4506](https://github.com/wled/WLED/pull/4506) merged 2025-02-17 (the 0.16 line), refined in [PR #4630](https://github.com/wled/WLED/pull/4630). To save ~12 KB flash it *replaced* classics (Fire 2012 → PS Fire, Bouncing Balls/Rolling Balls/Multi Comet → PS Pinball, …); `WLED_PS_DONT_REPLACE_FX` restores the originals. 16 2D + 16 1D effects are built on it — the physics kernel earns its bytes there. + +Also worth knowing when reading either codebase: mainline 0.16 replaced FastLED math with its own (`sin16_t`, `perlin8` with an `inoise8` alias, re-implemented `beatsin`) and reads the **ESP32 hardware RNG register** for `hw_random8/16` — free real entropy, faster than the FastLED LCG. WLED-MM still uses FastLED's originals. + +### The PS design vocabulary (measured, ESP32-proven — concepts to learn, not code to port) + +- **Integer sub-pixel space:** 1 pixel = 64 units 2D (`>>6` to pixels), 32 units 1D. Positions `int16_t`, velocities `int8_t` clamped ±120 so collision math can't overflow. A 10-byte particle: `x, y, ttl, vx, vy, hue, sat`; flags live in a *separate parallel byte array* for alignment. +- **3.4 fixed-point forces:** a force of 16 = +1 velocity/frame; smaller forces accumulate in a 4-bit per-particle counter until they overflow into a ±1 step. This is how sub-unit acceleration stays smooth with 1-byte velocities — the key trick for "inertia" feel. +- **Frame order:** gravity → size animation → collisions → move → render; collisions run before move so pushes can't render out of bounds. +- **Physics ops:** `applyForce` (the accumulator), `applyAngleForce` (polar via `sin16/cos16`), `applyGravity` (one shared dv per frame, applied to all — not skipping dead particles because the branch costs more), `applyFriction` (`v·(255−k)/255`, exponential decay), `pointAttractor` (inverse-square, clamped near-field, optional "swallow"), `bounce` (invert, scale by wall hardness, snap inside; wall *roughness* transfers perpendicular into parallel speed for diffuse scattering). +- **Collisions:** broad phase = spatial binning in x only (y-binning tried and measurably not worth it — documented in-code); narrow phase = axis-separated distance checks with one-frame velocity lookahead against tunneling; response = textbook elastic impulse in `int32`, mass ratio ∝ size², sub-threshold hardness adds periodic "sticky" friction so soft particles pile instead of sloshing; overlap resolved by pushing *one* particle chosen by a free pseudo-random bit (pushing both oscillates — documented). +- **Rendering:** 2×2 bilinear splat — corner weights `(64−dx)(64−dy)·b >> 12` — with brightness gamma-corrected up front and each sub-pixel weight passed through *inverse* gamma, so after the global output gamma the spatial distribution is linear: no flicker as particles cross pixel boundaries. Compositing is a SWAR saturating add that *rescales all channels* on overflow (preserves hue instead of clipping to white). Motion blur = scale-framebuffer decay; optional smear blur after. +- **Rounding trap, documented:** never plain right-shift signed values (−1>>1 = −1, asymmetric drift); use divide (1-cycle on ESP32) or the sign-corrected shift. + +### WLED's top-10 primitives by counted use in FX.cpp + +1. `setPixelColor`/`setPixelColorXY` (213+59 — the float XY overload is anti-aliased) · 2. palette lookup (129+45) · 3. hardware random (141+120) · 4. `beatsin8/16` (53+15) · 5. `sin8/sin16` (79+31) · 6. fade-toward-background (30+26) · 7. `color_blend` (59) · 8. `blur` (27) · 9. `fill` (45) · 10. `perlin8/16` (30+10). + +**One-line synthesis:** WLED effects = *time-phase generators → palette lookup → additive sub-pixel compositing → decay*. The PS is that pipeline made stateful. + +## Prior art 2 — the industry-standard canon (per primitive: name, source, fixed-point verdict) + +**Rasterization.** Bresenham line (*IBM Systems Journal*, 1965 — pure integer, ideal) and midpoint circle (Bresenham 1977 / Van Aken 1984 — ideal); Xiaolin Wu anti-aliased line (SIGGRAPH 1991 — excellent in 8.8/16.16); the **Wu pixel** 2×2 bilinear splat (the single-point case; WLED's `wu_pixel`, the PS's renderer — 4 muls + 4 saturating adds, the primitive that makes motion smooth on a coarse matrix); thick lines (Murphy/IBM 1978, perpendicular Bresenham — no trig); scanline polygon fill (Foley & van Dam — fine, but few LED effects decompose into it: low priority); bitmap fonts (BDF/Adafruit-GFX convention — we already have `draw::glyph/text`). + +**Physics.** **Semi-implicit Euler** (Fiedler; Hairer/Lubich/Wanner) — `v += a; x += v`, energy-bounded at fixed timestep, the right default; Verlet + Jakobsen constraints (GDC 2001) only when rope/cloth chains arrive (restitution is awkward in Verlet — a reason particle systems prefer Euler); restitution bounce `v = -(v·e8)>>8`; Stokes drag `v *= (256−k)/256`; **critically damped smoothing** (Lowe, *Game Programming Gems 4* — Unity's SmoothDamp) with its cheap degenerate the one-pole `x += (target−x)>>n`, the standard VU smoother; boids (Reynolds, SIGGRAPH 1987 — beautiful, O(n²), fine to ~32 agents, decomposes only swarm effects: below the cut); cellular automata (Gardner 1970 Life; Wolfram 1983; Margolus block-CA for falling sand — one byte-grid + rule kernel covers Life/sand/matrix-rain; the LED-canonical sand is Adafruit_PixelDust); **fire2012** (Kriegsman 2013 — cool/drift-up/spark on a heat byte-plane; our [FireEffect.h](../../src/light/effects/FireEffect.h) already is this family) vs noise-fire (Petrick lineage — needs the noise primitive); **Elias two-buffer ripple** (`new = neighbors/2 − old`, damp, swap — the discretized wave equation, adds and shifts only); metaballs (Blinn, *ACM TOG* 1982 — per-pixel field sums; on big matrices the SDF/smooth-min form is cheaper). + +**Fields & signal.** Perlin gradient noise (SIGGRAPH 1985/2002; FastLED `inoise8/16` is the embedded reference — known quirk: output clusters mid-range, budget a rescale; ~1-2 µs/sample); **our `inoise8` is value noise** — cheaper, blobbier; the top-down should decide whether to add gradient noise or rescale ours. fBm octaves (Mandelbrot; 2-3 octaves is the LED sweet spot); **domain warping** (Quilez — `noise(p + a·noise(p))`, one composition rule, enormous payoff); plasma (Vandevenne's tutorial — sum of phase-shifted `sin8`, trivially cheap); Lissajous (1857 — one particle + trail); **polar/kaleidoscope LUT** (precompute per-pixel r,θ once — every 1D effect becomes a mandala; PixelBlaze/Animartrix's "expensive look for free"; per the PO decision, the Petrick/Animartrix idiom — polar + layered warped noise + palette — is an explicit coverage target for this family); Penner easings (2002; FastLED `ease8InOut*` integer forms — the primitive separating programmer motion from designer motion). + +**Shaders (feasibility, honestly).** The Shadertoy model is `color = f(x,y,t)`, stateless. Budget at 240 MHz: 16×16@60 ≈ 15,600 cycles/pixel (anything goes); 32×32@60 ≈ 3,900 (comfortable fixed point); **128×128@50 ≈ 293 cycles/pixel** — one field sample + palette map + blend, only as compiled code. PixelBlaze (Hencke) is the interpreted-VM precedent: JS-like source → bytecode → 16.16 VM, `render2D(index,x,y)` per pixel, coordinates pre-normalized 0..1, `time(n)` sawtooths, ~48k pixel-evals/s on ESP32 — proves the ergonomics, an order of magnitude short of large matrices. **No embedded project interprets or JITs real GLSL on an MCU**; GLSL-class shading exists on Pi (GPU) and desktop only. Two shader-world primitives that DO earn a CPU place: **2D SDFs** (Quilez's catalog — circle `|p|−r`, box, segment, + polynomial smooth-min; free AA via `clamp(0.5−d/px)`, free outline `|d|−w`, free glow via LUT; subsumes metaballs) and **cosine gradient palettes** (Quilez — `a + b·cos(2π(c·t+d))`, 12 constants per palette, bake to LUT on parameter change). + +**Color.** HSV→RGB rainbow vs spectrum (Smith 1978; FastLED's `hsv2rgb_rainbow` is the LED de-facto — perceptually balanced yellow; WLED uses spectrum where round-trip fidelity matters); gamma via 256-byte LUT (Adafruit canon; trap: 8→8-bit LUT posterizes low fades — fix via 16-bit + temporal dithering or CIE lightness); color temperature (Planckian locus, curve-fits by Krystek/McCamy/Helland — bake presets); saturating 8-bit arithmetic with the correct `/255` rounding (Blinn, "Three Wrongs Make a Right", *Dirty Pixels* 1996 — the substrate of everything); Porter-Duff *over* (SIGGRAPH 1984) only when sprites/layers-with-alpha arrive — additive + scale covers light-native compositing. + +## The candidate set (synthesis) + +These families group functions by *algorithm*, which is how they were discovered. The build order is the five **phases** in [the top-down plan](power-functions-analysis-top-down.md#5-migration-plan-stage-1-and-example-effects-stage-2), which group the same functions by what lands in the repo together and name the families each phase carries; "what are we building next" is answered there, not here. + +Merging in-repo demand, WLED's usage counts, and the canon's coverage-per-byte ranking — nine families, ~34 functions (family 9, Projection, was added on review; the gather group below came from the canon survey). Dimension-generic per the PO decision; every entry is integer/fixed-point; *(have)* = exists for compiled effects today, so the work is exposure + adoption, not invention. + +| # | Family | Functions | Grounding | +|---|---|---|---| +| 1 | **Frame ops** | `fill` *(have)*, `fade` *(have)*, `blur` *(have — already dimension-generic)*, `scroll(axis, delta, wrap)` | WLED #6/#8/#9; FreqMatrix's hand-rolled shift | +| 2 | **Pixel ops** | `pixel`/`get`/`addPixel`/`blendPixel` *(have)*, **`splat(fx, fy, c)`** — the Wu sub-pixel writer, 12.4 or 16.16 coords | WLED-PS renderer; ParticlesEffect's private 12.4 math; "modern motion" on coarse matrices | +| 3 | **Geometry** | `line` *(have)*, `lineAA` (Wu 1991), `circle`/`fillCircle` (midpoint), `rect`/`fillRect`/`bar` (the audio-meter staple), `text` *(have)*; **SDF trio** `sdCircle/sdBox/sdSegment` + `smin` + coverage-AA | 4 effects hand-roll bars; SDFs subsume metaballs/glow/outline | +| 4 | **Fields** | `noise` 1/2/3D *(have — value; decide gradient vs rescale)*, `fbm(octaves)`, `warp` (as a composition rule), `plasma` (or just document sum-of-sin8), **polar/kaleido LUT** `toPolar`, `kaleido(n)` | WLED #10; LavaLamp/Metaballs/Rings/Spiral's four distance implementations | +| 5 | **Time & motion** | `sin`/`beatsin` (16-bit contract; the 8-bit forms become internal), **`beatPhase(bpm)`** — the stateful uint64 accumulator 9 effects hand-roll, `triwave/quadwave/cubicwave` (16-bit), `easeInOutQuad/Cubic` (Penner, 16-bit), **`smoothFollow`** (one-pole + critically-damped forms), **`peakHold(value, decay)`** — the falling-peak meter idiom (instant attack, slow decay), the standard VU primitive | the single biggest hand-roll count in-repo; GEQ's hand-rolled peak dot; the big-display stepping rule | +| 6 | **`particles`** (the industry name — Reeves 1983) | SoA pool, semi-implicit Euler `step()`, `gravity`, `force` (3.4 accumulator), `drag`, `bounce` (restitution + wall roughness), `attract` (inverse-square), emitters (`spray`, `angleEmit`), optional binned `collide`; plus `ripple` (Elias two-buffer) and a CA step (Life/sand share one kernel) | five in-repo particle representations; 32 WLED-PS effects; BouncingBalls/Tetrix/StarField/Particles/StarSky converge onto it, replacing their non-PS forms | +| 7 | **Color** | `colorFromPalette` *(have)*, `hsvToRgb` *(have)*, `blend` *(have)*, `cosPalette` (Quilez, baked), `gamma8` LUT, saturating math *(have — `qadd8/scale8`)* + `sin16/scale16` gaps | WLED #2/#7; 27 in-repo users | +| 8 | **Random** | `Random8` *(have)*, bounded forms `below/range` as builtins, hardware-RNG seed on ESP32 (free entropy, per WLED) | 12 in-repo users each with an adapter; WLED #3 | +| 9 | **Projection** | `project(Coord3D, fov)` — pinhole/perspective 3D→2D in fixed point; painter's-order depth sort; the vanishing-point line form | Three effects hand-roll it: StarField's `1/z` pinhole, GEQ3D's converging foreshortening, RubiksCube's voxel-to-face classification. Same repeated-pattern evidence that justified `beatPhase`; the prerequisite for any "3D scene on a 2D panel" effect | +| — | **Support** | `map16/map32` (fencepost-safe), `isqrt`, `dist16`, dims/time as script symbols | 6 in-repo `imap` copies; PaintBrush's `isqrt`; the MoonLive gap | + +*Added on review (2026-08-06), from a second pass over the effects that fit none of the eight original families:* **projection** (family 9) and **`peakHold`**. Both are repeat-count-justified in the same way the original entries were, and both were missed because the first pass grouped by *algorithm* (drawing, fields, physics) rather than by *what the leftover effects actually do*. + +### The gather gap (found 2026-08-06 by a canon-vs-us survey) + +A survey against WLED, FastLED master, Pixelblaze and the demoscene canon found the set strong on *generation* (noise, SDF, palettes) and *simulation* (particles, ripple, fire, CA), with the gaps clustered on one structural absence: + +**There is no way to READ the framebuffer as a texture at a transformed coordinate.** The Wu splat is the *write* side (scatter with interpolation); the *gather* side is missing, and the two are transposes — neither builds the other. Roughly a third of the classic canon is that one primitive wearing different hats: rotozoom, tunnel, lens/glass distortion, twister, feedback/zoomblur, Voxel Space, texture kaleidoscope, wobbly text. FastLED ships it (`fl::sampleBilinear`, `src/fl/gfx/sample.h`); WLED hand-rolls it inside both `mode_2Dsoap` and `mode_2Dplasmarotozoom` for want of a shared version — the same duplication evidence that justified `beatPhase`. + +| # | Primitive | Why it is new (not composable) | +|---|---|---| +| G1 | **`sampleWrap(src, u, v)`** — bilinear gather, Q16.16, power-of-2 wrap | The transpose of splat. Destination-driven with a constant per-pixel step, so no division or trig in the inner loop (~8 MACs/pixel). Needs a second buffer: cannot resample in place | +| G2 | **`combine(a, b, op)`** — per-pixel two-buffer arithmetic (add/sub/mul/screen/min/max/difference) | `blend` blends *colors*; this blends *buffers with an operator*. Highest composability leverage found: makes bump mapping, moiré, XOR texture, glow/bloom compositions rather than primitives. WLED independently ships 17 of these as segment blend modes | +| G3 | **`mat23`** — fixed-point 2D affine transform with push/pop | The API-shape gap: we have `sin16`/`cos16` and 3D projection but no reusable 2D transform, so every effect hand-rolls its rotation. With G1 it gives inverse mapping for one division per *frame*. Pixelblaze exposes exactly this | +| G4 | **Asymmetric attack/release envelope** | Sharpens the planned `smoothFollow`: the symmetric one-pole is the WRONG ballistic for a meter — it makes attacks as sluggish as decays and rounds off drum hits. WLED, FastLED and LedFx all converged independently on the asymmetric form (~5 cycles) | +| G5 | **Beat-phase PLL + spectral-flux onset** | The missing *input* to `beatPhase`: period by autocorrelation with harmonic enhancement, phase by a gated P/I loop. Elegant in fixed point — phase as a `uint32` where full range is one beat, so beat detection IS the overflow and reinterpreting as `int32` IS the wrapped error. Turns every existing `beatsin` effect beat-locked. **Belongs in the audio service, not the power functions** (it is signal analysis; every effect then gets it free) | +| G6 | **`fillTriangle`** — two-edge integer DDA | `fillRect` is the axis-aligned degenerate case and cannot make a rotated quad. Unlocks filled vectors, vectorballs, 3D cube, Kefrens bars, twister slices | +| G7 | **Bayer 8×8 ordered dither** (64 bytes) | **Neither WLED nor FastLED ships this** — a gap in the canon rather than versus it. Directly relevant to LED bit depth: visibly better gradients. Ordered, not Floyd–Steinberg: error diffusion crawls between frames on animated content | +| G8 | **Worley/cellular noise** | A noise *class* value noise + fBm cannot synthesize: crystalline/organic-cell/caustic structure. ~9 distance evals per pixel — budget as an effect, not a free primitive | + +Smaller, cheap, high value: **`map8_to_16`-style bit-replication rescalers** (`map8_to_16(255) == 65535` exactly, where `x<<8` gives 65280 — silently fixes full-scale loss when widening); **`hashInt`** — stateless position-addressable randomness, distinct from an xorshift *stream*, which is what lets a dissolve transition carry zero per-pixel state; **sub-LSB force dithering** (accumulate sub-unit forces, emit ±1 on overflow) — the mechanism that makes weak gravity work at 8-bit velocity precision, already noted from WLED-PS. + +**Rejected as composable** (the useful half of the survey): feedback/zoomblur/motion-blur/bloom (= `fade` + G1 resample + draw — what is actually needed is a ping-pong buffer convention, infrastructure not a primitive); bump mapping (= `scroll` + G2 + palette); metaballs (`smin` of circle SDFs already IS metaballs); starfield (the particle pool + projection); flow-field/curl advection (`p.v += vecFromAngle(noise(...))`); boids (particle pool + the binned neighbour queries we already have); copper bars, scrollers, palette cycling, Lissajous, moiré, XOR texture (all `beatsin`/`sin16` + `bar`/`text`/`combine`); reaction-diffusion (the 3×3 Laplacian is our separable blur); AGC (= G4 in the dB domain + clamp + gate). Rejected outright: fractal flame (needs megapixels and float histograms — expensive and pointless at 64×64), Scheirer comb-filter beat tracking (RAM-disqualified: ~320 KB of delay lines, more than a classic ESP32's DRAM; autocorrelation gets the same tempo for ~1% of it). + +Two cross-cutting MCU notes: every effect in this canon hoists reciprocals to row/slice setup to keep division out of the inner loop — worth preserving in the API shape; and the [Xtensa 64-bit variable shift](../history/lessons.md) lesson bites directly on Q16.16, so shift amounts in `sampleWrap`/`mat23` stay compile-time constants. + +Below the cut, with reasons: boids (only swarm effects), filled polygons (few LED effects decompose into them), Verlet+constraints (until rope/cloth), Porter-Duff (until sprite layers), font additions (cost is fonts, not code), GPU anything (not portable; a desktop accelerator of the same contract later). + +## Constraints the top-down must respect + +- **Hot path:** power functions run inside `tick()` per frame at up to 16K+ lights; per-light work stays integer, per-frame float is allowed where already conventional ([EffectBase.h:128](../../src/light/effects/EffectBase.h)). No allocation in any power function; particle pools and LUTs allocate at `prepare()` via the existing ScratchBuffer discipline. +- **MoonLive ABI:** multi-arg host calls, a bigger builtin table, and coordinate/time symbols are prerequisites for family exposure (§ the MoonLive gap). The runtime already threads `t` and dims to the entry point — the gap is grammar/ABI, not plumbing. +- **Buffer model:** the frame buffer persists across frames (trails are a feature); power functions compose with the collected `fadeToBlackBy` rather than each fading privately — three effects' private-plane idiom migrates onto this. +- **Licensing/derivation:** WLED + PS are EUPL v1.2; standing rule is fresh implementations from the textbook sources named above. Concepts, measurements, and API shapes are fair learning; code is not. +- **Naming/credit:** each shipped power function's doc block names its canonical source (Bresenham 1965, Wu 1991, …) — same convention the effects already follow for their origins. +- **One style:** a single coherent architecture across all eight families — shared coordinate model, one fixed-point vocabulary (the in-repo idioms above), uniform naming — so the set reads as one library, not eight provenances. +- **Module boundary:** power functions are usable from effects *and* modifiers; the effect/modifier concept split stays intact, and the Stage-1 rewrite audits each effect for hidden modifiers to extract. + +## Out of scope for Stage 1 + +Exact signatures and namespaces; the `draw::` vs new-namespace split; MoonLive grammar redesign (variables, loops, per-pixel vs per-frame model — the livescripts top-down owns the engine, this doc feeds it the builtin surface); hardware benchmarks; migration order for the 39 effects; palette-system changes. All Stage 2: **power-functions-analysis-top-down.md**. + +## Sources + +In-repo: every file cited inline above. External, read 2026-08-05: `wled/WLED` @ c1838ed, `MoonModules/WLED` @ 7c55f91, `FastLED/FastLED` @ b2a1344 (clones under the session scratchpad, disposable); WLED PRs [#4506](https://github.com/wled/WLED/pull/4506), [#4630](https://github.com/wled/WLED/pull/4630), [#4543](https://github.com/wled/WLED/pull/4543). Canon: Bresenham 1965/1977; Van Aken 1984; Wu, SIGGRAPH 1991; Murphy 1978; Foley/van Dam; Reeves 1983; Reynolds 1987; Verlet 1967; Jakobsen GDC 2001; Fiedler, gafferongames.com; Lowe, *Game Programming Gems 4*; Gardner 1970; Wolfram 1983; Toffoli & Margolus 1987; Kriegsman fire2012 (FastLED examples); Hugo Elias, "2D Water"; Blinn 1982 & *Dirty Pixels* 1996; Perlin 1985/2002; Mandelbrot 1982; Quilez (distfunctions2d, smin, palettes, warp — iquilezles.org); Vandevenne (plasma); Penner 2002 / easings.net; Smith 1978; Porter & Duff 1984; Poynton; Adafruit "LED Tricks: Gamma Correction"; Adafruit_PixelDust (Burgess); PixelBlaze (Hencke — bhencke.com/pixelblazegettingstarted). Credits: Damian Schneider (DedeHai) for the WLED Particle System; ewowi (Ewoud Wijma) for WLED 2D (originated in WLED-SR, migrated to wled/wled v14 with blazoncek); Aircoookie, blazoncek, Andrew Tuline for WLED/WLED-SR; Mark Kriegsman & Daniel Garcia for FastLED; Stefan Petrick (Animartrix — friend of projectMM) whose polar-noise idiom is a named coverage target. diff --git a/docs/backlog/power-functions-analysis-top-down.md b/docs/backlog/power-functions-analysis-top-down.md new file mode 100644 index 00000000..f0723d50 --- /dev/null +++ b/docs/backlog/power-functions-analysis-top-down.md @@ -0,0 +1,286 @@ +# Power functions — top-down build spec + +> **Forward-looking design document — exception to CLAUDE.md present-tense rule.** Stage 2 of the power-functions work: turns the [bottom-up catalog](power-functions-analysis-bottom-up.md) into an implementable spec — homes, types, signatures, migration order, tests, budgets. Written 2026-08-06 against the nine product-owner decisions recorded there. Where this document makes a NEW decision it is marked **(proposal)** and listed in § Decisions for sign-off. Companion boundary: the [livescripts top-down](livescripts-analysis-top-down.md) owns the MoonLive *engine* (grammar, IR, codegen); this document owns the *builtin surface* the engine calls into. + + +## Status legend + +Every claim below carries its status, checked against the code rather than against intent: + +| | Meaning | +|---|---| +| ✅ | **Done.** Built and in the tree. | +| 📖 | **Moved.** The durable version now lives in the docs or the code, so this copy is a historical note. Where it went is named inline. | +| 🔨 | **To do.** Still open, and the reason it is open is stated. | +| ❓ | **Unsure.** A claim, a number or a decision that has not been verified — treat as a question, not a fact. | + +An unmarked line is context or rationale rather than a deliverable. + +## TL;DR + +- ✅ **The set lands in the existing homes, grown — not a new parallel library.** `core/math16.h` (new: the 16-bit contract tier), `core/noise.h` (grows fbm/warp/16-bit sampling), `light/draw.h` (grows splat, AA line, circle, rect/bar, scroll, the SDF trio), `light/particles.h` (new: the particle kernel), `light/polar.h` (new: the polar/kaleido LUT, modifier-first), `light/Palette.h` (grows cosine palettes + gamma). One style: same free-function shape `draw::` already has, same fixed-point vocabulary everywhere **(proposal)**. +- ✅ **Three shared types carry the whole contract:** `pos_t` = `int32_t` positions in **24.8 sub-pixel fixed point** (±8M pixels — covers a 16K-light strip where WLED-PS's int16 cannot; one word on every 32-bit target); `angle16` = `uint16_t`, 65536 = full turn; `frac16` = `uint16_t` 0..65535 fractions. Velocities are `int16_t` 8.8 per frame. The 8-bit tier (`math8.h`) stays as the internal fast path and for inherently mod-256 domains (palette index, hue) **(proposal)**. +- ✅ **The 22-effect boilerplate dies with one struct:** `draw::Canvas{buf, dims, cpl}`, returned by `EffectBase::canvas()`. Taken as `const Canvas&` (measured: a non-const reference costs ~3% more instructions in a tight per-pixel loop, because the extents become memory re-loads the compiler cannot hoist past a possible alias with the buffer; passing dims by value avoids that today). The gain is **correctness, not speed**: buffer and dims are currently two independent arguments nothing checks for agreement, and the pairing becomes unrepresentable-if-wrong — plus the 16 `depthDim()` copies are deleted rather than centralised, and `splat`/SDF-coverage/projection get a home for their context instead of adding loose parameters at every call site. The existing `(Buffer&, dims)` overloads remain during migration and their removal is **mandatory, not aspirational** — a permanent two-API window is worse than either option alone. + + **Const means the surface, not the pixels.** Every draw call takes `const Canvas&` yet writes to the buffer: the Canvas is a *descriptor* (a raw pointer plus extents), so const protects the description — nothing can retarget a call to a different buffer or silently change the extents mid-frame — while the pixels behind the pointer stay writable. This is the same shape as a `std::span` passed by const reference. Stated because a call that mutates through a `const&` is otherwise a surprise **(proposal)**. +- ✅ **`particles` is a pool the effect owns, not a module:** SoA arrays in ScratchBuffer, allocated at `prepare()`, semi-implicit Euler `step()`, the named forces (`gravity/force/drag/bounce/attract`), two emitters, optional binned collisions, rendered through the sub-pixel `splat`. Sized by the effect; zero static RAM when unused. +- ✅ **Noise: keep value noise, widen it — gradient noise is a swap-in upgrade, not a blocker.** `noise16(x,y,z)` returns full-range 16-bit (our existing value noise rescaled and interpolated up); the name promises the *field*, not the algorithm, so Perlin gradient noise can replace the core later without touching any caller **(proposal)**. +- ✅ **Migration order is by leverage, cheapest risk first:** *(phases ① ② ④ ⑤ done; ③'s kernel is built and the five convergences are 🔨 open)* ① `beatPhase` + `map16` + `Canvas` (mechanical, pixel-identical, kills the three biggest hand-roll counts) → ② geometry + bars (4 audio effects) → ③ `splat` + `particles`, converging the five particle-shaped effects (bench-judged, the PS-replaces-twin decision) → ④ fields + polar (LavaLamp/Metaballs/Rings/Spiral) → ⑤ hidden-modifier extraction as encountered (FreqSaws `invert` first). Each pixel-identical claim is pinned by a **golden-frame test** (fixed seed, fixed time, byte-compare) — a new, small test harness capability. +- 🔨 **MoonLive exposure is stage 3 and states only its requirements here:** a built-in table of ≥ 64 entries, typed multi-arg host calls (up to 6 args + return), the symbols `x/y/z/w/h/d/time` (already threaded to the runtime, unexposed), and a per-frame entry point alongside the per-pixel one — the bottom-up's feasibility math says scripts *compose* kernels per frame; they do not interpret per pixel on large surfaces. The calling convention itself belongs to the livescripts engine work. +- ✅ 📖 **Measured on hardware (ESP32-S3, 240 MHz, 128×128, 2026-08-06)** *(the per-effect numbers now live in [effects.md](../moonmodules/light/effects.md) beside each effect)* — the theoretical budget below was an upper bound; these are the real numbers, and they reframe it. Today's *existing* effects already cost **305–692 cycles/pixel** and run at **21–48 fps** on a 128×128 panel, so "292 cycles/pixel at 50 fps" describes a frame rate this fixture size does not reach in the first place, with or without power functions. What the budget genuinely constrains is *added* cost per pixel, and the measured SDF forms are small against that: `sdBox` ≈ 6, squared-distance `sdCircle` ≈ 14, and the full `isqrt` form ≈ 108 cycles/pixel (desktop instruction counts; the ESP32 divide penalty makes the last one worse, the first two barely move). A squared-form SDF plus `smin` plus a palette lookup is a fraction of what Plasma already spends. **Design consequence:** the squared forms are the default path and the sqrt form is opt-in for true distance (outline width, linear glow). +- ✅ **Budgets are stated per family and gated:** the render loop's ceiling stays the bottom-up's 293 cycles/pixel at 128×128@50; the particle budget is ~40 cycles/particle/frame (2048 particles ≈ 0.34 ms at 240 MHz); every function gets a host micro-benchmark and the migrations ride the existing `collect_kpi` gate. Zero static RAM for everything unused (`check_footprint`). + +## 1. Homes and style ✅ 📖 *(shipped; the headers themselves are the reference now)* + +CLAUDE.md's rule is extend-don't-duplicate, and the PO's one-codebase decision demands a single style. Both are satisfied by growing the existing homes with one consistent convention rather than opening a parallel `fx::` library: + +| Home | Gains | Notes | +|---|---|---| +| `core/math16.h` **(new)** | `sin16/cos16` — 130-byte quarter-wave 16-bit table + lerp (0.031% error; the zero-table variant was tried and rejected — see §6), `triwave16/quadwave16/cubicwave16`, `ease16InOutQuad/Cubic`, `map16/map32` (fencepost-safe), `isqrt32`, `dist16`, `scale16`, `BeatPhase` (the stateful uint64 accumulator, `phase(bpm, ms)` → `angle16`), `beatsin16` rebuilt on the LUT+lerp sine | The contract tier. `math8.h` is unchanged and becomes internal/domain-specific (palette index, hue). | +| `core/noise.h` | `noise16(x[,y[,z]])` full-range, `fbm16(p, octaves)`, `warp16` (the one composition rule) | Same 16.0 fixed coordinate convention it already has. | +| `light/draw.h` | `Canvas`, `splat` (24.8 sub-pixel Wu write, the PS/WLED weight math with the inverse-gamma note), `lineAA` (Wu 1991), `circle/fillCircle` (midpoint), `rect/fillRect/bar`, `scroll(axis, delta, wrap)`, `sdCircle/sdBox/sdSegment + smin` + `coverage(d)` AA helper | Free functions, `const Canvas&` first arg — the `draw::` shape it already has. | +| `light/particles.h` **(new)** | `particles::Pool` (SoA over ScratchBuffer), `step`, `gravity/force/drag/bounce/attract`, `spray/angleEmit`, `collide` (x-binned, optional), `render(const Canvas&, palette)` | The industry name (Reeves 1983). Fire2012-style heat, Elias ripple, and the CA step are siblings in the same header — stateful field kernels. | +| `light/polar.h` **(new)** | `PolarLut` (per-pixel r,θ baked at prepare), `kaleido(n)` fold | Modifier-first per the PO decision; effects may consume the LUT read-only. | +| `light/Palette.h` / `core/color.h` | `cosPalette` (Quilez 12-constant, baked to the existing 16-entry `Palette` on change), `gamma8` LUT | `colorFromPalette` stays the one hot-path seam. | + +Every function's doc block names its canonical source — the convention the effects already follow. + +## 2. Types ✅ + +- **`pos_t = int32_t`, 24.8 fixed point.** One pixel = 256 sub-units. Chosen over WLED-PS's int16+6-bit (±512 px — too small for a 16K 1D strip) and our ParticlesEffect's 12.4 (±2048 px — same problem). int32 is single-word on every target; `>>8` decodes; the sign-corrected shift idiom from the bottom-up applies (never bare `>>` on negatives). +- **`angle16 = uint16_t`**, 65536 = full turn; overflow is the free 2π wrap. The 8-bit angle survives only inside `math8.h`. +- **`frac16 = uint16_t`** 0..65535 for interpolation/easing inputs and outputs. +- **Velocity `int16_t` 8.8 per frame**; forces via the 3.4 accumulator (the WLED-PS smooth-sub-unit trick, reimplemented fresh). +- **Time**: `elapsed()` ms as today; `BeatPhase` owns the uint64 numerator-divide-late idiom the nine effects hand-roll. +- **Dimension-generic rule**: every geometry/field function takes `Coord3D`; 1D/2D degenerate by extent (the `draw::blur` model — one call, every axis with extent > 1). +- **Dimension audit (verified against the dimension-generic decision):** fully generic by construction — frame ops, pixel ops (`splat` = 2/4/8 corners for 1D/2D/3D), fields (`noise16` has all three arities), time/color/random, and the particle kernel (SoA per axis — one system where WLED-PS maintains two; 3D collisions correct, x-binning just less selective). The SDF trio is the strongest case: `|p|−r` IS two points / circle / sphere, one formula. Five named 2D-primary items, each with its path: `lineAA` (3D = splat along the 3D Bresenham line — falls out of the generic splat), `text` (glyphs are 2D; renders a z-slice on 3D fixtures, meaningless in 1D), `PolarLut`/`kaleido` (cylindrical/spherical variants wait for a consumer), `angleEmit` (3D needs the spherical two-angle form), `ripple`/fire (volumetric variants wait for a consumer). None is a blocker: the pipeline already lifts lower-dim output via `Layer::extrude()`, so a 2D-primary function stays usable on every fixture, like today's 2D effects. +- **Fixed point is the default and invisible (standard approach, PO decision):** an effect writer works in `pos_t`/`angle16`/`frac16` and the power functions, and never chooses a width or a representation per case — the vocabulary IS fixed point. The only per-case judgment left is effect-private math outside the power functions, already governed by the existing rule: per-frame float allowed, per-light float not ([coding-standards § numeric types](../coding-standards.md)). + +## 3. The particle kernel ✅ 📖 *(built; the API listing lives in [power-functions.md](../moonmodules/light/power-functions.md#particles))* + +```cpp +particles::Pool pool; // POD view over ScratchBuffer arrays +pool.init(scratch, count); // at prepare(): SoA x/y/z, vx/vy/vz, ttl, hue — no allocation later +pool.gravity(g); // one dv per frame, applied to all (branch costs more than work) +pool.force(i, fx, fy); // 3.4 accumulator per particle +pool.drag(k); // v *= (256-k)/256 +pool.step(); // semi-implicit Euler: v += a; x += v (Fiedler) +pool.bounce(e, roughness); // reflect at walls, v = -(v*e)>>8; roughness scatters +pool.attract(p, strength); // inverse-square, near-field clamped +pool.spray(emitter); pool.angleEmit(emitter, angle16, speed); +pool.collide(); // optional; x-binned broad phase, impulse response +pool.render(canvas, palette); // sub-pixel splat per live particle; ttl fades brightness +``` + +**Defaults (standard approach, PO decision):** `render()` composites **additively with saturation** (light adds; hue-preserving rescale on overflow, never clip-to-white) through the **sub-pixel splat** — the effect writer gets both without deciding. Case-by-case is opt-OUT: `RenderStyle::Hard` for single-pixel retro rendering, nothing else to choose. Trails are deliberately NOT a pool feature: decay stays the one existing mechanism (the collected `fadeToBlackBy`), so the system has a single decay path rather than a second one hidden inside particles. + +Costs (from the bottom-up's measured prior art): step ≈ 6 ops/axis, splat ≈ 4 mul + 4 saturating adds, collide only when enabled. Budget ~40 cycles/particle/frame without collisions. Pool size is the effect's choice against its ScratchBuffer — the pay-for-what-you-use rule; nothing static. + +**What the kernel is FOR (clarified 2026-08-07, PO):** new effects, first. Anything that behaves +like matter — sparks, rain, snow, smoke, confetti, a fountain, a swarm, debris, an audio band +throwing off embers — is the same forces over the same state, so a working integrator + emitter +makes a new look a few lines of composition. Converging the effects that already hand-roll this is a +real but SECOND goal: it follows from the kernel being right, and each move is bench-judged on its +own rather than done as a batch. + +**Convergence outcome (2026-08-07), checked one at a time against the code rather than as a batch:** + +- ✅ **Particles** — converged. The private 12.4 struct, the hand-rolled integrate and the four wall + tests are gone; the pool owns them. No golden moved. +- ✅ **StarField** — converged onto `shader::project`. Its float pinhole and the integer form were + verified identical across 1264 samples of the (x, z) range it produces, and its golden did not + move — which is the proof that matters, since a ported effect that looks different is a regression. +- 🔨 **StarSky — NOT converged, and should not be.** Its stars never move: the index is assigned at + respawn and only the brightness animates, so there is no position, velocity or force to share. + Converging it would mean a linear index stored in a position field, four unused velocity arrays, + and a pool that never calls `step()` — more state and less clarity than the four small arrays it + has. The plan listed it for "SoA aging = ttl", but the aging IS the whole effect rather than a + particle behaviour. +- 🔨 **BouncingBalls — worth converging, and it is a REWORK not a swap.** Assessed 2026-08-07. + Its physics is the closed-form projectile equation (position from elapsed time), which is exact, + cheap and already framerate-independent — so Euler alone would be a downgrade: measured against the + closed form it drifts 8 cm low on a 1 m trajectory after one second, more than a pixel on a 16-row + panel, and the error grows every bounce. What the kernel genuinely adds is what the closed form + *cannot* express: balls that collide with EACH OTHER and can move sideways, instead of each being + trapped in its own vertical lane. Under the corrected migration rule ("beauty is the goal") that is + an improvement worth making and worth judging on the panel. The work is real though — the effect + keeps up to 16 balls per COLUMN (hundreds on a wide panel) in per-column arrays, so converging means + one shared pool plus a decision about how many balls a wide fixture should have. Its own commit. +- ✅ **Tetrix — NOT converging, correctly.** The plan already said "the state machine keeps its logic, + positions ride `pos_t`". Its falling brick is a state machine (idle → start-roll → falling → landed) + with a stack height per column, not a particle: nothing bounces, nothing collides, and `pos` is a + single scalar per column. It already uses the shared `FrameTime` for its fall rate and start-roll, + which was the part worth sharing. + +The five convergence candidates and what each would pin: Particles (12.4 → 24.8, wall bounce), BouncingBalls (analytic float → Euler + restitution — the named non-identical case, bench-judged), StarField (perspective divide stays effect-side; the pool carries state), StarSky (SoA aging = ttl), Tetrix (state machine keeps its logic, positions ride `pos_t`). + +## 4. MoonLive requirements 🔨 *(stage 3, deferred by the PO — do this after the library is complete)* + +What the builtin surface needs from the engine, recorded for the livescripts work: + +1. Built-in table ≥ 64 entries (today 16). +2. Typed multi-arg host calls, ≤ 6 args + optional return (today: one `uint32_t` in, one out — `drawLine` is inexpressible). +3. Script symbols `x/y/z/w/h/d/time` — already threaded to the runtime entry point, needs only grammar exposure. +4. **Two entry shapes:** `frame()` (compose kernels — the scalable path per the 293-cycles/pixel math) and `pixel(x,y,z)` (the PixelBlaze-ergonomics path, honest ceiling ~32×32 interpreted). Scripts choose; large fixtures use `frame()`. +5. Stateful objects (a `Pool`, a `BeatPhase`) exposed as *handles* — script-declared, arena-allocated at compile, passed as an opaque first arg. No script-side memory management. + +Until the ABI lands, stages 1–2 proceed compiled-side; nothing here blocks on the engine. + +## 5. Migration plan and example effects ✅ *(11 of 12 showcases built; VectorBalls landed 2026-08-07)* + +Order by leverage, cheapest risk first; every batch lands with its tests and the branch stays under ~100 files. **These five phases are the project's one numbering for this work** — the bottom-up document's nine *families* group functions by algorithm, while the phases below group them by what lands in the repo together, so each phase names the families it carries. + +1. ✅ **Foundations** — `math16.h`, `Canvas`, `BeatPhase`, `map16`: mechanical replacement in the 9 phase-accumulator effects, the 6 `imap` copies, the 22 preambles, the 16 `depthDim()`s. Pixel-identical (same arithmetic, one home) → golden-frame pinned. *(families 5 Time & motion, Support)* +2. ✅ **Geometry** — `bar/rect` into the 4 audio meters; `scroll` into FreqMatrix; `splat` lands with its unit tests; the SDF trio + `smin` + `coverage`. *(families 1 Frame ops, 2 Pixel ops, 3 Geometry)* +3. 🔨 **`particles`** — the kernel is ✅ built and two new effects use it; the five convergences are OPEN. Per the PO the kernel targets future effects first, so each convergence is bench-judged on its own rather than done as a batch. The kernel + the five convergences, one effect per commit, bench-judged (PS-replaces-twin decision); the old private representations deleted. *(family 6)* +4. ✅ **Fields + polar** — the shared blob oscillator (LavaLamp ≡ Metaballs) onto `sin16`+`splat`; Rings/Spiral onto `PolarLut`; `noise16` under Noise2D with a rescale note. *(family 4 Fields)* +5. ✅ **Hidden-modifier extraction** — audit each effect for a transform that belongs to the modifier chain as it migrates (the effects-vs-modifiers decision). *(no family: an orthogonal cleanup the migration surfaces)* + + **Audited 2026-08-06, and nothing was extracted.** The named candidate was "FreqSaws `invert` → + MirrorModifier". Two findings, in order: + + - That mapping does not hold. `MirrorModifier` folds an axis onto itself, HALVING the logical + extent; FreqSaws flips alternate columns end-to-end at full extent. Different transforms. + - A general `WeaveModifier` was then built and **reverted**. The right test is "does this add + value to every effect?", and on the grid it looked like it did. But the control exists for + FreqSaws columns mapped onto RINGS, where flipping alternate columns makes adjacent wheels + appear to counter-rotate. Measured against `WheelLayout`: a spoke spans many grid columns + (spoke 0 covers columns 6-11, spoke 3 runs 5 down to 1), so a column flip cuts across spokes + and cannot reproduce that look. The modifier would have carried the name of an effect it does + not achieve. `invert` stays in FreqSaws, where the geometry it depends on is known. + + The lesson worth keeping: "would this help every effect?" is the right question, but answering it + requires knowing what the control is FOR. Here the intent lived in the product owner's head, not + in the code or its comment. + +**GEQ3D checked, and it does NOT want `project` (2026-08-07, PO asked).** The family-9 entry lists it +as one of three effects hand-rolling projection, but reading it: GEQ3D draws its perspective as +`draw::line(..., shorten)` running toward a moving vanishing point — the third form that entry names, +not the `1/z` divide. It is therefore ALREADY on a shared primitive, and pushing `project` into it +would replace a working construct with a worse-fitting one. StarField's `1/z` pinhole is the genuine +`project` candidate; RubiksCube's voxel-to-face classification is a third thing again. + +Families 7 Color, 8 Random and 9 Projection carry no phase of their own: they land inside whichever phase first needs them (`cosPalette`/`gamma8` with the showcases, `hashInt` with Dissolve, `project` with VectorBalls). + +**Commit split (PO decision, 2026-08-06).** The remaining work ships in two commits: **everything except particles and shaders now** — the rest of phase 2, then phases 4 and 5, with their showcase effects — and **particles plus the shader tier next** (phase 3, `FireworksEffect`, `BallpitEffect`, `RaymarchEffect`). The split keeps each commit reviewable line-by-line and well under the ~100-file CodeRabbit ceiling; it also puts the two items needing bench judgement (the PS-replaces-twin decision, the `hasHeavyCompute` float exception) together in one commit rather than spread across both. + +**Golden-frame harness (new, small):** render N frames at fixed seed/fixed `elapsed()` into a buffer, hash, compare against a checked-in golden. Only for effects claiming pixel-identical; a deliberate divergence replaces the golden in the same commit with the bench note. Lives beside the existing effect tests. + +Two things learned building it (2026-08-06), both by mutation-testing the harness rather than trusting it: + +- **A short render proves nothing.** At a typical default speed the phase advances a few units over 8 frames, moving nothing by a whole pixel on a 16-wide grid — the hash compared two near-identical frames and passed even with the animation perturbed 7x. The harness renders 200 frames (4 s) for that reason. +- **A golden is only as strong as the effect's visible output, and it is NOT a statement that the effect looks good.** It pins what the code renders today so a "changes nothing" refactor can be checked. Several effects are awaiting a tuning pass (some generated rather than derived, with arbitrary parameters — two saturate their field to full brightness at default settings and render a nearly static frame). When tuning moves a golden deliberately, that is the system working. The only rule is that no hash moves *silently*. + +**Corollary for the migration: power-function work and effect tuning feed each other.** Migrating an effect surfaces what its parameters actually do (the saturation above was found by a phase mutation, not by looking), and tuning decisions then re-baseline the goldens. Neither waits for the other; the goldens simply record where each effect stands. + +### Stage 2 — new showcase effects + +The goal is **beautiful effects, not conditioned ones** (PO decision): each showcase leans on the toolbox for its heavy lifting — the named power functions carry the effect's core mechanic — and is otherwise free to add any effect-local code that makes it better. That is the coverage proof (the library did the hard part) and the reference value (a writer sees the functions in real use), without a purity rule that would make an effect worse to keep a list clean. New showcases exist only where stage 1's migrations do not already exercise a family; everything else is proven by the rewrites themselves. + +| Effect | Showcases | Power functions exercised | +|---|---|---| +| ✅ `FireworksEffect` | **particles** — the full kernel in one look | `Pool`, `spray`/`angleEmit`, `gravity`, `drag`, ttl fade, sub-pixel `splat`, additive default | +| ✅ `BallpitEffect` | **particles collisions** — the piece Fireworks leaves off | `collide` (binned, impulse), `bounce` + wall roughness, `force` tilt via controls | +| ✅ `SdfShapesEffect` | **the shader look** — anti-aliased morphing shapes | `sdCircle/sdBox/sdSegment`, `smin`, `coverage` AA, `cosPalette`, `beatPhase` | +| ✅ `PolarNoiseEffect` | **the Petrick idiom** — the named coverage target | `PolarLut`, `fbm16`, `warp16`, `colorFromPalette`, per-target headroom | +| ✅ `WaterRippleEffect` | **the field kernels** — a true propagating simulation (the existing `RipplesEffect` is closed-form) | `ripple` (Elias two-buffer), `splat` drops, `blur` | +| ✅ `RaymarchEffect` *(any target with a hardware FPU)* | **the ceiling clause made visible** — a raymarched 3D SDF scene (rotating smooth-min blobs, soft shadows, the Quilez canon) | the SDF *concepts* in 3D; `cosPalette`; gated on a `hasHeavyCompute` platform constant (the `hasNetwork` pattern) — streamable to a real wall via NetworkSend. **Per-light float is a stated exception here, bounded by measurement** (see § the float exception below) | +| ✅ `TunnelEffect` | **the gather primitive** — the structural gap the canon survey found; one effect proves the whole texture-mapping third of the canon | `sampleWrap` (G1), `mat23` (G3) for the per-frame rotation, `PolarLut`, ping-pong buffers, `cosPalette` | +| ✅ `EchoEffect` | **feedback composition** — that feedback is 3 lines once gather exists, not a primitive (the survey's own argument, made visible) | `sampleWrap` + `fade` + `combine` (G2, screen/max op), ping-pong swap convention | +| ✅ `VectorBallsEffect` | **projection + filled geometry** — a rotating 3D object, the classic demoscene proof | `project` (family 9), `depthSort`, `fillTriangle` (G6), `lineAA`, `circle/fillCircle`, `mat23` | +| ✅ `SpectrumEffect` | **the audio primitives** — replaces GEQ's hand-rolled meter machinery with the real ballistics | asymmetric envelope (G4), `peakHold`, `smoothFollow`, `map8_to_16`, `bar`; beat-locked motion via the audio service's onset/PLL (G5) | +| ✅ `DissolveEffect` | **stateless randomness + dithering** — a transition carrying zero per-pixel state | `hashInt` (position-addressable), `bayerDither` (G7), `easeInOutQuad` (Penner), `gamma8` | + +Attached to the effects above rather than earning their own: 🔨 `worley` (G8) was deliberately NOT built — [backlogged](backlog-light.md) on the industry audit's advice, since no effect wants cells yet; `attract` joins `BallpitEffect` (an attractor well the balls fall into); `kaleido` joins `TunnelEffect` (the same polar LUT, folded); `quadwave/cubicwave` and `isqrt/dist16` are used wherever they are the cheaper shape, not showcased for their own sake. + +✅ Added after this table was written: **`TruchetEffect`** — the representative 2D shader (space folding, position-addressed variation, distance + smoothstep), cheap enough for any target, and a better introduction to the form than the raymarcher. + +Families with no new effect, deliberately: frame ops, geometry bars, time/motion and color are exercised by the stage-1 migrations (the audio meters, the nine `beatPhase` conversions, the 27 palette users); the CA kernel already has `GameOfLifeEffect`; `text` has `TextEffect`. A showcase that duplicates a migration would not earn its place. + +**How far each type goes (coverage vs limits, stated up front):** + +- **Particles**: everything the WLED-PS canon expresses (32 effects' worth of emitters/forces/collisions/fire) is expressible. Ceilings: *count* — thousands on ESP32 (~40 cycles, ~16 B each; 2048 ≈ 0.34 ms/frame), far more on desktop, never GPU-class millions; *deferred families* — constraint chains (Verlet/Jakobsen rope-cloth) and boids wait for a consuming effect; WLED-PS's per-particle size/wobble renderer is covered by SDF-circle glow instead of a second render path. +- **Petrick idiom**: fully expressible (polar + layered warped noise + palette). The limit is per-pixel budget, not vocabulary: 5–10 field samples/pixel is full-rate on ≤32×32 classic, medium sizes on S3, uncapped on desktop — but a 128×128@50 wall affords ~1 sample/pixel. Animartrix itself is FPU-bound to Teensy/S3-class at moderate sizes; the escape hatches are half-resolution field + upscale (the virtual-layer downscale lever), a field rate below the render rate, or desktop headroom. +- **Shader look**: anti-aliased shapes, outlines, glow, smooth-min morphing — yes, everywhere; general Shadertoy — never via GLSL (it is composition of our kernels, not a transpiler), and on ESP32 **it depends on the fixture size, not on the chip**. The budget is per pixel, so it scales with pixel count (240 MHz, measured): **16×16@60 = 15,600 cycles/pixel** (raymarching, fractals and feedback all reachable — a small panel is a legitimate shader target), **32×32@60 = 3,900** (rich multi-sample fields), **64×64@50 = 1,170** (a few samples), **128×128@50 = 292** (one field sample + palette + blend). So an advanced shader effect is not "desktop-only" — it is *small-fixture-and-desktop*, and the same effect simply needs a bigger machine as the wall grows. An effect that wants both can scale its own sample count from `nrOfLights()`. Three SDFs ship (circle/box/segment); more of Quilez's catalog only with a consuming effect. **On desktop the ceiling clause applies**: thousands of cycles per pixel make raymarching, fractals and feedback genuinely reachable — `RaymarchEffect` is the named showcase, gated on a `hasHeavyCompute` platform constant, and desktop frames stream to physical fixtures over NetworkSend, so the heavy tier lights real walls, not just the preview. + + **The float exception, stated rather than implied.** [coding-standards](../coding-standards.md) prefers integers and bars per-light float on the render path; a raymarch loop is per-light float by nature, so `RaymarchEffect` needs an explicit exception rather than a quiet one. Its bound: the effect is **compiled only where `hasHeavyCompute` is true**. **Revised 2026-08-07 (PO): that is targets with a hardware FPU — desktop, ESP32-S3 and ESP32-P4 — not desktop alone.** The original desktop-only framing made a decision on the wrong axis: the cost is per PIXEL, not per chip (measured 0.30 ms/frame at 32x32 on desktop), so a small panel on an S3 is a legitimate target while a 128x128 wall is not, on any hardware. The classic ESP32 has no FPU and carries none of the code, so the rule stands unweakened where it matters most. Running it on a small ESP32 panel — which the cycle budget above says is arithmetically reachable — requires that constant to be true for that target, which is a **separate, measured decision** (single-precision FPU on S3/P4, none on classic ESP32), not something this showcase grants. Every *portable* power function stays integer; this is one gated effect, not a precedent for the contract. + +## 6. Resource accounting ✅ ❓ *(the measurements hold; the flash-delta projections were never re-checked after the kernels landed)* + +Verified against CLAUDE.md § Principles and [architecture.md § Hot path discipline / § Core and light domain](../architecture.md). What the set costs, what it removes, and the gates that keep the balance visible: + +- **Flash:** the 16-bit tier costs **130 bytes of table** plus code. The zero-table variant (interpolating the existing 8-bit `sin8_lut`) was implemented first and **rejected on measurement**: rounding the endpoints to 8 bits distorts the segments the interpolation runs between, giving 1.1% of amplitude — worse than the 0.69% it was supposed to beat. Measured against FastLED **master** (b2a1344): classic `lib8tion sin16` 0.69%; **ours 0.031%** (130 B); master's `fl::sin32` near-exact but 1040 B plus two int64 multiplies per call. 130 bytes for a 22x improvement over lib8tion is the minimalism call — and the estimate-then-verify order is the lesson: the first design's headline number was an unmeasured guess. New kernels (particles, geometry, SDF) add low-single-digit KB; the migrations *delete* the nine phase accumulators, six `imap`s, sixteen `depthDim`s, five private particle representations and the local `plot`/`triangle8` re-implementations, and PS-replaces-twin removes whole effect bodies (WLED's same move saved ~12 KB). **Gate: the per-target flash table in repo-health is read per migration batch; a batch that grows flash needs its reason in the commit.** +- **RAM:** everything sized is `prepare()`-time ScratchBuffer/`platform::alloc` (PSRAM-preferred), zero static — `check_footprint` enforces. Two honest costs, stated rather than hidden: a 2D particle at `pos_t` is ~16 B vs WLED-PS's 10 B (the price of addressing a 16K strip WLED's int16 cannot; pools are effect-sized, so small fixtures pay small); `PolarLut` defaults to **8-bit r,θ (2 B/pixel — 24 KB on 48×256)**, with the 16-bit variant (4 B/pixel) as an explicit opt-in — large fixtures require PSRAM already (`nrOfLightsType` gates on it). +- **Cycles:** per-light work is integer throughout (the fixed-point-default decision); budgets in § Testing; the KPI tick gate catches a regression at its cadence. +- **Repo:** golden-frame tests store **hashes, never frame blobs** — repo-health's size trend stays flat. +- **Boundary:** `math16`/`noise` are domain-neutral core (no light knowledge — "core primitives, not one-offs": each has many callers by construction); `draw`/`particles`/`polar` are light domain. No new mixing. +- **Complete construct, real consumer:** per architecture.md's surviving rule, each power function is built as the cleanest complete version (no crippled subsets) — and lands in the same PR as its first real consumer, so nothing ships speculatively: `beatPhase` is *extracted from* the nine effects that prove it. +- **Subtraction closes the loop:** after stage 1, `math8.h` keeps only entries with remaining callers (palette/hue and internal fast paths); superseded 8-bit forms and the temporary `(Buffer&, dims)` overloads are removed, and the five converged effects' private state code is deleted, not deprecated. + +## 6b. Determinism ✅ 📖 *(the rule now lives in [architecture.md](../architecture.md#effects); supersync itself is unbuilt)* + +A planned capability — **supersync**, one effect rendered across several devices — constrains this API, and honoring it now is nearly free while retrofitting it later is not. The requirement: two devices given the same time and the same controls must produce the same frame, without exchanging pixels. + +**The rule: a power function is a pure function of (position, time, seed) unless it has a stated reason not to be.** "Time" means a **shared origin**, not each device's own `elapsed()` — that is the part the rule stands or falls on, so it is stated first: + +- **A shared epoch, distributed once.** Devices agree on a common `t0` and derive `syncTime = now - t0` from it; `elapsed()` (milliseconds since *this* device's render start) differs per device by however long each has been powered, so two devices reading their own clocks agree on nothing. Which device is authoritative, and how the epoch is distributed and corrected for drift, belongs to supersync's own design, not here. What belongs here is the seam: every time-driven power function reads **one** time source, so pointing that source at a synced clock is a wiring change rather than a rewrite of nine effects. +- **Quantised, so rounding cannot split the group.** `hashInt(x, y, t, seed)` and any other time-seeded randomness take a **quantised** time — a frame index derived from `syncTime / frameMs`, not raw milliseconds — because two devices sampling a continuous clock a millisecond apart would otherwise hash to different values and render different pixels. Quantising makes "close enough in time" mean "identical output". +- **Stateful kernels resync by replay or keyframe.** Particles, ripple, fire and CA evolve state that no formula reconstructs from time alone, so a device that joins late or drops a frame cannot catch up by computing harder. Two mechanisms, both standard in lockstep networking: **deterministic replay** (same seed + same input sequence from the epoch → same state, which works when the input is small and the history short) or an explicit **keyframe** (the authoritative device ships the pool/grid state periodically). Which one per kernel is a supersync decision; what this document fixes is that each kernel exposes a deterministic re-seed entry point so either is possible. + +Three further consequences, each checkable: + +- **Time, never frame count.** `BeatPhase` already satisfies this — it integrates `elapsed()`, so a device that drops frames still arrives at the same phase. This is the property that makes the nine-accumulator migration *more* than tidying: each hand-rolled copy also added `now * bpm` on its first tick, so its phase depended on device uptime and two devices could never agree. That is removed by construction (verified: it is the sole cause of the one golden that moved). +- **Position-addressable randomness beside the stream.** `Random8` advances per *call*, so a device that renders one extra frame — or a different light count — desynchronizes permanently and never recovers. `hashInt(x, y, t, seed)` (identified in the canon survey as the dissolve-transition primitive) is the supersync form: ask "what is this pixel's random value" rather than "what is next in the stream". Both ship; the hash form is the default for anything a synced effect uses, the stream stays for effects that are legitimately local. +- **Stateful kernels declare a resync point.** Particles, ripple, fire and CA carry evolving state that cannot be recomputed from time alone; a lost or late device cannot silently drift. Each exposes a deterministic re-seed from (time, seed) so a joining device can be placed into the same state — the same "keyframe" idea lockstep networking uses. Their *inputs* (emitters, forces) stay pure so only the state needs syncing, not the physics. + +Two non-goals here: this does not specify the sync protocol (clock distribution, keyframe cadence, and which device is authoritative are supersync's design, not the power functions'), and it does not forbid local-only effects — it requires that an effect which *wants* to be synced can be, without rewriting the primitives underneath it. + +## 6c. Two questions answered by the migration so far ✅ + +**Does using power functions make an effect more 3D-compatible?** Indirectly yes, but it is not automatic and it is worth being precise about which half it solves. Measured today: 21 effects declare `D2`, 13 declare `D3`, and 12 never read `depth()` at all. The `draw::` primitives are already dimension-generic (`line` is 3D Bresenham, `blur` covers every axis with extent > 1), so an effect built from them inherits 3D addressing for free — `FixedRectangle` and `PaintBrush` are `D3` on 3-4 `draw::` calls, while `Plasma` hand-rolls a `for z` loop to reach the same place. So power functions **remove the mechanical barrier** (addressing, extents, clipping, the `depthDim()` guard) and the `Canvas` migration removes it for the remaining 22 preambles. + +What they do *not* remove is the conceptual one: an effect is 3D when its idea is 3D. `Metaballs` computes `dx² + dy²`; no primitive turns that into a sphere — someone must add `dz²`. The family that genuinely closes this is the one not yet built: the **SDF trio**, where `|p| − r` is a circle in 2D and a sphere in 3D from identical code (§ the dimension audit). Expect 3D coverage to move with family 3, not with the current batches. + +**Is all the power functionality out of the effects yet?** No — roughly a third. Extracted so far: the nine BPM accumulators and five `imap` copies (the two highest-count patterns). Still embedded, from the bottom-up inventory: **22 `Canvas` preambles and ~15 `depthDim()` copies** (only StarSky migrated), **14 effects hand-rolling flat-index pixel writes**, **5 different particle representations**, **4 different distance implementations**, **3 private scratch-plane fade-and-blit idioms**, `FreqMatrix`'s scroll, and **4 bar-fill implementations**. Each is already a named candidate in the catalog; the remainder lands with families 1-3 and 6. + +## 6d. Live performance ("DeeJaying") ❓ *(an argument that it is reachable, not a built capability)* + +A stretch goal worth recording because the infrastructure is largely built: **playing effects live from the control surface — pads, faders, encoders — with no code changes.** What already exists: control changes reach a running module without a rebuild (`MoonModule::onControlChanged`), the surface routes faders and encoders through `Scheduler::setControl` (the same domain-neutral primitive IR and MQTT use), Layers composite with blend modes and opacity, and presets snapshot and restore whole subtrees. + +Power functions sharpen this in a specific way: **the more of an effect's mechanics live in shared, control-driven primitives, the more of it is playable rather than fixed.** A hand-rolled accumulator is private state a surface cannot reach; a `BeatPhase` fed from a control is a tempo a performer can ride. The same holds for `particles` (gravity, drag, emission as live parameters) and the field family (warp amount, octaves). + +What is genuinely missing, so the gap is not overstated: + +- **Crossfade between states.** Presets apply instantly; a performer needs a transition (the `easeInOut` family plus Layer opacity is most of it, and applying a preset *into* a second layer rather than over the live one is the shape to consider). +- **Beat lock.** The audio service's onset/PLL (G5) is what makes `beatPhase` follow the music rather than a number — the difference between "animated" and "on the beat". +- **Per-control assignment.** `faderTarget` is currently hardcoded (`fader1` → `Drivers.brightness`); a performer needs to bind any control to any fader, which is a UI + persistence job on the existing seam, not new core. +- **Latency budget.** Untested end to end: a physical surface's control change must reach the render within a frame or two to feel live. + +Deliberately not designed here — this is a capability the power functions and the ControlModule surface *enable*, recorded so neither is built in a way that closes the door on it. It also sets a direction for MoonLive: a script whose parameters are surface-bound is a live instrument, not just a compact effect. + +## 7. Testing and budgets ✅ 📖 *(the harness rules live in `test/unit/light/golden_frame.h`)* + +- **Unit**: every power function gets behavior-named tests (bounds, wrap, saturation, the fencepost cases the effects documented); the particle kernel gets the WLED-PS-derived edge list (tunneling lookahead, zero-distance pairs, sticky pile-up) implemented as behaviors, not ported assertions. +- **Golden frames** as above. +- **Scenario coverage, per family rather than one token case.** CLAUDE.md's rule is that a full pipeline gets a scenario test, and a family is only *finished* when one exists for it. The rule that decides which: a scenario is owed wherever a family changes the pipeline's **shape or timing** — allocation at `prepare()`, per-frame state, or a control that resizes something — because that is what a unit test on a bare buffer cannot reach. By family: **particles** (pool allocation, control-driven emission, the collision path), **fields** (a `PolarLut` sized at prepare and rebuilt on a resize), **frame ops** (`scroll` + `blur` on a live pipeline at several grid sizes), and the **desktop tier** (`RaymarchEffect` behind its platform gate). Families with no such surface — geometry, time/motion, color, random, projection — are covered by unit tests plus the golden frames of the effects that consume them, which is the approved exception rather than an omission. +- Each scenario lands **with its family**, not batched at the end; a family whose scenario is missing is not done. +- **Perf**: host micro-bench per function (a small `bench_powerfunctions` target, numbers into performance.md); on-device via the existing `collect_kpi` gate per migration batch. Ceilings: 293 cycles/pixel composite at 128×128@50; ~40 cycles/particle; `sin16` ≤ 12 cycles; `splat` ≤ 30. +- **Footprint**: `check_footprint` zero-static for every family; pools and LUTs are ScratchBuffer/prepare-time only. +- **The final gate is the wall**: stage-1 batches 3–5 get judged on the big fixture, per the pixel-identical divergence clause. + +## 8. Decisions for sign-off ✅ *(all signed off; this is the record of what was decided and why)* + +1. Homes: grow existing headers + `math16.h`/`particles.h`/`polar.h`; no umbrella namespace (§1). +2. Types: `pos_t` int32 24.8, `angle16`, `frac16`, velocity 8.8 (§2). +3. `Canvas` + `EffectBase::canvas()`; old overloads subtracted after migration (§1, §2). +4. Noise: widen value noise to `noise16` now; gradient noise is a swap-in later (§ TL;DR). +5. Migration order and the golden-frame harness (§5, §7). +6. MoonLive requirement list handed to the livescripts work; stages 1–2 do not block on it (§4). +7. Determinism: pure-function-of-(position,time,seed) as the default, `hashInt` beside `Random8`, resync points on stateful kernels (§6b). +8. The resource accounting and its gates: flash read per batch, PolarLut 8-bit default, goldens as hashes, complete-construct-with-real-consumer, the math8 subtraction pass (§6). + +Carried unchanged from the bottom-up: dimension-generic; one set everywhere without capping desktop; demo effects/pixel-identical-by-default; effects-vs-modifiers; Petrick coverage target; one codebase; `particles` naming; PS-replaces-twin; the 16-bit contract. Added on review (PO, 2026-08-06): **particle blending and fixed point are defaults, not per-effect decisions** — additive+splat rendering out of the box with a single opt-out, and the fixed-point vocabulary invisible to the writer (§2, §3). + +## Out of scope *(deliberate non-goals, not open work — an unmarked list on purpose)* + +Exact per-function signatures beyond §3's shapes (the PR is the spec); the MoonLive grammar/ABI design (livescripts work); GPU acceleration of the contract on desktop; boids/Verlet-constraints/Porter-Duff (below-the-cut list stands); palette-system changes beyond `cosPalette`; a public "effect SDK" doc page (falls out of the migrated effects + catalog when stage 2 lands). diff --git a/docs/backlog/rename-to-moonlight.md b/docs/backlog/rename-to-moonlight.md index 3d9ac392..4e498a43 100644 --- a/docs/backlog/rename-to-moonlight.md +++ b/docs/backlog/rename-to-moonlight.md @@ -64,7 +64,7 @@ Decoupling and groundwork that's safe while both repos still hold their current > **Could we reuse `library.json`'s `name` now where a literal sits (subtraction, not a new constant)?** Surveyed `moondeck/` for it — verdict: **no genuine low-hanging fruit.** ~95% of `projectMM` literals there are the **binary name** (`build/…/projectMM`, `.bin`, `.exe`, `.log`, `pkill projectMM`, crash `.ips`) which must track the **CMake target**, not `library.json` (wiring them to the product name would break the path to the file on disk); plus one **wire literal** (`_net_probe.py` ArtNet source-name, must byte-match the device) and ~15 prose/docstrings. The only product-name candidates — `generate_manifest.py`'s manifest `name`/`home_assistant_domain` — must *stay* `projectMM` today (Step 2), don't currently read `library.json`, and flip alongside `library.json` in the sweep anyway, so wiring them is new plumbing for zero present benefit. The principle (reuse an existing source of truth over a hardcoded literal) is right; it just has no payoff here because the literals are either binary-coupled or static-until-the-switch. (The real home for product-identity reuse is still the library API — see the box above.) > - > **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string), per *Concrete first, abstract later* — not speculatively now. Tracked as a seed in [backlog-core](backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it. + > **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string) — not speculatively now. Tracked as a seed in [backlog-core](backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it. 4. **Author the mechanical sweep script** — ✅ **Done:** [`moondeck/rename/rename_to_moonlight.py`](../../moondeck/rename/rename_to_moonlight.py), dry-run by default (`--apply` writes; reserved for switch-day Phase 3.3, *after* the repo rename). What the dry-run against today's tree established: replaces two tokens (`ProjectMM` the enum, then `projectMM`) — a plain token swap is correct for *every* form (repo URL, host path, `projectMM.bin`, product name, `deviceName` slug) since `projectMM` is never a substring of another token; file list comes from `git ls-files` so build output (`build/`, `esp32/build/`) is excluded without a brittle blocklist; `docs/history` (era record) + the rename doc itself are content-excluded. Verified: **542 hits across 113 files**, and `MoonLive` / predecessor `MoonLight` / `namespace mm` are provably never touched (0 files where their count changes). The enum rename is safe — device classification keys on the `"modules"` marker, not the label string. The script de-risks switch-day; it is NOT run with `--apply` until then. 5. **Prep MoonDeck / `moondeck.json` / bench registry** — ✅ **investigated; nothing to change now, two things flagged for switch-day.** (a) **The functional chain stays `projectMM` until the switch (and flips together in the sweep):** `moondeck_config.json`'s `process_name: "projectMM"` ↔ the CMake binary `projectMM` ↔ the `build//projectMM` run/log path ↔ `pkill projectMM`. These are tracked files the sweep rewrites in one pass, so they stay consistent — changing `process_name` early would break MoonDeck's process detection against today's binary, so don't. (b) **The sweep cannot reach the gitignored bench registry** `moondeck/moondeck.json` (it's private, per [[bench-setup]]; the sweep uses `git ls-files`). Its `"board": "projectMM testbench …"` values reference catalog `name`s that *do* flip — so after the switch they'd mismatch only on your bench. **Switch-day local-tooling note: hand-update `moondeck/moondeck.json` board names** (and re-provision bench devices if you want the new mDNS identity) — the sweep covers tracked files only. The MoonDeck prose (`MoonDeck.md`, code comments) flips in the normal sweep. diff --git a/docs/backlog/system-modules.md b/docs/backlog/system-modules.md index 18becd49..e0fbd85b 100644 --- a/docs/backlog/system-modules.md +++ b/docs/backlog/system-modules.md @@ -41,7 +41,7 @@ The clean model, matching your framing that *"System is really to view/manage th - **System** = the device's **fixed hardware + its inspection**: identity (deviceName, chip, mac), live vitals (uptime, fps, tick), reboot — **and the System Modules hang here: Tasks, Memory, Pins, I2cScan.** Always present, not user-added. These are the device's **inspection / bring-up toolkit** — Tasks (what runs), Memory (what's allocated), Pins (what's assigned), I2cScan (what's on the I²C bus). All fixed, all "inspect *this* device." - **Services** (new top-level container) = the **user-added capability modules**: Audio, IR — this-device bridges to the outside world. Optional, per-board. -**DevicesModule is deliberately NOT in either bucket** — it is **fleet-scope** (discovers/lists *other* devices, drives Hue, the seed of future multi-device features: groups, sync, orchestration), so it's neither a this-device System Module nor a this-device Service Module. It stays a wired-by-code child of Network for now; its eventual home is a **later decision** — a standalone top-level module, or a "Fleet"/"Devices" top-level *container* once a second fleet module exists to justify one (don't build a container for one child, *Concrete first*). Flagged here so the distinction isn't lost. +**DevicesModule is deliberately NOT in either bucket** — it is **fleet-scope** (discovers/lists *other* devices, drives Hue, the seed of future multi-device features: groups, sync, orchestration), so it's neither a this-device System Module nor a this-device Service Module. It stays a wired-by-code child of Network for now; its eventual home is a **later decision** — a standalone top-level module, or a "Fleet"/"Devices" top-level *container* once a second fleet module exists to justify one (don't build a container for one child). Flagged here so the distinction isn't lost. This is a principled boundary — *observe the fixed hardware* vs *add an optional capability* — not just decluttering. diff --git a/docs/backlog/ui-extensibility-analysis-bottom-up.md b/docs/backlog/ui-extensibility-analysis-bottom-up.md index 0ce7101f..a963b754 100644 --- a/docs/backlog/ui-extensibility-analysis-bottom-up.md +++ b/docs/backlog/ui-extensibility-analysis-bottom-up.md @@ -91,7 +91,7 @@ Across HA, VS Code (contribution points), Grafana (panel plugins), the shape is The convergent answer is **a small registry + a per-module widget contract, both dead-standard (Custom Elements + a `Map` dispatch)** — projectMM needs no framework, no build step, and already has the substrate (ES modules) and one worked example (preview3d). The work is: (1) define the widget contract (how a module widget receives state + emits changes), (2) a registry app.js consults instead of the hardcoded branches, (3) migrate FileManager out of app.js as the first citizen (proving the contract on the hardest existing case), (4) a middle tier — richer generic list-detail — so not every custom need forces a full widget. Then Tasks/Memory/Pins each add a file + a registry entry, and app.js stops growing per-module. -There are **three tiers** the top-down should name, so a module reaches for the lightest that fits (*Concrete first*): (a) **generic controls** — no custom UI, the default; (b) **generic-with-richer-list-detail** — a module whose need is just nested/tabular detail; (c) **full custom widget** — a Custom Element for genuinely bespoke UI (file tree, board diagram). FileManager is (c); TasksModule today is (a)-with-a-workaround that (b) would fix; Memory/Pins are likely (b) or (c). +There are **three tiers** the top-down should name, so a module reaches for the lightest that fits (minimalism): (a) **generic controls** — no custom UI, the default; (b) **generic-with-richer-list-detail** — a module whose need is just nested/tabular detail; (c) **full custom widget** — a Custom Element for genuinely bespoke UI (file tree, board diagram). FileManager is (c); TasksModule today is (a)-with-a-workaround that (b) would fix; Memory/Pins are likely (b) or (c). This is directly tied to the [System Modules design](system-modules.md): Tasks/Memory/Pins are the *next* modules that will want tier (b)/(c) UI, so the extension architecture should land before (or alongside) building Memory and Pins — otherwise each repeats FileManager's inlined-in-app.js mistake. diff --git a/docs/history/FastLED-FastLED.md b/docs/history/FastLED-FastLED.md index bd10cad6..e96621dd 100644 --- a/docs/history/FastLED-FastLED.md +++ b/docs/history/FastLED-FastLED.md @@ -2,6 +2,38 @@ 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). +## July 2026 + +No release cut this month (3.10.4, 2026-06-16, remains the latest), so the month is not split. Two big threads: finishing the Raspberry Pi Pico driver family, and cutting the ESP32 platform loose from the Arduino core. + +**New** +- Raspberry Pi Pico / RP2040: automatic parallel PIO output finally works for real — 2/4/8 strips driven from one PIO program, with a single-lane fallback for mixed layouts. +- Raspberry Pi Pico gains fixed-function SPI+DMA drivers, a UART DMA driver, and a public hardware-SPI routing API. +- WS2814 RGBW strips are now a first-class chipset with datasheet timing. +- Classic ESP32 gains a second I2S bank — up to 32 parallel strip outputs — plus a second UART output lane. +- FastLED can be built as a plain ESP-IDF project with no Arduino core at all: IDF's own time, serial, SPI, LEDC and heap calls are now the default on ESP32, with Arduino only as an opt-in fallback. +- Classic ESP32 also gains an I2S-based signal capture backend (reading WS2812 data in), alongside the existing RMT and LPC845 capture paths. +- LPC845 now defaults to its UART DMA output path. +- Screenmaps can describe EL wire and EL panel shapes; a new HydroPack example drives two EL panels from a microphone beat detector. +- `fl::printf`/`snprintf` accept a generic `{}` placeholder. + +**Fixed** +- ESP8266: `addLeds()` no longer watchdog-resets when GPIO12 (D6) is used with P9813. +- `rgb2hsv_approximate()` no longer turns orange into green; CHSV values now compare by field instead of by their RGB rendering. +- TM1829 timing (FLIP + wait time) restored after a refactor dropped it. +- SK9822/APA102 on the classic `addLeds` path now emit correct all-ones end clocks. +- ESP32 I2S clock divider no longer silently truncates, which could produce wrong strip timing. +- `m0clockless` brightness scaling was broken and always output zero. +- Teensy 4.x SPI drivers no longer depend on the Arduino `SPI` library; Renesas boards no longer pull in an I2S header they don't have. +- WASM/browser preview: microphone capture recovers after the user cancels access, and the default renderer works again. + +**Watching** +- Report that RGBW output has been broken since 3.10.3 (#3622, closed) fed the month's RGBW colorimetry cleanups. +- An open thread (#3762) blames an unconditional deep yield in `show()`'s refresh throttle for a long-standing frame-timing regression — no fix shipped yet. +- A port to the WCH CH32V003 (48 MHz, 2 KB RAM) is proposed (#3755). + +_Auditability: 212 first-parent commits on `master` with author-date 2026-07-01..2026-07-31. Issues via `search/issues` for `repo:FastLED/FastLED+is:issue+created:2026-07-01..2026-07-31` (110 opened) and `closed:2026-07-01..2026-07-31` (106 closed); the great majority are the project's own phase/meta bring-up trackers for RP2040, LPC845 and the classic-ESP32 I2S driver, plus CI and linter work — only the user-facing ones are surfaced above. No versioned release published in July, so no month split._ + ## June 2026 (up to 3.10.4) Released **3.10.4** (2026-06-16), cut from `master`. diff --git a/docs/history/MoonModules-WLED-MM.md b/docs/history/MoonModules-WLED-MM.md index fa7cc778..19bde151 100644 --- a/docs/history/MoonModules-WLED-MM.md +++ b/docs/history/MoonModules-WLED-MM.md @@ -2,6 +2,16 @@ 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). +## July 2026 + +*Summarised from 2 commits on `mdev`, both 2026-07-01 (no versioned release cut this month; the `nightly` prerelease republished on 2026-07-02 packages June's work).* + +Near-dormant month — a single small change and a build-number bump. + +- The instance list now shows which repo (WLED-MM or upstream WLED) each discovered instance runs, and reports the right release for upstream WLED instances. + +*Auditability: 2 commits on `mdev`, author-date 2026-07-01..2026-07-31 (range 12b0238 … 7c55f91; 7c55f91 is a build-number bump, omitted as not user-facing). Issues checked: `repo:MoonModules/WLED-MM is:issue created:2026-07-01..2026-07-31` (0) and `closed:2026-07-01..2026-07-31` (0), and `updated:2026-07-01..2026-07-31` (0) — no issues opened, closed, or even commented on all month. (The June window returns 2 on the same query form, so the zeros are real rather than a mistyped repo name: the search API needs `MoonModules/WLED-MM`, not `MoonModules/WLED`.)* + ## 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).* diff --git a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md index e459fcc1..81620e91 100644 --- a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md +++ b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md @@ -2,7 +2,19 @@ What landed on [NightDriverStrip](https://github.com/PlummersSoftwareLLC/NightDriverStrip)'s `main` 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). -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. +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. Releases are noted as context rather than used as month boundaries: **v1.3.0** (published 2026-01-10) was tagged from a late-November commit, and the latest are **v2.0.0** and **v2.0.1**, both published 2026-06-14. Neither is a clean month boundary, so months are kept whole. + +## July 2026 + +A quiet month: one feature merge, no release, no issues. + +- **Mesmerizer matrix panels switched to the HUB75-DMA backend** (replacing SmartMatrix), so the LED-matrix output path is now shared across all Mesmerizer boards. +- Two new supported boards: **ESP32-DevKitC V4** (local effects only, no PSRAM) and **ESP32-S3-DevKitC-1 N16R8** (16 MB flash, 8 MB PSRAM, USB serial logging). On both, the BOOT button steps through effects. +- The startup splash screen now renders immediately instead of waiting for WiFi. +- Better behaviour on low-memory boards: the firmware degrades gracefully instead of failing when memory runs short. +- Fixed the JPEG decoder not being ready in time for the startup splash, and corrected serial status output on S3 boards. + +_Auditability: 3 commits on `main` author-dated 2026-07-01..2026-07-31 (1 first-parent merge — PR #901, merged July 18 — plus a whitespace commit); `commits?sha=main&since=…&until=…`. Issues checked via `search/issues` for `created:2026-07-01..2026-07-31` (0), `closed:` (0) and `updated:` (0) in the same range. No versioned release published in July (latest are v2.0.0/v2.0.1, both June 14), so the month is kept whole._ ## June 2026 (up to v2.0.0) diff --git a/docs/history/README.md b/docs/history/README.md index d9c50209..a23e893b 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -20,6 +20,7 @@ Monthly logs of what shipped on related open-source LED projects — the live la - [hpwit-I2SClocklessLedDriver.md](hpwit-I2SClocklessLedDriver.md) — hpwit's I2S/LCD DMA clockless LED driver (parallel multi-strip output). - [hpwit-I2SClocklessVirtualLedDriver.md](hpwit-I2SClocklessVirtualLedDriver.md) — the shift-register "virtual pins" variant of the above (dormant since 2024). - [hpwit-ESPLiveScript.md](hpwit-ESPLiveScript.md) — hpwit's live C-like script compiler for the ESP32 (main quiet; work moved to version branches). +- [hpwit-new-parser.md](hpwit-new-parser.md) — **ESPLiveScript2**, hpwit's from-scratch rewrite of the above (repo is named `new-parser`; the library lives in `asmparser2/`). Dormant May 2025 → August 2026, then an active rewrite whose stated goal is a *verifiable* compiler: host builds plus QEMU running the actual compiled Xtensa bytes. ### Prior-project inventories diff --git a/docs/history/hpwit-ESPLiveScript.md b/docs/history/hpwit-ESPLiveScript.md index b8d4878a..3d124221 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`. +## July 2026 + +No user-facing activity: no commits on `main` **or any of the 38 version branches** (v2.x/v3.x/v4.x, `vjson`/`vjson2`/`vdrop`, `dev`, `mem*`) in July 2026, and no notable issues. (Latest commit on `main` predates the window — June 2025; the newest commit anywhere is `vjson2`, February 2026.) + +_Checked: commits author-dated 2026-07-01..2026-07-31 on `main` and every one of the 38 branches — 0 on each; issues created / closed / updated 2026-07-01..2026-07-31 (0 each); PRs created in-window (0); no versioned release published in July 2026._ + ## 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.) diff --git a/docs/history/hpwit-I2SClocklessLedDriver.md b/docs/history/hpwit-I2SClocklessLedDriver.md index af8e8edc..447a9775 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. +## July 2026 + +No user-facing activity: no commits merged to `main` (latest activity is April 6, 2026) and no notable issues. No branch saw commits either — the newest work anywhere is the `esp32-p4-support` branch, last touched April 11, 2026. + +_Auditability: commits on `main` author-dated 2026-07-01..2026-07-31 = 0 (0 merged), and 0 on every other branch; issues created/closed/updated in July 2026 = 0; PRs created = 0. No versioned release published in July (latest tag `1.4`, 2026-04-06)._ + ## June 2026 No user-facing activity: no commits merged to `main` (latest activity is April 6, 2026) and no notable issues. diff --git a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md index a7038d4c..b3ee6d7b 100644 --- a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md +++ b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md @@ -2,7 +2,13 @@ What landed on [hpwit/I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2SClocklessVirtualLedDriver), month by month. External-context reference — a factual log of a friend repo's activity, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). -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`. +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`, `dev`, `optomize`), not just `main`. + +## July 2026 + +No user-facing activity this month: no commits merged to `main` (latest commit on `main` dates to November 2024), no commits on any other branch (newest anywhere is `variable`, December 2024), no releases published, and no issues opened, closed, or updated. + +_Checked: commits with author-date 2026-07-01..2026-07-31 on `main` and every branch (`integration`, `int2`, `variable`, `hpwit-patch-1`, `dev`, `optomize`) — 0 on each; issues created 2026-07-01..2026-07-31 (0), closed in that range (0), and updated in that range (0); PRs created (0); releases (none in July — latest versioned tag is 2.1, Jan 2024)._ ## June 2026 diff --git a/docs/history/hpwit-new-parser.md b/docs/history/hpwit-new-parser.md new file mode 100644 index 00000000..56ccbf77 --- /dev/null +++ b/docs/history/hpwit-new-parser.md @@ -0,0 +1,23 @@ +# hpwit/new-parser (ESPLiveScript2) — monthly activity digest + +What landed on [hpwit/new-parser](https://github.com/hpwit/new-parser), month by month. External-context reference — a factual log of a friend repo's activity, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). + +The library: **ESPLiveScript2**, Yves Bazin's (hpwit) from-scratch C++ rewrite of [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) — the same idea (a small C-like language compiled on-device to real Xtensa machine code, no interpreter, so a script runs at near-native speed) reimplemented independently rather than refactored. The library ships inside the repo as `asmparser2/` (PlatformIO name `ESPLiveScript2`, at v1.3.0). Summarised via the GitHub commits API. + +**Repo note:** the repository name is `new-parser`, but the library and its README call it **ESPLiveScript2** — the name to search for. Sibling digest for v1: [hpwit-ESPLiveScript.md](hpwit-ESPLiveScript.md). + +## Timeline note (added 2026-08-06) + +Added to the digest set on 2026-08-06, after the product owner flagged the rewrite. History to date, from the commit log: created March 2025, six commits across March–May 2025, then **dormant for over a year**, then **12 commits in the first days of August 2026** — the rewrite as it now stands is days old at the time of writing. July 2026 is therefore empty, and the August work is summarised in next month's digest rather than pre-empted here. + +What the rewrite is, from its README (context for future months, not an endorsement): + +- A **verifiable** compiler is the stated reason for rewriting rather than refactoring: the whole toolchain (tokenizer, parser, assembler, loader) builds and runs as an ordinary host program with no ESP32 or Arduino framework involved. +- Its tests run the **actual compiled bytes** on a real Xtensa CPU emulator (QEMU, Espressif's ESP32/ESP32-S3 machine models) and check real results, and every example script from v1's own corpus is compiled and checked against that pipeline. +- Day-to-day script authoring is said to be unchanged from v1; the differences are in the implementation and its testability. + +## July 2026 + +No activity: no commits on `main` in July 2026, and no issues. (The repo was dormant between May 2025 and August 2026 — the current rewrite work begins 2026-08-01, outside this window.) + +_Checked: commits author-dated 2026-07-01..2026-07-31 on `main` (0); issues created 2026-07-01..2026-07-31 (0) and closed in the same window (0); no versioned release published in July 2026._ diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 4326c870..b10937cf 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -416,3 +416,39 @@ Streaming panel-card frames from an S31 at ~5 300 packets/s degraded with uptime **Recovery still earns its place.** `esp_eth_stop()` + `esp_eth_start()` re-runs negotiation and resets the descriptor rings: the only way back from a wedge short of a reboot, since no ioctl writes the driver's link flag. Bench-verified twice, recovering in ~17 s. Attempted once per wedge, never repeatedly: a restart cannot fix an unplugged cable, and retrying would bounce the interface under the user. + +## Lessons from the power-function branch (shared toolbox, particles, shaders) + +A branch that built a shared library and migrated existing effects onto it. The gotchas all +share a shape: a green build, plausible-looking output, and a defect only counting could see. + +- **A ported function's SIGN is part of its contract, and getting it wrong is silent.** Our + `sin16`/`cos16` returned unsigned 0..65535; FastLED master (`i16 sin16lut`) and WLED main + (`int16_t sin16_t`) both return signed. WLED effects write `sin16_t(x) + 32768` when they + want an unsigned value — arithmetic that is exactly half a scale wrong against an unsigned + return, with no compile error and no crash. A ported effect just looks subtly off. **When + adopting a function that exists upstream, verify its range and sign against upstream's + `master`/`main`, not against a release and not against memory** — the contract to bind to is + the one users will have. Local checkouts of both live beside this repo for exactly this. + +- **"It looks like noise" is not evidence that it IS noise.** `fbm16` summed its octaves after + shifting each sample down by 8 — which fits a 32-bit accumulator and produces output that + passes every visual check, while being an 8-bit field wearing a 16-bit type: 195 distinct + values over 20,000 samples, low byte never set. That is precisely the banding the 16-bit tier + exists to remove, shipped inside the fix for it. **For a function whose value IS its + distribution, assert on the distribution — count distinct values, check the range is + reached.** An eyeball, and a test that only checks bounds, both pass. + +- **Two framerate bugs can cancel, and fixing one alone looks like a regression.** Fireworks + counts frames for its launch roll (20x the shells at 1200 fps) *and* floors its trail fade at + 1 per render (erasing the trail 20x faster). Together they roughly cancel, so the effect + looked fine and the audit passed. Fixing only the launch made the measured ratio worse — + three times, before the second bug was found. **When a targeted fix makes a metric worse, + stop patching and look for the compensating bug** rather than tuning the fix. + +- **A "no-op" rewrite of a shared primitive needs a differential test, not a review.** + Replacing `wrap()`'s per-span loops with modulo changed its endpoint behaviour: the loops are + asymmetric (reducing from above stops *at* `span`, climbing from below stops at `0`), which + no amount of reading the new code reveals. Diffing old against new across every span and + 2.8M inputs found it in seconds. **For a hot-path primitive whose replacement is meant to be + behaviour-identical, prove it by exhaustive comparison against the original.** diff --git a/docs/history/plans/Plan-20260731 - ControlModule and presets.md b/docs/history/plans/Plan-20260731 - ControlModule and presets.md new file mode 100644 index 00000000..7451fce4 --- /dev/null +++ b/docs/history/plans/Plan-20260731 - ControlModule and presets.md @@ -0,0 +1,135 @@ +# Plan: ControlModule and presets + +## Context + +There is no way to save a device's configuration and bring it back. Every change edits the live tree, +and the only persistence is the automatic one that restores exactly what was there at reboot. A user +who finds a look they like cannot keep it, and cannot switch between looks. + +MoonLight solved this inside `ModuleLightsControl`, and the mechanism is the one to copy: **a preset +is a JSON file, saving is copying a file, selecting is reading one back**. MoonLight's presets cover +only effects and modifiers. We make it generic, and put it in **core** rather than the light domain, +so a preset can carry any part of the tree. + +`ControlModule` is also where external control belongs later (MIDI surfaces, IR, a hardware panel): +one place that says "put the device in this state", whatever asked for it. Presets are its first +capability, not its only one. + +**Naming.** `LightPresetsModule` already exists and is a different thing: named channel-role wirings +per fixture. It keeps its name here; the collision is noted in the module comment on both sides so a +reader is not misled. If the two prove confusable in use, renaming that one to a fixture profile is a +separate, PO-called change. + +## Decisions taken + +- **A preset captures a SELECTABLE set of top-level subtrees**, recorded in the file. A `Layers`-only + preset is hardware-portable; adding `Drivers` makes it a device snapshot that carries pin maps. + The file says which, so applying one is never a surprise. +- **Named files**: `/.config/presets/.json`. Delete is a file delete; a preset uploaded through + the File Manager just appears. This is the PO's stated principle, taken literally. +- **Playlists are NOT in this branch.** The cycling hook is designed in and left unbuilt; multiple + named playlists get their own plan, informed by real presets to cycle. + +## Design + +### The file + +```json +{ + "captures": ["Layers", "Layouts"], + "Layers": { "enabled": true, "0.type": "Layer", "0.0.type": "NoiseEffect", "0.0.speed": 128 }, + "Layouts": { "enabled": true, "0.type": "GridLayout", "0.width": 128 } +} +``` + +Each captured subtree is **exactly the bytes `FilesystemModule` already writes** for that module +(`writeNode`, `FilesystemModule.cpp:348`): a flat map of dotted positional keys, with `.type` +per child. Reusing that format means save and restore reuse the engine that already reconciles a tree +against JSON, rather than a second serializer that could drift from it. + +### What has to be added to core + +`FilesystemModule` can already do both halves, but neither is reachable at runtime: + +- **`saveSubtreeTo(MoonModule*, JsonSink&)`** — factor the body of `saveSubtree` + (`FilesystemModule.cpp:319`) so it can write into a caller's sink instead of straight to + `/.config/.json`. The existing method becomes a thin caller of it. +- **`applySubtree(MoonModule*, const char* json, const char* prefix)`** — a public wrapper over the + private `applyNode` (`FilesystemModule.cpp:191`), which already creates, replaces and destroys + children by type and tolerates unknown types. **It must also drive the lifecycle `applyNode` + leaves undone**: `applyNode` calls only `defineControls()` on a created child, because at boot the + Scheduler's phases 3 and 4 follow. At runtime the caller must do what `applyAddModule` does + (`HttpServerModule.cpp:1591`): `setup()` then `applyState()`, then one `prepareTree()`. + +Both go on `FilesystemModule` because that is where the format and the reconciliation live. No new +serializer, no second copy of the tree-walking rules. + +### ControlModule + +A top-level module, peer of Layouts/Layers/Drivers, registered in `main.cpp` alongside them. Not +under `Services`: it reaches *across* the top-level modules, so it cannot be a child of one. + +Controls: + +| control | what it does | +|---|---| +| `presets` | An editable `List` (`ListSource`, `Control.h:190`) — one row per file, with the captured subtrees shown per row. | +| `name` | Text: the name to save under. | +| `capture` | Which subtrees a save includes. One `addBool` per top-level module, so the set is explicit. | +| `save` | Button: write `/.config/presets/.json`. | +| `status` | ReadOnly: what happened, and which preset is currently applied. | + +Applying a row uses the list's existing per-row edit path (`setListRowField`), which reaches the +source with an arbitrary field name, so a row gets an "apply" affordance with no new UI primitive. +A row also carries delete and rename through the CRUD the list already provides. + +**Save** flushes pending writes first (`FilesystemModule::flushPending()`, `.cpp:100`) so the file +captures the live state rather than a stale debounce, then walks the selected top-level modules and +writes one object per capture. + +**Apply** reads the file, and for each key in `captures` that resolves to a live top-level module, +calls `applySubtree`. A capture naming a module this build does not have is skipped with a status +line: the same degrade-never-crash rule `applyNode` already follows for unknown child types. + +### The hot path + +Applying a preset rebuilds modules, and every structural mutator already quiesces the render worker +(`MoonModule::quiesceForMutation`, `MoonModule.h:510`). But mutations run inline on the render tick, +so a large restore stalls rendering for its duration. **Batch it**: mutate every captured subtree, +then one `prepareTree()` and one `requestFullResync()` at the end, rather than per subtree as the +existing add path does. `tick()` is untouched, since presets are a cold-path feature. + +## Files + +- `src/core/ControlModule.h` — new. The module, its controls, the preset `ListSource`. +- `src/core/FilesystemModule.h` / `.cpp` — `saveSubtreeTo` + `applySubtree`; `saveSubtree` refactored + to call the former. +- `src/main.cpp` — register the type, create it, `scheduler.addModule` it. +- `src/ui/app.js` — the row-apply affordance, **in both render paths** (`renderCards` and + `updateModuleControls`; a rule added to one only is invisible on a WebSocket update). +- `docs/moonmodules/core/control.md` + the catalog card. +- `test/unit/core/unit_ControlModule.cpp` — new. + +## Verification + +1. **Unit**: a preset round-trips (save a tree, mutate it, apply, the tree matches); a capture naming + an absent module is skipped without throwing; a corrupt file degrades to a status rather than a + crash; an unknown child type inside a capture is skipped and the rest still applies. +2. **Scenario**: save a preset, change effects and layout live, apply the preset, assert the pipeline + still renders non-zero, which is the wired-pipeline gate the other scenarios use. +3. **`check_footprint --module ControlModule`**: zero static RAM when not used. +4. **`clang-hotpath`**: no new blocking call on the render path. +5. **Bench, and the gate that matters**: on a real board, save a look, change it, bring it back, and + confirm the panels show what they showed before. **PO judgement.** +6. Hardware portability, deliberately: a `Layers`-only preset saved on one board applies on a board + with different pins and drives its own hardware. + +## Deliberately not in this plan + +- **Playlists**, per the decision above. The apply path is the hook they will need. +- **Apply-on-boot.** MoonLight explicitly does not (its preset branch is guarded against firing at + boot); WLED does. Worth deciding once presets exist and the behaviour can be felt. +- **Renaming `LightPresetsModule`.** Noted as a collision, not acted on: it is a PO call and a + separate change. +- **External control (MIDI, hardware surfaces).** This is what `ControlModule` exists to host, but + the first capability is presets; adding a control surface has its own plan. diff --git a/docs/history/troyhacks-WLED.md b/docs/history/troyhacks-WLED.md index 937dcd32..ddb77fc7 100644 --- a/docs/history/troyhacks-WLED.md +++ b/docs/history/troyhacks-WLED.md @@ -6,6 +6,14 @@ 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. +## July 2026 + +No user-facing activity: no commits were merged to `mdev` in July 2026 (the branch's most recent commit is still dated 2026-05-20), and no versioned release was published. The repository's issue tracker is disabled, so no issues were opened or closed. + +- **Experimental branches:** nothing moved in July either — the most-recently-touched branch, `P4_experimental` (ESP32-P4), was last pushed in early August, and no other branch saw a July commit. + +_Checked: merged commits on `mdev` for author-date 2026-07-01..2026-08-01 (0 commits); commits on `P4_experimental` for the same window (0); releases published in July 2026 (none); issue search `repo:troyhacks/WLED is:issue created:2026-07-01..2026-07-31` and `closed:2026-07-01..2026-07-31` (0 results — issues disabled on this fork)._ + ## 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. diff --git a/docs/history/wled-WLED.md b/docs/history/wled-WLED.md index d07ab5d8..14fd09f2 100644 --- a/docs/history/wled-WLED.md +++ b/docs/history/wled-WLED.md @@ -4,6 +4,30 @@ 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. +## July 2026 + +The month `main` switched to the **V5** platform: WLED's trunk moved from the ESP-IDF 4.4 / arduino-esp32 v2 build to ESP-IDF 5.3 / arduino-esp32 v3, and the long-running `V5` branch became the development trunk (merged July 19). Maintainers warned publicly that `main` would be unstable for a while, and the web UI now shows a "development build" banner. v16.0.1 shipped July 7 from a release branch, so the month is not split. + +**New** +- Trunk builds move to ESP-IDF 5.3 / arduino-esp32 v3, opening the door to the newer chips (ESP32-C5, C6 and P4 build targets ride along). +- ESP-NOW now uses WLED's own code instead of the QuickESPNow library — faster, and roughly 10 KB more free memory on ESP32 (1.5 KB on ESP8266). +- New `esp32_eth_V4` build for Ethernet boards; ESP32-C6 boards get 4 MB and 8 MB builds. +- Nightly builds renamed, and the web UI warns when you're running a development build. +- Audio-reactive now compiles on all the newer chips. + +**Fixed** +- ESP32-C3 and S3 no longer boot-loop on the new platform (device-ID and audio-reactive builds rescued). +- Usermod settings: non-pin dropdowns no longer reserve GPIO pins, which was blocking pin choices elsewhere. +- Several DMX-input crashes and a robustness fix in its configuration; a possible array overrun in the Improv response. +- ESP8266 minimum build shrank by 1.5 KB; the unmaintained `WLED_SAVE_RAM` build option was removed. + +**Watching** +- The loudest thread of the month is #5746, asking the project to do unstable work on a `dev` branch rather than breaking `main`. +- Ethernet users want the WiFi access point to switch off entirely once the wired link is up (#5762). +- Open v16 field reports: Animated Staircase segments switching instead of fading (#5731), APA102 on GPIO19 misbehaving (#5728), HUB75 colour-order options hidden and mostly unimplemented (#5723), and a request for current-limited LED support in the brightness limiter (#5715). + +_Auditability: 40 first-parent commits on `main` with author-date 2026-07-01..2026-07-31 (56 including merged sub-commits). Issues via `search/issues` for `repo:wled/WLED+is:issue+created:2026-07-01..2026-07-31` (20 opened) and `closed:2026-07-01..2026-07-31` (15 closed); only user-facing ones surfaced. v16.0.1 (2026-07-07) is not an ancestor of `main` (GitHub compare reports `main` and `v16.0.1` diverged), so no month split._ + ## 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`. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 2dd812ce..2a6fbad6 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "cca3fe8e", + "commit": "2e23f158", "flash": { - "esp32": 1678960, - "esp32p4-eth": 1503232, + "esp32": 1744736, + "esp32p4-eth": 1503280, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1667216, + "esp32s3-n16r8": 1739968, "esp32s3-n8r8": 1666992, - "esp32s31": 1924992, - "desktop": 945752 + "esp32s31": 1932624, + "desktop": 1100648 }, "perf": { "desktop": { - "tick_us": 127, - "fps": 7874 + "tick_us": 140, + "fps": 7142 }, "esp32": { "tick_us": 4164, @@ -20,54 +20,54 @@ } }, "loc": { - "core": 14889, - "light": 20214, - "platform": 12526, - "ui": 5811, - "test": 35504, - "moondeck": 19968 + "core": 16537, + "light": 23604, + "platform": 12590, + "ui": 6467, + "test": 39761, + "moondeck": 20039 }, "comments": { "core": { - "lines": 5583, + "lines": 6216, "ratio": 0.409 }, "light": { - "lines": 7815, - "ratio": 0.427 + "lines": 9092, + "ratio": 0.426 }, "platform": { - "lines": 4198, - "ratio": 0.371 + "lines": 4233, + "ratio": 0.372 }, "ui": { - "lines": 1518, - "ratio": 0.278 + "lines": 1670, + "ratio": 0.274 }, "test": { - "lines": 6073, - "ratio": 0.198 + "lines": 6777, + "ratio": 0.197 }, "moondeck": { - "lines": 3187, + "lines": 3198, "ratio": 0.183 } }, "tests": { - "cases": 1008, + "cases": 1267, "scenarios": 22 }, "docs": { - "md_files": 170, - "md_lines": 22818, - "plans_files": 90, - "backlog_lines": 3114, + "md_files": 176, + "md_lines": 24123, + "plans_files": 91, + "backlog_lines": 3611, "lessons_lines": 418, "claude_md_lines": 135 }, "complexity": { - "functions": 2188, - "over_threshold": 140, + "functions": 2417, + "over_threshold": 149, "worst_ccn": 93 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 396c38d0..c91ce5c6 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `cca3fe8e`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `2e23f158`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,55 +8,55 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 924 KB (+0 KB) ⚠ | -| esp32 | 1,640 KB | +| desktop | 1,075 KB (+1 KB) ⚠ | +| esp32 | 1,704 KB | | esp32p4-eth | 1,468 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,628 KB | +| esp32s3-n16r8 | 1,699 KB (+3 KB) ⚠ | | esp32s3-n8r8 | 1,628 KB | -| esp32s31 | 1,880 KB | +| esp32s31 | 1,887 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 127 µs | 7,874 | +| desktop | 140 µs (+8 µs) ⚠ | 7,142 (−433) ⚠ | | esp32 | 4,164 µs | 240 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 14,889 | 5,583 | 40.9 % | -| light | 20,214 (+16) ⚠ | 7,815 | 42.7 % | -| platform | 12,526 (+25) ⚠ | 4,198 | 37.1 % (+0.1 %) ⚠ | -| ui | 5,811 | 1,518 | 27.8 % | -| test | 35,504 (+75) ⚠ | 6,073 | 19.8 % | -| moondeck | 19,968 | 3,187 | 18.3 % | +| core | 16,537 (+104) ⚠ | 6,216 | 40.9 % (−0.1 %) ✓ | +| light | 23,604 (+282) ⚠ | 9,092 | 42.6 % (+0.1 %) ⚠ | +| platform | 12,590 | 4,233 | 37.2 % | +| ui | 6,467 | 1,670 | 27.4 % | +| test | 39,761 (+215) ⚠ | 6,777 | 19.7 % | +| moondeck | 20,039 | 3,198 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,008 (+1) ✓ | +| unit cases | 1,267 (+13) ✓ | | scenarios | 22 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,188 (+1) ✓ | -| over threshold | 140 (+1) ⚠ | +| functions | 2,417 (+12) ✓ | +| over threshold | 149 (−2) ✓ | | worst CCN | 93 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 170 | -| markdown lines | 22,818 (+9) ⚠ | -| plan files | 90 | -| backlog lines | 3,114 | +| markdown files | 176 | +| markdown lines | 24,123 (+94) ⚠ | +| plan files | 91 | +| backlog lines | 3,611 (+62) ⚠ | | lessons lines | 418 | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/core/control.md b/docs/moonmodules/core/control.md new file mode 100644 index 00000000..0582428e --- /dev/null +++ b/docs/moonmodules/core/control.md @@ -0,0 +1,79 @@ +# Core control + +The device's control surface — the place that says "put the device into this state", whatever asked for it. A preset applied from the grid, and later a fader moved on a MIDI desk, arrive at the same code. Its first capability is presets; the surface layout exists so external controllers map onto something that already looks like them. + +`ControlModule` is a top-level module, a peer of Layouts / Layers / Drivers rather than a child of Services: it reaches *across* the top-level modules, so it cannot sit inside one. + +## Control modules + + + +### Control + +A grid of preset pads, a row of rotary encoders above them, and a bank of faders below — the layout of a Mackie-style control desk ([X-Touch](https://www.behringer.com/product.html?modelCode=0808-AAF), [QCon Pro G2](https://www.iconproaudio.com/product/qcon-pro-g2/)), so a physical surface maps onto it without a translation layer. + +Control module surface: encoders, preset pads, faders + +- `presets` — the pad grid (8×8). One pad per preset file; click to apply, right-click (or long-press) to name it, pick which single subtree it captures, save or delete. Drag a pad to rearrange the surface. +- `enc1` … `enc8` — rotary encoders. Drag or scroll to turn; right-click shows what each drives. +- `fader1` … `fader8` — faders. `fader1` drives `Drivers.brightness`; the rest are unassigned until bound. + +Detail: [technical](moxygen/ControlModule.md) + +[Tests](../../tests/unit-tests.md#controlmodule) + +## Presets + +A preset is a file: `/.config/presets/.json`. Saving writes one, applying reads one, deleting removes one. Nothing else holds preset state, so there is no second copy to keep in step: the list is rebuilt from the folder rather than persisted alongside it. That rescan runs at startup and after every save, rename and delete — a reorder only rewrites the affected files and re-sorts the rows in place, since the folder's contents have not changed. So a preset added or removed through the File Manager appears once the module next rescans (a reboot, or a save, rename or delete on the surface), not the instant the file lands. + +The name becomes the file name, so it is restricted to printable ASCII without `/`, `\` or `.` — a validator on the control, which every write path runs. `slot` records which pad the preset occupies, so a surface arranged to match a physical desk survives a reboot. + +### What a preset carries + +A preset captures **exactly one** top-level subtree, recorded in the file: + +```json +{ + "slot": 12, + "captures": "Layers", + "Layers.enabled": true, "Layers.0.type": "Layer", "Layers.0.0.type": "NoiseEffect" +} +``` + +Each captured subtree is exactly the bytes the persistence engine already writes for that module, namespaced under a `.` key prefix. Save and restore therefore reuse the engine that reconciles a tree against JSON ([`saveSubtreeTo` / `applySubtree`](moxygen/FilesystemModule.md)) rather than a second serializer that could drift from it. + +One subtree per preset is the whole model: a preset is *a look*, or *a geometry*, or *a hardware setup*, or *a service configuration. Never a combination. A `Layers` preset is a look, and applies to a board with completely different hardware; a `Drivers` preset carries pin maps and is device-specific. Choosing the role is a single radio button when saving, and the pad's color says which role it holds. + +A preset naming a subtree this build does not have is refused with a reason rather than partially applied, and a file written by an older build that names several subtrees is listed but not applied, so it can be seen and deleted rather than silently vanishing. A malformed file leaves the live tree untouched. + +### One active preset per role + +Each subtree is a **role**: layout, layer, driver, service. A preset holds its own role and leaves the other three alone, so a layout preset and a look can be active at the same time, and applying a new look replaces only the look. + +A pad is tinted by its role: layout blue, layer violet, driver green, service amber. + +### Applying is a rebuild + +Applying a preset creates, replaces and destroys modules to match what the file describes — it is a restore, not a value overlay: a preset carrying more than the device has adds it, and one describing less removes what it omits. + +Structural mutation quiesces the render worker, and mutations run inline on the render tick, so a large restore stalls rendering for its duration. The captured subtree is applied and `prepareTree()` runs once at the end. Presets are a cold-path feature; the tick path is untouched. + +## Home Assistant + +Looks reach Home Assistant two ways, and only `Layers` presets travel either of them. + +**The WLED integration** (`/presets.json`) is the native path: HA renders looks in its own preset dropdown, shows which one is applied, and applies one when it is chosen. This is what HA calls a preset. + +**MQTT discovery** publishes the same looks as the light entity's **effect list**. HA has no preset concept over MQTT, so they arrive as effects — the same result from the user's side, reached through a different mechanism. + +HA caches the preset list and re-fetches only when the device's `info.fs.pmt` value changes, so the device reports a revision counter there that bumps on every preset save, rename and delete — a counter rather than a timestamp, so two changes inside one second still read as two. A constant there leaves HA showing the list it read at setup forever; over MQTT the same revision re-announces the effect list mid-session. + +Only looks are exposed, on both paths. A `Drivers` or `Layouts` preset rewires pins or geometry, which must not be reachable from something that believes it is choosing a color scheme — the restriction is enforced at the apply entry point, not merely by omitting them from the list. + +Home Assistant's WLED integration connects on **port 80 only**: its host field rejects a port, so a desktop build (which defaults to 8080) needs `--port 80`, and that needs root: + +```sh +sudo uv run moondeck/run/run_desktop.py --port 80 +``` + +The discovery buffers are sized to the looks this device actually has, and grow or shrink as presets are added and removed. There is no cap on the number: a fixed one would either reserve memory a small setup never uses, or silently publish nothing once the list outgrew it. diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index c2da5cd6..737ac378 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -2,6 +2,8 @@ MoonLive is projectMM's **live-script engine** — author an effect as text and run it on a running device, compiled to native machine code so it executes at near-hand-written speed in the render hot path. The broader design lives in [livescripts-analysis-top-down.md](../../backlog/livescripts-analysis-top-down.md) (a backlog design study); this page documents the module. +Scripts call the same [power functions](power-functions.md) compiled effects use, reached through the builtin table — so the vocabulary is shared, in its flat scalar form. + A scripted effect carries its **script source** as an editable, persisted multi-line text control (a resizable `textarea` in the UI), and a front-end (lexer → parser → IR → per-ISA assembler) compiles it to native code on the next tick. The grammar is a function-call statement with **expression arguments** — any argument may be a literal or a nested call: ``` diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index 30e11863..b65be320 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -2,9 +2,9 @@ Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel color into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index color read the global palette (the `palette` control on [Drivers](moxygen/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](moxygen/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](../../adr/0015-library-is-a-tag-not-a-folder.md).) -**Jump to:** [MoonLight](#moonlight-effects) · [MoonModules](#moonmodules-effects) · [WLED](#wled-effects) · [FastLED](#fastled-effects) · [projectMM-native](#projectmm-native-effects) +Effects are built from the shared [power functions](power-functions.md) — the drawing, field and motion routines every effect composes; that page lists each one with its callers. -**Migrating an effect — behaviour is the spec.** A ported effect must reproduce the original's **exact** visual behaviour: end users have relied on these for years, so a port that looks different is a regression, not an improvement. Don't get creative with defaults, oscillator math, color mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result; pin it with unit + scenario tests; then write our **own** implementation against `EffectBase`/our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. +**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. @@ -249,6 +249,201 @@ Detail: [technical](moxygen/RubiksCubeEffect.md) [Tests](../../tests/unit-tests.md#rubikscubeeffect) + + +### Fireworks 🔬 · 2D + +Shells rise, stall, and burst into sparks that arc over and fall. Every stage is a particle-kernel call: spawn, gravity, angleEmit, drag, age. Nothing schedules the apex — the shell decelerates under gravity and bursts when its vertical velocity crosses zero, so a faster launch bursts higher without a second control. + +- `launchRate` — how often a new shell goes up. +- `launchSpeed` — how hard it is thrown, and so how high it bursts. +- `gravity` — how fast everything falls, per 60 Hz of simulated time. +- `sparks` — sparks per burst. +- `sparkLife` — how long a spark survives. +- `drag` — air resistance flattening the arc. +- `fade` — trail length (the Layer's decay, not the pool's). + +Physics is driven by elapsed time, not frame count, so the same settings behave identically on a desktop at thousands of fps and an ESP32 at a few hundred ([architecture § tick rate](../../architecture.md#effects)). + +Origin: projectMM original, on the WLED Particle System's firework family (@Brandon502 / WildCats08) + + + +### Ballpit 🔬 · 2D + +Falling balls that pile up and shove each other aside. The heap is emergent: gravity pulls, the floor stops, and contact between neighbours produces the shape. `tilt` turns the pit into a slope and the whole pile slides and re-settles. + +- `balls` — how many share the pit. +- `gravity` — how hard they fall. +- `size` — contact radius in pixels: how far apart balls sit when touching. +- `bounce` — restitution: how much speed a contact keeps. +- `tilt` — sideways force, turning the pit into a slope. +- `drag` — damping, so the heap settles instead of sloshing. + +Exercises the half of the particle kernel [Fireworks](#fireworks) leaves untouched: sparks never notice each other, these do. Collisions are the one non-linear part of the kernel, so the pool is deliberately small. + +Origin: projectMM original, on the WLED Particle System's ballpit family (@Brandon502 / WildCats08) + + + +### Dissolve 🔬 · 2D + +Two color fields trade places pixel by pixel in an order that looks random but is computed, so the transition needs no per-pixel state and no shuffled index list. Two devices rendering the same frame dissolve identically without exchanging anything. + +- `bpm` — how fast one transition completes. +- `spread` — how much of the transition pixels spend mid-flight; 0 gives a hard edge. +- `eased` — ease the progress instead of sweeping linearly. +- `scatter` — random order; off gives a positional wipe from the same code. + +Origin: projectMM original, on the classic dissolve transition in its position-addressed (shader) form + + + +### Echo 🔬 · 2D + +The previous frame fed back through a zoom and rotation, dimmed, with a bright source drawn on top — trails that spiral away from themselves, like a camera pointed at its own monitor. + +- `bpm` — how fast the source orbits. +- `zoom` — how much the feedback grows each frame. +- `rotate` — rotation per frame, which turns the trail into a spiral. +- `decay` — how fast the echo fades; higher is a shorter trail. +- `size` — radius of the bright source. + +Shows that feedback is not a primitive: once the grid can be read as a texture (`sampleWrap`), the whole family of trails, zoom blur and smear is a few lines. + +Origin: projectMM original, on video feedback and the standard texture-feedback shader shape + + + +### Spectrum 🔬📊 · 2D + +An audio analyser with real meter ballistics: bars rise fast enough to catch a transient and fall slowly enough to read, and a peak dot marks the recent maximum and drifts down. + +- `attack` — how fast a bar rises toward a new level. +- `release` — how fast it falls back. +- `peakDecay` — how fast the peak dot drifts down. +- `showPeaks` — draw the floating peak dots. +- `colorByColumn` — color per band instead of by height. + +The asymmetry is the whole point; a symmetric follower either misses the hit or flickers. + +Origin: projectMM original, on standard VU/PPM meter ballistics and WLED's GEQ band mapping + + + +### Truchet 🔬 · 2D + +A maze of interlocking arcs that never repeats, drawn without storing a single tile. Randomly-turned tiles with arcs at their edges join into continuous winding paths across the whole surface — the pattern looks designed, and nothing designed it. + +- `bpm` — how fast the pattern drifts. +- `scale` — tiles across the short side. +- `thickness` — how fat the arcs are. +- `softness` — edge softness: the anti-aliasing width. +- `shuffle` — reshuffles which way the tiles face. +- `drift` — slide the pattern instead of holding still. + +**The representative 2D shader**, and a better introduction to the form than [Raymarch](#raymarch): no 3D, no rays, no float, cheap on any target. It shows the three moves most shader effects are built from — folding space so one tile becomes hundreds (`repeat`), deciding each tile's orientation from its position alone (`hashInt`, so no array remembers it and two devices agree without exchanging anything), and turning a distance into a soft edge (`smoothstep`). + +Origin: projectMM original, on Sébastien Truchet's 1704 tiling and the standard shader fract/hash/smoothstep idiom + + + +### Tunnel 🔬 · 2D + +A texture mapped onto the inside of an infinite tube, so the viewer appears to fly down it forever. Nothing is 3D: the angle around the centre is one texture coordinate and the reciprocal of the distance is the other, which is perspective for the price of a divide. + +- `bpm` — how fast the tunnel flies past. +- `depth` — texture scale along the tunnel; higher is finer rings. +- `twist` — rotation per unit depth, so the tunnel corkscrews. +- `segments` — kaleidoscope the wall; 1 leaves it plain. +- `octaves` — wall texture detail, and the cost knob. +- `vignette` — darken toward the vanishing point so it reads as receding. + +Origin: projectMM original, on the standard demoscene tunnel + + + +### VectorBalls 🔬 · 2D + +A rotating 3D object drawn as shaded spheres — the demoscene classic that named the technique. The smallest complete demonstration of putting 3D on a panel: rotate, project, sort back-to-front, shade by distance, draw. + +- `bpm` — rotation speed. +- `size` — ball radius at the object's centre, in pixels. +- `spread` — how far apart the balls sit. +- `distance` — how far the object is from the viewer. +- `fade` — dim the far balls, which is what reads as depth. + +Painter's ordering matters more than it sounds: without it a far ball can paint over a near one and the object reads as turning inside out. Costs a few microseconds a frame at default settings — 14 points rather than a per-pixel loop, so it is the cheapest of the showcases. + +Origin: projectMM original, on the Amiga-era demoscene vector-ball effect + + + +### WaterRipple 🔬 · 2D + +A propagating wave simulation: drops land, their rings spread outward, reflect off the edges and interfere where they cross. The crossing is what a closed-form ripple cannot fake, because two rings meeting have to add and cancel. + +- `speed` — simulation steps per second: how fast the water itself moves, independent of the framerate. +- `dropRate` — how often drops land, in time rather than per frame. +- `damping` — how fast waves lose energy; higher is calmer water. +- `strength` — how hard a drop hits. +- `colorByHeight` — color the surface by height so crests and troughs read differently. +- `hueBase` / `hueSpread` — where in the palette the still surface sits, and how far a crest and a trough reach from it. + +Distinct from [Ripples](#ripples), which draws expanding rings from a closed-form radius: that one is cheaper and always looks like clean concentric circles, this one behaves like water. Costs two int16 buffers sized to the grid. + +Origin: projectMM original, on Hugo Elias's water surface algorithm + + + +### Raymarch 🔬 · 2D + +A lit 3D scene rendered by marching a ray through a distance field, one ray per pixel. Nothing draws a sphere: the scene is a function returning the distance to the nearest surface, and the spheres emerge because each ray stops where that function says a surface is. The lighting is derived too — the surface normal is the gradient of the distance field. + +- `bpm` — how fast the scene animates. +- `steps` — ray marching steps: the quality and cost knob. +- `blend` — how much the two spheres melt into each other. +- `cameraY` — camera height above the floor. +- `showFloor` — include the ground plane. + +**Compiled only where the SoC declares a hardware FPU** (`SOC_CPU_HAS_FPU`, which every ESP32 variant and the desktop satisfy). This is the one stated exception to the integer-only render-path rule, and it is gated rather than assumed. The cost is per *pixel*, not per chip — measured at 0.30 ms/frame for 32×32 on desktop, and 1.64 ms for 4096 lights on an ESP32-S3 while still holding 409 fps. What limits it is pixel count; `steps` trades quality for cost. Frames also stream over NetworkSend, so a desktop can drive a fixture that could never compute this locally. + +Origin: projectMM original, on Iñigo Quilez's raymarching and distance-function articles + + + +### PolarNoise 🔬 · 2D + +A warped noise field addressed by angle and radius, folded into a kaleidoscope. The field turns and breathes around the centre rather than scrolling past it. + +- `bpm` — how fast the field drifts. +- `scale` — noise cells across the grid: low is broad shapes, high is fine detail. +- `segments` — kaleidoscope wedges; 1 disables the fold. +- `warp` — domain-warp strength; 0 gives a plain field. +- `octaves` — fbm octaves, and the main cost knob. +- `twist` — how much the radius shears the angle, setting the spiral. + +Cost scales with `octaves` and `warp`: at `warp` > 0 and `octaves` 2 it is roughly 4 noise samples per pixel. On a large wall set `octaves` to 1 or `warp` to 0, which degrades to a plain polar noise that still reads well. + +Origin: projectMM original, after Stefan Petrick's polar/noise vocabulary and Iñigo Quilez's domain warping + + + +### SdfShapes 🔬 · 2D + +A circle and a box orbit and melt into each other, drawn as signed distance fields rather than rasterized outlines. One distance per pixel yields three looks at once: an anti-aliased fill, an outline (`|d| - width`), and a glow that falls off into the surrounding field. + +- `bpm` — orbit speed. +- `radius` — circle radius, as a fraction of the short side. +- `boxSize` — box half-extent, same scale. +- `blend` — melt radius; 0 unions the shapes hard. +- `outline` — 0 fills the shape; higher draws an outline of that width. +- `glow` — tint the field around the shape by distance. + +Measured on an ESP32-S3 at 128×128: 20 fps, 728 cycles/pixel using the true-distance form, alongside StarSky (692) and Metaballs (647) at the same size. + +Origin: projectMM original, after Iñigo Quilez's distance-function catalogue and polynomial smooth-minimum (iquilezles.org) + ### Solid 💫 · 3D @@ -692,4 +887,3 @@ Origin: MoonLight (Sinus, AI-generated) · via [MoonLight](https://github.com/Mo Detail: [technical](moxygen/SineEffect.md) [Tests](../../tests/unit-tests.md#sineeffect) - diff --git a/docs/moonmodules/light/modifiers.md b/docs/moonmodules/light/modifiers.md index 5e74b002..8b3fde2b 100644 --- a/docs/moonmodules/light/modifiers.md +++ b/docs/moonmodules/light/modifiers.md @@ -2,6 +2,8 @@ Every modifier, one block each: its preview, what it does, and what each control means — together. A modifier sits between an [effect](effects.md) and the output: it reshapes *where* pixels land (or masks them) without changing the effect's drawing. Modifiers compose — a [Layer](moxygen/Layer.md) folds its whole modifier stack each rebuild; a *dynamic* modifier (one that overrides `modifyLive`) also runs a per-frame pass. See [ModifierBase](moxygen/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](../../adr/0015-library-is-a-tag-not-a-folder.md).) +A modifier folds coordinates rather than drawing, so it reaches for very little of the shared [power function](power-functions.md) toolbox — that page states the split and lists which modifiers use what. + ## MoonLight modifiers diff --git a/docs/moonmodules/light/power-functions.md b/docs/moonmodules/light/power-functions.md new file mode 100644 index 00000000..19819ef6 --- /dev/null +++ b/docs/moonmodules/light/power-functions.md @@ -0,0 +1,252 @@ +# Power functions + +The shared toolbox the light domain is built from: a small set of named, integer-only routines that +[effects](effects.md) compose into a look. One home per idea — an effect that needs a distance, a +bar, a noise field or a smooth follower calls the same one every other effect calls, so behaviour is +consistent, the cost is measured once, and a fix reaches everything at once. + +Three consumers share this vocabulary, and each uses a different slice of it: + +- **[Effects](effects.md)** are the main consumer — they draw, so they reach for nearly all of it. +- **[Modifiers](modifiers.md)** fold coordinates through `modifyLogical` and never draw, so they + reach for almost none of it. That asymmetry is the architecture, not a gap: **an effect decides + what a pixel looks like, a modifier decides where a pixel comes from.** +- **[MoonLive](MoonLiveEffect.md)** scripts reach the same routines through the builtin table + (`core/moonlive/MoonLiveBuiltins.h`), which carries plain scalar arguments — so a script sees the + flat form of a function, not the C++ callback form a compiled effect can use. + +Sources: [draw.h](moxygen/draw.md) (drawing), `core/math16.h` (16-bit math), `core/noise.h` (fields). +The caller lists below are generated by reading the call sites, so they record what the code does +rather than what it intends. + +## Migrating an effect — two steps, in this order + +**Step 1, the port: behave identically.** Bringing an effect over from WLED or MoonLight reproduces the original's visual behaviour exactly, because the original is the best available description of what the effect should look like. At this stage a difference is a bug, not a variation — pin it with a golden so any drift is visible. Don't get creative with defaults, oscillator math, color mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result, then write our **own** implementation against `EffectBase` and our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. + +**Step 2, the tuning: change it deliberately.** Once the port is faithful it becomes ours to improve. Adopting a [power function](power-functions.md) often makes an effect look better as a side effect — bouncing balls that collide with each other because the physics is now the shared kernel's, a gradient that stops banding because the maths went 16-bit — and that is a real gain, not a regression. The rule is only that the change is deliberate and visible: say what moved, re-baseline the golden in the same commit, and let the product owner judge it on the panel. What is forbidden is drifting silently. + +## Used by nearly everything + +Not a category so much as the floor: three things almost every effect touches whatever else it does. If you read only one row of this page, read these — an effect that uses none of them is doing something unusual. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `colorFromPalette` | Reads a color out of the shared palette by index, so every effect follows the user's palette choice | **39 of 47** | — | +| `draw::pixel` | Writes one pixel, clipped to the grid | **24 of 47** — the rest reach it through a higher-level primitive | — | +| `BeatPhase` | A BPM phase accumulator that keeps its numerator in 64 bits and divides late, so animation never freezes on sub-millisecond frames | **13** — Dissolve, DistortionWaves, Echo, LavaLamp, Metaballs, Noise, Plasma, PolarNoise, SdfShapes, Sine, Spiral, Tunnel, Wave | — | + +
+ +## Frame and pixel operations + +**Whole-buffer work: writing, reading, and moving what is already there.** + +These act on the grid as a surface rather than on a shape. Between them they cover the four things an effect does to a frame before it draws anything: clear it, dim what was there (the trail), blur it, or shift it bodily. Most effects open with one of these and close with per-pixel writes. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `draw::fill` | Fills every light with one color, leaving channels beyond RGB untouched | AudioSpectrum, Blurz, RubiksCube, Solid, Spectrum, Text | — | +| `draw::fade` | Fades every channel toward black — the trail primitive | **13, through `Layer::fadeToBlackBy`** — Blurz, BouncingBalls, FixedRectangle, FreqSaws, GEQ, GEQ3D, Lissajous, NoiseMeter, PaintBrush, Random, SphereMove, StarField, StarSky | — | +| `draw::blur` | Separable box blur across every axis with extent > 1; one call covers 1D, 2D and 3D | Blurz | — | +| `draw::get` | Reads one pixel back, black outside the grid | Echo, GameOfLife | — | +| `draw::blendPixel` | Lerps a pixel toward a color by an amount, rather than replacing it | GameOfLife, Tetrix | — | +| `draw::addPixel` | Adds light to a pixel, saturating instead of wrapping to black | Blurz | — | +| `draw::scroll` | Shifts the whole grid along an axis, optionally wrapping — the shift register | FreqMatrix | — | +| `draw::splat` | Draws a point at a fractional position, splitting its light across neighbouring pixels so motion is smooth on a coarse grid | *(no caller yet — the particle kernel is its consumer)* | — | + +
+ +## Geometry + +**Drawing a shape by walking the pixels it covers.** + +The classical rasteriser: given endpoints, a centre and a radius, or a run length, light exactly the cells the shape passes through. Integer-only and exact, with no distance computed anywhere — which makes these the cheap way to draw when the shape sits on the grid and does not need to move smoothly between pixels. + +Contrast with signed distance fields below: same shapes, opposite approach, different trade-off. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `draw::line` | A straight line between two points (3D Bresenham) | GEQ3D, PaintBrush | — | +| `draw::bar` | A run of cells growing from an origin along one axis, colored per cell — the audio-meter staple | AudioSpectrum, GEQ, Spectrum | — | +| `draw::fillCircle` | A filled disc (midpoint algorithm), colored per row | Echo | — | +| `draw::text` | Draws a string, returning its pixel width (`glyph` is reached through it, not called directly) | DemoReel, Text | — | +| `draw::circle` | A circle outline on integer coordinates, exact and symmetric | *(no caller yet)* | — | +| `draw::rect`, `fillRect` | An axis-aligned rectangle, outlined or filled | *(no caller yet)* | — | +| `draw::lineAA` | An anti-aliased line (Wu 1991) that splits its light between the two cells straddling the true path | *(no caller yet)* | — | + +
+ +## Signed distance fields + +**Describing a shape as "how far away is it", then reading a picture out of that number.** + +Instead of drawing a circle, an SDF answers *how far is this pixel from the circle's edge* — negative inside, zero on it, positive outside. That one number does far more work than a rasteriser's yes/no: the sign fills the shape, its magnitude gives an anti-aliased edge for free, taking the absolute value turns it into an outline, and two distances combine into a third shape with a single `min` or `smin`. + +This is what makes shapes composable and smooth-moving. It costs a distance per pixel, so it is the right tool when a shape moves sub-pixel or merges with another, and the wrong one for a static bar. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `sdCircle` | True signed distance to a circle's edge: negative inside, zero on the rim, positive outside | SdfShapes | — | +| `sdBox` | Signed distance to an axis-aligned box | SdfShapes | — | +| `smin` | Smooth minimum of two distances — the operator that makes shapes flow together instead of merely overlapping | SdfShapes | — | +| `coverage` | Turns a distance into 0..255 coverage, which is anti-aliasing for free | SdfShapes | — | +| `sdCircleSq` | The squared form: same sign contract without the square root, for a plain fill | *(no caller yet)* | — | +| `sdSegment` | Signed distance to a thick line segment (a capsule) | *(no caller yet)* | — | + +
+ +## Fields + +**Smooth pseudo-random values across space: everything organic.** + +Noise is the source of anything that should look natural rather than drawn — clouds, fire, smoke, water, marbling, drifting colour. The defining property is that nearby points get similar values (unlike a raw hash), so the result flows instead of flickering. + +One sample is a soft blur; the character comes from composing them. Summing octaves adds structure at every scale, folding the field creases it into flame, and displacing the sample coordinate by another field is what produces the flowing, liquid look. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `inoise8` | Value noise in 1D, 2D or 3D: a smooth, deterministic pseudo-random field | Noise, Noise2D, NoiseMeter, Wave | — | +| `fbm8` | Sums noise octaves at doubling frequency and halving amplitude, turning a blur into cloud and terrain structure | PolarNoise, Tunnel | — | +| `warp8` | Samples noise at a coordinate that noise itself displaced — the flowing, marbled look | PolarNoise | — | +| `turbulence8` | Sums the folded absolute value of noise, whose creases read as billowing smoke and flame | *(no caller yet)* | — | +| `blobCentres` + `blobField` | Orbits N sources on sine paths and sums their inverse-square falloff — the metaball field behind anything fluid or molten | LavaLamp, Metaballs | — | + +
+ +## Polar and geometry math + +**Addressing the grid by angle and radius instead of by x and y.** + +Swapping coordinate systems is the cheapest way to change what an effect looks like. Anything radial — rings, spirals, rotation, kaleidoscopes, tunnels, radial wipes, a spectrum bent around a circle — is an ordinary pattern read through polar coordinates rather than a special algorithm. + +The 16-bit forms matter here: the 8-bit versions step visibly on a large fixture and their distance is an octagon rather than a circle. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `atan16` | The angle of a point as a 16-bit turn, smooth enough that a sweep shows no steps on a large fixture | PolarNoise, Spiral, Tunnel | — | +| `dist16` | True Euclidean radius — not the octagon `dist8` approximates, and it does not saturate at 255 | PolarNoise, Rings, Spiral, Tunnel | — | +| `kaleido` | Folds an angle into n mirrored wedges, giving any field n-fold symmetry for one modulo | PolarNoise, Tunnel | — | +| `isqrt` | Integer square root with no divide and no float | PaintBrush, WaterRipple | — | +| `sin8` / `cos8` | The 8-bit oscillators — the internal fast path where a mod-256 result is exactly right | *(the 16-bit forms are the contract)* | **Rotate** | + +
+ +## Time, motion and randomness + +**How a value changes between frames, and how to get randomness that behaves.** + +Two related problems. First, motion: raw linear movement reads as mechanical, so easings shape it, followers smooth it, and peak-hold gives a meter its characteristic instant-rise slow-fall. Second, randomness that is *reproducible* — addressed by position rather than drawn from a stream, so the same pixel gets the same value on every device and every frame. + +The framerate rule lives here too: everything in this group is driven by elapsed time, never by frame count ([architecture](../../architecture.md#effects)). + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `sin16` / `cos16` | 16-bit oscillators, smooth where the 8-bit forms visibly step on a large fixture | Echo, SdfShapes | — | +| `map32` | Maps a value between ranges, clamped, with the fencepost handled once so the last column is never lost | FreqMatrix, FreqSaws, GEQ, GEQ3D, Spectrum, StarField | — | +| `hashInt` | Hashes position and time to a random-looking but reproducible value, so devices agree without exchanging anything | Dissolve, WaterRipple | — | +| `peakHold` | Rises instantly to a new high then decays slowly — the falling peak dot every VU meter has | Spectrum | — | +| `smoothFollow` | Moves a value a fraction of the way toward its target each frame, so it stops jittering | Spectrum | — | +| `easeInOutQuad` | Accelerates from rest and decelerates to rest, so motion reads as deliberate rather than mechanical | Dissolve | — | +| `easeInOutCubic`, `easeOutQuad` | The same family with a longer settle, and a fast-start curve that arrives and rests | *(no caller yet)* | — | + +
+ +## Particles + +**Things that move under forces: sparks, rain, snow, smoke, confetti, debris, a swarm.** + +Anything that behaves like matter is the same handful of forces over the same state, and the part that differs between one look and another is *which* forces are applied and how particles are emitted — not the physics. So the state and the integrator live in [particles.h](../../../src/light/particles.h) and the character stays with the effect. + +Storage is structure-of-arrays over the caller's own buffers, so a pass that touches only velocity walks only velocity, and the pool never allocates after `prepare()`. Positions are the same sub-pixel type `splat` takes, so a particle at x=3.5 lands half on each pixel instead of snapping. + +Frame order matters and is the caller's to get right: forces, then `collide()`, then `step()`, then walls, then `age()`, then `render()`. Collisions run *before* the move because resolving an overlap afterwards can shove a particle through a wall the bounce pass already checked. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `Pool` | The state: SoA positions, velocities, life, hue and optional size over caller-owned buffers. Owns nothing, allocates nothing | Fireworks, Ballpit, Particles | — | +| `gravity`, `force`, `drag`, `attract` | The forces. Each is one pass over one array, so an effect pays only for the ones it uses | Fireworks, Ballpit | — | +| `forceSmall` | A force too weak to move an integer velocity, accumulated until it does — what makes a light breeze read as inertia rather than as nothing | *(no caller yet)* | — | +| `step` | Semi-implicit Euler: position integrates the already-updated velocity, which is what stays stable under a constant force | Fireworks, Ballpit, Particles | — | +| `bounce`, `wrap`, `killOutside` | What happens at the walls: reflect with restitution, re-enter the opposite edge (snow, rain, marquee), or simply stop existing | Fireworks, Ballpit, Particles | — | +| `collide` | Particles notice each other. The one non-linear part of the kernel, so it is opt-in | Ballpit | — | +| `spawn`, `angleEmit`, `spray` | Emitters: one particle, a directed cone, or an undirected scatter | Fireworks, Ballpit | — | +| `age`, `render` | Life counts down and brightness rides it, so a particle fades as it dies | Fireworks, Ballpit | — | +| `FrameTime` | Converts elapsed time into a per-frame scale, so the same settings behave identically at 60 fps and at 5000 | Fireworks, Ballpit, Particles, Echo, BouncingBalls, Lissajous, Tetrix | — | + +
+ +## Shaders + +**One function of (position, time) evaluated per pixel — the other way to write an effect.** + +Everything above draws *into* a grid: set this pixel, walk this line, move this row. A shader inverts that — it never draws anything, it answers a question. Given where a pixel is and what time it is, what colour is it? The framework runs that function everywhere. + +That inversion is why shaders compose so freely. There is no state to keep in step and no order of operations to get right, so an effect is built by transforming the *coordinate* before answering: fold space and one shape becomes a thousand, rotate it and the whole design turns, displace it by a noise field and everything flows. + +[shader.h](../../../src/light/shader.h) is the standard GLSL vocabulary in fixed point, deliberately using the familiar names so anyone who has read shader code needs no translation. It runs on every target. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `each` | The runner: supply one function of position and time, and it handles the loop, the coordinate mapping and the write | Truchet, Raymarch | — | +| `uv` | Pixel to shader space, centred and scaled by the SHORT side — which is what keeps a circle circular on a non-square panel | Truchet, Raymarch | — | +| `clamp`, `mix`, `fract`, `step`, `smoothstep` | The five built-ins in essentially every shader. `fract` is the one that tiles a pattern; `smoothstep` is the one that anti-aliases an edge | Truchet | — | +| `length`, `rotate` | Vector basics. Rotating the coordinate spins the entire design for one operation | Truchet | — | +| `repeat`, `mirror` | Domain operators: fold space so one shape becomes a lattice. The objects do not multiply — the coordinate does the work | Truchet | — | +| `opUnion`, `opIntersect`, `opSubtract`, `opShell`, `opRound` | Combine two shapes into a third, which is how an SDF scene is composed rather than drawn | Truchet | — | +| `sdRoundBox`, `sdPolygon` | Shapes beyond the circle/box/segment trio in [Signed distance fields](#signed-distance-fields) | *(no caller yet)* | — | +| `cosPalette`, `mixColor` | A whole colour ramp as twelve numbers instead of a table | *(no caller yet)* | — | + +
+ +### Raymarching — one technique inside a shader + +Raymarching is one technique a shader can use, for rendering 3D. A scene is described as a *function*: say how far the nearest surface is from any point, and the renderer walks a ray outward until it arrives. The world is arithmetic — geometry emerges from the distance function rather than being stored. + +[raymarch.h](../../../src/light/raymarch.h) is compiled only where the SoC declares a hardware FPU, because a raymarch is per-pixel float by nature. That gate is the one bounded exception to the integer-only render path, and it is a whole-header switch rather than a rule weakened in place. Everything in `shader.h` stays fixed point and runs everywhere. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `march` | Sphere tracing: walk a ray until it hits. Takes the scene as a callable, so that function *is* the world | Raymarch | — | +| `normalAt` | The surface normal as the gradient of the distance field — which is why lighting works on a shape that was never modelled | Raymarch | — | +| `sdSphere`, `sdBox`, `sdPlane`, `sdTorus` | 3D distance primitives, same sign contract as the 2D family | Raymarch | — | +| `smin`, `opUnion`, `opIntersect`, `opSubtract`, `opRepeat` | The 3D operators. `smin` melts surfaces together; `opRepeat` tiles space into an endless lattice | Raymarch | — | +| `Camera`, `diffuse` | Where the viewer stands and how a surface is lit — the parts every raymarch effect would otherwise re-derive | Raymarch | — | + +
+ +## Gather + +**Reading the grid back as a texture.** + +Everything else writes; this reads. Once a frame can be sampled at an arbitrary sub-pixel coordinate, a whole family follows from a few lines each: feedback and motion trails, zoom, rotation, tunnels, plasma warping. Without it every one of those needs its own bespoke loop. + +
+ +| Power function | What it does | Effects | Modifiers | +|---|---|---|---| +| `sampleWrap` | Reads the grid as a texture at a sub-pixel coordinate, bilinear and wrapping — the primitive behind feedback, tunnels and zoom | Echo | — | +| `combineMax` | Combines two colors by the brighter channel, so a trail brightens instead of averaging away | *(no caller yet)* | — | + +
+ +**On the "no caller yet" entries.** Each was added for a named consumer in the [power-function plan](../../backlog/power-functions-analysis-top-down.md): `splat`, `combineMax` and the remaining SDF and easing forms are what the particle kernel and the shader tier build on. They are listed rather than hidden so the gap between what exists and what is used stays visible. diff --git a/mkdocs.yml b/mkdocs.yml index fcc1fd4f..f4c5e971 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,9 +132,11 @@ nav: - Modifiers: moonmodules/light/modifiers.md - Drivers: moonmodules/light/drivers.md - Live scripting: moonmodules/light/MoonLiveEffect.md + - Power functions: moonmodules/light/power-functions.md - Supporting: moonmodules/light/supporting.md - Core: - System: moonmodules/core/system.md + - Control: moonmodules/core/control.md - Services: moonmodules/core/services.md - Supporting: moonmodules/core/supporting.md - Web UI: moonmodules/core/ui.md diff --git a/moondeck/run/run_desktop.py b/moondeck/run/run_desktop.py index 080d2743..d4b841f3 100644 --- a/moondeck/run/run_desktop.py +++ b/moondeck/run/run_desktop.py @@ -13,6 +13,7 @@ leaves the device running independently). """ +import argparse import os import platform import subprocess @@ -41,10 +42,28 @@ def _resolve_executable() -> Path: bdir / "projectMM.exe", bdir / "Release" / "projectMM.exe", bdir / "projectMM", + # A plain `cmake --build build` writes here rather than into the per-host dir, so this path + # is often the NEWER binary. Both are considered and the freshest wins below: picking the + # first that merely exists served a stale build whose changes appeared to be no-ops. + ROOT / "build" / "projectMM", + ROOT / "build" / "projectMM.exe", # the same root-build case on Windows ] + # Only this host's artefact shape is a candidate: a stale projectMM.exe left in a shared + # checkout must never be picked on macOS/Linux (and vice versa), however new it is. + want_exe = platform.system() == "Windows" + # Read each candidate's mtime in the SAME step that proves it exists: a rebuild running + # alongside this script can replace a binary between an exists() check and a later stat(), + # which would raise FileNotFoundError from inside max(). + stamped = [] for c in candidates: - if c.exists(): - return c + if (c.suffix == ".exe") != want_exe: + continue + try: + stamped.append((c.stat().st_mtime, c)) + except (FileNotFoundError, OSError): + continue # vanished or unreadable: simply not a candidate + if stamped: + return max(stamped)[1] # Return the most-likely candidate so the error message points somewhere # informative if the binary genuinely isn't there. return bdir / ("projectMM.exe" if sys.platform == "win32" else "projectMM") @@ -79,6 +98,17 @@ def _kill_running(): def main(): + ap = argparse.ArgumentParser(description="Run the desktop build in the background.") + # Ports below 1024 need root, so the default stays 8080. Port 80 exists for Home Assistant's + # WLED integration, which hardcodes port 80 and offers no way to specify another + # (`sudo uv run moondeck/run/run_desktop.py --port 80`). + ap.add_argument("--port", type=int, default=None, + help="HTTP port (default 8080; 80 needs root, for the Home Assistant WLED integration)") + args = ap.parse_args() + if args.port is not None and not (1 <= args.port <= 65535): + print(f"--port must be 1..65535, got {args.port}") + sys.exit(1) + if not EXECUTABLE.exists(): print(f"Executable not found: {EXECUTABLE}") print("Run build_desktop.py first.") @@ -116,7 +146,10 @@ def main(): else: popen_kwargs["start_new_session"] = True # own session, immune to our SIGTERM - proc = subprocess.Popen([str(EXECUTABLE)], **popen_kwargs) + cmd = [str(EXECUTABLE)] + if args.port is not None: + cmd += ["--port", str(args.port)] + proc = subprocess.Popen(cmd, **popen_kwargs) print(f"PID {proc.pid} — log: {log_path}") print("Press the Run button again to restart; the app keeps running otherwise.") diff --git a/src/core/AudioLevel.h b/src/core/AudioLevel.h index 6fa4c542..e489a5da 100644 --- a/src/core/AudioLevel.h +++ b/src/core/AudioLevel.h @@ -6,6 +6,8 @@ #include #include +#include "core/math16.h" // isqrt64 — the shared integer root + namespace mm { // Shared magnitude → 0..255 mapping on a LOGARITHMIC (decibel) scale — used by @@ -81,18 +83,8 @@ struct DcBlocker { // 24-bit value in an int32, then accumulate in 64-bit so a full block can't // overflow. -// 64-bit integer square root (Newton's method, converges in a handful of steps -// for our range). Free function so AudioBands.h can reuse it; the level path -// stays free of (it is otherwise all integer), so this is the one root. -inline uint64_t isqrt64(uint64_t x) { - if (x == 0) return 0; - uint64_t r = x, last; - do { - last = r; - r = (r + x / r) >> 1; - } while (r < last); - return last; -} +// The 64-bit integer square root this RMS path needs lives in core/math16.h beside the other +// integer roots (isqrt), so there is one implementation rather than two. // Analyse `n` samples into `frame.level` — the overall RMS loudness mapped // through the same log/dB window the bands use (magToByte), so the VU meter and diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 07fab165..b7e6232b 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -42,6 +42,16 @@ const char* controlTypeName(ControlType t) { return "unknown"; } +bool isPersistable(const ControlDescriptor& c) { + // A List defers to its source: rows re-derived at setup are not worth writing (see + // ListSource::persistsList). Every other type answers from the type alone. + if (c.type == ControlType::List) { + auto* src = static_cast(c.ptr); + if (src && !src->persistsList()) return false; + } + return isPersistable(c.type); +} + bool isPersistable(ControlType t) { // Display-only / device-derived types: no point saving — the next // tick1s overwrites them. diff --git a/src/core/Control.h b/src/core/Control.h index 5e2eacdc..d5614f65 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -187,6 +187,13 @@ struct ListSource { // the control system stays generic. Returns true if it took. virtual bool restoreList(const char* /*json*/, const char* /*key*/) { return false; } + // Is this list's VALUE worth writing to flash? False for a list whose rows are re-derived at + // setup from a source that is itself already persistent — a folder of files, the live module + // tree, the pin map. Persisting such a list writes a large array on every save that the loader + // then discards (restoreList returns false), which is flash wear for nothing. + // Default true, so a list that genuinely owns its rows keeps persisting unchanged. + virtual bool persistsList() const { return true; } + // --- Editable list (the CRUD extension) ----------------------------------------- // A ListSource that supports adding / removing / reordering / editing rows. This is // the editable-data-grid primitive (the write half of the same data-source/adapter @@ -202,6 +209,31 @@ struct ListSource { // maps the result onto an HTTP status. virtual bool isEditableList() const { return false; } + // Render the rows as a GRID OF PADS rather than a stacked list: one uniform button per row, + // labelled with the row's `name`, clicking it fires the row's `activate` field. + // + // For rows that are TRIGGERED far more often than they are edited, a list is the wrong shape: it + // costs a click to expand before the action is even visible, and it hides which row is currently + // active. A pad grid is what a MIDI deck uses for the same job, and it is the same affordance + // whether the rows are a handful of named presets or a dense field of numbered channels. + // + // Deliberately domain-neutral, and a PRESENTATION hint only — the rows, their ids and the + // edit/delete/reorder ops are unchanged, so a pad list is still a list and still editable. A + // source that opts in should: + // - emit `"name"` per row (the pad label; short — a number or a word, not a sentence), + // - mark the current row `"active":true` so the UI can highlight it, + // - accept an `activate` field in setListRowField (the click). + // Everything else about the row stays as it was. + virtual bool listAsPads() const { return false; } + + // The pad grid's shape. Non-zero means a FIXED surface: the UI renders cols x rows cells and + // places each row at its own `slot`, so an empty cell is a real position rather than an absence. + // That is what separates a control surface from a list drawn in columns — pad 14 is pad 14 + // whether or not anything is in it, and deleting pad 3 does not slide pad 4 into its place. + // Zero (the default) keeps the flowing layout: pads in row order, wrapping to the card width. + virtual uint8_t listGridCols() const { return 0; } + virtual uint8_t listGridRows() const { return 0; } + // Append a new row with default values; write the new row's stable id into `outId`. // Returns false if the list is full or otherwise refuses (e.g. a read-only source). virtual bool addListRow(uint32_t& /*outId*/) { return false; } @@ -260,6 +292,11 @@ struct ControlDescriptor { // renders number-only for the same reason — a GPIO is an identity, not a // magnitude; this extends that to non-Pin numerics without the Pin type's // pin-ownership-map claim.) + // Appended AFTER the other flags and before `validate`: the two addText-family initializers + // below are positional, so this field's place in the order is load-bearing. + bool fader = false; // Render as a vertical fader (see ControlList::setFader). Presentation only. + bool encoder = false; // Render as a rotary encoder (see ControlList::setEncoder). + const char* faderTarget = nullptr; // What the fader/encoder drives ("Drivers.brightness"), or null. // Optional per-control input validator (Text/Password only; nullptr = accept anything // that fits the buffer). applyControlValue calls it on the incoming string BEFORE the // write and returns ApplyResult::Malformed on reject, so the check covers EVERY write @@ -361,7 +398,8 @@ class ControlList { void addText(const char* name, char* var, uint16_t bufSize = 16, bool (*validate)(const char*) = nullptr) { grow(); - controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize, false, false, false, false, validate}; + controls_[count_++] = {.ptr = var, .name = name, .type = ControlType::Text, + .max = bufSize, .validate = validate}; } // Like addText but the UI renders a resizable multi-line