diff --git a/CMakeLists.txt b/CMakeLists.txt index faa90e5..4c59834 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,13 +55,21 @@ add_library(mm_core STATIC src/core/HttpServerModule.cpp src/core/FilesystemModule.cpp src/core/Scheduler.cpp + src/core/moonlive/MoonLive.cpp + src/core/moonlive/MoonLiveCompiler.cpp ) target_include_directories(mm_core PUBLIC src/) target_link_libraries(mm_core PUBLIC mm_platform) # `add_dependencies(mm_core ui_embed)` is below, after the ui_embed target is defined. -# Platform library (desktop) -add_library(mm_platform src/platform/desktop/platform_desktop.cpp) +# Platform library (desktop). moonlive_emit.cpp is the desktop MoonLive backend (host-ISA +# codegen) β€” it lives here because emitted machine code is platform/ISA-specific. +add_library(mm_platform + src/platform/desktop/platform_desktop.cpp + src/platform/desktop/moonlive_emit.cpp + src/platform/desktop/moonlive_asm_host.cpp + src/platform/desktop/moonlive_lower_host.cpp +) target_include_directories(mm_platform PUBLIC src/ src/platform/desktop/) # Winsock for the desktop socket surface on Windows. target_link_libraries(mm_platform PUBLIC $<$:ws2_32>) diff --git a/README.md b/README.md index 475ed46..ddc6dc6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, Windows, macOS or Linux desktop. One source tree, multiple targets. -![Web UI](docs/assets/screenshots/ui_overview.png) +![Web UI](docs/assets/screenshots/ui_theme.gif) πŸ‘‰ **Try it now:** flash an ESP32 straight from your browser β†’ β€” step-by-step in the [Getting started guide](docs/gettingstarted.md). diff --git a/docs/architecture.md b/docs/architecture.md index 87d63a0..e0edf39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,7 @@ Coding conventions live in [coding-standards.md](coding-standards.md); how to bu - [Effects](#effects) - [Dimensionality](#dimensionality) - [Robustness rules](#robustness-rules) + - [MoonLive: the live-script engine](#moonlive-the-live-script-engine) - [Modifiers](#modifiers) - [Mapping and blending](#mapping-and-blending) - [Drivers](#drivers) @@ -210,6 +211,7 @@ Only abstract what you actually need. Currently: - **Time**: `millis()`, `micros()`. Monotonic, microsecond resolution. (`esp_timer` / `std::chrono`) - **Memory**: `alloc(size)`, `free(ptr)`. Prefers PSRAM on ESP32, falls back to regular heap. `freeHeap()`, `maxAllocBlock()` for diagnostics. (`heap_caps_malloc` / `std::malloc`) +- **Executable memory**: `allocExec(size)` / `freeExec(ptr, size)` allocate memory the CPU can *fetch and execute* from, and `writeExec(dst, src, len)` copies emitted machine code into it safely. Used by the MoonLive live-script engine (below) to place the native code it compiles. All the W^X / instruction-cache quirks live behind these three functions: ESP32 IRAM via `MALLOC_CAP_EXEC` with 32-bit-aligned stores plus a cache sync so the core fetches fresh code; an `mmap` `PROT_EXEC` page on desktop (macOS-arm64 `MAP_JIT` + a write-protect toggle). (`heap_caps_malloc(MALLOC_CAP_EXEC)` / `mmap`) - **Networking**: `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeSome` is a non-blocking partial write (returns bytes written, 0 = would-block) so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets) - **Scheduling**: `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `delayUs(us)` (microsecond busy-wait, only for sub-millisecond hardware timing a driver owns β€” e.g. the WS2812 β‰₯300 Β΅s inter-frame latch in `RmtLedDriver`; never for general pacing, which uses the non-blocking `millis()` gate), `reboot()`. (`vTaskDelay` / `esp_rom_delay_us` / `esp_restart` on ESP32; `std::this_thread::sleep_for` / `std::exit` on desktop) - **Platform config**: `platform_config.h` per platform: compile-time constants like `hasPsram` and `hasWiFi`. Each platform provides its own version; `types.h` includes it without `#ifdef`. Core code branches on these via `if constexpr` (e.g. NetworkModule drops its WiFi cascade when `hasWiFi` is false), so the dead branch is removed from the binary with no `#ifdef` outside `src/platform/`. @@ -353,9 +355,9 @@ A **Layer** (a MoonModule, child of Layers) owns: A layer can have **multiple effects**. Each effect writes to the buffer sequentially in its listed order, overwriting or adding to the previous β€” so the effects stack (a base-colour effect followed by a sparkle effect). -A layer applies its **first enabled modifier** during LUT build (`Layer::rebuildLUT`). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). Applying several modifiers in sequence (chaining) is on the [backlog](backlog/README.md). +A layer applies **all its enabled modifiers as a chain** during the mapping build (`Layer::rebuildLUT`): each modifier is a coordinate fold, and they compose in child order (Mβ‚βˆ˜Mβ‚‚βˆ˜β€¦). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). The fold contract (the three hooks, the physicalβ†’logical build, the live pass) is documented in [ModifierBase](moonmodules/light/ModifierBase.md). -Each layer references the shared Layouts. The layer builds its own LUT by iterating the Layouts container's coordinates and applying its static modifiers in order. Different layers in Layers can have different modifiers, producing different LUTs from the same Layouts. +Each layer references the shared Layouts. The layer builds its mapping by walking the Layouts container's **physical** coordinates and folding each through the static modifier chain to its logical cell β€” N physical lights folding onto one logical cell is the fan-out (a Multiply kaleidoscope), so the build never produces a fan-out overflow. Different layers in Layers can have different modifiers, producing different mappings from the same Layouts. ## Effects @@ -398,14 +400,26 @@ See NoiseEffect / MetaballsEffect for the canonical pattern. Animation speed mus **An effect renders a pattern; it does not transform geometry.** When migrating or adding an effect, strip out anything that is really a *modifier* β€” mirroring, tiling, rotation, scrolling/offset, a kaleidoscope fold, masking, any remap of *where* pixels land β€” and add it as a separate [modifier](#modifiers) instead. WLED (and other sources we port from) routinely fold these into the effect's own loop (a "mirror" checkbox, a "2D" rotation, a built-in pinwheel), because WLED has no modifier concept; we do. Keeping them out of the effect is what lets any effect compose with any modifier (the same RotateModifier rotates Fire, Noise, or a network-received frame) instead of every effect re-implementing its own half-baked mirror. The test: an effect's `loop()` should only *write colours into the logical buffer for its own coordinates*; if it's reading or rewriting positions to move/fold/duplicate the image, that behaviour belongs in a modifier. (This is the light-domain face of *Complexity lives in core; domain modules stay simple* β€” geometry transforms are the modifier's job, shared once, not duplicated into every effect.) +## MoonLive: the live-script engine + +MoonLive lets you author an effect (later: a layout, modifier, driver, or core rule) as **text** and run it on a running device, with no recompile-and-flash cycle. Its standout property is *how* it runs the script: not a bytecode interpreter, but a **native-codegen compiler** β€” source text is lexed, parsed, lowered to a typed IR, and assembled to real machine code that the render loop calls through a plain function pointer, so a scripted effect runs at near-hand-written speed in the hot path. This is the core construct; a scripted effect (`MoonLiveEffect`) is the thin binding that gives it the MoonModule lifecycle. + +The engine is a **domain-neutral core** with one narrow seam, structured as three tiers so adding a CPU is additive, never a rewrite: + +- **Front-end** (`src/core/moonlive/`, platform-independent): a recursive-descent lexer + parser over an expression grammar (every function argument is a literal or a nested call) that lowers each statement to a typed **IR** β€” a flat list of three-address ops over virtual registers. The IR is the seam: it knows *operations*, never an ISA and never a domain. It is compile-time only β€” consumed during lowering and discarded, so it costs nothing at run time; the CPU executes only the final native instructions. +- **Host builtin table** (the domain seam): the core owns no function names. A *host* registers `{name β†’ descriptor}` β€” `setRGB`/`fill`/`random16` for LEDs (`src/light/moonlive/`), something else for a display or sensor. A descriptor is either a `Call` (a generic call to a host C function pointer β€” a pure helper like `random16`) or an `Inline` op (a neutral opcode tag the backend emits inline β€” a buffer writer, no per-pixel call). This is the [ESPLiveScript / ARTI bound-function model](backlog/livescripts-analysis-top-down.md); it is what keeps the core LED-free while the hot path stays inline. The LED *names* and the "an element is 3 RGB bytes" meaning live only in the light-domain registration and the per-ISA lowering, never in core. +- **Per-ISA backend** (`src/platform/`, behind the boundary): a tiny named-instruction MacroAssembler (the textbook V8 / LLVM / asmjit shape β€” append one instruction, back-patch label offsets) plus the IRβ†’bytes lowering that drives it. Xtensa (classic ESP32 / S3), RISC-V (P4), and the host ISA (desktop arm64/x86-64) each are *a new backend file behind the unchanged IR* β€” the front-end and IR never branch on ISA. Emitted code goes into an `allocExec` block (see [Β§ Platform abstraction](#platform-abstraction)) and is called each tick. + +A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `onBuildState()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark β€” robust to any input. The full design (the staged language ladder, the safety model, the performance budget, the memory-arena plan as the language grows) lives in [docs/backlog/livescripts-analysis-top-down.md](backlog/livescripts-analysis-top-down.md); the module contract is [docs/moonmodules/light/moonlive/MoonLiveEffect.md](moonmodules/light/moonlive/MoonLiveEffect.md). + ## Modifiers -A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface: the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). +A modifier (MoonModule) lives inside a layer alongside its effects. Modifiers expose a virtual interface: the Layer calls modifier methods without knowing the concrete type (no `dynamic_cast`). A layer applies **all** its enabled modifiers as a chain, in child order β€” each a coordinate fold composed into one mapping (see [Β§ Layers and Layer](#layers-and-layer)). -A modifier can: +A modifier is a coordinate transform, applied in one of two ways (the fold contract is in [ModifierBase](moonmodules/light/ModifierBase.md)): -- Transform the mapping LUT via `transformCoord()`: rebuilt on the cold path, zero render cost. -- Transform light values via `transformLights()` on the hot path: per-light cost, enables dynamic animations like rotation. +- **Static** (`modifyLogicalSize` + `modifyLogical`): folded into the mapping during the cold-path build, so it costs nothing per frame (Region crop, Multiply tile/mirror, a mask). +- **Live** (`modifyLive`): a per-frame coordinate remap for animation (rotation), run only when an enabled modifier needs it β€” a static-only chain pays nothing. **Dimensionality** for modifiers defaults to `Dim::D3` (assumed to work in all three axes unless declared otherwise). Unlike for effects, this is purely advisory: the Layer doesn't extrude modifier output. It exists so the UI can render the πŸ“/🟦/🧊 chip on the card. **MultiplyModifier** is D3 (it has independent multiplyX/Y/Z + mirrorX/Y/Z toggles). @@ -522,7 +536,7 @@ The light domain plugs into the UI at three points: a fixed top-level tree (Layo ## What we leave undesigned -Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item is a committed design that simply isn't coded yet (multi-layer composition, two-core handover, time sync); the items here are ones where the *design itself* isn't settled, deferred until a concrete need forces the decision: +Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item has a settled, committed design (two-core handover, clock sync, device-to-device light distribution) β€” code is written toward it; the items here are ones where the *design itself* is still open, deferred until a concrete need forces the decision: - **WiFi runtime disable**: today the eth-only build profile compiles WiFi out. Whether runtime gating 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 one is. - **Mixing light types in one Layouts**: each layout child describes one light type (all LED strips, or all par lights). Whether a single Layouts container should hold mixed types (LED strips + par lights together), and how the channel layout would reconcile across them, isn't designed; one Layouts per light type is the current model. diff --git a/docs/assets/screenshots/ui_light.png b/docs/assets/screenshots/ui_light.png new file mode 100644 index 0000000..f088a8a Binary files /dev/null and b/docs/assets/screenshots/ui_light.png differ diff --git a/docs/assets/screenshots/ui_overview.png b/docs/assets/screenshots/ui_overview.png index bb56a6e..9941b8d 100644 Binary files a/docs/assets/screenshots/ui_overview.png and b/docs/assets/screenshots/ui_overview.png differ diff --git a/docs/assets/screenshots/ui_theme.gif b/docs/assets/screenshots/ui_theme.gif new file mode 100644 index 0000000..3c7dc5c Binary files /dev/null and b/docs/assets/screenshots/ui_theme.gif differ diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 825f90d..11d7216 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -38,7 +38,7 @@ A map of everything in the three files, by theme. ### Mixed ([backlog-mixed.md](backlog-mixed.md)) -- MultiplyModifier mapping-LUT memory at large grids; composed modifiers (chain the whole stack, not just the first); intermittent ~0.5 s RMT LED pauses; NoiseEffect simplex cost on ESP32. +- MultiplyModifier mapping-LUT memory at large grids; intermittent ~0.5 s RMT LED pauses; NoiseEffect simplex cost on ESP32. ## In-flight draft specs diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 4473c32..2d1f34b 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -336,7 +336,7 @@ Forward-looking companion to the shipped UI spec, [moonmodules/core/ui.md](../mo These don't block the shipped baseline but should be answered before 1.0: - **Multi-layer UI** β€” [architecture.md](../architecture.md) plans for N layers blended into one Drivers. The current card layout shows one Layer. Likely needs a tab/accordion to switch layers, or a per-layer column. -- **Modifier chain visualization** β€” show the modifier order visually. Today they're a flat list, and only the **first enabled** modifier actually applies (the `children[]` order is *not* yet an apply order β€” see [Composed modifiers](backlog-mixed.md#composed-modifiers--chain-the-whole-modifier-stack-not-just-the-first-planned-multi-commit)). This viz item only becomes meaningful *after* composed modifiers land; until then a chain UI would imply a stacking the engine doesn't do. +- **Modifier chain visualization** β€” show the modifier order visually. They're a flat list today, but the `children[]` order **is** the apply order now (modifiers compose as a chain, Mβ‚βˆ˜Mβ‚‚βˆ˜β€¦), so a visual that conveys the stacking (and that order matters) would help users reason about a multi-modifier layer. - **Presets** β€” save/load named bundles of control values. Persistence already stores them; needs a UI surface. - **Canvas/node-graph view** β€” v2 attempted this. Powerful for complex setups but doubles the UI surface. A reasonable v3 follow-up gated on user demand. diff --git a/docs/backlog/backlog-mixed.md b/docs/backlog/backlog-mixed.md index a9e5b4d..c477d65 100644 --- a/docs/backlog/backlog-mixed.md +++ b/docs/backlog/backlog-mixed.md @@ -6,27 +6,10 @@ Forward-looking items whose work genuinely spans **both** the core and light dom ### MultiplyModifier mapping-LUT memory at large grids (investigation, re-verify on classic) -`scenario_perf_full` on the S3 (2026-06-17) measured the MultiplyModifier's cost across grid sizes. The finding, stated correctly: the modifier **reduces compute** (with the default 2Γ—2 kaleidoscope the effect renders only the ΒΌ-size logical quadrant β€” Noise+Multiply at 16K is 29,647Β΅s vs 50,555Β΅s for Noise alone), and its real cost is **memory** β€” the 1:N fan-out mapping LUT. Measured modifier heap cost on the S3: 16Β²β†’1.7KB, 32Β²β†’10.8KB, 64Β²β†’23.5KB, **128Β²(16K)β†’93KB** (the LUT destinations array; `nrOfLightsType` is `uint32_t` on a PSRAM board). On the S3's 8MB PSRAM this is trivial. [Composed modifiers](#composed-modifiers--chain-the-whole-modifier-stack-not-just-the-first-planned-multi-commit) would multiply this memory cost by the chain depth β€” size it there. +`scenario_perf_full` on the S3 (2026-06-17) measured the MultiplyModifier's cost across grid sizes. The finding, stated correctly: the modifier **reduces compute** (with the default 2Γ—2 kaleidoscope the effect renders only the ΒΌ-size logical quadrant β€” Noise+Multiply at 16K is 29,647Β΅s vs 50,555Β΅s for Noise alone), and its real cost is **memory** β€” the mapping LUT's destinations array. Measured modifier heap cost on the S3: 16Β²β†’1.7KB, 32Β²β†’10.8KB, 64Β²β†’23.5KB, **128Β²(16K)β†’93KB** (`nrOfLightsType` is `uint32_t` on a PSRAM board). On the S3's 8MB PSRAM this is trivial. Under the physicalβ†’logical fold build each physical light contributes ≀1 destination, so the destinations array is bounded by the real light count regardless of chain depth β€” there is no build-time fan-out. **This is NOT a no-PSRAM blocker** β€” 16K Noise + Multiply has run on a classic ESP32 (no PSRAM, 320KB internal) before at **10–20 FPS** (WiFi vs Ethernet), sending frames out over **ArtNet to a display, not physical LED drivers**. It works there because classic's `nrOfLightsType` is `uint16_t` (half the LUT size) and the modifier shrinks the logical render grid. So the action is **re-verify the working classic setup when a classic board is connected** (find the config β€” grid, mirror, ArtNet target β€” that reproduces the historical 10–20 FPS), not "fix an impossibility." Worth investigating only if that re-verification shows the LUT memory has regressed since: the destinations array is the obvious lever (it stores a `nrOfLightsType` per physical destination; a 2Γ— kaleidoscope is 1:1 in *count* so the LUT need not store fan-out > the physical count β€” confirm it isn't over-allocating to `maxMultiplier()` when the effective fan-out is 1). Capture the classic numbers into performance.md's multi-board table first. -### Composed modifiers β€” chain the whole modifier stack, not just the first (planned, multi-commit) - -**Confirmed scope, not an open question:** multiple modifiers per Layer applied as a stack was always the plan, and it ships in **MoonLight** (Mirror, Rotate, Transpose, Kaleidoscope, … all composable on one layer β€” see [moonlight-inventory.md](../history/moonlight-inventory.md)). projectMM's single-modifier behaviour is the not-yet-finished state, not a design choice. - -Today a Layer applies **only the first enabled modifier**. `Layer::rebuildLUT()` finds the first enabled `Modifier` child and `break`s ([Layer.h](../../src/light/layers/Layer.h) `rebuildLUT`), and `Layer::loop()` ticks only that one (with an explicit comment that ticking a later one would desync the LUT, since a dynamic modifier's `loop()` can drive a rebuild the LUT must reflect). So with two modifiers on a Layer the second is dead weight β€” dragging it above the first is the only way to make it the active one. The intended behaviour is **modifier order = apply order**: a stack where each modifier reshapes the result of the one below ("modifiers on modifiers"), e.g. Multiply (kaleidoscope) *then* Rotate the kaleidoscoped result. The [modifier-chain-viz UI item](backlog-core.md#open-design-questions) is the surface for it and only becomes meaningful once this lands. - -**Mechanism β€” follow MoonLight's proven model, our own code** ([*Industry standards, our own code*](../../CLAUDE.md#principles)). MoonLight composes by streaming the layout's coordinates through each modifier's `modifyLayout`/`modifyLight` in order while the mapping table is built, so the *final* table already encodes the whole chain β€” the per-frame hot path stays a single lookup. We do the same with our pieces: `rebuildLUT()` walks the layout's coordinate stream (`Layouts::forEachCoord`) and passes each coordinate through modifier 1, then 2, …, then *n* before recording the destination, so the built `MappingLUT` is the composition `M₁ ∘ Mβ‚‚ ∘ … ∘ Mβ‚™` collapsed to one `logicalβ†’driver` table. Composition is a **cold-path, build-time** concern; modifiers stay simple (each still answers `logicalDimensions()` + its own per-coordinate transform), so the complexity lives in the core per *[Complexity lives in core](../../CLAUDE.md#principles)*. Worth studying MoonLight's `PhysMap` 1:0/1:1/1:N packing (inventory Β§1) when sizing the table β€” a deep chain with fan-out is exactly where the per-entry byte cost matters. - -Why it's not a one-liner: - -- **Build path** β€” `rebuildLUT()` must iterate *all* enabled modifiers bottom-up, threading each stage's logical dimensions into the next, and fold the per-stage transforms into one final LUT. The single-modifier `maxDest` / fan-out ceiling math (the `maxMultiplier()` clamp that fixed the multiplyZ overflow) has to generalise to a **product** of multipliers across the chain β€” the dominant new correctness risk (and the memory blow-up noted in the MultiplyModifier-LUT item above: a 2-deep 2Γ— chain is up to 4Γ— the destinations). -- **Tick path** β€” a dynamic modifier (RandomMapModifier, RotateModifier) calls back into `Layer::onBuildState()` on its timer to rebuild the LUT. With a chain, *any* dynamic stage rebuilding must recompose the *whole* chain, and `loop()` must tick every enabled modifier (not `break` after the first) in the right order, after the effect pass. -- **Degrade path** β€” the per-stage OOM degrade (`degradeIdentity`) must decide what "degrade" means mid-chain (drop the offending stage? collapse to identity?) without leaving a stale partial LUT. -- **Tests** β€” `unit_Layers_container` / the modifier unit tests pin single-modifier behaviour; composed-order needs new cases (A∘B β‰  B∘A, a disabled middle stage is skipped not collapsed, the fan-out product ceiling holds at no-PSRAM `uint16_t`), plus a scenario that reorders a 2-modifier stack and asserts the composite changes. - -**Estimate: medium β€” roughly 4–6 commits.** (1) design note pinning the coordinate-stream composition model + the fan-out-product ceiling rule (reference the MoonLight inventory); (2) `MappingLUT` compose/fold primitive + unit tests in isolation; (3) `rebuildLUT()` chain iteration + `loop()` tick-all-in-order, behind the existing single-modifier tests staying green; (4) degrade-path decision + tests; (5) reorder scenario + `performance.md` memory capture at depth 2–3; (6) UI follow-up (the modifier-chain-viz item β€” see the correction noted there). Gate the depth: most setups are 1 modifier, so the chain path must cost nothing when `n == 1` (the current fast path stays the `n == 1` branch). - ### Intermittent ~0.5 s LED pauses with the RMT driver (pending investigation) Observed on the bench (2026-06): LED output running on the RMT driver occasionally freezes for about half a second. Postponed by the product owner until more observations exist. Ranked suspects from the initial analysis, each with a cheap experiment: diff --git a/docs/backlog/livescripts-analysis-top-down.md b/docs/backlog/livescripts-analysis-top-down.md index e729070..49aa3cf 100644 --- a/docs/backlog/livescripts-analysis-top-down.md +++ b/docs/backlog/livescripts-analysis-top-down.md @@ -185,6 +185,16 @@ Memory placement routes through the existing `platform::` seam, so it's one poli - **Graceful degradation when full.** When the next script won't fit, the device does what the light pipeline already does at the memory edge ([architecture.md Β§ scaling to available memory](../architecture.md#scaling-to-available-memory)): the compile/bind fails cleanly, the module reports a "not enough memory" status, and everything already running keeps running β€” no crash, no reboot (the robustness + no-reboot principles). The cap is reached by *degrading*, never by bricking. - **The hot-path cost is per-*running* script, not per-*loaded* script.** Memory scales with how many scripts are loaded; tick time scales with how many are *enabled and rendering*. A device can hold a large library of scripts in PSRAM and run only the active ones, so "infinitely scalable in memory" doesn't mean "infinitely slow" β€” a disabled scripted module costs RAM but no tick time (and the disable-releases-resources backlog item, when it lands, lets it cost neither). +**Memory-scaling status + the one refactor ahead (watch-item as the language grows).** A memory review at the 3-builtin stage (`setRGB`/`fill`/`random16`) pinned where the per-effect cost lives and what scales with language richness β€” recorded here so the optimisation is planned, not discovered: + +- **Already in place (don't redo):** the exec block is allocated at the *emitted length* (word-rounded), not a worst-case cap β€” a `fill` is ~50–70 B of native code, a four-call `setRGB` ~400 B, so per-effect heap scales with the script, not a flat reservation. And the effect reports `setDynamicBytes(codeLen())`, so the UI card's "+ dynamic" reflects the JIT'd program (0 when a compile fails and frees it). These two are the right shape for everything below. +- **Flat regardless of language size:** the engine's at-rest members (~48 B/instance) and each `Builtin` descriptor (32 B). A full math/colour library is ~30–50 builtins β†’ a ~1.5 KB table that should become a **`static const` built once** (today `lightBuiltins()` returns a ~520 B table by value per `onBuildState` β€” fine at 3 entries, wasteful at 50; make it static when the library grows). +- **Scales with script complexity (the exec block, heap, per effect):** today's one-liners are 50–400 B; a Ripples-class effect (per-pixel `sqrt`/`sin`, 3D, a fade loop, two controls) is a few hundred instructions β†’ an estimated **1.5–4 KB of native code**, comparable to the equivalent compiled effect (the point of native). PSRAM-first `allocExec` (Β§ above) absorbs this. +- **The one architectural change ahead β€” transient compile buffers must move stack β†’ reusable heap arena.** Today `compile()` puts the staging buffer (`kCodeCap`), the `IrProgram` (`kMaxIrOps=64` ops Γ— 32 B β‰ˆ 2 KB), and the assembler buffer on the **stack** (~4 KB peak, off the hot path, in `onBuildState`). This is fine for the current grammar but **will not hold once scripts have loops + real expressions**: a Ripples body easily exceeds 64 IR ops, and growing `kMaxIrOps` to 256–512 makes the IR alone 8–16 KB β€” too large for an MCU task stack. The planned fix (matches "code+data arenas" above): a **single heap arena the compiler borrows during `onBuildState` and releases**, sized once and reused across recompiles, instead of stack arrays. Introduce it **when the op count first outgrows the stack-safe range β€” likely Stage 3 (math + loops)**, not before; it's a contained change (the compiler's buffers, not the IR or front-end) and the `allocExec(len)` direction already set the precedent. +- **New at full language β€” a per-script *data* arena.** Scripts are stateless pure functions today (no per-instance data beyond the source text). Once a script declares controls (`uint8_t speed = 50;`) or state (`static float lut[256];`), each effect needs a **data arena** (`platform::alloc`, PSRAM-first) alongside its code block β€” a few hundred bytes to a few KB per effect, sized per script. This is the second arena Β§3.7 already names; it lands with Stage 1 (controls) and grows through the oscillator/LUT stages. + +Net: no resource cliff β€” at-rest per-effect cost stays PSRAM-friendly (code + data arenas, a few KB each) β€” but **one deliberate refactor is queued** (stack compile-buffers β†’ shared reused arena), best landed exactly when a real script body first needs it. + ### 3.8 Execution model β€” inline by default, task as the exception (decision: sync) **A script runs inline in the `Scheduler` tick by default β€” not in its own task.** A scripted effect's `loop()` is called exactly like a compiled effect's `loop()`, on the render task, each tick. The task-per-script model some engines use fits when a script *is* the top-level loop and owns the device; in projectMM a scripted module is one `MoonModule` among many, called from the same single-threaded render loop as every compiled module, so inline is the consistent shape. Three reasons make inline the default, not just a choice: diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 0b057d4..fe7077e 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -5,7 +5,7 @@ straight from your web browser β€” no software to download, no command line. In few minutes you'll have lights running and the device on your network, and the device's own web interface open in your browser ready to play with. -This guide has two chapters. **Chapter 1** gets projectMM onto your board. +This guide has two chapters. **Chapter 1** gets projectMM onto your device. **Chapter 2** is a tour of the interface you land in afterwards, so you know what every part does and where to start building your own light show. @@ -32,26 +32,26 @@ Chrome or Edge, then plug your ESP32 into a USB port. Click **USB Port β†’ Pick a port…**. Your browser shows a small list of connected devices β€” choose the one that appeared when you plugged in the ESP32. (Not sure -which? Unplug, look at the list, plug back in β€” the new entry is your board.) +which? Unplug, look at the list, plug back in β€” the new entry is your device.) ![Selecting the USB port](assets/gettingstarted/01-02-select-port.png) Once a port is chosen, the installer recognises the chip and tells you how many -boards match it, so you know you're on the right track before you pick one. +devices match it, so you know you're on the right track before you pick one. ![Port selected, chip detected](assets/gettingstarted/01-03-port-selected.png) ## 3. Pick your device -Choose your board from the **Device** picker. Each card shows a picture, the -chip, and what the board can do (LEDs, WiFi, a button, a microphone…); click +Choose your device from the **Device** picker. Each card shows a picture, the +chip, and what the device can do (LEDs, WiFi, a button, a microphone…); click **details** on any card to see exactly what it is and a link to its product page. ![Picking a device](assets/gettingstarted/01-04-pick-device.png) ![A device card with its details](assets/gettingstarted/01-05-device-details.png) -The little coloured pills are the board's capabilities, and the colour tells you +The little coloured pills are the device's capabilities, and the colour tells you how ready each one is: - 🟒 **Green** β€” set up and working the moment you install. This capability is @@ -88,7 +88,7 @@ it takes under a minute. ## 5. Get it on your network -What happens next depends on your board: +What happens next depends on your device: - **WiFi:** enter your network name and password when prompted, then **Connect**. (Click **Skip** to set WiFi up later from the device itself.) @@ -104,7 +104,7 @@ network. Click it. ![Device is online over WiFi](assets/gettingstarted/01-10-online-wifi.png) -You'll see this same "Device is online!" box however your board connected β€” over +You'll see this same "Device is online!" box however your device connected β€” over Ethernet, or when it rejoins a network it already knows: ![Online over Ethernet](assets/gettingstarted/01-11-online-ethernet.png) @@ -174,10 +174,10 @@ The top of the list is your device's "about" section β€” read-outs and connectio settings. You rarely need to touch these, but they're the first place to look if something seems off. -**System** β€” who this device is and how it's doing: its name, the board model, +**System** β€” who this device is and how it's doing: its name, the device model, uptime, frame rate, and live memory / storage bars. You may also see an **Audio** -module here β€” boards with a built-in mic come with it set up for you, and on any -board you can add it yourself (it's how sound-reactive effects hear the music). +module here β€” devices with a built-in mic come with it set up for you, and on any +device you can add it yourself (it's how sound-reactive effects hear the music). Audio is just the first of many: any sensor or input β€” from hardware or over the network β€” lives here as its own module, and we're adding more all the time. @@ -196,7 +196,7 @@ USB cable needed once it's on your network. **Network** β€” your connection: WiFi or Ethernet, signal strength, and the address others reach it at. The **Devices** section underneath finds other -projectMM boards on the same network, so a roomful of them can discover each +projectMM devices on the same network, so a roomful of them can discover each other. ![The Network module](assets/gettingstarted/02-07-UI-Network.png) @@ -261,5 +261,5 @@ keep going. - **Build from source** or target Teensy / Raspberry Pi: [building.md](building.md). Stuck, or something didn't work? Open an -[issue](https://github.com/MoonModules/projectMM/issues) β€” and tell us what board +[issue](https://github.com/MoonModules/projectMM/issues) β€” and tell us what device you used and where it stopped. diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 7b90324..aeb43e9 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -709,3 +709,40 @@ The installer was reworked so a board catalog ([`boards.json`](../install/boards One sub-decision the implementation forced: the boundary rounding. The original spec said inclusive-ceil ("start 33/end 66 on a 4-wide axis β†’ pixels 1..3"), which on a 128-wide axis makes `end=50` land on pixel 64 *inclusive* β€” so two abutting layers (0..50, 50..100) **overlap by one pixel** at the seam. The product owner chose **half-open `[start, end)`** instead: `end=50` β†’ pixels 0..63, and 0..50 + 50..100 tile a 128 axis into 0..63 / 64..127 exactly, no overlap, no gap (with a min-1-pixel floor so tiny panels still get a non-zero region). Lesson: when a region/range feature will be used to *tile* a space, half-open intervals are the textbook choice (same reason `[begin, end)` is the C++ iterator convention) β€” inclusive bounds double-count the seam. Lessons: (1) a persisted-but-inert control is a feature with no home yet β€” before wiring it where it sits, ask whether an existing mechanism already expresses it (the modifier interface did, completely). (2) "make it the fastest at the default" is often best met by making the default the *absence* of the feature, not a fast branch inside it. (3) a feature framed as "a Layer property" may really be "a composable transform" β€” the modifier framing also unlocked stacking for free. (4) reach for half-open intervals whenever regions abut. + +## Composable modifiers β€” invert the map build (physicalβ†’logical), don't bolt fan-out onto the old interface + +Modifiers needed to chain (Region then Multiply then Rotate), but the old interface β€” `mapToPhysical(logicalCoord) β†’ [physical indices]`, a virtualβ†’physical **fan-out** β€” didn't compose: stages emitted flat indices, not coordinates, and chaining would need a product-of-`maxMultiplier` fan-out ceiling (the exact overflow class that caused the multiplyZ black-screen). The fix was to **invert the build to physicalβ†’logical**, adopting MoonLight's proven model (the product owner's prior engine) in projectMM's own code: each modifier becomes an in-place coordinate fold, and the Layer walks the *physical* lights, folding each through the enabled chain to its logical cell. Three hooks β€” `modifyLogicalSize` (fold the box), `modifyLogical` (fold a coord, return false to reject), `modifyLive` (per-frame remap for Rotate). + +Why the inversion was the right call, not just the chaining bolt-on: +- **Fan-out becomes free.** N physical lights folding onto one logical cell *is* the fan-out β€” no fan-out list, no product ceiling, no overflow. `destinationCount ≀ driverCount` is now a hard invariant. The whole `maxMultiplier`/scratch-buffer/`buildBoxToDriver`/`buildSparseIdentityLUT` machinery deleted. +- **The hot path is untouched.** Our `MappingLUT` is a CSR keyed by logical index; the inverted build is a scatter onto arbitrary logical keys, which doesn't fit `setMapping`'s in-order contract β€” so the build is a textbook **counting-sort CSR construction** (count, prefix-sum, scatter, replay) entirely on the cold path. The per-frame `forEachDestination` read is byte-identical. +- **Static vs dynamic split correctly.** Mask/tile/crop fold forward at build time (`modifyLogical`); rotation gathers backward per frame (`modifyLive`) β€” each in its natural direction, so rotation keeps its clean inverse-sample (no gaps), and a static-only chain pays *nothing* per frame (the live pass is gated on `hasModifyLive()`). + +Lessons: (1) when a feature "doesn't compose," check whether the *interface direction* is wrong before adding machinery to force it β€” inverting the build deleted more code than it added. (2) A proven external model (MoonLight) is worth adopting wholesale when it's the textbook approach (backward mapping + LUT bake), but write it fresh against your own structures (our CSR, our names) rather than porting. (3) Matrices compose the *affine* subset cleanly (Rotate is written as an explicit 2Γ—2 matrix, the codebase's matrix reference) but can't express masks/tiles β€” so the coordinate fold is the general composition model, with a matrix-backed modifier as a special case the same interface hosts. + +## MoonLive: build around expressions + host-bound functions, not statement shapes + +The MoonLive live-script compiler (IR rung) was first built around the *statement shape* `setRGB(idx, r, g, b)` β€” the parser had per-slot rules (index could be `random16`, colours were literal-only) and the IR had an RGB-specific `Store` op baked into the **core**. Three product-owner remarks exposed the same root flaw: (1) `random16` only worked in the index slot, not any argument; (2) `random16(255)` capped at a byte because the index/colour validators conflated ranges; (3) the core compiler was light-domain-specific (`setRGB`/`fill`/`Store` hardcoded), violating *Domain-neutral core*. + +The fix was the ESPLiveScript / ARTI / doc-Β§3.4 model: **the core knows only expressions + a generic call mechanism; the host registers its functions in a builtin table.** Every argument parses as an expression (a literal or a nested call), so `setRGB(random16(256), random16(256), 30, 0)` works and a number is a uint16. `setRGB`/`fill`/`random16` β€” the LED *names* and the RGB meaning β€” live only in the light-domain registration (`MoonLiveBuiltins_light.h`); the core sees a neutral `BuiltinTable` of `{name β†’ Call(fn ptr) | Inline(opcode tag)}`. A buffer writer is `Kind::Inline` (lowers to stores β€” the hot-path fast path, no per-pixel call); a pure helper is `Kind::Call`. A mechanised test pins the neutrality: with an empty table the core knows *no* functions, and a host can register an arbitrary name (`paint`) against the same machinery. + +Two codegen lessons surfaced fixing it: (1) **the live-vreg-across-call contract must hold for ANY expression, not a hand-ordered one** β€” once arguments can be calls, a value computed before one call can be live across a *second* call; the assembler's `call()` must save/restore the whole caller-saved register set (host: a full stp/ldp frame; Xtensa: s32i/l32i of the rotate-out registers a8/a9/a11, with the result stashed in a non-saved reg across the restore). (2) **register budget is real on the MCU** β€” fold the address into a dead vreg (WriteRGB writes into the index register after `index *= cpl`) rather than reserving fresh scratch, so a multi-call statement fits the small windowed register file. + +Lessons: (1) when a language "can't express X in slot Y," the fix is almost always *real expressions*, not a per-slot special case β€” build the general grammar once. (2) Domain-neutrality is testable: assert the core, given an empty host table, knows nothing β€” if it compiles a domain function, the domain leaked in. (3) The bound-function table is the same seam for *speed* and *neutrality*: the descriptor carries how it lowers (inline store vs call), so the core stays LED-free while the hot path stays inline. + +### MoonLive RISC-V backend + vreg reuse (the third ISA, and what it exposed) + +Bringing up the ESP32-P4 (RISC-V) backend β€” a third per-ISA assembler + lowering behind the same neutral IR β€” was mechanical *and* revealing. Mechanical: RV32 is uniform 4-byte instructions and a standard (non-windowed) call ABI, so the assembler is simpler than Xtensa's; every encoding was verified by disassembling the assembler's own output with `riscv32-esp-elf-as`/`objdump` before flashing (the same discipline that caught every Xtensa encoding bug). Revealing: the P4 was the first target where multi-call statements *failed to compile*, exposing two limits the host (14 regs) and Xtensa (12 regs, but the heaviest test was 2 calls) had masked. + +First, the **register file**. The front-end allocated a fresh virtual register per sub-expression and never reused it, so `setRGB(random16(..), random16(..), random16(..), 0)` needed more live vregs than the 12-register device pool β€” "codegen failed." The fix is the textbook tree-walk register stack: a free-list allocator where each argument temp is returned to the pool the moment its call consumes it, so a chain of N calls peaks at a handful of registers instead of growing 2N. `vregsUsed` (the lowering's reservation) is the high-water mark, which now *shrinks* because freed vregs are reused at low indices. This is the "concrete-first, the allocator arrives when a real script exhausts registers" point the design anticipated β€” and a 4-call statement is exactly that script. + +Second, the **code arena**. RISC-V's `call()` saves the full caller-saved set around each host call (~140 bytes), so four calls in one statement is ~600 bytes β€” past the original 256-byte staging cap. Rather than grow per-script (a moving target), the arena is sized once for the heaviest *realistic* single statement (four-arg-all-calls on the bulkiest ISA). Exec memory is cheap; a fixed worst-case cap is simpler and more predictable than dynamic growth. + +Lessons: (1) a third backend is the cheap insurance that the IR seam is real β€” if adding RISC-V had needed front-end changes, the seam was a lie; it needed none (only the new register/byte *limits* surfaced, which are target properties, not design leaks). (2) Register allocation is where "it works on my 14-register host" quietly diverges from "it works on the 12-register device" β€” bring up the smallest register file early. (3) Verify every emitted instruction against the real toolchain's disassembler before trusting it on hardware; it is faster than debugging a `StoreProhibited` on-device, every time. + +### MoonLive: size the exec block to the program, and bound every fixed table the codegen fills + +Two follow-on lessons after the engine landed, both about *fixed-capacity buffers in a code generator*. (1) **Allocate the live exec block at the emitted length, not the worst-case cap.** The first cut allocated `allocExec(kCodeCap)` (the 768-byte ceiling sized for a four-call statement) for *every* script, so a `fill(0,0,255)` β€” ~60 bytes of native code β€” reserved 768. The fix: `place()` allocates `allocExec(len)` (word-rounded for the IRAM 32-bit-store rule), so per-effect heap scales with the script; the staging *buffer* stays worst-case (it's transient stack), only the *retained* block is right-sized. Paired with it: the effect must **report that block** (`setDynamicBytes(codeLen())`) or the JIT'd code is invisible to the memory accounting β€” the UI card showed only `sizeof(MoonLiveEffect)` and none of the native code it allocated. (2) **A code generator's label/fixup tables need the same bounds check as its code buffer.** The assemblers guarded `emit()` against `kCap` from the start but let `newLabel()`/the branch-fixup enqueue write `labelPos_[]`/`fixups_[]` unbounded β€” a script with enough branches would corrupt memory past the arrays. The fix routes both through the same `overflow_` signal `emit()` already uses (a guarded `addFixup()` helper, a bounds check in `newLabel()`), so *every* fixed table the codegen fills fails cleanly, not just the byte buffer. + +Lessons: (1) when a JIT retains a fixed buffer per unit, size the *retained* allocation to the actual output and keep only the *scratch* at worst-case β€” and report the retained bytes, or the memory accounting lies. (2) a bounds check on the code buffer is not enough: audit *every* fixed-capacity array the generator appends to (labels, fixups, relocation tables) and route them all through one overflow signal β€” robustness is per-table, not per-generator. (3) these surfaced from a deliberate memory review at the small-language stage, before loops/expressions multiply the branch count β€” cheaper to pin the discipline now than after a real script first overruns a table. diff --git a/docs/history/plans/Plan-20260626 - Composable modifiers (chain the whole stack).md b/docs/history/plans/Plan-20260626 - Composable modifiers (chain the whole stack).md new file mode 100644 index 0000000..c5b3ab4 --- /dev/null +++ b/docs/history/plans/Plan-20260626 - Composable modifiers (chain the whole stack).md @@ -0,0 +1,125 @@ +# Plan β€” Composable modifiers (chain the whole modifier stack) + +## Context + +Today a Layer applies **only its first enabled modifier**: `Layer::rebuildLUT()` finds the first enabled `Modifier` child and `break`s, and `Layer::loop()` ticks only that one. A second modifier on a Layer is dead weight. The product owner has always intended **modifier order = apply order** β€” a stack where each modifier reshapes the result of the one below (Region *then* Multiply-mirror *then* Rotate), the way it works in MoonLight (the product owner's prior engine, 3 years proven). + +The current `ModifierBase` interface β€” `logicalDimensions()` + `mapToPhysical(coord) β†’ [flat physical indices]` β€” is a **virtualβ†’physical fan-out** model that does not compose: each stage emits flat indices, not coordinates, so stage N+1 can't consume stage N's output, and chaining would need a product-of-`maxMultiplier` fan-out ceiling (the exact 64-bit-overflow bug class that caused the multiplyZ black-screen). + +**The fix is to adopt MoonLight's proven model, written fresh in projectMM style:** invert the map build to **physicalβ†’logical**, where each modifier is an **in-place coordinate fold**. Composition becomes a plain loop over enabled modifiers mutating one coordinate. Fan-out stops being a build-time concern (N physical lights folding onto one logical cell *is* the fan-out). The product-ceiling math, the per-light scratch buffer, `buildBoxToDriver`, `buildSparseIdentityLUT`, and `isNaturalOrder`'s shuffle role all disappear. + +Three tiers of hook (MoonLight's structure, projectMM names): +- `modifyLogicalSize(Coord3D& size)` β€” static, build-time, run once in child order; folds the logical box (Multiply shrinks, Region crops, Mirror halves). +- `bool modifyLogical(Coord3D& pos, …)` β€” static, build-time, run per physical light in child order; folds a physical coord into logical space, returns `false` to reject (mask/out-of-region). +- `modifyLive(Coord3D& pos, …)` β€” **dynamic, per-frame**; the per-frame coordinate transform for smooth rotation/scroll, applied without a LUT rebuild. + +**Pay-for-what-you-use is the load-bearing guarantee (product owner's explicit requirement):** a modifier with no `modifyLive` override imposes **zero per-frame cost** β€” the hot path runs at exactly today's speed. Per-frame cost exists only when a dynamic modifier is actually present, and that cost is inherent to dynamic motion (moving pixels every frame). MoonLight proves it's viable. + +## Decisions locked with the product owner + +- **Full three-tier model** (static size + static fold + dynamic per-frame), not a static-only first cut. +- **Reject signal = `bool modifyLogical()` return** (not a sentinel coord β€” avoids a later modifier's `% size` aliasing a sentinel back into range). +- **No `modifyXYZ` override β‡’ max hot-path speed.** The dynamic pass is gated behind a build-time "any live modifier?" flag; absent it, the render path is byte-identical to today. +- **Box may grow** (MoonLight's Rotate `expand` grows it). Do NOT hard-forbid growth; size the LUT from the *post-fold* logical box and clamp/guard defensively (robust-to-any-input), rather than asserting shrink-only. + +## Architecture mapping (MoonLight idea β†’ projectMM) + +| MoonLight | projectMM | +|---|---| +| `Coord3D` | new `Coord3D` in `light_types.h`, our naming + rationale comment | +| "virtual" space | **logical** box (our existing word) | +| `modifySize` | `modifyLogicalSize(Coord3D& size)` | +| `modifyPosition` (mutate, reject via UINT16_MAX) | `bool modifyLogical(Coord3D& pos, const Coord3D& phys, const Coord3D& logical)` (mutate, reject via `false`) | +| `modifyXYZ` (per-frame at `setRGB(pos)`) | `modifyLive(Coord3D& pos, const Coord3D& logical)` β€” per-frame coordinate remap; seam described below | +| `PhysMap` table | existing `MappingLUT` CSR (**unchanged** β€” see build note) | +| `addLight(physicalPos)` gather | the physicalβ†’logical counting-sort build in `rebuildLUT()` | + +## Key build-mechanics finding (verified) + +The hot-path **read** (`BlendMap::blendMap` β†’ `for li in logicalCount: forEachDestination(li) β†’ dst[physIdx]=src[li]`) stays **byte-identical**. Only the cold-path **build** inverts. + +But `MappingLUT::setMapping` requires **sequential, in-order, one-shot** writes (`offsets_[logicalIdx] = destinationCount_`, monotonic append). A physicalβ†’logical loop scatters onto *arbitrary, repeated* logical indices β€” a scatter, not an in-order gather. So `rebuildLUT()` builds the CSR with a **counting sort** (the textbook way to build CSR from scattered keys), entirely in `Layer` on the cold path, then replays it through `setMapping` in logical order. **No `MappingLUT` structural change.** + +``` +Pass A (count): forEachCoord β†’ fold each driver light to logIdx (or reject) β†’ counts[logIdx]++ +Prefix-sum: counts β†’ offsets +Pass B (scatter):forEachCoord (same fold) β†’ scratchDests[cursor[logIdx]++] = driverIdx +Replay: for logIdx 0..N-1: setMapping(logIdx, &scratchDests[offsets[logIdx]], counts[logIdx]); finalize() +``` + +`maxDest` passed to `MappingLUT::build` is now exactly `driverCount` (each physical light folds to ≀1 logical cell β†’ contributes ≀1 destination total). The product-ceiling/overflow math is **deleted** β€” `destinationCount_ ≀ driverCount` is a hard invariant. `overwrites_` stays `true` for the fold build (each physical cell appears in exactly one logical entry's run; no within-layer additive accumulation can arise), simplifying `BlendMap`'s `overwrites()` handling for this path. + +Two `forEachCoord` passes + a `logicalCount`-sized counts array is the build cost β€” cold path, bounded, comparable to today's `boxToDriver` + `physicals` scratch. + +## The dynamic (per-frame) seam β€” projectMM placement + +MoonLight applies `modifyLive` per pixel at write time because effects call `setRGB(pos)`. projectMM effects write a **flat logical buffer**, then `blendMap` scatters. So our seam is a **per-frame logicalβ†’logical remap** applied between the effect write and the scatter, only when a live modifier exists: + +- At build time, compute `hasLive_` = any enabled modifier overrides `modifyLive`. Store on the Layer. +- `Layer::loop()`: after effects fill `buffer_` and static modifiers are already baked into `lut_`, if `hasLive_`, run one pass that, for each logical cell, folds its coordinate through the enabled `modifyLive` chain to a *source* logical coordinate and gathers (a coordinate remap over the logical buffer into a scratch buffer, then swap). If `!hasLive_`, skip entirely β€” **the buffer goes straight to the scatter, zero added cost** (the guarantee). +- Rotation is naturally an **inverse-sample gather** here (for each destination logical cell, sample its rotated source) β€” which is exactly how our current `RotateModifier` already reasons (`// map a destination light to its rotated SOURCE`), so no visual regression. The live pass is the right home for that inverse-sample logic that did NOT fit the forward static fold. + +This resolves the Plan-agent's concern: dynamic modifiers do **not** force the forward-fold visual change, because they live in the per-frame gather seam, not the static build. Static modifiers fold forward (build); dynamic modifiers gather inverse (per-frame). Each in its natural direction. + +## Files + +### New / core types +- **`src/light/light_types.h`** β€” add `struct Coord3D { lengthType x,y,z; }` with `+ - % / ==` operators, a one-line rationale comment matching that file's house style. Used by the fold hooks. + +### `src/light/modifiers/ModifierBase.h` β€” interface inversion +- Replace `logicalDimensions()` + `mapToPhysical()` + `maxMultiplier()` with: + - `virtual void modifyLogicalSize(Coord3D& size) const {}` (default: no resize) + - `virtual bool modifyLogical(Coord3D& pos, const Coord3D& phys, const Coord3D& logical) const { return true; }` (default: pass-through) + - `virtual void modifyLive(Coord3D& pos, const Coord3D& logical) const {}` (default: no per-frame work; presence detected so absence = zero cost) +- Keep `dimensions()` (the πŸ“/🟦/🧊 chip) and `controlChangeTriggersBuildState` (already `true`). +- `phys`/`logical` passed in (not stashed on the modifier) so modifiers stay stateless and the two-pass build can't desync a cached size. + +### `src/light/layers/Layer.h` β€” the heart +- `rebuildLUT()`: scan **all** enabled modifiers (not first-only). Run `modifyLogicalSize` chain β†’ `width_/height_/depth_`. Then the counting-sort fold build above. Keep the dense-natural `setIdentity` memcpy shortcut (cheap `isNaturalOrder`-style check, retained only for that gate). n==0 and n==1 take the same paths with zero per-frame overhead. +- **Delete**: `buildBoxToDriver`, `buildSparseIdentityLUT`, the `maxMultiplier` scratch + `physicals[]`, the `maxDestWide`/`ceiling` product-clamp. (`isNaturalOrder` demoted to gating only the memcpy shortcut.) +- `loop()`: drop the `break;` after the first modifier β€” tick **all** enabled modifiers in child order. Coalesce dynamic rebuilds with a single dirty flag (avoid N rebuilds/frame when several modifiers tick). Add the `hasLive_`-gated per-frame remap pass. +- Defensive bounds-guard on the folded coord before flatten (a buggy modifier must not write past `counts[]`). + +### `src/light/modifiers/*.h` β€” rewrite all 5 to the fold interface +- **MultiplyModifier** β€” `modifyLogicalSize`: divide per axis; `modifyLogical`: fold `pos` into the tile, mirror odd tiles (mirror is already a Multiply control β€” no separate Mirror class). ~10 lines, simpler than today. +- **RegionModifier** β€” `modifyLogicalSize`: `axisCount`; `modifyLogical`: subtract `axisStart`, return `inBox(pos, logical)` (reject outside region). Reuses the existing `axisStart`/`axisCount` helpers. +- **CheckerboardModifier** β€” `modifyLogicalSize`: identity; `modifyLogical`: parity test, return `false` to drop. ~5 lines. +- **RotateModifier** β€” `modifyLive` (inverse-sample gather, its current math moves here, unchanged visuals); `expand` grows the box via `modifyLogicalSize`. No static fold. +- **RandomMapModifier** β€” bijective permutation; can express as static `modifyLogical` (1:1) since it's a permutation; keep its `loop()` beat-reshuffle. + +### Tests +- `unit_Coord3D` β€” operator coverage. +- Rewrite `unit_MultiplyModifier`, `unit_RegionModifier`, `unit_CheckerboardModifier`, `unit_RotateModifier`, `unit_RandomMapModifier` to the fold interface (they call the old virtuals directly today, so they change in lockstep with their modifier). +- `unit_Layer_sparse_mapping` β€” the green gate for the build (asserts CSR contents, not the interface). Update corner-fan-out + region cases to the fold values. +- New `unit_Layer_modifier_chain` β€” the payoff: Region∘Multiply-mirror composed CSR; A∘B β‰  B∘A; a disabled middle modifier is skipped. +- New scenario `scenario_modifier_chain` β€” reorder a 2-modifier stack live, assert the composite changes; perf capture at depth 2–3. + +### Docs +- `ModifierBase.md` (or the modifier specs) β€” the three-hook contract, the reject convention, the pay-for-what-you-use guarantee. +- `architecture.md` Β§ Layers and Layer / Β§ Modifiers β€” update "first enabled modifier" β†’ "modifier chain", document physicalβ†’logical build + the live seam. +- `docs/backlog/backlog-mixed.md` β€” delete the "Composed modifiers" item (shipped). +- `decisions.md` β€” the inversion lesson (forward-fold static, inverse-gather dynamic; CSR-via-counting-sort keeps the hot path untouched). +- `performance.md` β€” chain-depth + dynamic-modifier per-frame cost. + +## Commit breakdown (~6) + +1. `Coord3D` + new `ModifierBase` hooks **alongside** the old ones (no behavior change); `unit_Coord3D`. All green. +2. Implement new hooks on all 5 modifiers (old hooks still present, Layer still uses old); rewrite each modifier's unit test to the new hooks. Green. +3. New `rebuildLUT()` counting-sort fold build using the new hooks; delete `buildBoxToDriver`/`buildSparseIdentityLUT`/scratch/ceiling; keep `setIdentity` shortcut. `unit_Layer_sparse_mapping` is the gate. The big commit. +4. Delete old `mapToPhysical`/`logicalDimensions`/`maxMultiplier` from base + modifiers; remove old-hook test cases. Green. +5. Dynamic tier: `loop()` ticks all modifiers + dirty-flag coalesced rebuild + the `hasLive_`-gated per-frame remap seam; Rotate/RandomMap to the new model; rewrite their tests. Green. +6. `unit_Layer_modifier_chain` + `scenario_modifier_chain` + docs + backlog delete + decisions/perf. Green. + +**Test-green honesty:** commits 1, 4, 6 are cleanly additive/green. Commits 2, 3, 5 rewrite tests in the same commit as the contract they pin (normal for an interface inversion) β€” "green" means "the new contract's tests pass in that commit," not "old tests still pass." + +## Risks / watch-items +- **Build cost rises** (two `forEachCoord` passes + counts array, every rebuild even for n==1). Cold path, bounded β€” don't sell it as free. +- **Coalesced dynamic rebuild**: today a modifier's `loop()` re-enters `Layer::onBuildState()`; with N modifiers, gate to one rebuild/frame via a dirty flag (cleaner than today's re-entrancy). +- **`expand`-style growth**: size the LUT from the post-fold box; guard the flatten against `nrOfLightsType` overflow on a grown box. +- **Per-frame seam scratch buffer**: the live remap needs a logical-sized scratch (allocated when `hasLive_`, off the hot path). Confirm it degrades gracefully on OOM (fall back to static LUT, no live motion, status warning). + +## Verification +- `ctest` + `uv run scripts/scenario/run_scenario.py` green at each commit boundary. +- New `unit_Layer_modifier_chain` proves Region∘Multiply composes (A∘B β‰  B∘A, disabled-middle skipped). +- Live on the bench: build a Region + Multiply(mirror) stack on the S3, confirm the carved-then-mirrored result in the preview; add a Rotate on top, confirm smooth (not stepped) dynamic rotation with the static chain underneath. +- Perf: capture single-layer (no modifier) tick on S3/classic/P4 β€” must match today (max-speed guarantee); capture a static 2-chain and a dynamic (Rotate) chain to quantify the live-seam cost. diff --git a/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md b/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md new file mode 100644 index 0000000..cbdad8e --- /dev/null +++ b/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md @@ -0,0 +1,114 @@ +# Plan β€” MoonLive Stage 0: native-codegen load-bearing spike + +> Approved plan record (CLAUDE.md *Plan before implementing*). Implements the first, smallest step of [livescripts-analysis-top-down.md](../../backlog/livescripts-analysis-top-down.md) β€” its Stage 0 "load-bearing spike", split one notch finer so the single novel hardware risk is isolated and proven before any compiler front-end is written. S3-only, bare-minimum assembler, near-zero language. + +## Goal + +Prove the one link nothing else can de-risk: **text-authored intent β†’ native machine code we generated β†’ `allocExec` executable memory β†’ called every render tick β†’ it writes the real producer buffer β†’ visible on the S3, no crash.** Everything above that link (tokenizer, parser, IR, real codegen) is conventional, desktop-testable compiler work; this spike attacks only the part that can't be tested off-hardware. + +Visual acceptance: add a scripted effect on the S3 β†’ the grid lights solid blue; then a per-frame hue sweep. Checkable by eye in the preview. + +## Why split Stage 0 into 1a/1b/2 + +The analysis doc's Stage 0 bundles two risks of very different character into one increment: +- **The novel, hardware-only risk:** emit Xtensa bytes β†’ IRAM `allocExec` β†’ cache-sync β†’ call via the windowed-register ABI β†’ write `buffer()`. Nothing in projectMM does this yet. +- **The conventional, low-risk, desktop-testable risk:** a hand-written recursive-descent front-end (lex β†’ parse β†’ emit) for one statement. + +Bundling them means a first-pixel failure is ambiguous ("bad codegen, or bad allocExec?"). So: + +- **1a/1b = the load-bearing spike** β€” hand-emit the bytes (no language at all), prove the scary link. The hand-emitted bytes are not throwaway: they become the **golden reference** the real codegen (step 2) must reproduce. +- **2 = the genuine Stage-0 vertical slice** β€” replace the hand-emitted array with a minimal real `lex("fill(blue)") β†’ parse β†’ emit the SAME bytes`, reusing the exact `allocExec` + binding + buffer surface 1a proved. + +This is "small in depth AND broad": depth = one statement; broad = the whole vertical (binding β†’ allocExec β†’ call β†’ buffer), each piece minimal. + +## Decisions locked with the product owner + +- **First commit = hand-emitted bytes (Stage βˆ’1), then grow into the doc's Stage 0** (a tiny real front-end). Not one-shot Stage 0 β€” the two risks are separated so a first-pixel failure is unambiguous, and 1a is the test oracle for 2. +- **Solid colour first (1a), then per-frame hue (1b)** β€” a static fill answers "does our native code run and write the buffer" unambiguously; passing `elapsed()` then proves dynamic input reaches native code, a distinct fact worth isolating. +- **S3 only.** Xtensa backend + the desktop x86-64 backend (needed anyway for in-process unit tests); no other ISA this spike. +- **Aligns with the precompiled-effect surface.** The emitted function uses the exact `buffer()` + `nrOfLights()*channelsPerLight()` raw-write surface a compiled effect uses today, so swapping hand-bytes β†’ codegen later changes nothing host-side. If the producer-buffer set/get surface wants to change (the RGB-into-buffer question the product owner flagged), this spike is the cheapest place to discover it. + +## Architecture placement (respecting the boundaries) + +Per [Β§3.9](../../backlog/livescripts-analysis-top-down.md) (domain-neutral engine core, thin binding) and the **platform boundary** hard rule (ISA codegen lives only in `src/platform//`): + +``` +src/platform/platform.h ← + allocExec/freeExec seam (declaration only) +src/platform/esp32/platform_esp32.cpp ← S3: heap_caps_malloc(MALLOC_CAP_EXEC) IRAM + cache sync +src/platform/desktop/platform_desktop.cpp ← desktop: mmap(PROT_READ|WRITE|EXEC) +src/core/moonlive/MoonLive.h ← neutral engine: holds an exec block, run(buf,n,cpl); compile() stubbed + (the hand-emitted byte arrays are ISA-specific β†’ they live behind the platform line, + emitted by a tiny per-ISA function the engine calls; the engine itself stays neutral) +src/platform/esp32/moonlive_emit_xtensa.* ← the ~15 hand-coded Xtensa bytes (step 1) β†’ real emit (step 2) +src/platform/desktop/moonlive_emit_host.* ← the x86-64 equivalent (so unit tests run in-process) +src/light/moonlive/MoonLiveEffect.h ← thin EffectBase binding; loop() = engine_.run(buffer(), nrOfLights(), channelsPerLight()) +docs/moonmodules/core/MoonLive.md ← spec (required: every new module .h needs one; check_specs enforces) +``` + +The engine (`src/core/moonlive/`) never sees `EffectBase`/`Buffer`/`ModuleRole`; the binding (`src/light/moonlive/`) translates. The engine reaches native code only through `platform::allocExec` + a per-ISA `emit*()` the platform layer provides. Dependency direction one-way: binding β†’ engine β†’ platform seam. + +## Steps + +### Step 1a β€” `allocExec` + hand-emitted solid-fill, called over the buffer + +**New platform seam** (`platform.h` + both backends): +```cpp +void* allocExec(size_t bytes); // executable memory; nullptr on failure (degrade, never crash) +void freeExec(void* ptr, size_t bytes); +``` +- **S3:** `heap_caps_malloc(bytes, MALLOC_CAP_EXEC)` (IRAM); after copying code in, flush/invalidate so the I-cache sees fresh bytes (the Xtensa cache-coherency step β€” the real unknown). Return nullptr if IRAM is exhausted. +- **Desktop:** `mmap(NULL, bytes, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, -1, 0)`; `munmap` in freeExec. + +**Engine (`MoonLive.h`, neutral):** +```cpp +class MoonLive { +public: + bool compile(); // step 1: calls platform emit*() β†’ fills an allocExec block. true on success. + void run(uint8_t* buf, nrOfLightsType n, uint8_t cpl); // calls the block as a fn ptr + void free(); + bool ok() const; const char* error() const; +}; +``` +`run` casts the exec block to `void(*)(uint8_t*, uint32_t, uint8_t)` and calls it. The emitted function's contract: fill `n*cpl` bytes of `buf` with a fixed BGR/RGB pattern, return. ~10–15 Xtensa instructions, hand-encoded with a comment naming each (`entry`, the loop, `s8i` store, `addi`, `bne`, `retw`). Desktop emit: the equivalent x86-64 (or, honestly, a C function whose address we hand back β€” the desktop path's job is to test the *binding + engine API*, not ISA encoding; the real ISA test is the Xtensa hardware run). + +**Binding (`MoonLiveEffect.h`):** a normal `EffectBase`; `setup()`β†’`engine_.compile()`; `loop()`β†’ `if (engine_.ok()) engine_.run(buffer(), nrOfLights(), channelsPerLight())`; `teardown()`β†’`engine_.free()`. Register in `main.cpp` + `scenario_runner.cpp`. + +**Acceptance:** desktop unit test β€” compile, run over a known buffer, assert every light == the fill colour. On the **S3**: add `MoonLiveEffect` to a Layer (via API), grid lights **solid blue**, fps stable, no crash, survives add/delete/replace. + +### Step 1b β€” per-frame hue (dynamic input reaches native code) + +Extend `run` to pass `elapsed()`; the emitted code derives a hue from it (simplest: write `elapsed()>>k` into the R channel, or call a host `hsv8(hue)` built-in β€” pick the cheaper to hand-encode). Proves a host-supplied per-frame value flows into the native code and changes the output. + +**Acceptance:** S3 grid **hue/brightness sweeps** per frame, smoothly, within budget. + +### Step 2 β€” a minimal real front-end emits the same bytes + +A bare recursive-descent slice in `src/core/moonlive/` (neutral): tokenize β†’ parse **one statement** (`fill(blue);` or `fill();`) β†’ the Xtensa/host emitter produces the **same** bytes step 1a hand-wrote. Still no `.ml` file (source is a hardcoded string or a `source` text control on the effect β€” TBD in the step, the file system + editor are explicitly later per the analysis doc). No IR yet β€” direct ASTβ†’emit is fine at one statement; the IR seam is introduced when a second statement/type forces it (a later stage), per *concrete-first*. + +**Acceptance:** the byte output of `compile("fill(blue)")` **equals** the step-1a golden array (a desktop unit test diffs them); the S3 still lights blue, now from parsed source. This is the no-language-leak proof at its cheapest. + +## Tests (every increment is a tested increment β€” Β§6 of the analysis) + +- `test/unit/core/unit_moonlive_emit.cpp` β€” desktop: `compile()` produces a non-empty exec block; `run()` over a known buffer yields the exact fill (golden buffer). Step 2 adds: parsed-source bytes == hand-emitted golden bytes. +- `test/unit/core/unit_platform_allocexec.cpp` β€” `allocExec` returns executable, writable memory; a trivial emitted "return 42" function called through it returns 42 (desktop); nullptr-on-exhaustion path degrades. +- `test/scenarios/light/scenario_moonlive_hello.json` β€” wire `MoonLiveEffect` as a real MoonModule: add, measure (buffer non-zero == fill colour), delete, re-add (robustness), at a couple grid sizes incl 0Γ—0Γ—0 (no crash). Runs in-process (desktop backend) on every commit; runs live on the S3 over REST for the ISA backend. +- Robustness: add/delete/replace the scripted effect in any order alongside compiled effects β€” the hard rule. + +## Validation + +- `ctest` + `uv run scripts/scenario/run_scenario.py` green at each step. +- **The hardware acceptance is the point:** S3 solid blue (1a), S3 hue sweep (1b), S3 blue-from-source (2) β€” eyeballed in the preview, the product owner confirms. +- Desktop build zero-warnings (`-Wall -Wextra -Werror`); platform-boundary check passes (all ISA bytes behind `src/platform/`). +- `check_specs.py` green β€” `docs/moonmodules/core/MoonLive.md` written (it is the module's home; carries the neutral engine API, the allocExec contract, the Β§3.9 boundary, and the ESPLiveScript/ARTI-FX/MoonLight prior-art block staged in the analysis doc). + +## Risks / watch-items + +- **Xtensa cache coherency after writing IRAM** β€” the genuine unknown. If the called bytes execute stale, the fill won't show; the fix is the correct flush/invalidate around the copy (Espressif's `MALLOC_CAP_EXEC` + cache-sync pattern). This is *why* 1a is hand-emitted: 15 known-correct bytes make this the only variable. +- **Windowed-register call ABI (`entry`/`retw`)** β€” the emitted function must open/close a register window correctly or the call corrupts the stack. Hand-encoding it first means the ABI is debugged in isolation. +- **IRAM scarcity on the S3** β€” exec blocks compete with WiFi/driver IRAM; `allocExec` must degrade (nullptr β†’ effect reports "no memory" status, keeps running dark) not crash. Pin it in the allocExec test. +- **Desktop backend honesty** β€” the desktop path tests the binding/engine API and the front-end, NOT Xtensa encoding. The plan states this so the desktop green is never mistaken for ISA validation; the S3 run is the real gate. +- **Scope creep** β€” no IR, no file system, no editor, no second statement, no controls in this spike. Each is a later ladder rung. If a step wants one of those, it's out of scope and gets backlogged, not smuggled in. + +## Out of scope (explicit β€” later rungs) + +IR seam (introduced when a 2nd statement/type forces it), `.ml` files + file manager + editor window, the second ISA seam-proof (analysis Stage 0.5 β€” done after the front-end exists, not at hello-world), controls binding (Stage 1), math/time/2D/3D, RipplesEffect graduation. This plan is Stage 0 only. diff --git a/docs/history/plans/Plan-20260627 - MoonLive Stage 3 (IR seam + assembler, second statement).md b/docs/history/plans/Plan-20260627 - MoonLive Stage 3 (IR seam + assembler, second statement).md new file mode 100644 index 0000000..e5e65b3 --- /dev/null +++ b/docs/history/plans/Plan-20260627 - MoonLive Stage 3 (IR seam + assembler, second statement).md @@ -0,0 +1,95 @@ +# Plan β€” MoonLive Stage 3: the IR seam + a tiny assembler (second statement) + +> Approved plan record (CLAUDE.md *Plan before implementing*). The next rung of MoonLive after the shipped 1aβ†’1bβ†’2 + P4 spike: add the **second statement kind** to the language, which forces the **typed IR** and a **per-ISA assembler** to earn their place (the current ASTβ†’emitFill shortcut only works for one fixed routine). Builds on [livescripts-analysis-top-down.md](../../backlog/livescripts-analysis-top-down.md) Β§3.2 (IR seam), Β§4 (bounds-check at the IR), Β§3.4 (host built-ins). + +## Goal + +Compile `setRGB(i, r, g, b);` β€” a single-pixel write at a computed index β€” to native code, with a **bounds-check** so an out-of-range index can't overrun the buffer. This is the smallest statement that is *not a fill*: it has a computed store address (`i*cpl`), a guard, and no loop. Doing it honestly requires three things the spike deliberately deferred: + +1. A **typed IR** β€” AST lowered to a flat list of three-address ops over virtual registers β€” so codegen stops being "patch 3 bytes into a fixed template" and becomes "lower a sequence of ops." +2. A **tiny per-ISA assembler** β€” named-instruction emitters (`movi`, `strb`, `add`, `cmp`, `b.cond`, `ret`) with **label back-patching** β€” because a multi-op statement's bytes must be *composed* at compile time (branch offsets, register liveness across ops), which can't be done as hand-grouped byte fragments (that's the StoreProhibited crash class the spike hit). +3. The **bounds-check as an IR op** so every backend inherits it and it's switchable by deleting nodes (doc Β§4). + +The verbatim hand-encoded `fill` blobs from the spike do not die β€” they become the **golden regression fixture**, but as a **behavioral** anchor, not a byte-identical one: the assembler-built `fill` and the hand blob are both run over the same buffer and must produce **identical output**. (Byte-identical would force the clean assembler to mimic the hand blob's arbitrary register choices and instruction selection β€” coupling good code to an artifact. Real compilers assert behavioral equivalence + a size bound against a reference, not byte-equality; that's the *Common patterns first* choice here.) The assembler keeps its own clean register convention; the hand blobs stay as documented references the behavioral test pins against. + +## Decisions locked with the product owner + +- **Minimal IR + tiny assembler** (not a second hand-template, not a full SSA IR). ~7 ops, a fixed virtual-register file with last-use freeing β€” no SSA, no general register allocator (both deferred to *concrete-first* until a script exhausts registers). +- **Second statement = `setRGB(i, r, g, b)`**, in two half-steps: **3a** literal index (`setRGB(5, 0,0,255)`) β€” single write + index math + first BOUNDS, no host call; **3b** `setRGB(random16(N), …)` β€” adds the CALL op + the first host built-in, giving the tutorial hello-world. +- **Desktop (arm64/x86-64) first**, then Xtensa, then RISC-V β€” the stage-0.5 logic applied to codegen: prove the IRβ†’assembler path on the in-process backend (instant feedback, golden-fill regression), then bring up the device ISAs against the proven IR. + +## The IR (minimal, ~7 ops, no SSA) + +A flat list of three-address ops over virtual registers `v0..vN` plus the named host args (`buf`, `nLights`, `cpl`, `t`). Defined once in the neutral core (`src/core/moonlive/`): + +| Op | Meaning | +|---|---| +| `Const vd, imm` | load an integer immediate | +| `Mul/Add/Shl vd, va, vb` | integer arithmetic (index scaling `i*cpl`, range reduction) | +| `Bounds vidx, vlimit` | the Β§4 guard β€” skip the store if `vidx >= vlimit` (its own op so bounds on/off = delete the node, and every backend inherits it) | +| `Store buf, vaddr, vr, vg, vb` | write RGB at a computed byte address | +| `Call vd, builtin, varg…` | call a host built-in (`random16`), result in `vd` β€” the single seam for all host functions | +| `Loop vcounter, vlimit { … }` | a counted loop body (what makes `fill` a loop over all lights) | + +- `fill(r,g,b)` = `ConstΓ—3` + `Loop{ Store }`. +- `setRGB(, r,g,b)` = `Const idx` + `ConstΓ—3` + `Bounds` + (`Mul idx,cpl`) + `Store`. +- `setRGB(random16(N), …)` = `Const N` + `Call random16` + `Bounds` + `ConstΓ—3` + `Store`. + +The set is **closed under the rest of the ladder**: 2D/3D addressing is more index arithmetic (already have Mul/Add); oscillators add float variants; Ripples adds `Call sqrt/sin`. Later rungs add op *variants*, never redesign the IR. + +**Deliberate scope cuts:** no SSA, no register allocator. A fixed vreg file (16 regs) with last-use freeing β€” a statement uses ≀ a handful. SSA + a real allocator are the "complexity in core" deferred until a script exhausts registers. + +## The assembler (per-ISA, named instructions, label back-patching) + +`src/platform//moonlive_asm_*.{h,cpp}` (behind the platform boundary β€” it emits ISA bytes). A small `Assembler` that appends instructions to a byte buffer and back-patches label offsets: + +- `mov(reg, imm)`, `store8(rbase, roff, rval)`, `add(rd, ra, rb)`, `mul/shl(rd, ra, imm)`, `cmp(ra, rb)`, `branchIf(cond, label)`, `label(l)`, `ret()` β€” the ~10–15 encodings the IR actually uses, hand-encoded **once per ISA** (vs. once per instruction Γ— per statement template). Label back-patching kills the hand-computed-branch-offset crash class permanently. +- This is the textbook `MacroAssembler` shape (V8 `Assembler`, LLVM `MCInst`, asmjit) β€” passes *common patterns first*. +- The IRβ†’bytes lowering (`lowerToBytes(const IrProgram&, Assembler&)`) lives per-backend; the IR itself is neutral core. + +**The vregβ†’machine-register + calling-convention contract** between IR and assembler is settled and pinned by a test BEFORE Xtensa: a `Call` surrounded by live vregs must preserve them (which machine regs are caller-saved across the host call, who saves them). Getting this wrong is the multi-round-trip rework *Refactor for simplicity* warns against, so it's nailed on the desktop backend first. + +## Files + +### Neutral core (`src/core/moonlive/`) +- **`MoonLiveIr.h`** (new) β€” the IR op structs + `IrProgram` (a flat `std::array`/fixed-capacity list of ops, no heap in the hot build path; sized like `kCodeCap`). Pure data, no ISA. +- **`MoonLiveCompiler.{h,cpp}`** β€” the parser grows a second production (`setRGB(...)`) and a `random16(...)` primary; codegen becomes **AST β†’ IR** (`lower()`), replacing the direct `emitFill` call. `compileSource` now: parse β†’ lower to IR β†’ hand the IR to the per-ISA `lowerToBytes`. + +### Per-ISA assembler + lowering (`src/platform//`) +- **`moonlive_asm_host.{h,cpp}`** (desktop, arm64 + x86-64 by `#if`), **`moonlive_asm_xtensa.cpp`**, **`moonlive_asm_riscv.cpp`** β€” the named-instruction assembler + `lowerToBytes` per ISA. The existing `emitFill`/`emitAnimatedFill` stay (the golden fixtures) until the assembler reproduces them, then `emitFill` is re-expressed as `lower(fill-IR) β†’ lowerToBytes` and the hand-blob becomes a test constant. + +### Engine + binding (unchanged seam) +- `MoonLive.{h,cpp}` β€” `compile(source)` already routes through `compileSource`; no change to the engine API. The binding (`MoonLiveEffect.h`) is untouched β€” the source control now accepts the richer grammar for free. + +### Tests +- **`unit_moonlive_ir.cpp`** (new) β€” ASTβ†’IR lowering: `setRGB(5,0,0,255)` produces the expected op list; the `Bounds` node is present before the `Store`; `random16` lowers to a `Call`. +- **`unit_moonlive_asm.cpp`** (new) β€” the assembler: each named instruction emits the right bytes (golden per ISA); label back-patching resolves a forward/backward branch; the **`Call`-preserves-live-vregs** contract test. +- **`unit_moonlive_compiler.cpp`** (extend) β€” `setRGB` golden bytes (assembler-built), an out-of-range index is bounds-rejected at runtime (the buffer's other pixels untouched), `random16` index lands in-range, every new parser diagnostic. +- **`unit_moonlive_fill.cpp`** (extend) β€” **golden regression**: the assembler-built `fill` == the original hand-encoded blob, byte-for-byte (per ISA), proving no codegen-quality regression. +- **`scenario_MoonLiveEffect_livescript.json`** (extend) β€” a `setRGB(...)` source step + a deliberately out-of-range `setRGB(99999, …)` step (renders safely, no overrun, device keeps ticking) on PC/S3/P4. + +## Steps (each independently green) + +1. **IR + desktop assembler, `fill` only.** Define the IR; build the host assembler; lower the `fill` IR to bytes; assert byte-identical to the hand blob (golden regression). No language change yet β€” pure infrastructure swap, proven by the golden test. *The big commit; everything else builds on a proven assembler.* +2. **`setRGB(, r,g,b)` β€” desktop.** Parser second production β†’ IR (`Const`+`Bounds`+`Store`) β†’ host bytes. Unit tests: golden bytes, runtime bounds-reject, the cpl-scaling. +3. **`random16(N)` β€” desktop.** Add the `Call` op + the `random16` built-in (host function) + its lowering; pin the live-vreg-across-Call contract. Now `setRGB(random16(N), blue)` compiles β€” the hello-world. +4. **Xtensa assembler.** Bring up `moonlive_asm_xtensa`: reproduce the golden `fill`, then `setRGB`/`random16`. Flash S3, run the scenario live. +5. **RISC-V assembler.** Same for the P4. Flash, run the scenario live. +6. **Docs + scenario.** Update `MoonLiveEffect.md` (the IR/assembler pieces, the grammar), extend the scenario, decisions.md (the IR-forces-assembler lesson). + +## Validation + +- `ctest` + `uv run scripts/scenario/run_scenario.py` green at each step; desktop-first so steps 1–3 are pure in-process. +- **Golden-bytes regression** (assembler `fill` == hand blob) is the anchor proving the assembler reproduces hand-quality code. +- Hardware: S3 (Xtensa) + P4 (RISC-V) run `setRGB`/`random16` live via the scenario, including the out-of-range-index safety step. +- Build zero-warnings; platform-boundary check (assembler bytes behind `src/platform/`); `check_specs` green. + +## Risks / watch-items + +- **The assembler is the real cost** β€” ~10–15 instruction encodings Γ— 3 ISAs, hand-encoded once each, with label back-patching. Sized honestly: a few days per ISA, but each *instruction* is encoded once (not each instruction Γ— statement), and back-patching removes the hand-offset crash class. Desktop-first keeps the feedback loop instant. +- **vreg/calling-convention contract** β€” the one thing that, gotten wrong, causes multi-round-trip rework. Pinned by a test before Xtensa. +- **No silent scope creep** β€” no SSA, no register allocator, no float ops, no loops-in-source, no 2D/3D this rung. Each is a later rung. If a step wants one, it's backlogged, not smuggled in. +- **Golden fixtures must not rot** β€” keep the hand blobs as test constants even after `emitFill` is re-expressed through the assembler, so the regression anchor survives. + +## Out of scope (later rungs) +Read-modify-write / trails (stage 2 of the tutorial ladder), oscillators + float codegen (stage 3), 2D/3D addressing (stages 4–5), Ripples graduation, source-level loops, variables, the register allocator, SSA. This plan is the second statement + the IR/assembler it forces. diff --git a/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md b/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md new file mode 100644 index 0000000..64eebfb --- /dev/null +++ b/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md @@ -0,0 +1,98 @@ +# Plan β€” MoonLive: expressions + host-bound functions (domain-neutral core) + +> Approved plan record (CLAUDE.md *Plan before implementing* / *Refactor for simplicity*). A design correction on top of the IR rung (Steps 1-5, fill/setRGB/random16 on host + Xtensa): replace the `setRGB`-shaped special-case grammar with **general expressions**, and move the LED-domain functions (`setRGB`, `fill`, `random16`) out of the core compiler into a **host builtin table** the light-domain binding registers. Fixes three product-owner remarks at one root. + +## The three remarks, one root cause + +1. **`setRGB(random16(256), random16(256), 30, 0)` doesn't work** β€” the parser only allows `random16` in the *index* slot; colour slots are literal-only. Bespoke per-slot rules instead of "every argument is an expression." +2. **`random16(255)` caps at 255** β€” the index/colour validators conflate ranges; `random16` returns uint16 (0..65535). +3. **The core is light-specific** β€” `setRGB`/`fill`/the `Store` IR op / `buf[i*cpl]` are baked into `src/core/moonlive/`, violating *Domain-neutral core*. The engine should know *language* + *ISA*, never *LEDs*. + +Root cause: the compiler was built around the *statement shape* (`setRGB(idx, r, g, b)`) rather than around **expressions + a generic call mechanism**. The fix is the architecture ESPLiveScript/ARTI-FX use and the MoonLive doc Β§3.4 specifies: the core knows expressions + `call(builtin, args…)`; the **host registers the functions**. + +## Decisions locked with the product owner + +- **Full generalization now** (not a quick per-slot patch): every argument is an expression; `setRGB`/`fill`/`random16` become host-bound functions; the core keeps only `Call` + arithmetic. +- **Host builtin table** (hpwit `arti_external_function` / ARTI / doc Β§3.4 model): the light-domain binding registers `{name β†’ descriptor}`; the core parser resolves a call by name against the table and codegen dispatches generically. The core owns *dispatch*, the light domain owns *the functions*. + +## The hot-path reconciliation (doc Β§3.4 + the product owner's choice) + +Doc Β§3.4 says pixel writers (`setRGB`) must lower to **direct stores** (the identity-mapping fast path), NOT a per-pixel host *call* β€” a `call` per pixel would wreck 16KΓ—50FPS. The product-owner choice is "all host-bound." These reconcile cleanly: **the builtin table is the binding mechanism for everything, and each descriptor carries HOW it lowers** β€” + +- **`Kind::Call`** β€” a pure helper (`random16`, later `sin`/`hsvToRgb`): lower to a generic `Call` to the host C function pointer. +- **`Kind::Inline`** β€” a buffer writer (`setRGB`, `fill`): the descriptor names an inline lowering the backend knows by an opcode tag (a small fixed set), so it lowers to stores, no call. + +Crucially, **the core does not hardcode `setRGB`** β€” it gets the name, the arg count, the kind, and (for inline) an opcode tag from the *table the light domain populates*. The core's inline lowering is generic over the opcode tag; the light domain decides which tags exist and registers the names. So the core stays domain-neutral (no string "setRGB", no RGB layout) while the hot path stays inline. This is the synthesis: domain-neutral core, fast path, host owns the vocabulary. + +## Architecture + +``` +src/core/moonlive/ + MoonLiveBuiltins.h (NEW) β€” the neutral descriptor + a fixed-capacity BuiltinTable: + { name, argc, Kind (Call|Inline), const void* fn (Call), + uint8_t inlineOp (Inline) }. No LED knowledge. + MoonLiveCompiler β€” parser: a real expression grammar (primary := number | call; + call := ident "(" args ")"). Resolves each call name against + the injected BuiltinTable. Emits IR: Call for Kind::Call, + a generic InlineOp(tag, args…) for Kind::Inline. No setRGB/fill + strings, no Store-with-RGB-shape baked in. + MoonLiveIr.h β€” drop the RGB-specific Store; add a neutral `InlineOp` carrying an + opcode tag + operand vregs. Keep Const/Add/Mul/Bounds/Call/Loop. + (Bounds stays β€” it's a neutral guard the inline writers request.) + +src/light/moonlive/ + MoonLiveBuiltins_light.{h} (NEW) β€” the LIGHT-DOMAIN registration: builds a BuiltinTable with + setRGB (Inline op=WriteRGB), fill (Inline op=FillRGB), + random16 (Call β†’ host fn). This is where "setRGB" the NAME and + the RGB semantics live. The binding injects this table into the + engine at compile time. + MoonLiveEffect.h β€” passes the light builtin table to engine_.compile(source, table). + +src/platform// + moonlive_lower_*.cpp β€” the per-ISA inline-op lowering: given InlineOp(WriteRGB, addr,r,g,b) + emit the store sequence; InlineOp(FillRGB, …) emit the fill loop. + The opcode tags are a small neutral enum in core; the backends + implement them. (random16's Call lowering already exists.) +``` + +The opcode-tag enum (e.g. `InlineKind::WriteRGB`, `FillRGB`) lives in core as a neutral list β€” it's "the inline operations a backend knows how to emit," not "LED operations." The light domain maps its function *names* to these tags; a different host (a display, a sensor) would register different names against whatever inline ops its backend supports, or use only `Call`. The core never says "RGB" in a domain sense β€” `WriteRGB` is just "store 3 consecutive bytes at a computed address," a neutral primitive. + +## Expression grammar (the real fix for #1, #2) + +``` +program := stmt ";" End +stmt := call // a statement is a (void) call: setRGB(...) / fill(...) +call := ident "(" [expr {"," expr}] ")" +expr := number // 0..65535 (uint16) β€” range checked at USE, not parse + | call // nested: random16(256) as an argument +``` + +- Every argument slot parses an `expr`, so `setRGB(random16(256), random16(256), 30, 0)` works (#1). +- A number literal is a uint16 (0..65535); `random16(N)` accepts N up to 65535 (#2). A value used as a colour is masked to a byte at the store (the inline writer does `& 0xFF`), so out-of-byte colours wrap rather than erroring β€” consistent, no bespoke per-slot range rule. +- Each `expr` lowers to a vreg (a `Const`, or a `Call` result). `setRGB`/`fill` then consume those vregs via their InlineOp. The bounds guard wraps the inline write as before. + +## Steps (desktop-first, each green) + +1. **Core: BuiltinTable + neutral IR.** Add `MoonLiveBuiltins.h` (descriptor + table) and the neutral `InlineOp` + `InlineKind` enum; remove the RGB-specific `Store` and the `buildSetRgbIr`/`buildFillIr`/`buildSetRgbRandomIr` helpers from core (they encode LED shape). The IR builders move to the light domain. +2. **Light: register setRGB/fill/random16.** `MoonLiveBuiltins_light.h` builds the table; the IR-construction for WriteRGB/FillRGB lives here (it knows RGB). `random16` registered as a `Call`. +3. **Compiler: expression parser + table resolution.** Parse expr-per-arg; resolve call names against the injected table; build IR (Call / InlineOp). Delete the `setRGB`/`fill` keyword special-cases. +4. **Host backend: inline-op lowering.** `moonlive_lower_host.cpp` lowers `InlineOp(WriteRGB/FillRGB)` to the store sequences (the bytes the old `Store`/fill produced). Behavioral golden test still passes (output unchanged). +5. **Xtensa backend: inline-op lowering.** Same for `moonlive_lower_xtensa.cpp`. Build + flash Olimex; verify `setRGB(random16(256), random16(256), 30, 0)` and `random16(65535)` live. +6. **Tests + docs.** Update unit tests (expression cases, every-arg-random, uint16 range, the domain-neutral-core assertion: grep core for "RGB"/"setRGB" β†’ none in the LED sense). Extend the scenario. decisions.md: the "built around the statement shape, not expressions" lesson. Update MoonLiveEffect.md (the builtin-table model, prior art: ESPLiveScript/ARTI bound functions). + +## Validation + +- Desktop: `setRGB(random16(256), random16(256), 30, 0)` writes a random pixel with a random red+green; `random16(65535)` accepted; behavioral golden (fill output unchanged) holds. All unit tests green. +- **Domain-neutral check** (the #3 fix, mechanised): a test/grep asserts `src/core/moonlive/` contains no LED vocabulary ("setRGB", "fill", "RGB" in the colour sense, "cpl"/"buffer" semantics) β€” only `Call`, `InlineOp`, arithmetic, the neutral opcode enum. +- Xtensa: the failing cases from the remarks work live on the Olimex; no crash. +- P4/RISC-V still builds (stub). + +## Risks / watch-items + +- **Don't let `WriteRGB` smuggle LED-ness into core.** The neutral framing must hold: the core enum entry is "store N bytes at a computed address," documented as such; the *name* `setRGB` and the 3-channel meaning live only in the light registration. If that line blurs, the refactor failed its own #3 goal. +- **Inline-op set stays small + neutral.** Resist adding `setRGBXY`-specific ops; XY/XYZ are index arithmetic feeding the same WriteRGB (expressions compute the index). Only genuinely-distinct store shapes get a tag. +- **No per-pixel call regression.** `setRGB` must stay inline (Kind::Inline), not become a `Call` β€” the doc Β§3.4 hot-path rule. The table's Kind enforces this. +- **Scope creep.** No `sin`/`hsvToRgb`/variables/`for`-in-source this plan β€” just the generalization + the 3 fixes. Those built-ins are later (they slot into the same table trivially, which is the point). + +## Out of scope (later) +Float built-ins (sin/cos/sqrt/hsvToRgb), source-level variables and loops, setRGBXY/XYZ sugar, the RISC-V inline lowering (P4), a real register allocator. This plan is: expressions, the host builtin table, and domain-neutral core β€” fixing the three remarks. diff --git a/docs/install/README.md b/docs/install/README.md index fb3fc5d..0c0571f 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -101,13 +101,12 @@ entry reads like a scenario's setup phase. The `deviceModel` identity is a unit control applies. **`replaceChildren`** (optional, on a container unit like `Layer` / `Layouts` / -`Drivers`): set it `true` to *replace* the container's boot-wired defaults instead of -adding alongside them. The inject is otherwise add-only, and a `Layer` renders only -its FIRST enabled effect/modifier β€” so a catalog entry that wants its own effect to -show (the testbench above swaps the default `NoiseEffect` for `AudioSpectrumEffect` + -`RandomMapModifier`) marks the container `replaceChildren`, which DELETEs the -container's current children before the entry's children are added. Without it, the -entry's effect would sit behind the boot default and never render. +`Drivers`): the inject is add-only by default, so an entry's children land *alongside* +the container's boot-wired defaults. Set `replaceChildren` `true` to DELETE the +container's current children first, so the entry's children are the only ones β€” used +when an entry wants its own effects to replace the defaults rather than stack with them +(the testbench above swaps the default `NoiseEffect` for `AudioSpectrumEffect` + +`RandomMapModifier`). **LED drivers are catalog-added, not boot-wired.** The only driver the firmware creates at boot is `Preview` (it needs the HTTP-server broadcaster the catalog diff --git a/docs/install/config-ops.js b/docs/install/config-ops.js new file mode 100644 index 0000000..fb3ac8b --- /dev/null +++ b/docs/install/config-ops.js @@ -0,0 +1,57 @@ +// Plan the ordered APPLY_OP sequence that applies a device-model catalog entry's config. +// Pure (no I/O, no browser globals) so the install orchestrator can import it and a node +// unit test can exercise the ordering directly. The orchestrator sends each op this +// returns over serial (APPLY_OP) β€” or the HTTP path POSTs the equivalent. +// +// Op order: a clearChildren pre-pass, then per-module add, then per-control set. +// +// The clear pre-pass is the fix for "defaults only apply if I also erase the flash". On a +// NON-erased device the persisted tree already holds modules the entry re-adds (a prior +// flash's drivers under Drivers, Audio under System, …). The device-side add is +// idempotent-on-id (an existing name returns AlreadyExists and is skipped), so without +// clearing first a stale module lingers and a structural change never lands. So clear the +// user-editable children of every parent the entry adds into (plus any container flagged +// replaceChildren) BEFORE adding. The device's clearChildren preserves boot-wired children +// (Preview, Improv), so a parent's apparatus survives and only swappable pipeline content +// is replaced β€” the no-erase path then converges to the same tree an erase+flash produces. +// A module the entry adds: a non-empty id, a parent to add it under, and a type. The clear +// pre-pass and the add pass MUST agree on this (one helper, used by both) β€” otherwise a +// malformed module could get a clearChildren on its parent without a matching add. +function isAddable(m) { + return !!(m && typeof m === "object" && + typeof m.id === "string" && m.id && + typeof m.parent_id === "string" && m.parent_id && + m.type); +} + +export function planConfigOps(entry) { + const ops = []; + const modules = entry && Array.isArray(entry.modules) ? entry.modules : []; + + // Modules the entry adds fresh β€” no need to clear their children (a just-created + // module has none), so a parent that is itself added is dropped from the clear-set. + const addedIds = new Set(modules.filter(isAddable).map(m => m.id)); + + const clearParents = new Set(); + for (const m of modules) { + if (!m || typeof m !== "object") continue; + if (m.replaceChildren && typeof m.id === "string" && m.id) clearParents.add(m.id); + if (isAddable(m)) clearParents.add(m.parent_id); + } + for (const parent of clearParents) { + if (addedIds.has(parent)) continue; // freshly added β†’ nothing to clear + ops.push({ op: "clearChildren", parent }); + } + + for (const m of modules) { + if (!m || typeof m !== "object" || typeof m.id !== "string" || m.id === "") continue; + if (isAddable(m)) ops.push({ op: "add", type: m.type, id: m.id, parent: m.parent_id }); + const controls = m.controls; + if (controls && typeof controls === "object") { + for (const [control, value] of Object.entries(controls)) { + ops.push({ op: "set", module: m.id, control, value }); + } + } + } + return ops; +} diff --git a/docs/install/deviceModels.json b/docs/install/deviceModels.json index 3df90a2..5f92dd5 100644 --- a/docs/install/deviceModels.json +++ b/docs/install/deviceModels.json @@ -570,6 +570,7 @@ { "type": "GridLayout", "id": "Grid", + "parent_id": "Layouts", "controls": { "width": 8, "height": 8 @@ -578,7 +579,7 @@ { "type": "Layer", "id": "Layer", - "replaceChildren": true + "parent_id": "Layers" }, { "type": "AudioSpectrumEffect", @@ -675,6 +676,7 @@ { "type": "GridLayout", "id": "Grid", + "parent_id": "Layouts", "controls": { "width": 8, "height": 8 @@ -683,7 +685,7 @@ { "type": "Layer", "id": "Layer", - "replaceChildren": true + "parent_id": "Layers" }, { "type": "NetworkReceiveEffect", diff --git a/docs/install/install-orchestrator.js b/docs/install/install-orchestrator.js index b938e72..adc91f9 100644 --- a/docs/install/install-orchestrator.js +++ b/docs/install/install-orchestrator.js @@ -45,6 +45,7 @@ import { buildImprovFrame, encodeApplyOpFrames, } from "./improv-frame.js"; +import { planConfigOps } from "./config-ops.js"; // --------------------------------------------------------------------------- // Manifest parser @@ -174,11 +175,10 @@ async function pushDefaultsOverSerial(port, board, applyDefaults, trackProgress, return await sendConfigOverSerial(port, board, onLog); } -// Push a device-model's whole catalog config to the device over serial. Walks the -// SAME deviceModels.json entry the HTTP path used (replaceChildren pre-pass, then -// per-module add + per-control set) but emits APPLY_OP ops instead of HTTP requests -// β€” so the defaults apply during provisioning with no HTTP and no browser handoff. -// Returns true if the entry was found + pushed, false if no catalog entry for `board`. +// Push a device-model's whole catalog config to the device over serial. Walks the SAME +// deviceModels.json entry the HTTP path used (see planConfigOps) but emits APPLY_OP ops +// instead of HTTP requests β€” so the defaults apply during provisioning with no HTTP and +// no browser handoff. Returns true if the entry was found + pushed, false if none. async function sendConfigOverSerial(port, board, onLog) { let entry; try { @@ -191,25 +191,8 @@ async function sendConfigOverSerial(port, board, onLog) { return false; } if (!entry) return false; - const modules = Array.isArray(entry.modules) ? entry.modules : []; - // replaceChildren pre-pass: clear a container's boot defaults before its catalog - // children are added (so the entry's effects replace, not stack). - for (const m of modules) { - if (m && m.replaceChildren && typeof m.id === "string" && m.id) { - await sendApplyOpFrame(port, { op: "clearChildren", parent: m.id }); - } - } - for (const m of modules) { - if (!m || typeof m !== "object" || typeof m.id !== "string" || m.id === "") continue; - if (m.parent_id && m.type) { - await sendApplyOpFrame(port, { op: "add", type: m.type, id: m.id, parent: m.parent_id }); - } - const controls = m.controls; - if (controls && typeof controls === "object") { - for (const [control, value] of Object.entries(controls)) { - await sendApplyOpFrame(port, { op: "set", module: m.id, control, value }); - } - } + for (const op of planConfigOps(entry)) { + await sendApplyOpFrame(port, op); } if (onLog) onLog(`[orchestrator] applied ${board} defaults over serial`); return true; diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md index ad08b2f..8ccf464 100644 --- a/docs/moonmodules/core/ImprovProvisioningModule.md +++ b/docs/moonmodules/core/ImprovProvisioningModule.md @@ -27,7 +27,7 @@ Both transports speak the same Improv-WiFi serial protocol β€” frames of `IMPROV - `GET_WIFI_NETWORKS` β€” runs a synchronous WiFi scan, returns up to 10 SSIDs with RSSI + auth flag. **Rejected while STA is connected** (see below). - `WIFI_SETTINGS` β€” writes SSID + password to NetworkModule via `setWifiCredentials`, polls `wifiStaConnected()` for up to 30 s, replies with success (carrying `http:///`) or `ERROR_UNABLE_TO_CONNECT`. - `SET_TX_POWER` (vendor, `0xFD`) β€” payload `[1][dBm]` (0–21; 0 lifts the cap); persists + applies `Network.txPowerSetting` **before** any association attempt. This is the provisioning escape hatch for boards whose LDO browns out at full TX power (a weak LDO / marginal supply): the cap MUST land before the first association or the board fails WiFi auth at 20 dBm before it is ever online. `improv_provision.py --tx-power 8` (and the MoonDeck flow) sends this ahead of the credentials; error `0x81` on an out-of-range value. -- `APPLY_OP` (vendor, `0xFC`) β€” **"Improv = REST over serial".** Carries ONE REST operation as JSON, the same shape an HTTP `POST /api/modules` / `/api/control` body has: `{"op":"add","type":…,"id":…,"parent":…}` / `{"op":"set","module":…,"control":…,"value":…}` / `{"op":"clearChildren","parent":…}`. On the device the op is routed to `HttpServerModule`'s apply-core β€” the *exact same code* the HTTP handlers call β€” so a REST call over the network and an `APPLY_OP` over serial execute identically. (One schema caveat: the serial `add` op names the parent `parent`, while the HTTP `POST /api/modules` body names it `parent_id`. Both feed the one `applyAddModule()` core but the two transports parse different keys, so an HTTP payload is **not** a drop-in `APPLY_OP` β€” rename `parent_id` β†’ `parent`. The serial key stays terse because every byte counts against the 128-byte frame.) The web installer pushes a device-model's whole catalog config this way during provisioning (a `clearChildren` pre-pass for any `replaceChildren` container, then an `add` per module + a `set` per control β€” **the deviceModel identity is just one of those `set` ops** on `System.deviceModel`, validated by that control's per-control validator like any other write), so the defaults apply **over the serial port the installer already owns during the flash** β€” which is what lets the HTTPS installer page configure an `http://` device that a browser fetch can't reach (mixed-content). Frame payload: `[0xFC][seq][last][chunk]` β€” most ops are one frame; a long value (e.g. a big `pins` list) chunks across frames into a reassembly buffer, applied on the device's main loop when `last=1`. Single-buffered: the device errors a new op (`0x82`) while the previous is unconsumed. The installer paces ops open-loop (a fixed delay between frames sized to the worst-case consume window) rather than reading the ack back, so a lost op is improbable rather than impossible; each op is idempotent, so a re-flash re-applies cleanly. (To re-apply a model to an already-running device, use MoonDeck on the LAN, which talks plain HTTP REST with no mixed-content barrier.) +- `APPLY_OP` (vendor, `0xFC`) β€” **"Improv = REST over serial".** Carries ONE REST operation as JSON, the same shape an HTTP `POST /api/modules` / `/api/control` body has: `{"op":"add","type":…,"id":…,"parent":…}` / `{"op":"set","module":…,"control":…,"value":…}` / `{"op":"clearChildren","parent":…}`. On the device the op is routed to `HttpServerModule`'s apply-core β€” the *exact same code* the HTTP handlers call β€” so a REST call over the network and an `APPLY_OP` over serial execute identically. (One schema caveat: the serial `add` op names the parent `parent`, while the HTTP `POST /api/modules` body names it `parent_id`. Both feed the one `applyAddModule()` core but the two transports parse different keys, so an HTTP payload is **not** a drop-in `APPLY_OP` β€” rename `parent_id` β†’ `parent`. The serial key stays terse because every byte counts against the 128-byte frame.) The web installer pushes a device-model's whole catalog config this way during provisioning (a `clearChildren` pre-pass for every parent the entry adds into β€” plus any `replaceChildren` container β€” then an `add` per module + a `set` per control β€” **the deviceModel identity is just one of those `set` ops** on `System.deviceModel`, validated by that control's per-control validator like any other write), so the defaults apply **over the serial port the installer already owns during the flash** β€” which is what lets the HTTPS installer page configure an `http://` device that a browser fetch can't reach (mixed-content). Clearing every add-parent (not only `replaceChildren` containers) is what makes "apply device defaults" land the same way with or without an erase: the device-side `add` is idempotent on the module id, so on a non-erased device a persisted child of the same name would otherwise survive and the re-add be skipped. Frame payload: `[0xFC][seq][last][chunk]` β€” most ops are one frame; a long value (e.g. a big `pins` list) chunks across frames into a reassembly buffer, applied on the device's main loop when `last=1`. Single-buffered: the device errors a new op (`0x82`) while the previous is unconsumed. The installer paces ops open-loop (a fixed delay between frames sized to the worst-case consume window) rather than reading the ack back, so a lost op is improbable rather than impossible; each op is idempotent, so a re-flash re-applies cleanly. (To re-apply a model to an already-running device, use MoonDeck on the LAN, which talks plain HTTP REST with no mixed-content barrier.) **The serial listener runs on every ESP32 target, including Ethernet-only builds** (`--firmware esp32-eth*`). An eth-only build compiles in the vendor RPCs (`SET_TX_POWER`, `APPLY_OP`) plus `GET_CURRENT_STATE` / `GET_DEVICE_INFO`, so the web installer pushes a device-model's config over serial to an eth device exactly as it does to a WiFi one; the WiFi-provisioning RPCs (`WIFI_SETTINGS`, `GET_WIFI_NETWORKS`) build only on WiFi targets, where there's an STA to provision and `esp_wifi_*` is available. On eth, `GET_CURRENT_STATE` reports "provisioned" + the device URL from the Ethernet link (`platform::ethConnected()` / `ethGetIPv4`) instead of the WiFi STA. diff --git a/docs/moonmodules/light/ModifierBase.md b/docs/moonmodules/light/ModifierBase.md new file mode 100644 index 0000000..7201236 --- /dev/null +++ b/docs/moonmodules/light/ModifierBase.md @@ -0,0 +1,31 @@ +# ModifierBase + +Light-domain MoonModule subclass for modifiers. A modifier is a **coordinate transform** that reshapes how a Layer's effect output maps onto the physical lights. Multiple modifiers on one Layer **compose**: they apply in child order, each reshaping the result of the one below (Region *then* Multiply-mirror *then* Rotate). + +## The fold contract + +A Layer builds its mapping by walking the **physical** lights and folding each through every enabled modifier in order β€” the composition `Mβ‚βˆ˜Mβ‚‚βˆ˜β€¦βˆ˜Mβ‚™` collapsed into one mapping, so the per-frame render stays a single lookup. Three hooks, each a no-op by default so a modifier implements only what it needs: + +- **`modifyLogicalSize(Coord3D& size)`** β€” static, build-time, once per rebuild in child order. Folds the logical box (Multiply divides it, Region crops it, a mask leaves it). The running `size` starts at the physical box; each modifier reshapes it. A modifier that needs its box in the per-light fold **stashes** it here (the MoonLight `modifierSize` pattern), so the Layer keeps no per-stage box array. +- **`bool modifyLogical(Coord3D& pos)`** β€” static, build-time, per physical light in child order. Folds a coordinate into this stage's logical space in place, reading any box it needs from its own stash. Returns **`false` to reject** β€” the coordinate has no logical source (a mask drops it, a region light falls outside the crop, a Multiply leftover-edge pixel has no tile). A bool, not a sentinel coord, so a later modifier's `% size` can't alias a sentinel back into range. +- **`modifyLive(Coord3D& pos, const Coord3D& logical)`** β€” dynamic, per-frame at render time. Remaps a coordinate without rebuilding the mapping (smooth rotation/scroll). The Layer runs this pass **only** when some enabled modifier reports `hasModifyLive()`, so a static-only chain pays nothing per frame β€” the render path stays at full speed (pay-for-what-you-use). It is a **backward** map: for each destination cell it returns the source cell to gather, so no destination is left torn. + +A beat-driven modifier (RandomMap reshuffles on a timer) sets a flag in `loop()`; the Layer polls `consumeNeedsRebuild()` across its modifiers and rebuilds the mapping **once** if any asks, coalescing several dynamic modifiers into a single rebuild. + +`dimensions()` (the πŸ“/🟦/🧊 chip) advertises which axes the modifier can transform. + +## Fan-out is free + +Because the build walks physical lights, fan-out (one logical cell driving N physical lights β€” a Multiply kaleidoscope) emerges naturally: N physical lights fold onto the same logical cell. There is no build-time fan-out list and no product-of-multipliers ceiling β€” each physical light contributes at most one destination, so the mapping can never overflow. + +## Affine modifiers and the matrix reference + +Most modifiers are **non-affine** (a mask is a predicate, a tile is modulo) and express their fold directly. [RotateModifier](modifiers/RotateModifier.md) is the exception and the codebase's **transform-matrix reference**: rotation is the canonical affine transform, written as an explicit integer 2Γ—2 rotation matrix in `modifyLive`. A future affine "Transform" modifier (translate+scale+rotate+shear in one) would compose its matrix the same way and apply it through the same hook β€” the fold interface hosts a matrix-backed modifier with no change. + +## Prior art + +The mapping bake is the textbook image-warping pattern (precompute a coordinate transform into a spatial LUT; build it by backward mapping so no output pixel is unfilled β€” [Forward and Backward Mapping for Computer Vision](https://towardsdatascience.com/forward-and-backward-mapping-for-computer-vision-833436e2472/)). Collapsing a **chain** of discrete pixel folds into one index table β€” instead of giving each node its own frame buffer as a PC node graph (TouchDesigner, shader graphs) would β€” is the MCU-memory synthesis credited to **MoonLight** ([M_MoonLight.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h), [VirtualLayer.cpp](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Layers/VirtualLayer.cpp)): `modifySize` / `modifyPosition` / `modifyXYZ` map to our `modifyLogicalSize` / `modifyLogical` / `modifyLive`, written fresh against our `MappingLUT`. + +## Source + +[ModifierBase.h](../../../src/light/modifiers/ModifierBase.h) β€” the hook contract. The Layer-side fold build (physicalβ†’logical counting-sort, the live pass) lives in [Layer.h](../../../src/light/layers/Layer.h). diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md index 90f17e5..1d2ad91 100644 --- a/docs/moonmodules/light/modifiers/CheckerboardModifier.md +++ b/docs/moonmodules/light/modifiers/CheckerboardModifier.md @@ -13,13 +13,13 @@ Static modifier. Masks the layer in a checkerboard pattern: lights in the "off" ## Effect on the pipeline -- **Logical dimensions unchanged** (identity) β€” the box is the same; only which cells contribute changes. -- **1:1 or 1:0 mapping** β€” each logical light maps to itself (one physical position) if its square is "on", or to **nothing** if "off". The "drop" is expressed as `outCount = 0` from `mapToPhysical`; `Layer::rebuildLUT` records that as a logical light with no destination (the same zero-destination path the sparse layout translation already uses), so a dropped light simply doesn't appear in the driver buffer. `maxMultiplier()` is 1 β€” it never fans out. +- **Logical box unchanged** β€” a mask doesn't resize the box (no `modifyLogicalSize`); only which cells contribute changes. +- **Pass or drop** β€” `modifyLogical` returns `true` to pass a light through unchanged, or `false` to drop it (an "off" square), so a dropped physical light has no logical source and stays dark. - **Square parity**: a light at `(x,y,z)` belongs to square `(x/size, y/size, z/size)`; the square is "on" when the sum of those indices is even (flipped by `invert`). ## Cross-domain wiring -A Layer applies its first enabled modifier during `rebuildLUT`; modifier chaining (where Checkerboard-then-Multiply differs from Multiply-then-Checkerboard) is not implemented β€” only the first enabled modifier applies. See [architecture.md Β§ Modifiers](../../../architecture.md#modifiers). The mask integrates with no `ModifierBase` contract change because the contract already permits a logical light to map to zero physical positions. +A Layer folds all its enabled modifiers as a chain (Checkerboard-then-Multiply differs from Multiply-then-Checkerboard). The fold + reject contract is in [ModifierBase](../ModifierBase.md). ## Tests @@ -31,7 +31,7 @@ A Layer applies its first enabled modifier during `rebuildLUT`; modifier chainin ### MoonLight β€” M_MoonLight.h Checkerboard ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) -MoonLight's Checkerboard drops lights by setting `position.x = UINT16_MAX` (a sentinel the layout pass skips), with `size`, `invert`, and a `group` flag. We express the drop as `outCount = 0` (our equivalent, no sentinel needed) and start with `size` + `invert`; `group` is deferred. +MoonLight's Checkerboard drops lights by setting `position.x = UINT16_MAX` (a sentinel the layout pass skips), with `size`, `invert`, and a `group` flag. We express the drop as `modifyLogical` returning `false` (no sentinel needed) and start with `size` + `invert`; `group` is deferred. ## Source diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md index b8efe1c..2249c39 100644 --- a/docs/moonmodules/light/modifiers/MultiplyModifier.md +++ b/docs/moonmodules/light/modifiers/MultiplyModifier.md @@ -20,13 +20,13 @@ The defaults (`multiply 2/2/1`, `mirror all on`) reproduce the canonical mirror- ## Effect on the pipeline - **Logical box shrinks by the multiplier**: `logW = physW / multiplyX` (etc.). 128Γ—128 with multiply 2/2 β†’ 64Γ—64 logical (the effect renders a quarter of the lights). The effective multiplier clamps to the axis extent β€” `multiplyZ` on a depth-1 layout clamps to 1 (no-op), never blanking the layer. -- **LUT produces 1:N mappings**: each logical light maps to `multiplyXΒ·multiplyYΒ·multiplyZ` physical positions (the tiles). There is **no fixed fan-out cap** β€” `Layer::rebuildLUT` sizes a per-light scratch buffer to the modifier's `maxMultiplier()` (heap, cold path), so the full fan-out is emitted, limited only by memory (an alloc failure degrades to the identity LUT). +- **Fan-out is the fold**: each physical light folds (`pos % logicalSize`) onto its logical cell, so the `multiplyXΒ·multiplyYΒ·multiplyZ` physical lights of the tiles all land on one logical light β€” the 1:N mapping emerges from the build with no fan-out list and no cap (see [ModifierBase Β§ Fan-out is free](../ModifierBase.md)). - **Tile vs fold**: with mirror **off** on an axis, tiles repeat (translate); with mirror **on**, odd-numbered tiles reflect within their tile (`size βˆ’ 1 βˆ’ pos`), so multiply 2 + mirror = a fold. - **Integer division**: a physical extent not divisible by the multiplier leaves uncovered cells at the high edge (they map to nothing) β€” the same edge behaviour the old mirror had on odd widths, without a shared centre line. ## Cross-domain wiring -A Layer applies its first enabled modifier during `rebuildLUT` β€” modifier chaining (where order matters, e.g. multiply-then-checkerboard β‰  checkerboard-then-multiply) is not implemented; see [architecture.md Β§ Modifiers](../../../architecture.md#modifiers). `mapToPhysical` emits physical box indices; the Layer translates them to driver indices and drops any that fall outside the real light set. +A Layer folds all its enabled modifiers as a chain (order matters: multiply-then-checkerboard β‰  checkerboard-then-multiply). The fold contract is in [ModifierBase](../ModifierBase.md). ## Tests diff --git a/docs/moonmodules/light/modifiers/RegionModifier.md b/docs/moonmodules/light/modifiers/RegionModifier.md index 97d2abd..09117cb 100644 --- a/docs/moonmodules/light/modifiers/RegionModifier.md +++ b/docs/moonmodules/light/modifiers/RegionModifier.md @@ -2,38 +2,42 @@ Static modifier. Carves the layer down to a sub-rectangle of the physical bounding box: the effect renders only inside the region, everything outside is dark. The region is given as **percentages of the physical extent on each axis**, so it survives a physical resize β€” a `0..50` region stays the left half whether the panel is 64 or 128 wide. Default `0..100` on every axis is the full box (an identity carve). -A Layer applies only its **first enabled modifier**, so today a Layer uses *either* Region *or* another modifier (Multiply, …) at a time. Region and Multiply are independent, so stacking them (occupy a region *and* tile/mirror within it β€” Region then Multiply) is planned via [modifier chaining](../../../backlog/README.md). +Region and Multiply are independent and **compose**: a layer can occupy a region *and* be tiled/mirrored within it (Region then Multiply), since a Layer folds its whole enabled modifier chain in order (see [ModifierBase](../ModifierBase.md)). ## Controls - `startX` / `startY` / `startZ` (Int16, default 0) β€” region start, as a percentage of physical width / height / depth. - `endX` / `endY` / `endZ` (Int16, default 100) β€” region end, as a percentage of physical width / height / depth. -`Int16` (not a 0–100 slider) so negative and >100 values round-trip through `/api/state`, `/api/types`, and persistence; the carve math clamps them into the box. +`Int16`, not a 0–100 slider: the UI renders an unbounded int16 as a **βˆ’100..200 percentage slider** so the window can slide **off-screen** (negative start / >100 end). Values round-trip through `/api/state`, `/api/types`, and persistence. ## Region math -Per axis, **half-open** `[startPixel, endPixel)`: +Per axis, **half-open** `[startPixel, endPixel)`, **un-clamped** to the box: -- `startPixel = floor(start% / 100 Β· extent)`, clamped to `[0, extent-1]`. -- `endPixel = ceil(end% / 100 Β· extent)`, **exclusive**, clamped to `[startPixel+1, extent]`. -- region size = `endPixel βˆ’ startPixel` (always β‰₯ 1). +- `startPixel = floor(start% / 100 Β· extent)` β€” may be negative. +- `endPixel = ceil(end% / 100 Β· extent)`, **exclusive** β€” may exceed `extent`. +- window size = `endPixel βˆ’ startPixel` (floored to β‰₯ 1 on a non-empty axis). -Half-open is what makes abutting regions **tile exactly**: a `0..50` and a `50..100` layer split a 128-wide axis into pixels `0..63` and `64..127` β€” no overlap, no gap. `start` floors and `end` ceils so a small panel never rounds to an empty region (`start 33 / end 66` on a 4-wide axis β†’ `floor(1.32)=1` .. `ceil(2.64)=3` β†’ pixels 1, 2). Default `end 100` on a `W`-wide axis β†’ `ceil(W)=W` β†’ the full width. +Half-open makes abutting windows **tile exactly**: a `0..50` and a `50..100` layer split a 128-wide axis into pixels `0..63` and `64..127` β€” no overlap, no gap. `start` floors and `end` ceils so a small panel never rounds to an empty window. Default `0..100` on a `W`-wide axis β†’ the full width. + +### Off-screen windows (move, don't rescale) + +The window's **logical size is the full `start..end` span**, so the effect always renders at a fixed scale β€” moving `start` and `end` together slides the window without resizing it (like dragging an OS window). A physical light outside the window is dropped; window cells with no physical light under them (the off-screen part) stay dark. A window slid **entirely** off the box (e.g. `start=-100, end=0`) maps no lights β€” the layer goes dark, the way you move an effect completely out of view. Because the span is fixed, sweeping `start`/`end` translates an effect across and off the panel without distorting it. ## Effect on the pipeline -- **Logical dimensions = the region size** β€” `logicalDimensions()` reports the carved rectangle, so the Layer's render buffer (and the Layer status line `wΓ—hΓ—d`) shrinks to the region. The effect only ever renders the region; the rest of the layer has no logical source and stays dark. This is the same "the box is smaller than the physical box" mechanism a Mirror modifier uses. -- **1:1 mapping with a start offset** β€” `mapToPhysical()` translates a region-local cell `(lx,ly,lz)` to the box cell `(lx+startPixelX, ly+startPixelY, lz+startPixelZ)`, a single destination. Because the logical box is already the region size, every region cell is in-bounds; no per-cell drop is needed. `maxMultiplier()` is 1 β€” it never fans out. +- **Logical box = the region size** β€” `modifyLogicalSize` shrinks the box to the carved rectangle, so the Layer's render buffer (and the status line `wΓ—hΓ—d`) shrinks to the region. The effect only ever renders the region. +- **Fold + reject** β€” `modifyLogical` folds a physical light into region-local space (subtract the start offset) and returns `false` for any physical light outside the region, so everything beyond the region stays dark. A 1:1 fold, never fans out. - **Fast path**: the cheapest carve is *no modifier at all* β€” then `Layer::rebuildLUT` keeps its identity-memcpy / sparse fast path with zero carving cost. The default is to not add a RegionModifier; a full-region `0..100` one is correct but not the absolute cheapest, so full-coverage layers simply omit it. ## Cross-domain wiring -A Layer applies its first enabled modifier during `rebuildLUT`. Region is a normal `ModifierBase` (no contract change) β€” it expresses carving entirely through the existing `logicalDimensions()` + `mapToPhysical()` virtuals, the same two the LUT builder already calls. See [architecture.md Β§ Modifiers](../../../architecture.md#layers-and-layer). +Region is a normal `ModifierBase` β€” carving is its `modifyLogicalSize` + `modifyLogical` fold, composed into the chain like any modifier. See [ModifierBase](../ModifierBase.md). ## Tests -[Unit tests: RegionModifier](../../../tests/unit-tests.md#regionmodifier) β€” the region math (full box, exact half, abutting-tile, small-panel rounding, β‰₯1-pixel floor, out-of-range clamp, degenerate axes) and the coordinate offset mapping. [Unit tests: Layer](../../../tests/unit-tests.md#layer) adds the integration case: a RegionModifier shrinks the Layer's logical box to the region and the LUT maps only region cells. +[Unit tests: RegionModifier](../../../tests/unit-tests.md#regionmodifier) β€” the region math (full box, exact half, abutting-tile, small-panel rounding, β‰₯1-pixel floor, off-screen / fully-off / wider-than-box windows, degenerate axes) and the coordinate offset mapping. [Unit tests: Layer](../../../tests/unit-tests.md#layer) adds the integration case: a RegionModifier shrinks the Layer's logical box to the region and the LUT maps only region cells. ## Prior art diff --git a/docs/moonmodules/light/modifiers/RotateModifier.md b/docs/moonmodules/light/modifiers/RotateModifier.md index 1dbf957..2de85ff 100644 --- a/docs/moonmodules/light/modifiers/RotateModifier.md +++ b/docs/moonmodules/light/modifiers/RotateModifier.md @@ -1,20 +1,18 @@ # RotateModifier -A **dynamic modifier** that rotates the 2D image around its centre, turning continuously over time. Like [RandomMapModifier](RandomMapModifier.md), the rotation is a coordinate remap baked into the [Layer](../Layer.md)'s LUT; the angle advances on a `speed` timer and the LUT rebuilds when the angle crosses to a new step β€” not every frame. +A **dynamic modifier** that rotates the 2D image around its centre, turning continuously over time. The one modifier that overrides `modifyLive` (per-frame, no mapping rebuild) β€” so the rotation is smooth, and the Layer runs its live pass only because this modifier is present (a static-only chain pays nothing per frame; see [ModifierBase](../ModifierBase.md)). Also the codebase's **transform-matrix reference**. ## Controls -- `speed` β€” rotation speed (1–255, default 1). Higher turns faster and rebuilds the LUT more often (still bounded β€” a rebuild fires only on an angle-step change, not per frame). +- `speed` β€” rotation speed (1–255, default 1). `loop()` advances the angle on the timer; `modifyLive` applies it on the next frame (no rebuild). ## How it works -The angle is a `uint8_t` (256 steps per turn). Each destination light at (dx, dy) from the centre samples the **source** at the inverse rotation β€” `sx = dxΒ·cosΞΈ + dyΒ·sinΞΈ`, `sy = βˆ’dxΒ·sinΞΈ + dyΒ·cosΞΈ` β€” using the project's [`cos8`/`sin8`](../../core/Control.md) integer LUT (signed component `val βˆ’ 128`, divided back by 128), with nearest-neighbour sampling (no float, no bilinear). A source that falls outside the grid is dropped (that light goes dark at this angle), the same `outCount=0` path [CheckerboardModifier](CheckerboardModifier.md) uses. The per-frame tick (`loop()`, the dynamic-modifier hook also used by RandomMapModifier) advances the angle and, on a step change, calls the Layer's `onBuildState()` to rebuild the LUT β€” no per-frame allocation, no `Layer::render` coupling. - -2D only: the z axis passes through unchanged. +`loop()` advances the angle on the `speed` timer; rotation is applied each frame in the Layer's live pass (`modifyLive`), not baked into the mapping β€” so a `speed` change is a cheap live edit, no rebuild. A source that rotates outside the box leaves that destination dark. 2D only: the z axis passes through. The integer 2Γ—2-matrix backward map is in the header. ## Prior art -- **MoonLight β€” M_MoonLight.h Rotate / PinWheel** β€” a per-light `modifyXYZ()` coordinate transform on the hot path. This version carries the transform in the LUT instead, reusing the dynamic-modifier `loop()` hook and the existing rebuild path rather than a render-time transform. +- **MoonLight β€” M_MoonLight.h Rotate / PinWheel** β€” a per-light `modifyXYZ()` coordinate transform. Our `modifyLive` is the same per-frame hook; we carry an explicit rotation matrix. ## Source diff --git a/docs/moonmodules/light/moonlive/MoonLiveEffect.md b/docs/moonmodules/light/moonlive/MoonLiveEffect.md new file mode 100644 index 0000000..667b95e --- /dev/null +++ b/docs/moonmodules/light/moonlive/MoonLiveEffect.md @@ -0,0 +1,48 @@ +# MoonLive + +MoonLive is projectMM's **live-script engine** β€” author an effect as text and run it on a running device, compiled to native machine code so it executes at near-hand-written speed in the render hot path. The broader design lives in [livescripts-analysis-top-down.md](../../../backlog/livescripts-analysis-top-down.md) (a backlog design study); this page documents the module. + +A scripted effect carries its **script source** as an editable, persisted multi-line text control (a resizable `textarea` in the UI), and a front-end (lexer β†’ parser β†’ IR β†’ per-ISA assembler) compiles it to native code on the next tick. The grammar is a function-call statement with **expression arguments** β€” any argument may be a literal or a nested call: + +``` +setRGB(random16(256), 0, 0, 255); // a random pixel, blue +setRGB(5, random16(256), 0, 0); // pixel 5, a random red +fill(0, 0, 255); // every light blue +``` + +The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random16` are registered by the *host* (the light domain) in a builtin table; the core compiler owns only the grammar and a generic call/inline mechanism (the ESPLiveScript / ARTI bound-function model). The compiler emits machine code for whichever ISA the device runs (Xtensa on the classic/S3) or the host ISA on desktop, places it in executable memory, and the engine calls it each render tick. + +## Controls + +- `source` β€” the script text (default `fill(0, 0, 255);` β€” solid blue). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot). + +## Pieces + +- **`MoonLive`** (`src/core/moonlive/MoonLive.h/.cpp`) β€” the **domain-neutral engine core**. Owns a block of executable memory; `compile(source, table)` runs the front-end against a host builtin table and places the emitted code, `run(buf, nLights, cpl, t)` calls it. Includes only ``, the compiler/emitter seams, and the platform seam β€” never `EffectBase`, `Buffer`, or any LED type. +- **`MoonLiveBuiltins`** (`src/core/moonlive/MoonLiveBuiltins.h`) β€” the **neutral host-binding seam**: a `BuiltinTable` of `{name β†’ descriptor}`, where a descriptor is either `Call` (a host C function pointer β€” a pure helper like `random16`) or `Inline` (a neutral opcode tag the backend emits inline β€” the hot-path buffer writers, no per-pixel call). The core owns no function names; it resolves a call against whatever the host registered. +- **`MoonLiveCompiler`** (`src/core/moonlive/MoonLiveCompiler.h/.cpp`) β€” the **platform-independent front-end**: a recursive-descent lexer + expression parser that lowers each statement to the typed IR (`MoonLiveIr.h`). Pure (source + table in, IR out, deterministic). Knows the *language*, never an ISA and never a domain. +- **`MoonLiveBuiltins_light`** (`src/light/moonlive/MoonLiveBuiltins_light.h`) β€” the **light-domain registration**: the only place the LED vocabulary lives. Registers `setRGB`/`fill` (Inline, lowering to RGB stores) and `random16` (Call). A different host (display, sensor) writes its own table; the core is unchanged. +- **per-ISA assembler + lowering** (`src/platform//moonlive_asm_*` + `moonlive_lower_*`) β€” a tiny named-instruction MacroAssembler with label back-patching, and the IRβ†’bytes lowering that drives it. Xtensa for the classic/S3 (`__XTENSA__`), the host ISA on desktop (arm64/x86-64). Adding an ISA is a new assembler + lowering; the front-end and IR are unchanged. (`emitFill`/`emitAnimatedFill` remain as the hand-encoded `fill` references the assembler's output is checked against.) +- **`MoonLiveEffect`** (`src/light/moonlive/MoonLiveEffect.h`) β€” the **thin binding**: a first-class `EffectBase` carrying the `source` control, whose `loop()` delegates to the engine over its own `buffer()` and passes the light builtin table to `compile`. The engine is projectMM-agnostic; the binding is the only coupled layer. + +## Cross-domain wiring + +- **The executable-memory seam** is new platform surface (`src/platform/platform.h`): `allocExec(size)` / `freeExec(ptr,size)` allocate memory the CPU can *fetch* from (ESP32 IRAM via `MALLOC_CAP_EXEC`; an `mmap` `PROT_EXEC` page on desktop, with macOS-arm64 `MAP_JIT` + a write-protect toggle), and `writeExec(dst,src,len)` copies emitted code in safely β€” on ESP32 that means 32-bit-aligned IRAM stores plus an instruction-cache sync so the core fetches fresh code, not stale cache. All ISA/cache quirks live behind these three functions; the engine stays target-agnostic. +- **The producer buffer**: the emitted routine writes the same `buffer()` + `nrOfLights()*channelsPerLight()` surface a compiled effect writes β€” the identity-mapping fast path, no intermediate copy. The binding hands the engine `(buffer(), nrOfLights(), channelsPerLight())` each tick. +- A failed compile (no executable memory) leaves the effect `!ok()`: it renders dark and reports the error in its module status β€” the device keeps running (robustness, no reboot). + +## Prior art + +MoonLive's native-codegen approach β€” compile a small C-like language straight to machine code and call it as a function, so a live-authored effect runs at near hand-written speed β€” was pioneered by **Yves Bazin (hpwit)** in **[ESPLiveScript](https://github.com/hpwit/ESPLiveScript)**: a from-scratch tokenizer, parser, and Xtensa code generator that drives a 12,288-LED panel at ~85 fps where interpreted languages (Lua, Gravity) managed 3–10. That result is what makes "go native, not interpreted" the right call, and ESPLiveScript is the reference MoonLive is built against β€” studied closely, credited, and written fresh against projectMM's architecture, never copied, per [*Industry standards, our own code*](../../../../CLAUDE.md#principles). The live-scripting idea in this ecosystem also descends from **ARTI-FX / ARTI** (the interpreted-effects runtime in WLED MoonModules), which proved the load-a-script-and-run-it-live loop end to end. The host-binding surface (`setRGB`/`setRGBXY`/`setRGBXYZ`) is modelled on the **MoonLight** [effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/). + +## Tests + +[unit_moonlive_fill](../../../../test/unit/core/unit_moonlive_fill.cpp) runs the engine path in-process on the desktop host backend (`compile`/`run`, the animated routine, zero-lights, recompile, `free`, the `allocExec`/`writeExec`/`freeExec` round-trip, the buffer-shape guards). [unit_moonlive_ir](../../../../test/unit/core/unit_moonlive_ir.cpp) pins the **behavioral golden** β€” a compiled `fill` and the hand-encoded reference render an identical buffer β€” plus setRGB's single-pixel write and the runtime bounds guard. [unit_moonlive_compiler](../../../../test/unit/core/unit_moonlive_compiler.cpp) pins the expression grammar (`random16` in any/every argument slot, uint16 bounds), the parser diagnostics (no crash on malformed input), live recompile, and the **domain-neutral** property: with an empty builtin table the core knows *no* functions, and a host can register an arbitrary name against the same machinery. + +The grammar + bounds guard are verified live on the S3/Olimex (Xtensa) by editing the `source` control β€” the device compiles the expression on-chip and renders it. + +[scenario_MoonLiveEffect_livescript](../../../../test/scenarios/light/scenario_MoonLiveEffect_livescript.json) exercises the effect **as a wired MoonModule** β€” what the unit tests can't reach: add it, live-edit the `source` to recolour (recompile), push a broken script (`MoonLive::compile` fails, frees the previous code, `MoonLiveEffect` reports the parse error in the status and renders dark β€” no crash), recover, resize the grid to 1Γ—1 and back while rendering (the every-grid-size hard rule), then remove and re-add (exec memory re-acquired clean). It runs in-process on the desktop backend each commit, and the same JSON runs live over REST against the device backends. The Xtensa/RISC-V backends are validated by the live S3/P4 runs (a `MoonLiveEffect` on a Layer lights the grid from its `source`), which the desktop tests can't reach. + +## Source + +[MoonLive.h](../../../../src/core/moonlive/MoonLive.h) Β· [MoonLiveBuiltins.h](../../../../src/core/moonlive/MoonLiveBuiltins.h) Β· [MoonLiveCompiler.h](../../../../src/core/moonlive/MoonLiveCompiler.h) Β· [MoonLiveIr.h](../../../../src/core/moonlive/MoonLiveIr.h) Β· [MoonLiveBuiltins_light.h](../../../../src/light/moonlive/MoonLiveBuiltins_light.h) Β· [MoonLiveEffect.h](../../../../src/light/moonlive/MoonLiveEffect.h) diff --git a/docs/performance.md b/docs/performance.md index 7eab440..ba1a956 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -218,6 +218,18 @@ The cheapest (Lines, Checkerboard, PlasmaPalette) clear ~100 FPS even at 16K; th **Free internal heap** holds ~8.54 MB at small grids and ~8.46–8.49 MB at 16K β€” the ~50–100 KB delta is just the grid-sized render buffer (the `model` array), and it returns to ~8.54 MB whenever the grid shrinks: **no leak, no fragmentation creep** across the sweep. Largest free internal block stays ~90–110 KB throughout. (Internal RAM is not the constraint on this PSRAM board; the Layer buffer is in PSRAM.) +### MoonLive (scripted effect) β€” tick + memory + +A `MoonLiveEffect` compiles its `source` text to native Xtensa once on the cold path (`onBuildState`), then `run()` is a single function-pointer call each tick. Measured on the S3 at 16Γ—16 (the bench grid the engine is exercised on; the per-tick cost is the native loop, not interpretation): + +| Script | Tick (Β΅s) | Exec block (heap) | +|--------|----:|----:| +| `setRGB(5, 255, 0, 0)` (one pixel) | 26 | ~52 B | +| `setRGB(random16(256), 0, 255, 0)` (one host call) | 29 | ~140 B | +| `fill(0, 0, 255)` (loop over all lights) | 47 | ~68 B | + +The tick cost is native-code speed β€” a `setRGB` is a bounds-guard + three byte stores (~26 Β΅s including the per-tick module overhead), `fill` adds the per-light loop. The **exec block scales with the program**, not a fixed cap: a one-liner is tens of bytes of machine code (`place()` allocates the emitted length, word-rounded), reported as the module's dynamic memory (`setDynamicBytes(codeLen())`) so it shows on the UI card. At rest the engine itself is ~48 B of members + that exec block; the compile path's transient buffers (staging, IR, assembler β‰ˆ 4 KB) live on the cold-path stack and are freed on return β€” see [docs/backlog/livescripts-analysis-top-down.md Β§ 3.7](backlog/livescripts-analysis-top-down.md) for how this scales as the language grows. + --- ## Incremental cost analysis (`scenario_perf_light` / `scenario_perf_full`) diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md index ee139e8..d98db6f 100644 --- a/docs/tests/scenario-tests.md +++ b/docs/tests/scenario-tests.md @@ -295,6 +295,51 @@ Add NetworkSendDriver and run the bounded FPS measurement on the no-LUT path. - `pc-macos`: contract set 2026-06-02 "initial contract" Β· observed 2026-06-02 β†’ 2026-06-05 - `pc-windows`: observed 2026-06-07 +### scenario_modifier_chain + +`test/scenarios/light/scenario_modifier_chain.json` β€” Stack TWO modifiers on one Layer (Region then Multiply) and verify the chain composes live end-to-end β€” the capability the old single-modifier engine couldn't do. Prepares its own canvas: Layout(Grid 32x32) + Layer + NoiseEffect + Region(0..50) + Multiply(2x), measures the composite, then adds a third (Checkerboard mask) and measures again, then removes the middle modifier and measures β€” exercising add/remove on a multi-modifier chain. A broken fold (null buffer, wrong light count, crash on a disabled/removed stage) shows up as a failed measure. The fold composition + order semantics are pinned by unit_Layer_modifier_chain; this is the live end-to-end gate. + +**Mode**: `mutate` Β· **Also touches**: RegionModifier, MultiplyModifier, CheckerboardModifier, RotateModifier, NoiseEffect, Layouts, GridLayout, Drivers, NetworkSendDriver + +#### `add-mask` (add_module) πŸ“ + +Add a third modifier (Checkerboard mask) on top of the chain β€” a 3-deep fold. Measure that the deeper chain still renders. + +**Setup** (preceding non-measured steps): +- `region-then-multiply` (measure) β€” Two stacked modifiers: Region(top-left quarter) then Multiply(2x mirror) compose into one mapping. Measure the live composite. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 142,857-200,000 | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-26 + +#### `remove-middle` (remove_module) πŸ“ + +Remove the middle modifier (Multiply) β€” the chain re-folds with Region then Checkerboard, no stale state. Measure. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 45,455-55,556 | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-26 + +#### `add-live-rotate` (add_module) πŸ“ + +Add a DYNAMIC Rotate on top of the static chain β€” its modifyLive runs the per-frame remap pass over the composed buffer. Verifies a static chain + a live modifier coexist (the buffer is remapped each frame on top of the baked Region/Checkerboard mapping) without a crash or null buffer. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | β€” / 25,641-28,571 | β€” / unlimited | β€” / unlimited | + +- `pc-macos`: observed 2026-06-26 + ### scenario_modifier_swap `test/scenarios/light/scenario_modifier_swap.json` β€” Swap the Layer's modifier between Multiply and Checkerboard and verify the pipeline stays live across each replace. Prepares its own canvas (clear + rebuild) so it runs from any device state: one Layout(Grid 32x32) + one Layer + one effect + one modifier, then replace_module cycles the modifier MOD slot Multiply -> Checkerboard -> Multiply, measuring after each so a broken swap (null buffer / wrong light count) shows up. Exercises the modifier-replace path the UI's drag-replace uses. @@ -319,10 +364,16 @@ Multiply modifier active β€” pipeline live, LUT folds the grid. | Board | FPS | heap | block | |---|---|---|---| +| `esp32` | β€” / 1,783-2,179 | β€” / 145KB | β€” / 108KB | | `esp32-eth` | β€” / 1,580-7,752 | β€” / 172KB-225KB | β€” / 76KB-108KB | +| `esp32p4-eth` | β€” / 5,587-6,061 | β€” / 33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 1,773-2,571 | β€” / 8350KB | β€” / 92KB | | `pc-macos` | β€” / 50,000-166,667 | β€” / unlimited | β€” / unlimited | +- `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 +- `esp32p4-eth`: observed 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-25 - `pc-macos`: observed 2026-06-07 β†’ 2026-06-21 #### `checkerboard` (measure) πŸ“ @@ -336,10 +387,16 @@ Checkerboard modifier active β€” masks half the lights; pipeline stays live (dri | Board | FPS | heap | block | |---|---|---|---| +| `esp32` | β€” / 892-922 | β€” / 145KB | β€” / 108KB | | `esp32-eth` | β€” / 769-990 | β€” / 170KB-225KB | β€” / 76KB-108KB | +| `esp32p4-eth` | β€” / 2,747-2,762 | β€” / 33242KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 924-943 | β€” / 8349KB | β€” / 92KB | | `pc-macos` | β€” / 15,873-58,824 | β€” / unlimited | β€” / unlimited | +- `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 +- `esp32p4-eth`: observed 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-25 - `pc-macos`: observed 2026-06-07 β†’ 2026-06-25 #### `multiply-2` (measure) πŸ“ @@ -353,10 +410,16 @@ Back to Multiply β€” replace round-trips cleanly, pipeline live again. | Board | FPS | heap | block | |---|---|---|---| +| `esp32` | β€” / 2,079-2,208 | β€” / 145KB | β€” / 108KB | | `esp32-eth` | β€” / 1,587-2,278 | β€” / 169KB-225KB | β€” / 76KB-108KB | +| `esp32p4-eth` | β€” / 6,329-6,410 | β€” / 33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 2,146-2,604 | β€” / 8349KB-8350KB | β€” / 92KB | | `pc-macos` | β€” / 45,455-166,667 | β€” / unlimited | β€” / unlimited | +- `esp32`: observed 2026-06-25 - `esp32-eth`: observed 2026-06-07 β†’ 2026-06-08 +- `esp32p4-eth`: observed 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-25 - `pc-macos`: observed 2026-06-07 β†’ 2026-06-25 ### scenario_perf_full @@ -381,14 +444,14 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 7,752 | β€” / 134KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 14,925-17,241 | β€” / 33226KB-33244KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 5,376-9,009 | β€” / 8340KB-8346KB | β€” / 104KB-112KB | +| `esp32` | β€” / 7,692-8,929 | β€” / 134KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 14,925-17,544 | β€” / 33226KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 5,376-9,009 | β€” / 8340KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-no-audio` (measure) πŸ“ @@ -400,14 +463,14 @@ Bare minimum at 16Β²: Grid + Layer + Checkerboard, no output driver, audio/disco | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 8,621 | β€” / 134KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 18,182-18,868 | β€” / 33228KB-33244KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 8,065-9,901 | β€” / 8338KB-8346KB | β€” / 104KB-112KB | +| `esp32` | β€” / 8,621-9,901 | β€” / 134KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 18,182-18,868 | β€” / 33228KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 8,065-9,901 | β€” / 8338KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-quiet` (measure) πŸ“ @@ -421,14 +484,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 8,621 | β€” / 131KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 17,544-18,519 | β€” / 33226KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 8,696-9,901 | β€” / 8337KB-8346KB | β€” / 100KB-112KB | +| `esp32` | β€” / 7,246-9,901 | β€” / 131KB-146KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 17,544-18,519 | β€” / 33226KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 7,752-9,901 | β€” / 8337KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-modifier` (measure) πŸ“ @@ -440,14 +503,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 3,175 | β€” / 130KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 9,434-10,638 | β€” / 33224KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 3,413-4,237 | β€” / 8336KB-8344KB | β€” / 104KB-112KB | +| `esp32` | β€” / 2,786-3,610 | β€” / 130KB-145KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 8,772-10,638 | β€” / 33224KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 3,413-4,237 | β€” / 8336KB-8350KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-preview` (measure) πŸ“ @@ -460,14 +523,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 8,696 | β€” / 123KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33228KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 8,065-9,434 | β€” / 8335KB-8346KB | β€” / 92KB-112KB | +| `esp32` | β€” / 8,696-9,524 | β€” / 123KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 15,873-18,182 | β€” / 33228KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 8,065-9,434 | β€” / 8335KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 200,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-network` (measure) πŸ“ @@ -479,14 +542,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 7,194 | β€” / 131KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 14,493-17,544 | β€” / 33226KB-33240KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 7,092-8,065 | β€” / 8334KB-8344KB | β€” / 84KB-112KB | +| `esp32` | β€” / 6,098-7,194 | β€” / 131KB-145KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 14,493-17,544 | β€” / 33226KB-33244KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 6,452-8,065 | β€” / 8334KB-8351KB | β€” / 84KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-rmt` (measure) πŸ“ @@ -499,14 +562,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 6,579 | β€” / 106KB | β€” / 84KB | -| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33200KB-33219KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 8,333-9,259 | β€” / 8307KB-8321KB | β€” / 84KB-112KB | +| `esp32` | β€” / 6,579-9,174 | β€” / 106KB-122KB | β€” / 84KB-108KB | +| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33200KB-33221KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 7,194-9,346 | β€” / 8307KB-8328KB | β€” / 84KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-lcd` (measure) πŸ“ @@ -519,14 +582,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 8,403 | β€” / 126KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 16,129-17,857 | β€” / 33225KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 7,042-9,259 | β€” / 8336KB-8345KB | β€” / 92KB-112KB | +| `esp32` | β€” / 8,403-9,901 | β€” / 126KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33225KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 7,042-9,259 | β€” / 8333KB-8352KB | β€” / 88KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-parlio` (measure) πŸ“ @@ -539,14 +602,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 8,475 | β€” / 135KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33225KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 7,692-9,434 | β€” / 8338KB-8346KB | β€” / 104KB-112KB | +| `esp32` | β€” / 8,475-9,901 | β€” / 135KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 15,873-17,857 | β€” / 33225KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 7,692-9,434 | β€” / 8338KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-light-16` (measure) πŸ“ @@ -561,14 +624,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 6,711 | β€” / 134KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 15,385-18,868 | β€” / 33226KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 8,403-9,901 | β€” / 8336KB-8346KB | β€” / 100KB-112KB | +| `esp32` | β€” / 6,711-9,804 | β€” / 134KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 15,385-18,868 | β€” / 33226KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 8,403-9,901 | β€” / 8336KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-light-32` (measure) πŸ“ @@ -581,14 +644,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 2,801 | β€” / 134KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 7,246-7,519 | β€” / 33225KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 3,049-3,597 | β€” / 8331KB-8343KB | β€” / 100KB-112KB | +| `esp32` | β€” / 2,801-3,367 | β€” / 134KB-144KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 7,246-7,576 | β€” / 33225KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 3,049-3,597 | β€” / 8331KB-8350KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 333,333-1,000,000 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-light-64` (measure) πŸ“ @@ -601,14 +664,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 872 | β€” / 125KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 2,008-2,212 | β€” / 33218KB-33232KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 917-1,011 | β€” / 8312KB-8334KB | β€” / 88KB-112KB | +| `esp32` | β€” / 870-928 | β€” / 125KB-135KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 2,008-2,232 | β€” / 33218KB-33234KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 894-1,011 | β€” / 8312KB-8341KB | β€” / 88KB-112KB | | `pc-macos` | β€” / 12,658-250,000 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-light-128` (measure) πŸ“ @@ -621,14 +684,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 229 | β€” / 89KB | β€” / 62KB | -| `esp32p4-eth` | β€” / 515-573 | β€” / 33182KB-33196KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 126-134 | β€” / 8291KB-8298KB | β€” / 100KB-112KB | +| `esp32` | β€” / 224-238 | β€” / 89KB-99KB | β€” / 62KB | +| `esp32p4-eth` | β€” / 515-573 | β€” / 33182KB-33198KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 114-134 | β€” / 8291KB-8305KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 5,348-62,500 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-heavy-16` (measure) πŸ“ @@ -642,14 +705,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 990 | β€” / 136KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 2,899-3,311 | β€” / 33229KB-33243KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 1,148-1,361 | β€” / 8342KB-8346KB | β€” / 108KB-112KB | +| `esp32` | β€” / 990-1,224 | β€” / 136KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 2,865-3,367 | β€” / 33229KB-33245KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 1,100-1,361 | β€” / 8342KB-8352KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 62,500-333,333 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-heavy-32` (measure) πŸ“ @@ -662,14 +725,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 312 | β€” / 134KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 799-893 | β€” / 33227KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 290-356 | β€” / 8339KB-8343KB | β€” / 108KB-112KB | +| `esp32` | β€” / 306-314 | β€” / 134KB-144KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 799-898 | β€” / 33227KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 290-356 | β€” / 8339KB-8350KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 15,152-71,429 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-heavy-64` (measure) πŸ“ @@ -682,14 +745,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 73.8 | β€” / 125KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 196-229 | β€” / 33218KB-33232KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 87.9-90.3 | β€” / 8330KB-8334KB | β€” / 108KB-112KB | +| `esp32` | β€” / 73.8-79.4 | β€” / 125KB-135KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 196-229 | β€” / 33218KB-33234KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 85.2-90.3 | β€” / 8330KB-8341KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 2,924-16,129 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-21 #### `measure-heavy-128` (measure) πŸ“ @@ -702,14 +765,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 16.0 | β€” / 89KB | β€” / 62KB | -| `esp32p4-eth` | β€” / 55.5-57.4 | β€” / 33182KB-33196KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 19.6-20.8 | β€” / 8293KB-8298KB | β€” / 104KB-112KB | +| `esp32` | β€” / 16.0-19.0 | β€” / 89KB-99KB | β€” / 62KB | +| `esp32p4-eth` | β€” / 53.7-57.4 | β€” / 33182KB-33198KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 19.2-20.8 | β€” / 8293KB-8305KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 1,094-3,247 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-mod-16` (measure) πŸ“ @@ -723,14 +786,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 2,193 | β€” / 135KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 6,098-6,494 | β€” / 33224KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 2,193-2,618 | β€” / 8340KB-8344KB | β€” / 108KB-112KB | -| `pc-macos` | β€” / 333,333-1,000,000 | β€” / unlimited | β€” / unlimited | +| `esp32` | β€” / 2,020-2,222 | β€” / 135KB-145KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 5,263-6,494 | β€” / 33224KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 2,193-2,618 | β€” / 8340KB-8350KB | β€” / 92KB-112KB | +| `pc-macos` | β€” / 250,000-1,000,000 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-mod-32` (measure) πŸ“ @@ -743,14 +806,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 553 | β€” / 130KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 1,631-1,876 | β€” / 33218KB-33235KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 600-710 | β€” / 8329KB-8337KB | β€” / 100KB-112KB | -| `pc-macos` | β€” / 90,909-333,333 | β€” / unlimited | β€” / unlimited | +| `esp32` | β€” / 547-586 | β€” / 130KB-140KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 1,631-1,876 | β€” / 33218KB-33237KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 600-710 | β€” / 8329KB-8344KB | β€” / 92KB-112KB | +| `pc-macos` | β€” / 5,882-333,333 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-26 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-mod-64` (measure) πŸ“ @@ -763,14 +826,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 144 | β€” / 111KB | β€” / 96KB | -| `esp32p4-eth` | β€” / 438-486 | β€” / 33194KB-33208KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 153-162 | β€” / 8307KB-8311KB | β€” / 108KB-112KB | +| `esp32` | β€” / 144-149 | β€” / 111KB-122KB | β€” / 96KB-100KB | +| `esp32p4-eth` | β€” / 438-486 | β€” / 33194KB-33210KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 119-162 | β€” / 8307KB-8317KB | β€” / 92KB-112KB | | `pc-macos` | β€” / 23,256-71,429 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-26 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-mod-128` (measure) πŸ“ @@ -783,14 +846,14 @@ Quiet baseline: render-only, audio + discovery off. The cleanest render floor; t | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 35.1 | β€” / 36KB | β€” / 26KB | -| `esp32p4-eth` | β€” / 98.2-102 | β€” / 33089KB-33103KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 29.5-35.6 | β€” / 8202KB-8205KB | β€” / 108KB-112KB | -| `pc-macos` | β€” / 5,263-16,129 | β€” / unlimited | β€” / unlimited | +| `esp32` | β€” / 29.8-35.1 | β€” / 36KB-47KB | β€” / 24KB-26KB | +| `esp32p4-eth` | β€” / 86.3-102 | β€” / 33089KB-33105KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 16.8-35.6 | β€” / 8202KB-8212KB | β€” / 92KB-112KB | +| `pc-macos` | β€” / 5,128-16,129 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 ### scenario_perf_light @@ -817,14 +880,14 @@ Bare minimum: Grid(16Β²) + Layer + Checkerboard (light effect). No modifier, no | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 6,173-8,772 | β€” / 125KB-131KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 13,699-18,519 | β€” / 33228KB-33244KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 6,711-8,850 | β€” / 8316KB-8339KB | β€” / 80KB-104KB | +| `esp32` | β€” / 6,173-8,850 | β€” / 125KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 13,699-18,519 | β€” / 33228KB-33246KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 5,814-8,850 | β€” / 8316KB-8347KB | β€” / 80KB-104KB | | `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-24 #### `measure-with-modifier` (measure) πŸ“ @@ -838,14 +901,14 @@ Cost of the modifier + LUT over the minimal pipeline. Heap delta vs measure-mini | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 3,077-3,289 | β€” / 131KB-135KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 9,615-10,204 | β€” / 33226KB-33242KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 3,922-4,032 | β€” / 8330KB-8335KB | β€” / 96KB-100KB | +| `esp32` | β€” / 3,077-9,709 | β€” / 131KB-147KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 8,621-10,309 | β€” / 33226KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 3,195-4,032 | β€” / 8330KB-8345KB | β€” / 92KB-100KB | | `pc-macos` | β€” / β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 #### `measure-with-preview` (measure) πŸ“ @@ -856,14 +919,14 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 3,247-3,289 | β€” / 132KB-133KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 10,526-10,753 | β€” / 33226KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 4,115-4,202 | β€” / 8330KB-8334KB | β€” / 96KB-100KB | +| `esp32` | β€” / 3,067-9,804 | β€” / 132KB-146KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 10,417-10,753 | β€” / 33226KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 3,802-4,274 | β€” / 8330KB-8345KB | β€” / 84KB-100KB | | `pc-macos` | β€” / β€” | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 #### `measure-heavy-16` (measure) πŸ“ @@ -875,14 +938,14 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 1,905-3,268 | β€” / 131KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 5,556-6,494 | β€” / 33224KB-33241KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 2,463-2,506 | β€” / 8332KB-8333KB | β€” / 88KB-100KB | +| `esp32` | β€” / 1,142-3,268 | β€” / 131KB-146KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 5,556-6,494 | β€” / 33224KB-33243KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 2,299-2,506 | β€” / 8332KB-8342KB | β€” / 88KB-100KB | | `pc-macos` | β€” / 333,333-1,000,000 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-heavy-32` (measure) πŸ“ @@ -895,14 +958,14 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 539-826 | β€” / 130KB | β€” / 108KB | -| `esp32p4-eth` | β€” / 1,818-1,880 | β€” / 33221KB-33235KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 562-715 | β€” / 8330KB-8333KB | β€” / 100KB-104KB | +| `esp32` | β€” / 265-826 | β€” / 130KB-144KB | β€” / 108KB | +| `esp32p4-eth` | β€” / 1,603-1,880 | β€” / 33221KB-33237KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 562-715 | β€” / 8328KB-8333KB | β€” / 84KB-104KB | | `pc-macos` | β€” / 90,909-333,333 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-22 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 - `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 #### `measure-heavy-64` (measure) πŸ“ @@ -915,15 +978,15 @@ PreviewDriver is the pre-wired apparatus β€” it survives clear_children and is a | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | β€” / 151-227 | β€” / 111KB | β€” / 88KB-96KB | -| `esp32p4-eth` | β€” / 473-491 | β€” / 33195KB-33208KB | β€” / 376KB | -| `esp32s3-n16r8` | β€” / 146-157 | β€” / 8305KB-8307KB | β€” / 96KB-108KB | -| `pc-macos` | β€” / 22,727-71,429 | β€” / unlimited | β€” / unlimited | +| `esp32` | β€” / 77.1-227 | β€” / 111KB-135KB | β€” / 88KB-108KB | +| `esp32p4-eth` | β€” / 411-491 | β€” / 33195KB-33210KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 129-162 | β€” / 8302KB-8317KB | β€” / 92KB-108KB | +| `pc-macos` | β€” / 20,000-71,429 | β€” / unlimited | β€” / unlimited | -- `esp32`: observed 2026-06-17 -- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-22 -- `esp32s3-n16r8`: observed 2026-06-17 -- `pc-macos`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32p4-eth`: observed 2026-06-17 β†’ 2026-06-25 +- `esp32s3-n16r8`: observed 2026-06-17 β†’ 2026-06-25 +- `pc-macos`: observed 2026-06-17 β†’ 2026-06-26 ## Layers @@ -955,7 +1018,7 @@ Add NetworkSendDriver and run the bounded FPS measurement over the two-layer com | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | β€” / 6,135-10,417 | β€” / unlimited | β€” / unlimited | +| `pc-macos` | β€” / 6,135-18,519 | β€” / unlimited | β€” / unlimited | - `pc-macos`: observed 2026-06-25 @@ -1052,6 +1115,142 @@ Pipeline renders with the single remaining grid, same as the baseline. - `pc-macos`: observed 2026-06-05 - `pc-windows`: observed 2026-06-07 +## MoonLiveEffect + +### scenario_MoonLiveEffect_livescript + +`test/scenarios/light/scenario_MoonLiveEffect_livescript.json` β€” Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end β€” the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill colour recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate. + +**Mode**: `mutate` Β· **Also touches**: Layouts, GridLayout, Layers, Layer, Drivers, NetworkSendDriver + +#### `add-moonlive` (add_module) πŸ“ + +Add a MoonLiveEffect to the Layer. Its default source `fill(0, 0, 255);` compiles on-device to native code; measure that the wired effect renders. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 88.6 | β€” / 33211KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 249 | β€” / 8341KB | β€” / 104KB | +| `pc-macos` | β€” / 2,545-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `edit-source-red` (set_control) πŸ“ + +Live-edit the script source to a new colour. A source edit triggers a recompile (controlChangeTriggersBuildState gates on `source`); the new native code swaps in and keeps rendering. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 98.4 | β€” / 33213KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 225 | β€” / 8341KB | β€” / 104KB | +| `pc-macos` | β€” / 2,513-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `edit-source-broken` (set_control) πŸ“ + +Push a script that fails to parse. The compile fails, the engine reports the diagnostic in the module status and renders dark, but the device keeps running (robust, no reboot) β€” the script-editor failure path. The measure passes because the pipeline still ticks. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 94.6 | β€” / 33209KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 229 | β€” / 8340KB | β€” / 104KB | +| `pc-macos` | β€” / 3,745-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `edit-source-recover` (set_control) πŸ“ + +Push a valid script again. The engine recompiles cleanly and rendering resumes β€” a broken edit is fully recoverable. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 93.4 | β€” / 33212KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 248 | β€” / 8340KB | β€” / 100KB | +| `pc-macos` | β€” / 2,415-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `shrink-grid-1x1` (set_control) πŸ“ + +Resize the canvas to 1x1 while the scripted effect renders β€” the smallest non-empty grid. The native fill loops over a single light; the run guards (non-null buffer, cpl>=3) keep it in-bounds. Pins the 'runs at every grid size' hard rule for the JIT'd routine. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 862 | β€” / 33215KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 868 | β€” / 8341KB | β€” / 100KB | +| `pc-macos` | β€” / 1,000,000-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `grow-grid-back` (set_control) πŸ“ + +Resize back to a wider grid; the effect keeps rendering across the live dimension change (the no-reboot reconfiguration contract applied to scripted code). + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 97.0 | β€” / 33209KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 136 | β€” / 8333KB | β€” / 100KB | +| `pc-macos` | β€” / 71,429-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `remove-moonlive` (remove_module) πŸ“ + +Remove the scripted effect. teardown frees the exec block; the Layer keeps rendering (now empty). Measures add/remove robustness. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 88.2 | β€” / 33209KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 135 | β€” / 8333KB | β€” / 100KB | +| `pc-macos` | β€” / 100,000-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + +#### `re-add-moonlive` (add_module) πŸ“ + +Re-add a MoonLiveEffect after removal β€” the exec memory is re-acquired fresh, no leak, no stale pointer. The scripted effect renders again. + +**Performance** (contract / observed) β€” tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32p4-eth` | β€” / 90.9 | β€” / 33209KB | β€” / 376KB | +| `esp32s3-n16r8` | β€” / 121 | β€” / 8332KB | β€” / 100KB | +| `pc-macos` | β€” / 62,500-β€” | β€” / unlimited | β€” / unlimited | + +- `esp32p4-eth`: observed 2026-06-27 +- `esp32s3-n16r8`: observed 2026-06-27 +- `pc-macos`: observed 2026-06-26 β†’ 2026-06-27 + ## MoonModule ### scenario_MoonModule_control_change diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 362864a..f2fa29b 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -6,7 +6,14 @@ idf_component_register( "../../src/core/HttpServerModule.cpp" "../../src/core/FilesystemModule.cpp" "../../src/core/Scheduler.cpp" + "../../src/core/moonlive/MoonLive.cpp" + "../../src/core/moonlive/MoonLiveCompiler.cpp" "../../src/platform/esp32/platform_esp32.cpp" + "../../src/platform/esp32/moonlive_emit.cpp" + "../../src/platform/esp32/moonlive_asm_xtensa.cpp" + "../../src/platform/esp32/moonlive_lower_xtensa.cpp" + "../../src/platform/esp32/moonlive_asm_riscv.cpp" + "../../src/platform/esp32/moonlive_lower_riscv.cpp" "../../src/platform/esp32/platform_esp32_fs.cpp" "../../src/platform/esp32/platform_esp32_ota.cpp" "../../src/platform/esp32/platform_esp32_httpget.cpp" diff --git a/esp32/sdkconfig.defaults.esp32p4-eth b/esp32/sdkconfig.defaults.esp32p4-eth index 59cf6bb..eeb2528 100644 --- a/esp32/sdkconfig.defaults.esp32p4-eth +++ b/esp32/sdkconfig.defaults.esp32p4-eth @@ -31,3 +31,11 @@ CONFIG_ETH_USE_ESP32_EMAC=y CONFIG_ETH_DMA_BUFFER_SIZE=512 CONFIG_ETH_DMA_RX_BUFFER_NUM=10 CONFIG_ETH_DMA_TX_BUFFER_NUM=10 + +# MoonLive native codegen needs an executable heap (allocExec β†’ MALLOC_CAP_EXEC). Like the +# S3, the P4 has hardware memory protection (PMP) that IDF couples to disabling the exec +# heap, so disable memprot to make MALLOC_CAP_EXEC available β€” the standard ESP32-JIT config. +# Safety for scripted code is the staged bounds/watchdog checks, not the hardware W^X wall. +# (Same rationale as sdkconfig.defaults.esp32s3-n16r8.) +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n +CONFIG_HEAP_HAS_EXEC_HEAP=y diff --git a/esp32/sdkconfig.defaults.esp32s3-n16r8 b/esp32/sdkconfig.defaults.esp32s3-n16r8 index d698eb2..50e2ae1 100644 --- a/esp32/sdkconfig.defaults.esp32s3-n16r8 +++ b/esp32/sdkconfig.defaults.esp32s3-n16r8 @@ -12,3 +12,13 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # PSRAM CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=y + +# MoonLive native codegen needs an executable heap (allocExec β†’ MALLOC_CAP_EXEC IRAM). +# MALLOC_CAP_EXEC is gated behind CONFIG_HEAP_HAS_EXEC_HEAP, which IDF disables whenever +# memory protection (PMP/PMS W^X) is on β€” the same write-XOR-execute rule we handle on +# macOS. A JIT fundamentally needs writable-then-executable memory, so disable memprot +# (which enables the exec heap). This is the standard ESP32-JIT configuration (ESPLiveScript +# runs the same way); the safety story for scripted code is the staged bounds/watchdog +# checks, not the hardware W^X wall. +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n +CONFIG_HEAP_HAS_EXEC_HEAP=y diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 1cbea20..e4c44bf 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -28,6 +28,7 @@ const char* controlTypeName(ControlType t) { case ControlType::Pin: return "pin"; case ControlType::Bool: return "bool"; case ControlType::Text: return "text"; + case ControlType::TextArea: return "textarea"; case ControlType::Password: return "password"; case ControlType::ReadOnly: return "display"; case ControlType::ReadOnlyInt: return "display-int"; @@ -86,9 +87,10 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { sink.append(*static_cast(c.ptr) ? "true" : "false"); return; case ControlType::Text: + case ControlType::TextArea: case ControlType::Password: case ControlType::ReadOnly: - // All three are char-buffer-backed. Password is rendered as a + // All char-buffer-backed. Password is rendered as a // plain JSON string here; the HTTP API obfuscates separately // at the writeControls call site (persistence writes plaintext). // writeJsonString walks the source straight into the sink with @@ -192,6 +194,7 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { // Everything else: no extras. case ControlType::Bool: case ControlType::Text: + case ControlType::TextArea: case ControlType::Password: case ControlType::ReadOnly: case ControlType::IPv4: @@ -262,8 +265,10 @@ ApplyResult applyControlValue(const ControlDescriptor& c, *static_cast(c.ptr) = mm::json::parseBool(json, key); return ApplyResult::Ok; case ControlType::Text: + case ControlType::TextArea: case ControlType::Password: { - // Password parses identically to Text β€” only serialization differs. + // TextArea and Password parse identically to Text β€” only the UI render + // (TextArea) or serialization (Password) differs. // c.max is the buffer size; parseString writes up to maxLen-1 then // NUL-terminates, so passing c.max gives "fill the buffer". uint8_t maxLen = static_cast(c.max > 0 ? c.max : 16); diff --git a/src/core/Control.h b/src/core/Control.h index df1558b..0b5aa42 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -89,6 +89,10 @@ enum class ControlType : uint8_t { // across chips. Serializes/parses as a plain integer. Bool, Text, + TextArea, // multi-line text β€” identical char-buffer storage and parse/persist + // path to Text; differs only in the UI type string so the front-end + // renders a resizable