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..3edeb370 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 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/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/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..813a3752 --- /dev/null +++ b/docs/backlog/power-functions-analysis-bottom-up.md @@ -0,0 +1,190 @@ +# 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) + +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..5f4bbc8d --- /dev/null +++ b/docs/backlog/power-functions-analysis-top-down.md @@ -0,0 +1,193 @@ +# 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. + +## 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 **(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:** ① `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 builtin 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. +- **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 (proposal) + +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, `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(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 (proposal) + +- **`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 + +```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. + +The five converging effects and what each pins: 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 — stated, not built here) + +What the builtin surface needs from the engine, recorded for the livescripts work: + +1. Builtin 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 (stage 1) and example effects (stage 2) + +Order by leverage, cheapest risk first; every batch lands with its tests and the branch stays under ~100 files: + +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. +2. **Geometry** — `bar/rect` into the 4 audio meters; `scroll` into FreqMatrix; `splat` lands with its unit tests. +3. **`particles`** — the kernel + the five convergences, one effect per commit, bench-judged (PS-replaces-twin decision); the old private representations deleted. +4. **Fields + polar** — the shared blob oscillator (LavaLamp ≡ Metaballs) onto `sin16`+`splat`; Rings/Spiral onto `PolarLut`; `noise16` under Noise2D with a rescale note. +5. **Hidden-modifier extraction** — FreqSaws `invert` → MirrorModifier; audit the rest as they migrate (the effects-vs-modifiers decision). + +**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` *(desktop + small-fixture tier)* | **the ceiling clause made visible** — a raymarched 3D SDF scene (rotating smooth-min blobs, soft shadows, the Quilez canon) | the SDF *concepts* in 3D (raymarch loop is effect-local float, as any showcase may be); `cosPalette`; gated on a `hasHeavyCompute` platform constant (the `hasNetwork` pattern), and reachable on a 16×16 ESP32 panel too at 15,600 cycles/pixel — streamable to a real wall via NetworkSend | +| `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) is a palette-mapped field in `PolarNoiseEffect` alongside `fbm16`; `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. + +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. + +## 6. Resource accounting (the minimalism audit) + +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 (supersync-ready by construction) + +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.** Three 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 (2026-08-06) + +**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") — reachable, and closer than expected + +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 + +- **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**: one new scenario driving a particles effect through its controls (the live equivalent, same shape as the existing effect scenarios). +- **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 (new in this document) + +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 (deferred to implementation) + +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..b068ded5 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) — no issues opened, closed, or commented on all month.* + ## 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..4eca74db 100644 --- a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md +++ b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md @@ -4,6 +4,18 @@ What landed on [NightDriverStrip](https://github.com/PlummersSoftwareLLC/NightDr Summarised via the GitHub commits API (no local clone), so counts are all commits on `main`, not first-parent merges — the bullets filter out dependency bumps, whitespace, and pure refactors. The one release in the window, **v1.3.0**, was published 2026-01-10 but tagged from a late-November commit; it isn't a clean month boundary, so months are kept whole with the release noted as context. +## 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) The big one: **NightDriverStrip 2.0.0** shipped on June 14 — a major release cut from `main`. 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..d8bc3301 100644 --- a/docs/history/hpwit-I2SClocklessVirtualLedDriver.md +++ b/docs/history/hpwit-I2SClocklessVirtualLedDriver.md @@ -4,6 +4,12 @@ What landed on [hpwit/I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2S The library: Yves Bazin's (hpwit) "virtual pins" variant of the I2S clockless driver — drives far more strips than the chip has usable pins by fanning the I2S output through external shift registers. This multiplex technique is the load-bearing idea projectMM's LED-driver analysis singles out (factoring the shift-register multiplex out of the I2S/LCD peripheral code). Summarised via the GitHub commits API, read across all branches (`main`, `integration`, `int2`, `variable`, `hpwit-patch-1`), not just `main`. +## 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 No user-facing activity this month: no commits merged to `main` (latest commit on `main` dates to November 2024), no releases published, and no issues opened, closed, or updated. 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/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..9ee561d5 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "cca3fe8e", + "commit": "16bbeb54", "flash": { "esp32": 1678960, - "esp32p4-eth": 1503232, + "esp32p4-eth": 1503280, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1667216, + "esp32s3-n16r8": 1704256, "esp32s3-n8r8": 1666992, - "esp32s31": 1924992, - "desktop": 945752 + "esp32s31": 1932624, + "desktop": 1005960 }, "perf": { "desktop": { - "tick_us": 127, - "fps": 7874 + "tick_us": 132, + "fps": 7575 }, "esp32": { "tick_us": 4164, @@ -20,54 +20,54 @@ } }, "loc": { - "core": 14889, - "light": 20214, - "platform": 12526, - "ui": 5811, - "test": 35504, - "moondeck": 19968 + "core": 16155, + "light": 20273, + "platform": 12554, + "ui": 6467, + "test": 37181, + "moondeck": 20030 }, "comments": { "core": { - "lines": 5583, - "ratio": 0.409 + "lines": 6050, + "ratio": 0.408 }, "light": { - "lines": 7815, - "ratio": 0.427 + "lines": 7874, + "ratio": 0.429 }, "platform": { - "lines": 4198, - "ratio": 0.371 + "lines": 4205, + "ratio": 0.37 }, "ui": { - "lines": 1518, - "ratio": 0.278 + "lines": 1670, + "ratio": 0.274 }, "test": { - "lines": 6073, - "ratio": 0.198 + "lines": 6385, + "ratio": 0.199 }, "moondeck": { - "lines": 3187, + "lines": 3195, "ratio": 0.183 } }, "tests": { - "cases": 1008, + "cases": 1066, "scenarios": 22 }, "docs": { - "md_files": 170, - "md_lines": 22818, - "plans_files": 90, - "backlog_lines": 3114, + "md_files": 175, + "md_lines": 23552, + "plans_files": 91, + "backlog_lines": 3506, "lessons_lines": 418, "claude_md_lines": 135 }, "complexity": { - "functions": 2188, - "over_threshold": 140, + "functions": 2245, + "over_threshold": 144, "worst_ccn": 93 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 396c38d0..43ec8b9c 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 `16bbeb54`. 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) ⚠ | +| desktop | 982 KB (+0 KB) ⚠ | | esp32 | 1,640 KB | | esp32p4-eth | 1,468 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,628 KB | +| esp32s3-n16r8 | 1,664 KB (+2 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 | 132 µs (+7 µs) ⚠ | 7,575 (−425) ⚠ | | 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,155 (+51) ⚠ | 6,050 | 40.8 % | +| light | 20,273 (+26) ⚠ | 7,874 | 42.9 % (+0.1 %) ⚠ | +| platform | 12,554 | 4,205 | 37.0 % | +| ui | 6,467 | 1,670 | 27.4 % | +| test | 37,181 (+221) ⚠ | 6,385 | 19.9 % (+0.1 %) ⚠ | +| moondeck | 20,030 (+4) ⚠ | 3,195 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,008 (+1) ✓ | +| unit cases | 1,066 (+11) ✓ | | scenarios | 22 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,188 (+1) ✓ | -| over threshold | 140 (+1) ⚠ | +| functions | 2,245 (−1) ⚠ | +| over threshold | 144 | | worst CCN | 93 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 170 | -| markdown lines | 22,818 (+9) ⚠ | -| plan files | 90 | -| backlog lines | 3,114 | +| markdown files | 175 (+1) ⚠ | +| markdown lines | 23,552 (+180) ⚠ | +| plan files | 91 | +| backlog lines | 3,506 (+52) ⚠ | | 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..7734d33b --- /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, delete and reorder — so a preset added or removed through the File Manager appears once the module next rescans (a reboot, or any preset action 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. Every captured subtree is applied first and `prepareTree()` runs once at the end, rather than once per capture. 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/mkdocs.yml b/mkdocs.yml index fcc1fd4f..1425330f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - 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..b6221560 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,19 @@ 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 ] - for c in candidates: - if c.exists(): - return c + # 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" + existing = [c for c in candidates + if c.exists() and ((c.suffix == ".exe") == want_exe)] + if existing: + return max(existing, key=lambda c: c.stat().st_mtime) # 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 +89,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 +137,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/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