diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d42f18a..a4b3d349 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 - name: Verify tag matches library.json version if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') || (github.event_name == 'workflow_dispatch' && startsWith(inputs.tag, 'v')) # Pass the tag via --tag arg, not via an env var named GITHUB_REF_NAME. @@ -63,7 +64,7 @@ jobs: # warning on the direct ${{ }} expansion inside a shell string. env: TAG: ${{ inputs.tag || github.ref_name }} - run: python scripts/build/verify_version.py --tag "$TAG" + run: uv run scripts/build/verify_version.py --tag "$TAG" build-esp32: needs: verify-version @@ -148,26 +149,37 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v4 + # CMakeLists.txt calls find_program(UV_EXECUTABLE … REQUIRED) so the + # build-host Python (gzip / build_info.h) is reached through uv. The + # runners don't ship uv by default — install it before package_desktop. + - uses: astral-sh/setup-uv@v3 - name: Build + package macOS arm64 - run: python scripts/build/package_desktop.py + run: uv run scripts/build/package_desktop.py - uses: actions/upload-artifact@v4 with: name: desktop-macos path: dist/ - # build-windows: pending the Windows platform-layer port. The job was - # removed (rather than left red) so the release pipeline can produce a - # tagged 1.0 from the matching `dist/projectMM-macos-arm64-vX.Y.Z.tar.gz` - # + the four ESP32 firmwares. Re-add the build-windows job and the - # corresponding `dist/projectMM-*.zip` glob in `release` once the port - # lands; the surrounding scaffolding stays identical. + build-windows: + needs: verify-version + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + # Same uv prerequisite as build-macos — see the comment there. + - uses: astral-sh/setup-uv@v3 + - name: Build + package Windows x64 + run: uv run scripts/build/package_desktop.py + - uses: actions/upload-artifact@v4 + with: + name: desktop-windows + path: dist/ release: # Publish a release on: version tag push, main branch push, or workflow_dispatch. # Plain branch push without matching paths is filtered by the top-level `paths:` # so the build jobs already won't run; this condition is the belt to their braces. if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' - needs: [build-esp32, build-macos] + needs: [build-esp32, build-macos, build-windows] runs-on: ubuntu-latest permissions: contents: write # softprops/action-gh-release needs this @@ -272,6 +284,7 @@ jobs: --pattern 'firmware-*.bin' \ --pattern 'manifest-*.json' \ --pattern 'projectMM-*.tar.gz' \ + --pattern 'projectMM-*.zip' \ || echo " (skip $T — not yet released or no matching assets)" done @@ -282,6 +295,7 @@ jobs: cp dist/firmware-*.bin "pages/install/releases/$TAG/" || true cp pages-manifests/manifest-*.json "pages/install/releases/$TAG/" cp dist/projectMM-*.tar.gz "pages/install/releases/$TAG/" || true + cp dist/projectMM-*.zip "pages/install/releases/$TAG/" || true echo "Pages release tree:" find pages/install/releases -maxdepth 2 -type f | sort @@ -357,11 +371,11 @@ jobs: fail_on_unmatched_files: true # The `files:` block below is a multi-line literal string — every line # is a glob pattern, the action does NOT strip `#` as comment syntax. - # Add `dist/projectMM-*.zip` back when build-windows is restored. files: | dist/firmware-*.bin dist/manifest-*.json dist/projectMM-*.tar.gz + dist/projectMM-*.zip - id: deploy-pages uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 1f1e541b..ee67994c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,12 @@ /build/ /build-*/ +# Packaging output from scripts/build/package_desktop.py — the .tar.gz / .zip +# bundles attached to GitHub releases. CI is the authoritative producer +# (release.yml on tag/main push); local runs are scratch for dev verification. +# Never commit these — they bloat the repo and can drift from source. +/dist/ + # CMake generated files CMakeFiles/ CMakeCache.txt diff --git a/CLAUDE.md b/CLAUDE.md index 6dfc2ac4..7cf4b7d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ See `docs/architecture.md` for system design. This file contains only rules and - **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too — delete it. (Reuse a recognisable shape rather than inventing one — see *Common patterns first* above.) - **Data over objects in the hot path.** Where speed and memory matter most, design around plain contiguous data, not an object graph: a flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). This is a deliberate performance choice — a contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. The one deliberate class hierarchy is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. Don't add inheritance elsewhere, and don't wrap hot-path buffer data in objects. - **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works. +- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls — add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept — this principle is about what the software accepts as input. - **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation. - **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking). @@ -32,6 +33,8 @@ The design rationale for each rule below lives in [docs/architecture.md](docs/ar **Effects must run at every grid size and tick rate.** No crash on 0×0×0; animation math doesn't truncate to zero on fast devices. Full rule + rationale: [architecture.md § Effects](docs/architecture.md#effects). +**Robust to any input.** No UI action or API-call sequence crashes or wedges a running device — including deleting, replacing, or clearing modules in any order. A crash or hang is a bug, not the user's fault; the fix is incomplete until a test reproduces the sequence. Full rule + rationale: [architecture.md § Robustness](docs/architecture.md#robustness). + ## Process Rules **Specs before code.** Module docs (`docs/moonmodules/*.md`) and the UI spec must be sufficient to implement from before writing code. What's sufficient is case by case. When in doubt, ask. @@ -50,6 +53,10 @@ Then check the recommendation against [§ Principles](#principles) (minimalism, **Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. +**Use `uv` for every Python invocation.** Never type `python` or `python3` directly — always go through `uv run` (e.g. `uv run scripts/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([scripts/MoonDeck.md](scripts/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`. + +The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's bundled Python venv, not the project venv — adding uv to ESP-IDF docker would be a bigger CI lift than the portability win pays for. That file uses `find_package(Python3 REQUIRED COMPONENTS Interpreter)` and invokes `${Python3_EXECUTABLE}`, so CMake locates whichever Python IDF set up (`.venv\Scripts\python.exe` on Windows IDF, `.venv/bin/python3` on macOS/Linux IDF). The shared `src/ui/embed_ui.cmake` script takes a `PYTHON_CMD` parameter that callers pass: desktop passes `${UV_EXECUTABLE};run;python`, ESP32 passes `${Python3_EXECUTABLE}`. + **Consider extending before creating.** When adding a feature, check if an existing module can be extended cleanly. If a new file is genuinely cleaner, that's fine — but justify it. **Do not remove comments** unless they are outdated or factually wrong. Comments document intent and context. Removing them silently loses knowledge. @@ -112,7 +119,7 @@ A commit that touches *only* `.github/`, `docs/`, `scripts/` (non-test), `README **When "commit now" is received** — compile the commit message in this format and execute the commit: -8. Commit message format: +8. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line): - **Title line** — short imperative summary of the change (≤ 72 chars), e.g. `Add MirrorModifier and fix PreviewDriver sampling` - **Short summary** — a TL;DR for the commit: 1–3 sentences max, end-user readable, plain language. State *what* changed and *why* at the level a release-notes reader cares about — do NOT recap the change sections that follow (the bullets do that), and do NOT enumerate files. If your draft is longer than three sentences or restates section headers, cut it. A reader who only sees the title + this paragraph should know what shipped and why. - **KPI one-liner** — the `tick:Xus(FPS:Y)` line from step 7. Omit if the KPI gate didn't run (no `src/` changes). @@ -185,8 +192,10 @@ docs/ backlog.md ← the prioritised to-build list moonmodules_draft/ ← draft specs for unimplemented modules (promoted out as they ship) history/ ← backward-looking: accumulated wisdom + README.md ← index: what's here + cross-repo trends + digest prompt decisions.md ← actions, lessons, proven patterns *-inventory.md ← prior-project surveys (v1, v2, moonlight) + .md ← friend-repo monthly activity digests (FastLED, WLED, …) moonmodules/ ← one page per MoonModule (specs before code) ``` diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c6e054c..8591f272 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,13 +9,39 @@ set(CMAKE_CXX_EXTENSIONS OFF) # desktop build runs cleanly on Windows runners (no -W flags on MSVC) and on # macOS/Linux (no /W flags on Clang/GCC). if(MSVC) - add_compile_options(/W4 /WX) + # /W4 /WX matches the GCC/Clang `-Wall -Wextra -Werror` discipline. A few + # MSVC-only warnings have no GCC equivalent at -Wall -Wextra and are + # suppressed below rather than fought at every call site: + # C4702 — "unreachable code" inside `if constexpr` early-return branches + # (a well-known MSVC quirk: it analyses the discarded branch as + # live and flags everything after `return;`). GCC understands the + # constexpr discard and doesn't warn. + # C4244 / C4267 — implicit narrowing (int→smaller, size_t→smaller). GCC + # only warns under -Wconversion, which is not part of -Wall + # -Wextra. The codebase uses explicit static_cast at the sites + # where loss-of-data is the real concern; suppressing here brings + # MSVC noise level back in line with GCC. + add_compile_options(/W4 /WX /wd4702 /wd4244 /wd4267) + # MSVC marks the standard C library (strcpy, sprintf, fopen, localtime, …) + # as deprecated and emits C4996; /WX makes that fatal. Silence the family — + # the code uses these standard functions deliberately and consistently with + # the POSIX side, and the "secure" `_s` variants are a Microsoft extension. + add_compile_definitions(_CRT_SECURE_NO_WARNINGS _CRT_NONSTDC_NO_WARNINGS + _WINSOCK_DEPRECATED_NO_WARNINGS) # Static MSVC runtime so the Windows binary doesn't need vcredist. set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") else() add_compile_options(-Wall -Wextra -Werror) endif() +# `uv` is the project's Python launcher (see CLAUDE.md / scripts/MoonDeck.md). +# The build invokes Python helpers for build_info.h generation and UI embedding; +# resolving them through `uv run python …` keeps Windows (where `python3` isn't +# on PATH) on the same path as macOS / Linux without a `find_package(Python3)` +# detour and without per-host scaffolding. +find_program(UV_EXECUTABLE NAMES uv REQUIRED + HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin") + # Core library. Most modules ship header-only (single .h file with implementation # inline). Core service modules that bridge to the platform (HTTP server, # filesystem, network, …) ship as .h + .cpp per CLAUDE.md — list those .cpps @@ -33,28 +59,37 @@ target_link_libraries(mm_core PUBLIC mm_platform) # Platform library (desktop) add_library(mm_platform src/platform/desktop/platform_desktop.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>) # Generate build_info.h from library.json (carries version, build date, board name). add_custom_command( OUTPUT ${CMAKE_SOURCE_DIR}/src/core/build_info.h - COMMAND ${CMAKE_COMMAND} -E env python3 ${CMAKE_SOURCE_DIR}/scripts/build/generate_build_info.py + COMMAND ${UV_EXECUTABLE} run python ${CMAKE_SOURCE_DIR}/scripts/build/generate_build_info.py DEPENDS ${CMAKE_SOURCE_DIR}/library.json ${CMAKE_SOURCE_DIR}/scripts/build/generate_build_info.py COMMENT "Generating build_info.h" ) add_custom_target(build_info_gen DEPENDS ${CMAKE_SOURCE_DIR}/src/core/build_info.h) -# Embed UI files as C arrays +# Embed UI files as C arrays. Pass UV_EXECUTABLE through as a single value +# (no semicolons on the command line — those would either get split by `make` +# on POSIX hosts or land as literals in the value on MSBuild) and let +# embed_ui.cmake assemble the ` run python` list internally. add_custom_command( OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h - COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake + COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake COMMENT "Embedding UI files" ) add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h) -# mm_core's HttpServerModule.cpp consumes ui_embedded.h — wire the dep so a -# clean build orders them correctly. -add_dependencies(mm_core ui_embed) +# mm_core's HttpServerModule.cpp consumes ui_embedded.h and SystemModule.h +# (a mm_core public header) consumes build_info.h. Anything that links mm_core +# transitively includes those headers, so both generated files must exist +# before any mm_core compile unit starts. CI's parallel clean build exposes +# the race (test/ compiles SystemModule.h while build_info_gen is still +# running); wiring the dep here makes the ordering explicit on every consumer. +add_dependencies(mm_core ui_embed build_info_gen) # Application add_executable(projectMM src/main.cpp src/platform/desktop/main_desktop.cpp) diff --git a/README.md b/README.md index 7d4c0264..694c9381 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, - **Effects, modifiers, layouts, drivers** (output currently ArtNet, plus the built-in 3D preview) — all pluggable, all configurable live, all persisted across reboots. - **One firmware, many devices.** ESP32, Teensy, Raspberry Pi, Windows / macOS / Linux desktop — the same source builds for each. - **Native 3D** from the start. 2D and 1D are the cases where one or two dimensions are size 1; effects don't pick a mode. -- **Built-in browser UI.** The interface renders any module from its declared controls — adding a new effect needs zero UI code. +- **Built-in browser UI.** The interface renders any module from its declared controls — adding a new module needs zero UI code. - **DMX and addressable LEDs in the same setup.** RGB strips, RGBW pixels, multi-channel par lights, moving heads — all addressed through the same pipeline. ## Performance -What projectMM delivers, measured end-to-end through a full render pipeline — an effect, a modifier, and an output driver (the canonical sweep runs NoiseEffect → MirrorModifier XY → ArtNet, but any effect/modifier/driver combination runs the same path). **FPS shown is computed from the underlying tick measurement (FPS = 1,000,000 / tick_us)** — tick is the unit the contracts and assertions actually use; FPS is the headline number. +What projectMM delivers, measured end-to-end through a full render pipeline — an effect, a modifier, and an output driver (the canonical sweep runs NoiseEffect → MultiplyModifier XY (mirror fold) → ArtNet, but any effect/modifier/driver combination runs the same path). **FPS shown is computed from the underlying tick measurement (FPS = 1,000,000 / tick_us)** — tick is the unit the contracts and assertions actually use; FPS is the headline number. Every measurement below comes from a real scenario run on the listed board — `test/scenarios/light/scenario_GridLayout_grid_sizes.json` is the canonical sweep. Per-step `contract.` blocks carry the promises the device must hit; per-step `observed.` blocks carry the latest reading. @@ -56,9 +56,14 @@ The numbers above are observations. The **contracts** projectMM commits to — w ![Installer](docs/assets/screenshots/installer.png) -**Desktop — download and run.** Grab the macOS arm64 tarball from the [releases page](https://github.com/ewowi/projectMM/releases), unpack, run, open `http://localhost:8080/`. The binary is unsigned, so Gatekeeper prompts on first run — right-click → Open, or clear the quarantine flag with `xattr -dr com.apple.quarantine ./projectMM`. macOS arm64 is the only desktop binary that ships today; Windows needs its platform-layer port first. +**Desktop — download and run.** Grab the build for your OS from the [releases page](https://github.com/ewowi/projectMM/releases): -Once running, the UI lets you build a render pipeline visually (layouts → layers with effects + modifiers → drivers), preview the result in 3D, and save it. The source tree also builds for Teensy, Raspberry Pi, and Linux from source — see [building.md](docs/building.md) — though only the macOS and ESP32 binaries ship as releases. +- **macOS arm64:** `projectMM-macos-arm64-vX.Y.Z.tar.gz` — unpack, run `./projectMM`. The binary is unsigned, so Gatekeeper prompts on first run — right-click → Open, or clear the quarantine flag with `xattr -dr com.apple.quarantine ./projectMM`. +- **Windows x64:** `projectMM-windows-x64-vX.Y.Z.zip` — unzip, double-click `projectMM.exe`. SmartScreen may warn on first run because the binary is unsigned (More info → Run anyway). + +Then open `http://localhost:8080/`. + +Once running, the UI lets you build a render pipeline visually (layouts → layers with effects + modifiers → drivers), preview the result in 3D, and save it. The source tree also builds for Teensy, Raspberry Pi, and Linux from source — see [building.md](docs/building.md) — though only the macOS, Windows, and ESP32 binaries ship as releases. ### From source @@ -113,7 +118,7 @@ This is the current iteration of years of LED / light system development. Each p | **StarLight** | Standalone LED firmware | [ewowi/StarLight](https://github.com/ewowi/StarLight) | | **MoonLight** | Ground-up build: 60+ effects, memory-optimised mapping, 11 driver types | [MoonModules/MoonLight](https://github.com/MoonModules/MoonLight) | -Their lessons and proven patterns are distilled in [`docs/history/`](docs/history/) — the codebase this project cherry-picks from, never porting wholesale. +Their lessons and proven patterns are distilled in [`docs/history/`](docs/history/README.md) — the codebase this project cherry-picks from, never porting wholesale. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index efa65f21..b2bf78e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,7 @@ This document describes the system as it is. Coding conventions live in [coding- - [Parallelism](#parallelism) - [Data exchange between modules](#data-exchange-between-modules) - [Event triggering between modules](#event-triggering-between-modules) + - [Robustness](#robustness) - [Hot path discipline](#hot-path-discipline) - [Platform abstraction](#platform-abstraction) - [Firmware vs board](#firmware-vs-board) @@ -152,6 +153,19 @@ This is the recognised layout/prepare-pass pattern: JUCE's `prepareToPlay` and U If a module needs to actively notify a specific other module of an event (rather than publish data for polling, or change its own controls), the pattern is a direct method call from the producer to a known consumer — `ImprovProvisioningModule::loop1s` calls `networkModule_->setWifiCredentials(...)` when credentials arrive over UART. No event bus; the producer holds a pointer to the consumer set at wiring time (`main.cpp`). Pub/sub becomes the right pattern only when there are multiple unknown subscribers per event — projectMM has none today. +## Robustness + +A running device must tolerate **any sequence of UI actions or API calls** — add, delete, replace, move, or reconfigure any module in any order, at any grid size — and keep running. Degraded or idle is an acceptable outcome; a crash, a hang, or a boot loop is not. This is a defining strongpoint: the device is something an end user can poke at freely without bricking it. + +The contract is bounded to **what the software accepts as input**. Power loss, a malformed OTA image, a brown-out, or electrical faults are out of scope — the firmware can't intercept those. Everything that arrives through the HTTP API, the WebSocket, or the UI is in scope. + +Why this needs stating as its own guarantee: the mutation-driven rebuild above ([§ Event triggering](#event-triggering-between-modules)) means a single API call can free and rebuild a large slice of the module tree mid-render. The hazard is **stale references** — a module holding a pointer to something that was just torn down. The two patterns that keep it safe: + +- **Resolve links at `onBuildState`, don't cache them across mutations.** A module that depends on another (a `Drivers` reading the active `Layer`, a `Layer` reading its `Layouts`) re-resolves that link from the tree at every rebuild rather than pinning a pointer once at wiring time. When the dependency is gone, the link resolves to null — not to freed memory. +- **Tolerate null at the point of use.** Every consumer of a resolved link null-checks it and falls back to an idle state (no buffer, zero lights, nothing sent) rather than dereferencing. A driver with no Layer sends nothing; a Layer with no Layouts reports zero lights. Idle, not crashed. + +The enforcement is the test framework, not discipline alone (see the [Hard Rule](../CLAUDE.md#hard-rules)). When a sequence is found that crashes or wedges the device, the fix is **incomplete until a test reproduces that sequence** — so the same break can't return. Worked example: deleting the last Layer once left `Drivers` holding a dangling pointer to the freed Layer; `PreviewDriver` then read it and panicked (`LoadProhibited`), and because the tree persists, the device boot-looped. The fix made `Drivers` clear its drivers' Layer pointers to null when no Layer is active, and a regression test (`unit_PreviewDriver`, "tolerates the active Layer being deleted") drives a Layer delete + rebuild and asserts the driver ends up null, not dangling. The scenario layer adds the same coverage end-to-end: `clear_children` lets a scenario clear a container and rebuild its own pipeline from any starting tree, so the delete/rebuild path is exercised on real hardware, not just in unit tests. + ## Hot path discipline The render loop (`Scheduler::tick` and everything it calls — every effect, modifier, driver, layout) is the hot path. It runs roughly 50–10000 times per second depending on light count. Code there obeys three rules: @@ -279,7 +293,7 @@ A **Layer** (a MoonModule, child of Layers) owns: A layer can have **multiple effects**. Effects are not blended — they write to the buffer sequentially in their listed order, each overwriting or adding to the previous. That allows stacked patterns (a base-colour effect followed by a sparkle effect). -A layer can have **multiple modifiers**. Static modifiers chain during LUT build; dynamic modifiers chain during rendering. Order matters: mirror-then-rotate differs from rotate-then-mirror. +A layer applies its **first enabled modifier** during LUT build (`Layer::rebuildLUT`). Modifier *chaining* — applying several in sequence — is not implemented: only the first enabled modifier takes effect. Order matters for a chain (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror), which is why modifiers are reorderable in the UI even though only the first is applied today. Chaining is on the [backlog](backlog/backlog.md) — static modifiers chain during LUT build, dynamic modifiers during rendering. 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. @@ -331,7 +345,7 @@ A modifier can: - 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. -**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. **MirrorModifier** is D3 (it has independent mirrorX/Y/Z toggles). +**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). ## Mapping and blending diff --git a/docs/backlog/backlog.md b/docs/backlog/backlog.md index 03198ace..867c0fd6 100644 --- a/docs/backlog/backlog.md +++ b/docs/backlog/backlog.md @@ -6,25 +6,16 @@ Completed items are removed. This file is deleted when empty. ## Distribution -### Windows desktop port (blocker for 1.0 Windows binary) - -`src/platform/desktop/platform_desktop.cpp` won't compile under MSVC — it uses POSIX socket headers (`sys/socket.h`, `sendmsg`, `fcntl`, …) with no MSVC equivalent. The `build-windows` CI job and the `dist/projectMM-*.zip` upload are disabled in `release.yml` until this lands. - -Two approaches: -1. **Conditional includes in `platform_desktop.cpp`** — `#ifdef _WIN32` blocks around every socket call. Smallest diff; local damage inside `src/platform/`. -2. **Split into `platform_desktop_posix.cpp` + `platform_desktop_windows.cpp`** — CMake picks per host. Mirrors the ESP32/desktop split already in place. - -Either path: ~2–3 h translation + Windows-side testing. Once green, `build-windows` flips and the v1.0.0 Windows zip ships automatically. - ### Release 2.0 — distribution catches up to the source tree -1.0 ships ESP32 firmware (4 variants) + macOS arm64. Still to add: +1.0 ships ESP32 firmware (4 variants) + macOS arm64 + Windows x64. Still to add: - **ESP32-P4** firmware variant — new chip target, new sdkconfig fragment, fits the existing `FIRMWARES` table in `build_esp32.py`. - **Linux desktop binary** — third desktop job in `release.yml`, static-linked libstdc++. - **Teensy 4.1** — toolchain-file build, `.hex` for Teensy Loader. - **Raspberry Pi** — ARM64, cross-built or native. - **macOS code-signing** — drops the Gatekeeper "downloaded from internet" prompt. +- **Windows code-signing** — drops the SmartScreen warning on first run of `projectMM.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. - **Runtime PHY / pin config for Ethernet** — replaces build-time Olimex-pin baking in `sdkconfig.defaults.eth` with a runtime picker via `platform::ethPresent()` / `platform::wifiPresent()`. Once landed, `esp32-eth*` variants stop being Olimex-specific; NetworkModule's `onBuildControls()` flips the `hidden` flag on absent-interface controls. - **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. @@ -65,13 +56,24 @@ What we know: - `build/esp32-esp32-eth/sdkconfig` and `build/esp32-esp32-eth-wifi/sdkconfig` are **byte-identical** (3,617 lines each, `cmp -s` confirms). So lwIP buffer pools, DHCP timeouts, and Ethernet driver settings are the same. - Same hardware (Olimex ESP32-Gateway Rev G), same RMII pin/clock config (`EMAC_CLK_OUT` on GPIO17), same `ethInit()` code in `src/platform/esp32/platform_esp32.cpp`. - The only difference at link time: `esp32-eth` passes `EXCLUDE_COMPONENTS=esp_wifi;wpa_supplicant;esp_coex` to ESP-IDF (see `scripts/build/build_esp32.py:31`). -- `esp_coex` (WiFi/Bluetooth coexistence) is normally responsible for shared-radio timing arbitration. Hypothesis: even though Ethernet doesn't share the radio, something in `esp_coex` or its early init dependency chain warms a clock path (the shared 26 MHz crystal feeding both WiFi PHY and EMAC PLL) that helps Ethernet auto-negotiation. With it excluded, the EMAC takes longer to stabilise. +- `esp_coex` (WiFi/Bluetooth coexistence) was an early hypothesis: even though Ethernet doesn't share the radio, `esp_coex`'s init might warm a shared clock path that helps Ethernet auto-negotiation, and the eth-only build excludes it. **Disproven — see below.** + +**Firmware is ruled out (the evidence is contradictory across reboots with the *same* build, which by itself proves build-independence).** Over one session, on the same Olimex + cable: +- `esp32-eth-wifi` (keeps `esp_coex`) → flapped: `Ethernet link up` → `link down` repeating, never reached DHCP. +- `esp32-eth` (excludes it) → on one flash **came up immediately and worked**; on a later flash **flapped the same way**. -What we don't know: -- Which init step actually consumes the time — link-up (PHY negotiation) vs DHCP (lwIP) vs both? -- Whether the slow path is reproducible (does it always happen, or only on cold boot / after a reset / etc.)? +So the *same* eth-only build both works and flaps at different times, and the eth-wifi build flaps too. The instability does **not** track the firmware build — it's intermittent. That kills the `esp_coex` theory and any WiFi-interference theory. It also confirms our code isn't the cause: `mm_net: Ethernet link up/down` is logged straight from the ESP-IDF `ETHERNET_EVENT_CONNECTED/DISCONNECTED` events (`platform_esp32.cpp:238-243`) — the **PHY hardware reports the drops**; `NetworkModule` only reacts, never stops/restarts the link. Memory is ruled out (boot heap 286 KB, steady 133 KB free / 110 KB block — abundant). -Diagnostic to run when picking this up: flash both variants, capture `idf.py monitor` from boot to "got IP" on each, diff the timestamps of `Ethernet link up` and `Ethernet got IP` ESP_LOGI lines. If link-up is fast but DHCP is slow → lwIP init issue (look at `esp_netif_init`'s side effects from excluded components). If link-up itself is slow → PHY/clock path (try re-adding only `esp_coex` to see if it helps). +**Signature = physical layer.** When flapping, the link holds for ~2 s, drops, comes back ~10 s later — a repeating cycle, not random. That fingerprints the PHY auto-negotiating, holding briefly, then losing sync: marginal **PHY power, clock, cable, or connector**, not firmware. + +**Correlates with board reset.** The flapping tends to *start* right after a flash/soft-reset (this session: a reset preceded each flapping window; a clean power-up tended to link cleanly). Fits the documented slow/flaky PHY re-link on the Olimex after a reset — on some resets the PHY settles, on others it cycles for a long time before (or instead of) holding. + +What we still don't know (all **physical** tests — no code change is warranted): +- Does a **clean power-cycle** (vs soft reset) reliably link? (Tests the reset-relink correlation.) +- Does **barrel-jack / stronger 5V** power stop it? (Tests PHY brown-out under USB-only supply, a known Olimex-Gateway weakness.) +- Does **swapping cable / switch port** stop it? (Rules out cable/connector.) + +Bottom line: intermittent, build-independent, reset-correlated → a hardware/PHY issue, not a firmware bug. The earlier "slow DHCP at boot" is likely the same root cause (the PHY cycling many times before one window holds long enough to complete DHCP). Pick this up with the physical tests above before touching any code. ### NoiseEffect simplex cost on ESP32 (investigation) @@ -110,6 +112,20 @@ Fix options in increasing scope: - **PSRAM for Layer buffer + LUT** — ESP32-Gateway has 4 MB PSRAM unused on non-S3 builds. Moving the 49 KB pixel buffer + 64 KB LUT out of DRAM frees ~110 KB for radios. Cost: ~25% FPS hit (PSRAM bandwidth ~12 MB/s vs DRAM ~80 MB/s); needs measurement. See [decisions.md](../history/decisions.md) "Adaptive memory allocation design" for the allocation rules. - **Lazy WiFi init** — skip `esp_wifi_init` when `ssid_` is empty and no AP-fallback is pending. Helps only when credentials exist but the network is unreachable — niche. +### Boot-time buffer degradation on non-PSRAM at 128×128 (investigation) + +On the Olimex (no-PSRAM) at 128×128 with a modifier, the Layer sometimes comes up **degraded** at boot — status `"buffer reduced — not enough memory"`, with a visibly wrong render (the reduced render buffer overflows what the LUT/extrude expects). **Toggling any layout control (forcing a fresh `onBuildState`) fixes it** — the rebuild allocates the full buffer and the display is correct. So this is a boot-time allocation *race*, not a code bug: the same rebuild path that fails at boot succeeds moments later. + +Measured: the full pipeline needs two ~49 KB contiguous buffers (Layer render buffer + Drivers output buffer, both 128×128×3), plus the LUT. At boot the largest contiguous block is only ~14–20 KB while the network stack / mDNS / HTTP-server buffers are still settling into the heap — so the Layer can't claim a contiguous 49 KB and degrades (halves its dimensions). A rebuild after the heap settles wins the contiguous block and allocates full. The device is at the fragmentation edge either way (~42 KB free / ~14 KB largest block at 128×128 with the full pipeline up). + +The annoyance is purely that the device boots degraded and needs a poke to recover — it should come up working. Fix options in increasing scope: +- **Allocation order** — claim the big Layer/Drivers buffers *before* the network/mDNS/HTTP buffers fragment DRAM (i.e. wire the light pipeline's `onBuildState` ahead of network bring-up in `main.cpp`). Cheapest if the ordering is safe. +- **Boot retry** — if `onBuildState` degrades, schedule one more `buildState()` after boot settles (a one-shot, e.g. after the first `loop1s` or once the network reports up). Self-healing without reordering init. +- **Cap the default grid** on no-PSRAM to a size whose two buffers fit the post-boot largest block (same lever as the eth-wifi memory ceiling above). +- **PSRAM for the buffers** on PSRAM-equipped variants — sidesteps DRAM fragmentation entirely (related: the [Async ArtNet](#async-artnet-send--decouple-the-wire-from-the-render-tick-psram-only) and [Memory ceiling](#memory-ceiling-on-non-psram-esp32-with-eth-wifi-backlog) PSRAM notes). + +Related: this is the render/output-buffer face of the same non-PSRAM fragmentation cliff the paged `MappingLUT` already addressed for the *LUT*. The buffers themselves still allocate as single contiguous blocks. + ### Preview memory optimizations (backlog) - **Defer Preview allocation** — allocate the preview buffer on first WebSocket client connect; free on last disconnect. Saves ~5 KB when no browser is open. @@ -240,7 +256,7 @@ Audio-reactive lighting (and motion-reactive) is core to what WLED-MM / MoonLigh 4. The first audio-reactive effect(s) consuming it. 5. IMU and line-in slot into the same source-module + platform-API shape afterwards. -Cherry-pick the proven audio pipeline from MoonLight / WLED-MM (FFT band layout, AGC, beat detection) — reference, don't port wholesale, per [history](../history/) practice. Specs before code: a `MicrophoneModule.md` (and the source-category contract) get written and reviewed before implementation. +Cherry-pick the proven audio pipeline from MoonLight / WLED-MM (FFT band layout, AGC, beat detection) — reference, don't port wholesale, per [history](../history/README.md) practice. Specs before code: a `MicrophoneModule.md` (and the source-category contract) get written and reviewed before implementation. --- @@ -290,6 +306,16 @@ Not simple — own planning pass. Until then the preview is a faithful strided * - **JavaScript test harness** — `vitest` or `node --test` with `jsdom` for pure helpers in `install-picker.js` (`isCompatible`, `parseFirmwaresFromAssets`, `relativeTime`). Deferred until a second non-trivial JS module lands — one file doesn't justify the toolchain weight. - **Browser-level Improv automation** (deferred) — `scripts/build/improv_smoke_test.py` (added 2026-06-03) exercises the device-side Improv listener over plain serial; what's missing is the browser-side equivalent — Playwright driving Chrome's Web Serial, clicking through ESP Web Tools' install modal, filling the WiFi creds form, asserting `PROVISIONED`. Catches "ESP Web Tools changed its Improv handling in a way that broke our manifest format" failures the serial-only smoke test can't see. Hard to set up reliably (headless Chrome with Web Serial is finicky, needs a wired ESP32 in CI). Pick this up if a regression in the browser flow ever escapes the manual dev-environment test (preview_installer flash-ready mode at ). +### Live full-suite run leaks state between scenarios (test infra) + +`run_live_scenario.py --module all` runs scenarios in sequence against one device, and they share the live tree. Two scenario styles don't compose: +- **Canvas-preparing scenarios** (`scenario_modifier_swap`, `scenario_AllEffects_grid_sizes`) `clear_children` the containers and rebuild, then their cleanup leaves the tree **bare**. +- **Canonical-tree-assuming scenarios** (the older `scenario_GridLayout_*`, `scenario_MoonModule_control_change`, `scenario_NetworkModule_mdns_toggle`) are `mutate` scenarios that expect the boot tree (Grid / Noise / Multiply) to already exist and only tweak it. + +Run a bare-leaving scenario before a tree-assuming one and the latter fails pre-flight ("references ids neither on the device nor added by an earlier step"). Each passes **in-process** (fresh tree per scenario — the authoritative gate) and **live individually** (after a clean boot); only the chained live run trips. Not a product bug — a consequence of the "scenarios own their state, no restore" model the canvas-preparing scenarios follow, which the older ones predate. + +Fix options: (a) make every live mutate scenario clear+rebuild its own canvas (consistent with the newer ones) so order never matters; or (b) have the live runner reboot / restore the canonical tree between scenarios. (a) is the cleaner long-term shape. Until then, the in-process suite is the gate; live full-suite runs need a clean boot per scenario, or run scenarios individually. + --- ## Housekeeping diff --git a/docs/building.md b/docs/building.md index 03958e1f..da352814 100644 --- a/docs/building.md +++ b/docs/building.md @@ -63,18 +63,44 @@ Or use MoonDeck's PC tab for the same operations with a status dot per card. The Each host writes into its own build dir: `build/macos/`, `build/linux/`, `build/windows/`. The per-host layout mirrors the ESP32 side's `build/esp32-/` shape — one directory per target, no cross-target clobbering on a multi-host dev machine. +### Prerequisites + +Every host needs [uv](https://docs.astral.sh/uv/), CMake 3.20+, and a C++20 compiler. + +- **macOS:** `xcode-select --install` for Clang, `brew install cmake uv`. +- **Linux:** distro packages for `cmake`, GCC 12+ / Clang 15+, and `uv` from astral's installer. +- **Windows:** Visual Studio 2022 Build Tools with the **MSVC v143** workload and **Windows 11 SDK**, plus CMake. Quickest install (run in an elevated terminal): + + ```powershell + winget install Kitware.CMake + winget install Microsoft.VisualStudio.2022.BuildTools --override "--passive --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" + ``` + + Build and test from a **Developer PowerShell for VS 2022** (Start Menu → "x64 Native Tools…") so `cl.exe` and the SDK paths are on `PATH`. The default CMake generator on Windows is Visual Studio multi-config, so `projectMM.exe` lands at `build/windows/Release/projectMM.exe` and `mm_scenarios.exe` at `build/windows/test/Release/`. `build_desktop.py` and `run_scenario.py` look in both the `Release/` subdir and the build root, so Ninja (single-config) also works if preferred. + ## ESP32 The ESP32 target uses ESP-IDF directly, not the Arduino framework. -**Tested IDF version:** **v6.0.0** (`v6.1-dev-399-gd1b91b79b` internally). All builds and hardware tests use this tag. Minimum: ESP-IDF v5.1 (C++20 via GCC 12+); the project targets v6.x APIs (`esp_eth_phy_new_generic`, component manager for mDNS) so v5.x may need adjustments. +**Tested IDF version:** **v6.1-dev** (internal `v6.1-dev-399-gd1b91b79b5`). CI builds against this tag and local builds should match (clone command below). Minimum: ESP-IDF v5.1 (C++20 via GCC 12+); the project targets v6.x APIs (`esp_eth_phy_new_generic`, component manager for mDNS) so v5.x may need adjustments. ### Prerequisites -You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a C++20 compiler. Clone ESP-IDF into `~/esp/esp-idf` (the path the build scripts expect): +You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a C++20 compiler. Clone ESP-IDF (~2 GB) into the expected location for your OS — the build scripts search this path first via `Path.home() / "esp" / "esp-idf"`: + +**macOS / Linux:** ```sh -git clone --depth 1 --branch v6.0.0 https://github.com/espressif/esp-idf.git ~/esp/esp-idf +git clone --depth 1 --branch v6.1-dev https://github.com/espressif/esp-idf.git ~/esp/esp-idf +``` + +**Windows** (PowerShell — run once with admin to enable long paths if you haven't already): + +```powershell +# IDF and its tooling have deeply nested paths; without longpaths the clone +# trips MAX_PATH (260 chars) inside the v6.1-dev tree. +git config --global core.longpaths true +git clone --depth 1 --branch v6.1-dev https://github.com/espressif/esp-idf.git "$env:USERPROFILE\esp\esp-idf" ``` Then run the one-time Python environment setup — either open MoonDeck (`uv run scripts/moondeck.py`), go to the ESP32 tab, and click **Setup ESP-IDF**, or run it directly: @@ -86,6 +112,10 @@ uv run scripts/build/flash_esp32.py --firmware esp32 --port /dev/tty.usbserial-X uv run scripts/run/monitor_esp32.py --port /dev/tty.usbserial-XXXX ``` +`setup_esp_idf.py` runs the upstream installer for the host: `install.sh` on macOS/Linux, `install.bat` on Windows. Both create the same `~/.espressif/python_env/...` venv and download the same toolchains (~1.5 GB more) — only the wrapper differs. The Windows installer needs roughly 5 minutes on a fast link. + +On Windows, the `--port` argument is a `COM*` name (e.g. `COM3`) instead of `/dev/tty.usbserial-XXXX`. MoonDeck's port picker enumerates `COM*` automatically. + The ESP32 tab in MoonDeck wraps the same steps as cards (Setup → Firmware → Build → Port → Flash → Run). The Network bar at the top is the same one shown on the Live tab — it remembers which serial port and WiFi credentials belong to the current LAN, so moving the laptop between networks doesn't require re-picking. ![MoonDeck ESP32 tab](assets/screenshots/moondeck_esp32.png) diff --git a/docs/history/FastLED.md b/docs/history/FastLED.md new file mode 100644 index 00000000..10cbdc68 --- /dev/null +++ b/docs/history/FastLED.md @@ -0,0 +1,116 @@ +# FastLED — monthly activity digest + +What landed on [FastLED](https://github.com/FastLED/FastLED)'s main branch, month by month. External-context reference (like the v1/v2/MoonLight inventories) — a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these digests lives in [README.md](README.md). + +## May 2026 + +*Summarised from 150 first-parent commits on `master`, 2026-05-01 … 2026-05-31.* + +**New** + +- New **Channels API** for managing multiple LED drivers at once — a `fl::Bus` type, `FastLED.add(...)`, `fl::enableAllDrivers()`, and `FastLED.setExclusiveDriver(...)`, with a diagnostic that warns when a strip's driver doesn't match its bus. (The month's biggest effort.) +- **RGBW / RGBWW colour**: proper colorimetric RGB→RGBW conversion with a lookup table, colour-temperature (CCT) control, and an RGB+CCT mode. +- ESP32-P4 gains a SIMD (PIE) acceleration backend for faster pixel processing. + +**Faster** + +- Big speedups to the ESP32-P4 **PARLIO** parallel driver — encoding and transmission now overlap, and a chipset-aware encode path is ~5× faster than before. +- ESP32-P4 "Wave8" output ~1.2–2× faster via new transpose and lookup-table paths. + +**Hardware & build** + +- Builds cleanly on **ESP-IDF 6**, and can now be used as a standalone ESP-IDF component. +- ESP32 OTA support fixed (adds the required update/mDNS dependencies). +- Fixes for several boards: nRF52 Xiao BLE Sense and other nRF52 variants, ESP8266 / STM32 / ESP32-C3 / Teensy 4 / UNO R4 WiFi build issues, and ESP32-S3 LCD-clockless ISR safety. + +**Fixed** + +- RGBW driver no longer kept a dangling pointer to its colour profile (could crash or corrupt output). +- AVR boards no longer try to compile RGBWW examples that overflow their memory. + +## April 2026 + +*Summarised from 237 first-parent commits on `master`, 2026-04-01 … 2026-04-30.* + +**New** + +- **Audio "silence gate"** across the audio-reactive features — tempo, spectral metrics, and the Vibe effect now fade out cleanly when the input goes quiet instead of reacting to noise. +- Audio FFT can run on the ESP-DSP hardware backend (faster spectrum analysis on ESP32). +- ESP32-S3 LCD driver gains ISR-driven chunked DMA output (smoother large-strip output); coroutine tasks can be pinned to a chosen core. +- RMT receive (reading signals in) gains DMA streaming; a long-strip SPI bug (#2254) fixed. + +**Fixed** + +- ESP32-S3 LCD-clockless GPIO crash; SPI bus ownership/buffer-reuse issues when switching drivers. +- Board fixes: Teensy 4.1 pins 40–54, Arduino Due (sam3x8e), ESP8266 register-name clash, Digispark ATtiny85. + +## March 2026 + +*Summarised from 444 first-parent commits on `master`, 2026-03-01 … 2026-03-31.* + +- Mostly an internal stability and build-correctness month (sanitizer fixes, WASM build speed, IWYU/PCH hygiene) — little user-facing. +- **Fixed:** AVR builds (replaced defaulted `noexcept` with explicit implementations in container types); ESP32-C3/C5 and Teensy build breakages; printf/Arduino-compatibility shim; an audio-path bug. + +## February 2026 + +*Summarised from 516 first-parent commits on `master`, 2026-02-01 … 2026-02-28.* + +- **Fixed (broad platform-stability month):** AVR math bugs (left-shifts wider than AVR's 16-bit int; `PROGMEM` LUT reads), ESP32-C6 dual-mode async/sync SPI, ESP32 WROOM + mbedTLS, ESP8266, Teensy LC, UART compiler issues, RGBW mode, and the UCS chipset preamble. +- HTTP-server / loopback networking pieces stabilised; OTA validation fixed. + +## January 2026 + +*Summarised from 582 first-parent commits on `master`, 2026-01-01 … 2026-01-31.* + +- A heavy **stability-hardening month** — most of the work was fixing memory and initialization bugs surfaced by sanitizers (ASan/LSan/UBSan): use-after-free, memory leaks, static-initialization-order issues, shared-pointer errors. +- **Fixed (user-visible):** a crash in power management; RP2350 system defines not being included; an ISR error where an int was read as a bool; i2s/LCD-CAM. + +## December 2025 + +*Summarised from 392 first-parent commits on `master`, 2025-12-01 … 2025-12-31.* + +**New** + +- **PARLIO driver maturation** (ESP32 parallel output): streaming support, up to 16 lanes, larger per-channel LED counts, background DMA buffer worker, and a low-level hardware abstraction layer — plus many alignment/timing fixes (the "1-bit shift" buffer-boundary bug). +- **Validation / proof-of-life framework**: hardware-in-the-loop validation, an ESP32 watchdog (with a USB-disconnect fix), and a result banner. +- New `Potentiometer` class (hysteresis + calibration); 16-bit PWM pin support (`setPwm16`); per-channel gain on the HD108 chipset; `Serial` gains `printf`. +- Signal **receive (RX)** gains raw edge-time capture and a safe sketch-halt. + +## November 2025 + +*Summarised from 528 first-parent commits on `master`, 2025-11-01 … 2025-11-30.* + +**New** + +- **Channels / ChannelBusManager foundation** — a unified, priority-based driver manager with fallback, centralized SPI driver registration, and a new ChannelEngine-based SPI driver + RMT4 driver for ESP32 IDF 4.x. (Start of the multi-driver architecture that continues through to May.) +- **PARLIO** gains a runtime-configurable multi-channel driver with auto-select (and dropped ESP32-S3, which uses LCD instead). +- New **UCS7604** controller; a generic clockless waveform generator; video playback support. +- **Audio-reactive** effects expand — downbeat-darkness effect, AnimartrixRing audio reactivity, configurable UI audio. +- WASM web preview moves to dedicated worker threads (drops Asyncify) with incremental/PCH build speedups. +- Experimental RISC-V interrupt support. + +## October 2025 + +*Summarised from 1,185 first-parent commits on `master`, 2025-10-01 … 2025-10-31.* + +**New** + +- **RP2040 automatic parallel output** using the standard FastLED API. +- Per-platform ESP32 clockless controllers + configurable ESP32/ESP8266 timing; nanosecond timing support for ARM K66/KL26; SPI chipset controllers split into their own headers. +- Fallback **OTA** implementation for ESP-IDF < 4.0. +- PARLIO strategic buffer-breaking at colour boundaries. +- 8-bit math optimised for ATtiny; math template/float overloads; a beat-detection `AudioProcessor` facade. +- New "advanced effects" and "LED cookbook" documentation chapters. + +## September 2025 (post-3.10.3) + +*Summarised from 162 first-parent commits on `master`, 2025-09-21 … 2025-09-30 (after the 3.10.3 release on the 20th).* + +**New** + +- **WASM web-preview overhaul** — Three.js-based tile rendering, instanced LED rendering, SharedArrayBuffer zero-copy frames, a background-worker async controller, and an improved video recorder (native `captureStream`, 60 FPS, better MP4 compatibility). +- New hardware-accelerated **ezWS2812** GPIO + SPI drivers for Silicon Labs MGM240 / EFR32MG24 (MG24) boards. +- **Codec support** — progressive JPEG decoding (4 ms time budget), and metadata parsing for GIF/JPEG/MPEG1. +- Bilinear interpolation for upscaling effects; `FxNoiseRing` low-memory mode. +- README/wiring guidance for high-parallel LED setups (incl. ObjectFLED parallel capacity). + diff --git a/docs/history/NightDriverStrip.md b/docs/history/NightDriverStrip.md new file mode 100644 index 00000000..d3e43bcb --- /dev/null +++ b/docs/history/NightDriverStrip.md @@ -0,0 +1,101 @@ +# NightDriverStrip — monthly activity digest + +What landed on [NightDriverStrip](https://github.com/PlummersSoftwareLLC/NightDriverStrip)'s `main` branch, month by month. External-context reference — a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). + +Summarised via the GitHub commits API (no local clone), so counts are all commits on `main`, not first-parent merges — the bullets filter out dependency bumps, whitespace, and pure refactors. The one release in the window, **v1.3.0**, was published 2026-01-10 but tagged from a late-November commit; it isn't a clean month boundary, so months are kept whole with the release noted as context. + +## May 2026 + +*~75 commits on `main`, 2026-05-01 … 2026-05-31.* + +**New** + +- **RGBWW / SK6812 white-channel support** — W/WW helpers, a WarmGlow test effect, configurable SK6812 white extraction; colour-temperature naming cleanup. +- **New Setup Wizard / guided-installer WebUI** — and a push to make the UI a "non-special consumer": everything the official UI needs now comes from the firmware over the wire (spec/schema), not baked into `app.js`. +- M5 Stick S3 support (IR + WS2812B); optimised WS2812B draw path; `ACTIVITY_PIN` support; allow zero effects in the table. + +**Fixed / hardened** + +- Major **PSRAM strategy reversal** — switched to PSRAM-default routing (threshold 96, Mesmerizer's proven value) and removed the bespoke `psram_allocator` family; JSON save-path now uses internal RAM to avoid touching PSRAM during flash/cache-disabled windows. +- Several concurrency and memory-safety fixes across buffer / drawing / network / task paths; PolarMap and noise-generation bugs; weather-data state protection. + +## April 2026 + +*~60 commits on `main`, 2026-04-01 … 2026-04-30.* + +**New** + +- **New local WebUI** with a custom LED RMT output driver, dynamic settings, and a WS2812B output driver; first beat-detection / audio-architecture modernization pass. +- OTA: successfully cancels rollback on boot; unified hardware identity via eFuse. + +**Fixed** + +- LED-buffer crash (#842); socket race condition + NTP init issues; nullptr deref polling MAC before the C6 companion is ready; Cube/Noise matrix-effect refactors; PSRAM cache-issue partition correctness; hexagon build. + +## March 2026 + +*~32 commits on `main`, 2026-03-01 … 2026-03-31.* + +- **Replaced the RemoteDebug dependency** with a custom Logger + Telnet server — plus WiFi-stability and hardware-safety improvements; fixed a DebugCLI use-after-free for active telnet sessions and a stack-corruption (FD_SETSIZE) in the telnet sink. +- New Arduino-V3 partition layouts (standard, NOOTA, 8MB). +- Otherwise dependency bumps and include hygiene. + +## February 2026 + +*~13 commits on `main`, 2026-02-01 … 2026-02-28.* + +- **Replaced the IRremoteESP8266 library with a native RMT IR decoder** (Key44 remote support, string IR-code mapping). +- FFT implementation optimised (moved to member, weights enabled); SoundAnalyzer implementation moved to its own source file. + +## January 2026 + +*~60 commits on `main`, 2026-01-01 … 2026-01-31. (**v1.3.0** was published Jan 10.)* + +**New** + +- **Fuzzy effect selection** (CLI + firmware) with tab completion and brightness nudges; text-scrolling improvements. +- Smoke effect + noise-calculation precision improvements. +- Python client (`nightdriver_client`): layout mapping + auto-detection, PNG-sequence and contact-sheet output, fuzzy effect resolution. + +**Fixed / build** + +- Worked around an ESPAsyncWebServer macro/symbol conflict (renamed `STR` macros, adapted pattern subscribers); replaced the UrlEncode library with a local implementation; many `ENABLE_WIFI` / `ENABLE_NTP` guard fixes so non-networked builds compile. + +## December 2025 + +*~50 commits on `main`, 2025-12-01 … 2025-12-31.* + +**New** + +- HUB75: horizontal scrolling for long effect titles; QRCode + UrlEncode support. +- Decoupled `EFFECTS_MATRIX` from the SmartMatrix dependency; single `graphics_lib` build expansion; `nightdriver_client` added with auto-brightness and gain for dim displays. +- Better serial-port interactivity + enhanced CLI debugger; MeteorEffect refactored to a modern struct-of-arrays. + +**Fixed** + +- StarEffect works with silent/no-audio; `FillGetNoise` scaling/centering on non-square matrices; GCC 14 map-allocator constness; Apple5x7 font linker error; merged ESP-IDF4/5 audio layers. + +## November 2025 + +*2 commits on `main`, 2025-11-01 … 2025-11-30.* + +- Quiet month — a dependency bump (js-yaml). (The v1.3.0 tag was cut from a late-November commit but published in January.) + +## October 2025 + +*2 commits on `main`, 2025-10-01 … 2025-10-31.* + +- Quiet month — a dependency bump (vite). + +## September 2025 + +*~35 commits on `main`, 2025-09-01 … 2025-09-30.* + +**New** + +- Effect **visualizer synced to effect output** with improved FPS calculation; unified effects between Mesmerizer and M5StackDemo; Spectrum2 build uses the full matrix effects. +- One bitmap shared across all snowflakes; GFX classes renamed; HUB75 now implies MATRIX. + +**Fixed** + +- Dimension-overflow prevention; redundant-clear fix; toggle-button double-definition warnings; timezone file updates; release preparation. diff --git a/docs/history/README.md b/docs/history/README.md new file mode 100644 index 00000000..f5a993a2 --- /dev/null +++ b/docs/history/README.md @@ -0,0 +1,67 @@ +# History — index + +The backward-looking half of the docs (the forward-looking half is [`../backlog/`](../backlog/)). This folder is **not** present-tense and agents don't read it automatically — only when planning new work. See [CLAUDE.md § Documentation](../../CLAUDE.md) for how `history/` and `backlog/` relate. + +*Living index — kept current as the friend-repo digests are updated each month; the git log carries exact dates.* + +## What's here + +Three kinds of document: + +### Friend-repo activity digests + +Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches and cherry-picks from. Generated by the [digest prompt](#digest-prompt-reusable) below. + +- [FastLED.md](FastLED.md) — the LED-animation library; ESP32/Arduino driver + colour math. +- [WLED.md](WLED.md) — upstream WLED firmware. +- [WLED-MM.md](WLED-MM.md) — MoonModules' WLED fork (the direct lineage). +- [NightDriverStrip.md](NightDriverStrip.md) — Dave Plummer's LED matrix/strip firmware. + +### Prior-project inventories + +One-time surveys of earlier projects, used to decide what to harvest into projectMM. Reference, not maintained. + +- [moonlight-inventory.md](moonlight-inventory.md) — MoonLight (the closest prior art; CSR mapping, layer model, control mechanisms). +- [v1-inventory.md](v1-inventory.md) — projectMM v1 (release 1.4.0). +- [v2-inventory.md](v2-inventory.md) — projectMM v2. + +### Our own lessons + +- [decisions.md](decisions.md) — hard-won lessons, proven patterns, and non-obvious decisions, recorded with the code that proved them (the PR-merge carry-forward gate writes here). + +## Cross-repo trends + +Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem converged on over this release cycle (Sept 2025 → May 2026): + +- **ESP32-P4 / S3 parallel output.** FastLED poured effort into the PARLIO and LCD_CAM drivers (P4/S3 parallel LED output, big encode speedups); NightDriverStrip added custom RMT output. The frontier is parallel, DMA-driven output on the newer chips. +- **PSRAM strategy is unsettled everywhere.** All four wrestled with PSRAM this cycle — WLED-MM moved preview buffers into PSRAM, NightDriverStrip did a full PSRAM-default reversal (then tuned the threshold), WLED added S3-no-PSRAM builds. Nobody has a clean answer; the cache-disabled-during-flash hazard recurs across repos. +- **Audio-reactive maturing.** FastLED added a silence-gate + ESP-DSP FFT backend; WLED-MM and WLED both refined audio sync and auto-disable-during-realtime; NightDriverStrip modernised its SoundAnalyzer/FFT. Audio-reactive is table stakes now, and the polish is in *not* reacting to noise/silence. +- **The FastLED dependency question.** WLED merged a *full FastLED replacement* (its own colour/math); projectMM already made the same call (own colour math, no FastLED in core). Two independent projects concluded the dependency wasn't worth it. +- **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" — exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design. +- **Effect velocity.** WLED and WLED-MM shipped many new effects (PacMan, Color Clouds, Shimmer, the user_fx pack); new effects remain the most visible user-facing output. + +## What these projects do that projectMM doesn't (yet) + +Observational — where the landscape is ahead of projectMM. These are *not* commitments; real adoption decisions live in [`../backlog/backlog.md`](../backlog/backlog.md), cross-referenced where one already exists. + +- **Parallel multi-strip output on S3/P4** (PARLIO/LCD_CAM) — projectMM's driver story is ArtNet-out today; native parallel LED output is unbuilt. See the LED-driver analysis in [../moonmodules/light/](../moonmodules/light/) and the driver work in backlog. +- **Audio-reactive input** — none of projectMM's effects are audio- or motion-reactive yet. The Peripheral role + the Pi-sensor backlog entry are the foundation; the producer→effect wiring is backlog. +- **A guided setup/installer wizard on-device** (NightDriverStrip's Setup Wizard, WLED's installer) — projectMM has the web installer + Improv, but no on-device first-run wizard. +- **A large built-in effect library** — projectMM ships a focused set (concrete-first); the WLED family ships dozens. Breadth is a deliberate non-goal until the core is proven. + +## Refreshing + +To add a new month (or a new friend repo), run the digest prompt below. When a thread meaningfully shifts, update this index's "cross-repo trends". + +### Digest prompt (reusable) + +> **Friend-repo monthly digest.** For the repo `` (local clone at ``, or via `gh api repos//`), summarise what landed on its **main/default branch** during ``. +> +> 1. Read the merged commits on the default branch with author-date in that calendar month (`git log --first-parent --since/--until` on the local clone, or the GitHub API). Use `--first-parent` so it's the merged-feature view, not every squashed sub-commit. The default branch isn't always `main`/`master` — check (`git remote show origin`); e.g. WLED-MM's is `mdev`. +> 2. **Split a month at any release boundary — but only if the release was cut from the branch you're summarising.** If a *versioned* release was published mid-month (check `git for-each-ref refs/tags` / the GitHub releases API; ignore rolling tags like `nightly` and prereleases), AND the tag is an ancestor of the digest branch (`git merge-base --is-ancestor `), split that month at the release date into `## (up to v)` / `## (post-v)`. If the tag is NOT an ancestor (the project cuts releases from a separate release branch — e.g. upstream WLED tags off `0_15`/release branches, not `main`), do NOT split: keep the month whole and just note which release shipped that month as context, since the trunk you're summarising feeds future releases rather than being the release line. Whole months with no in-branch release stay one section. +> 3. Write an **end-user-readable** summary: what changed that a *user of the library* would notice or care about — new features, new hardware/platform support, notable fixes, breaking changes. Skip internal refactors, CI, test-only, and dependency bumps unless they affect users. +> 4. Format as **short bullet points**, each one line, plainest language, minimal jargon. Group only if there's a natural split (e.g. "New" / "Fixed"); otherwise a flat list. +> 5. Add it as a `## ` section to `docs/history/.md`, newest month on top. Don't editorialise or compare to projectMM — just report what they shipped. +> 6. State the commit range / count summarised so the digest is auditable. +> +> When backfilling several months (e.g. since the last release), run this once per month for a consistent timeline, then optionally add a `## Since v — overview` intro at the top with 3–5 bullets naming the multi-month threads the per-month slices can't show on their own. diff --git a/docs/history/WLED-MM.md b/docs/history/WLED-MM.md new file mode 100644 index 00000000..055816aa --- /dev/null +++ b/docs/history/WLED-MM.md @@ -0,0 +1,111 @@ +# WLED-MM — monthly activity digest + +What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (default) branch, month by month. External-context reference — a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). Months are split at versioned-release boundaries (the rolling `nightly` tag is not a release). + +## May 2026 + +*Summarised from 34 first-parent commits on `mdev`, 2026-05-01 … 2026-05-30.* + +- **Ethernet board support:** added QuinLED v4 Ethernet profiles, a legacy Olimex ETH-Gateway option, and fixed the KIT-VE PHY address. **Breaking:** a duplicate Ethernet option was removed — re-select your board if you used the previous Olimex-ESP32-Gateway entry. +- Audio-reactive auto-disables during DDP / DMX / Art-Net input (avoids the two fighting over the LEDs). +- Persistent on-screen error display when a restart is needed (errors no longer scroll away unseen); Improv and MQTT input hardened against malformed data. +- Web UI accessibility improvements. + +## April 2026 + +*Summarised from 47 first-parent commits on `mdev`, 2026-04-01 … 2026-04-30.* + +- **DDP input** hardened — rejects malformed / unsupported / "control" packets, relaxed header checks for compatibility. +- Robustness: recovers gracefully from an empty `{}` config file (`cfg.json` / `wsec.json`) instead of misbehaving; steadier serial on ESP32; ESP8266 build fixes. +- Otherwise a documentation / AI-contributor-guideline month (little user-facing). + +## March 2026 + +*Summarised from 43 first-parent commits on `mdev`, 2026-03-01 … 2026-03-31.* + +**New / effects** + +- New **Color Clouds** smooth effect; Flow effect fixed so the start/end of a segment flows correctly; Spots effect fixes. +- ESP-NOW remote gains 3 more buttons. +- Info page shows total LEDs, PSRAM size, GitHub repo, with restyling. + +**Fixed** + +- APA102 crash on classic ESP32 with PSRAM; a segment-index misalignment bug; ESP8266 flickering during file writes; default mic-level method changed from "floating" to "freeze". + +## February 2026 + +*Summarised from 42 first-parent commits on `mdev`, 2026-02-01 … 2026-02-28.* + +- **Memory / "Heap too low" work:** moved the WS-LED preview buffer into PSRAM, PSRAM-aware allocation, reduced JSON buffers on S3-without-PSRAM — fewer out-of-memory failures on tight boards. +- **Board support:** ESP32-S3 QSPI build, builds without the HUB75 driver (4 MB / 16 MB variants), better handling of ESP32 PICO-D2/V3 and D0WDR2-V3 (frees GPIO17), startup serial now prints HUB75 pins + full chip revision. +- Audio: disabled broken I2S 16-bit sampling; fixed Ethernet errors when using I2S audio. +- Spots effect fixes; fixed a short black-out when a playlist advances. + +## January 2026 (post-v14.7.1) + +*Summarised from 48 first-parent commits on `mdev`, 2026-01-13 (after v14.7.1) … 2026-01-31.* + +- **Animartrix** overhauled — optional gamma correction, always paints in 2D, big math speedups, dependency upgrade, and several bugfixes (segment-option changes now respected). +- **New ESP32 node types** for ESP-NOW (WizMote data); Philips Hue robustness; PixelForge GIF tool gains image rotation. +- **Fixed:** DMX-output now rate-limited to prevent watchdog resets; "relay does not turn on" sporadic issue; stack-smashing crash risk from `notify()`; better 2D preview colour accuracy and PS Fireworks trails. +- New V4 build environments incl. `esp32_16MB_V4_M_eth` (16 MB ESP32 with Ethernet); IR re-enabled for the Athom Music build. + +## January 2026 (up to v14.7.1) + +*Summarised from 32 first-parent commits on `mdev`, 2026-01-01 … 2026-01-13 — released as **v14.7.1**.* + +- **Release v14.7.1.** +- Random per-LED colours via the JSON API (`"col":["r","r","r"]`); manual/dual auto-white modes work with palettes; segment-palette functions inlined for speed. +- PixelForge effect adjustments; GIFs no longer blurred by default; the Colors column stays visible when toggling the GFX button. +- **Fixed:** preset/file-access glitches, status-LED stops blinking; clearer "Connection to light failed!" message; Info page shows the GitHub repo, build flags, and status-LED pin. + +## December 2025 + +*Summarised from 48 first-parent commits on `mdev`, 2025-12-01 … 2025-12-31.* + +**New / effects** + +- Several new and improved effects: **Shimmer** (new), **Color Clouds** groundwork, fixed "Flow Stripe" + palette support, refactored Android FX, DistortionWave update, Twinklecat reverse option, Soap effect ~3× faster, general effect-math speedups up to 3×. +- More segment data / no effect-data limits on PSRAM boards; Perlin noise now uses FastLED's `inoise16`. + +**Fixed** + +- ledmap parser robustness (no longer discards `ledmap.json` too early); FX checkmark sync; 2D matrix-generator preview update; a UI TypeError with a custom palette selected; random trail flickering. + +## November 2025 + +*Summarised from 100 first-parent commits on `mdev`, 2025-11-01 … 2025-11-30.* + +**New** + +- **DDP over WebSocket** added; improved 1D support for GIF images with a blur option; image-effect flicker fixer for WS2812b on RMT. +- UCS chipset and bus handling improvements; faster `map()`, inline gamma correction, HUB75 / 2D `drawPixel` speedups. +- Audio-reactive UDP sync improved. + +**Board / build** + +- Large reorganisation of the MoonModules build environments: larger program partition for Ethernet builds, 32 MB-flash (WROOM-2) and Adafruit board partition files, moved C3/S2 builds to the Tasmota platform, revived a "legacy V3" ESP32 build, fixed S3-without-PSRAM builds. ESP8266 GIF player removed (too little RAM). + +**Fixed** + +- Reboot loop on V3 "legacy" builds; AP-not-showing (default channel → 6); device-fingerprint / deviceId crashes; buffer-bounds and concurrency precautions; LED glitches during file writing. + +## October 2025 + +*Summarised from 28 first-parent commits on `mdev`, 2025-10-01 … 2025-10-31.* + +**New / effects** + +- ParticleFX better defaults for 64-px-height matrices and a framebuffer memory-calc fix; HUB75 drops colour-temperature correction for performance. +- WLED-MM-specific error effect instead of the plain orange flash on effect-memory failure; low-brightness gradient "jumpyness" fixed. + +**Fixed** + +- Random corruption of `presets.json`; IR JSON decode buffer overrun (#272, rotary usermod); PSRAM caching bug. Updated AsyncTCP / AsyncWebServer (3.4.7). + +## September 2025 + +*Summarised from 3 first-parent commits on `mdev`, 2025-09-01 … 2025-09-30.* + +- Quiet month on `mdev` — build instructions, npm `ci` for dependencies, and GitHub Copilot contributor instructions. Nothing user-facing. diff --git a/docs/history/WLED.md b/docs/history/WLED.md new file mode 100644 index 00000000..97d5d114 --- /dev/null +++ b/docs/history/WLED.md @@ -0,0 +1,123 @@ +# WLED (upstream) — monthly activity digest + +What landed on [wled/WLED](https://github.com/wled/WLED)'s `main` branch, month by month. External-context reference — a factual log of a friend repo's releases, not projectMM's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](README.md). + +Months are **not** split at release dates: upstream WLED cuts releases from separate release branches (`0_15`, `16_x`), so the version tags aren't on `main` — `main` is the development trunk that feeds future releases. Each month notes which release shipped, as context. + +## May 2026 + +*Summarised from 72 first-parent commits on `main`, 2026-05-01 … 2026-05-31. (Trunk after the **v16.0.0** release on May 3; v16 is codenamed "Kagayaki".)* + +**New / boards** + +- HUB75: FM6124 driver for 4-scan panels and the 64×64 limit removed on PSRAM boards; new ESP32-S3-without-PSRAM build environments; option to build against the Espressif framework instead of Tasmota. + +**Fixed / hardened** + +- Effects: restored palette wrap in `color_wheel()` (a regression since 0.15.x), Twinkle fixes, Dissolve "Complete" same-colour-as-background fix, gravity audio-reactive top-LED fix. +- Audio-reactive auto-suspends in realtime modes (but stays on with "use main segment only"). +- DDP and all realtime protocols: relaxed-but-safer header acceptance + bounds checks; Improv/UDP parsing hardened; `/reset` auth clarified. +- Auto-migration for legacy sunrise/sunset config; animated-staircase inverted-PIR support. + +## April 2026 + +*Summarised from 67 first-parent commits on `main`, 2026-04-01 … 2026-04-30. (v16.0.0-beta was tagged April 11.)* + +**New / effects** + +- Game of Life fix; FPS bump via a fast path in `blendSegment`; better packet queuing/pacing for custom-palette live preview. +- PixelForge palette/tools list moved into the repo; fxdata serialized without ArduinoJSON (smaller/faster). + +**Fixed** + +- Critical Candle-FX bug + Flow-FX integer issue; segment inputs no longer restrict trailing strips; iOS blending-style list filter; DDP flag-bit masking for compatibility; robustness rewinding file pointers before writes. +- (Otherwise a heavy AI-contributor-guideline / docs month.) + +## March 2026 + +*Summarised from 65 first-parent commits on `main`, 2026-03-01 … 2026-03-31. (v0.15.4 shipped March 14 from the 0_15 release branch; trunk version bumped to 17.0.0-dev.)* + +**New / effects** + +- **Full FastLED replacement** merged (#4615) — WLED's own colour/math instead of the FastLED dependency. +- Many new user_fx effects: Spinning Wheel, Color Clouds, Lava Lamp, Magma, Ants, Morse Code, Comet (fire particle system), a slow >4-hour transition FX, Tetris line-clear flash. +- Scrolling-text FX gains custom fonts + international UTF-8; stencil blending mode; ESP32-C3 audio-reactive (DSP FFT + integer math); more macro/timer slots; longer max playlist duration. +- OTA update page restyled (auto-sets download URL from `info.repo`); clearer UI tool icons. + +**Fixed** + +- Segment-index misalignment; hostname/DNS cleanup; hue preservation in colour fade; array-bounds on short WS payloads; DDP rejects unsupported/non-display packets. + +## February 2026 + +*Summarised from 35 first-parent commits on `main`, 2026-02-01 … 2026-02-28.* + +**New** + +- **Version scheme changed to Major.minor** (dropped the leading "0."), heading toward v16; bumped to 16.0.0-alpha. +- New **Pin Info** page (used/available pins overview); UI settings readability improvements. +- Improved bus handling — free choice of bus driver in any order, better memory calculations; gamma lower-limit removed (enables inverse gamma correction, applied to segment brightness too). +- Extended CCT blending (exclusive blend, colour-jump fix); full WiFi scan with BSSID apply; new ESP32-S3 8MB QSPI build; experimental ESP32-C5/C6 in the node list. + +**Fixed** + +- LED animations briefly pausing at bootup (ESP32); boot-up WiFi pause with extended scanning; removed dangerous mutex macros in the bus manager; Flow-FX flow at segment start/end. + +## January 2026 + +*Summarised from 38 first-parent commits on `main`, 2026-01-01 … 2026-01-31.* + +**New** + +- **New custom-palettes editor** (#5010); WPA-Enterprise WiFi support; random per-LED colours via JSON API; option to save unmodified presets to autosave; PixelForge GIF image rotation. +- Removed the MAX_LEDS_PER_BUS limit for virtual buses; new ESP32 node types; JSON validation + minify on file upload in the UI. + +**Fixed** + +- Relay not turning on at boot; GPIO0 always grabbed by a button; gamma correction on a fresh install; Ethernet static-IP ignored; HUB75 improvements; config exceeding the LED limit. + +## December 2025 + +*Summarised from 39 first-parent commits on `main`, 2025-12-01 … 2025-12-31. (v0.15.3 shipped December 4 from the 0_15 release branch.)* + +**New / effects** + +- **PacMan effect** and the new **"WLEDPixelForge"** image & scrolling-text interface (#4982); dynamic LED-type dropdown; improved 2D particle collisions; sequential UI-resource loading. +- Usermod Temperature uses full 12-bit precision; "peek" shows gaps; removed legacy EEPROM support. + +**Fixed** + +- FX checkmark sync; UI TypeError with a custom palette; rotary-encoder palette-count off-by-one; particle-collision binning; segment overflow. + +## November 2025 + +*Summarised from 46 first-parent commits on `main`, 2025-11-01 … 2025-11-30. (v0.15.2-beta1 → **v0.15.2** shipped Nov 9 / Nov 29 from the 0_15 release branch.)* + +**New** + +- **New file editor** (#4956) with ctrl+S, toasts, efficient ledmap reading, 0-byte-file handling. +- **DDP over WebSocket**; improved 1D GIF support with blur option; variable button count up to 32; Dissolve "Complete" mode (always fades fully). +- Aurora FX speedups; better PSRAM-MB usage reporting; bootloader offsets for C3/S3 + variable bootloader sizes per MCU; Adafruit board partitions. + +**Fixed** + +- Stale UI after firmware updates; AP-not-showing (default channel tweaks); device-fingerprint crashes; `millis()`-rollover robustness in wait logic; OTA update for C3 from 0.15; ESP8266 low-heap and DMA fixes. + +## October 2025 + +*Summarised from 9 first-parent commits on `main`, 2025-10-01 … 2025-10-31.* + +- Quiet month on trunk. **DDP over WebSocket** groundwork (shared WS connection in common.js); Twinkle blank-area fix; low-brightness gradient "jumpyness" fix; bootloop-tracker safety check; GIF-player inactive-segment + copy-FX bugfixes. + +## September 2025 + +*Summarised from 41 first-parent commits on `main`, 2025-09-01 … 2025-09-30.* + +**New / effects** + +- **Shimmer FX** added; "unrestricted" number of custom palettes; center-bin selection for 2D GEQ; Twinklecat reverse option; speed optimisations + `restoreColorLossy` fix. +- Heap-memory and PSRAM handling improvements; HUB75 AC fixes. + +**Fixed** + +- Tri Fade FX; custom-palette colour picker; Colortwinkles; LED buffer-size calculation; UDP name-sync rework; crash debug output added. diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 2a15b1f1..62b4cf5f 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -53,8 +53,8 @@ are concrete rules, not aspirations. loop. Add inter-packet delay and FPS limiting. Missing pacing looks like rendering bugs but is network flooding. 14. **Use virtual interfaces, not dynamic_cast.** Modules interact - through virtual methods. Layer should not know about MirrorModifier - or RotateModifier by name. + through virtual methods. Layer should not know about MultiplyModifier + or CheckerboardModifier by name. 15. **Rebuild propagation must be in the framework.** Don't check dirty flags per-module in main.cpp. Use an event/observer system or a centralized pipeline-changed signal. @@ -679,3 +679,9 @@ The apparent blocker was "core uses `lengthType`." On inspection the uses were i **Two adjacent traps the same effect hit:** - **First-frame `dt`.** `lastElapsed_` starts at 0, so the first `now - lastElapsed_` is the whole device uptime — a huge `dt` that pins the step accumulator above the beat threshold *permanently* (max rate forever, `bpm` ignored). Bootstrap `lastElapsed_` on the first call and take `dt = 0` that frame. - **Width of the change delta.** The stagnation check narrowed `alive - lastAlive_` to `uint16_t`; at the 512×512 max grid on a PSRAM board (`nrOfLightsType == uint32_t`, 262144 cells) that truncates and triggers false re-seeds. Counters derived from cell counts must be `nrOfLightsType`, not a fixed width. + +## uint16 intermediate overflow blanks the display — and a status check doesn't prove the render works (MultiplyModifier) + +A high fan-out modifier (`MultiplyModifier` at 8×8×4) black-screened the no-PSRAM Olimex while the desktop showed it working. Root cause: `Layer::rebuildLUT` computed `maxDest = logicalCount * mod->maxMultiplier()` in `nrOfLightsType`. On no-PSRAM that's `uint16_t`, and `256 * 256 = 65536` **wraps to 0** — the LUT was sized to ~nothing, so almost every light mapped nowhere and the frame went black. On desktop (`nrOfLightsType == uint32_t`) the product fits, so the bug was invisible there. **Fix:** compute the product in `uint64_t`, clamp to the ceiling, then narrow back. This is the same family as the GameOfLife delta-width trap above, but for an *intermediate product*, not a stored counter — **any `nrOfLightsType * nrOfLightsType` (or `× a multiplier`) can overflow uint16 even when both operands are individually small; do the arithmetic in a wider type before narrowing.** + +The harder lesson is about *verification*: the agent's hardware test asserted the Layer **status string** ("16×16×1") and called it a pass — but the status reflects `logicalDimensions`, which was correct; the *render* (driven by the corrupted LUT) was black. The product owner caught it by eye. **A status/dimension assertion is not proof the pipeline renders.** A correctness test for a mapping/effect must assert the **buffer or LUT is non-empty with the expected coverage** (the regression added here counts LUT destinations == physical light count), not just that the declared dimensions look right. And: a bug that depends on `nrOfLightsType` width is **invisible on the uint32 desktop build** — width-sensitive paths need either a uint16-typed unit test or hardware confirmation, not desktop-only. diff --git a/docs/install/README.md b/docs/install/README.md index 2862a296..7c153163 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -137,7 +137,7 @@ release is visible only to those who opt into the Pre-release channel. ```bash # Bump library.json to "1.0.0-rcN", commit, tag v1.0.0-rcN, push tag. # Workflow: -# - 4 ESP32 builds + macOS build run. +# - 4 ESP32 builds + macOS build + Windows build run. # - release job stages cumulative content (last 5 stable + 5 prerelease) # under pages/install/releases// on Pages. # - Publishes a GitHub Release flagged "Pre-release". @@ -152,7 +152,8 @@ don't ship the API. `release.yml` does this automatically on every `v*` tag (stable + RC): 1. `build-esp32` matrix produces four firmware bundles + four - `flasher_args.json` files. `build-macos` produces the desktop tarball. + `flasher_args.json` files. `build-macos` produces the macOS tarball; + `build-windows` produces the Windows zip. 2. The `release` job: - Generates manifests in two flavours: absolute URLs (for GitHub release assets, used by the OTA picker) and relative URLs (for the Pages copy, diff --git a/docs/install/index.html b/docs/install/index.html index bd3c2ce0..b7134791 100644 --- a/docs/install/index.html +++ b/docs/install/index.html @@ -78,6 +78,10 @@ } .button-row { margin-top: 16px; } .note { color: var(--muted); font-size: 13px; margin-top: 10px; } + /* `.windows-only` elements are `hidden` by default in the HTML; the tiny + userAgent check at the top of below removes `hidden` only on + Windows. Inverse to a CSS-only approach because CSS can't detect the + host OS — `[hidden]` already wins specificity-wise. */ .erase-row { margin-top: 12px; font-size: 13px; } .erase-row label { cursor: pointer; } .erase-row input { vertical-align: middle; margin-right: 6px; } @@ -324,6 +328,21 @@ + +

projectMM Installer

Plug in your ESP32. Pick a release + board. Flash. Get the device on your network. Open it in a browser.

@@ -470,6 +489,22 @@

Installing

Connecting to device over Web Serial…
+ +
+
diff --git a/docs/moonmodules/core/FilesystemModule.md b/docs/moonmodules/core/FilesystemModule.md index 74aee682..f47ad47a 100644 --- a/docs/moonmodules/core/FilesystemModule.md +++ b/docs/moonmodules/core/FilesystemModule.md @@ -86,7 +86,7 @@ ESP32 uses LittleFS via the `joltwallet/esp_littlefs` component on a dedicated p - Unit test (`test_filesystem_persistence.cpp`): - **Value round-trip**: set `deviceName` → save → fresh `Scheduler` + modules → load → assert. Uses `platform::fsSetRoot()` for test isolation. Wall time ~2.3s (the debounce window dominates). - - **Structural reconciliation**: hand-write a `Layer.json` with one child (RainbowEffect). Build a live tree with two children (NoiseEffect + MirrorModifier). After load, assert the tree reconciled — RainbowEffect at position 0, Mirror trimmed. + - **Structural reconciliation**: hand-write a `Layer.json` with one child (RainbowEffect). Build a live tree with two children (NoiseEffect + MultiplyModifier). After load, assert the tree reconciled — RainbowEffect at position 0, the modifier trimmed. ## First boot diff --git a/docs/moonmodules/light/Layer.md b/docs/moonmodules/light/Layer.md index 9340e741..db367825 100644 --- a/docs/moonmodules/light/Layer.md +++ b/docs/moonmodules/light/Layer.md @@ -53,6 +53,12 @@ Hot-path cost is zero for D3 effects (the default) and zero when the layer's unu See [EffectBase § Dimensions and auto-extrusion](EffectBase.md#dimensions-and-auto-extrusion) for the effect-side contract and [Unit tests: Layer](../../tests/unit-tests.md#layer) for the pinned tests (see `unit_Layer_extrude.cpp`). +## Status + +The Layer's status line (the `MoonModule` status slot) shows the **logical** box the effects render into — `"××"`, the dimensions a modifier and the start/end region carving reshape. This differs from the physical bounding box shown on the [Layouts](Layouts.md#status) container: a Mirror-XY modifier on a 128×128 physical layout renders into a 64×64 logical box (the half that gets folded), so the Layer reads `64×64×1` while Layouts reads `128×128×1`. The gap between the two is the modifier's effect, made visible. + +The same slot carries memory-degradation warnings (`Severity::Warning`/`Error`) when a build can't fit: `"modifier LUT skipped — not enough memory"`, `"sparse LUT build failed — not enough memory"`, `"buffer reduced — not enough memory"`, `"buffer allocation failed — not enough memory"`. A warning wins over the neutral box line — when the Layer is degraded, that's what the user needs to see. Recomputed on every rebuild (`onBuildState`), not per tick. + ## EffectBase overlap Consider whether Layer itself can provide the rendering context (buffer, dims, elapsed time) directly to effects, eliminating the need for a separate EffectBase class. The layer already knows everything the effect needs. diff --git a/docs/moonmodules/light/Layouts.md b/docs/moonmodules/light/Layouts.md index 6834645b..724cab45 100644 --- a/docs/moonmodules/light/Layouts.md +++ b/docs/moonmodules/light/Layouts.md @@ -33,7 +33,14 @@ Disabling a layout child (the `enabled` toggle in the UI) removes its lights fro Side effect: ArtNet universe assignments shift with the indices. To keep driver-to-fixture mapping stable across enable changes, disable the driver instead of the layout. +## Status + +The container's status line (the `MoonModule` status slot, rendered by the UI) shows the **physical** setup it describes: `" lights · ××"` — the total light count summed across all enabled layout children (this is the size of the driver output buffer) and the physical bounding box (the extent of all light coordinates, the size of the dense render buffer). For a dense grid the count equals the box volume; for a sparse layout (e.g. a sphere shell) the count is smaller than the box — the gap is the at-a-glance signal that the layout is sparse. An empty setup (no lights) reports `Warning` severity. Recomputed on every rebuild (`onBuildState`), not per tick. + +## Reordering + +Layout children are reordered by drag-and-drop in the UI (`POST /api/modules//move` with `{"to": }`). **Insert semantics, not swap:** the dragged layout takes the drop target's slot and the others shift to fill — the standard reorderable-list behaviour (Finder, Trello, SortableJS). Dropping onto a row (rather than a between-rows gap) means the landing is the target's absolute index, so dragging down lands after the target and dragging up lands before it. Order matters: it sets the physical index range each layout occupies, which drives ArtNet universe assignment. Same `move` op and semantics apply to every container (effects/modifiers under a Layer, drivers under Drivers). + ## What needs improvement - Layout control changes must propagate to every layer (LUT rebuild) and to the Drivers container (output buffer reallocation). Mechanism: [architecture.md § Rebuild propagation](../../architecture.md#rebuild-propagation). -- Runtime add / remove / reorder of layouts. diff --git a/docs/moonmodules/light/effects/NoiseEffect.md b/docs/moonmodules/light/effects/NoiseEffect.md index b205412f..821618e7 100644 --- a/docs/moonmodules/light/effects/NoiseEffect.md +++ b/docs/moonmodules/light/effects/NoiseEffect.md @@ -29,7 +29,7 @@ Animation: time scrolls the noise coordinate space (smooth drift). The scroll sp [Unit tests: NoiseEffect](../../../tests/unit-tests.md#noiseeffect) — non-zero output, spatial variation, differs from rainbow. -[Scenario: scenario_MirrorModifier_pipeline](../../../tests/scenario-tests.md#scenario_mirrormodifier_pipeline) — full pipeline with noise + mirror, performance bounds. +[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) — full pipeline with noise + multiply/mirror, performance bounds. ## Prior art diff --git a/docs/moonmodules/light/effects/PlasmaEffect.md b/docs/moonmodules/light/effects/PlasmaEffect.md index ca1baf34..f3afb06c 100644 --- a/docs/moonmodules/light/effects/PlasmaEffect.md +++ b/docs/moonmodules/light/effects/PlasmaEffect.md @@ -38,7 +38,7 @@ Phase accumulator matches NoiseEffect pattern — BPM changes do not jump the an [Unit tests: PlasmaEffect](../../../tests/unit-tests.md#plasmaeffect) — non-zero output, spatial variation, differs from NoiseEffect. -Default pipeline uses Plasma + MirrorModifier (see `src/main.cpp`). +Default pipeline uses Plasma + MultiplyModifier (see `src/main.cpp`). ## Prior art diff --git a/docs/moonmodules/light/modifiers/CheckerboardModifier.md b/docs/moonmodules/light/modifiers/CheckerboardModifier.md new file mode 100644 index 00000000..68dffc76 --- /dev/null +++ b/docs/moonmodules/light/modifiers/CheckerboardModifier.md @@ -0,0 +1,30 @@ +# Checkerboard Modifier + +Static modifier. Masks the layer in a checkerboard pattern: lights in the "off" squares are dropped (they receive nothing), lights in the "on" squares pass through unchanged. Unlike Multiply, this doesn't remap or resize — it's a spatial on/off mask applied to whatever the effect drew. + +## Controls + +- `size` (Uint8, 1–64, default 2) — checker square edge, in lights +- `invert` (Bool, default false) — flip which squares pass through + +## Effect on the pipeline + +- **Logical 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. +- **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. + +## Tests + +[Unit tests: CheckerboardModifier](../../../tests/unit-tests.md#checkerboardmodifier) — identity dimensions, the drop pattern for both `invert` phases, `size` grouping into squares. + +[Scenario: scenario_modifier_swap](../../../tests/scenario-tests.md#scenario_modifier_swap) — replaces the Layer's modifier between Multiply and Checkerboard and verifies the pipeline stays live across each swap. + +## Prior art + +### MoonLight — M_MoonLight.h Checkerboard ([source](https://github.com/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. diff --git a/docs/moonmodules/light/modifiers/MirrorModifier.md b/docs/moonmodules/light/modifiers/MirrorModifier.md deleted file mode 100644 index a92d6a99..00000000 --- a/docs/moonmodules/light/modifiers/MirrorModifier.md +++ /dev/null @@ -1,61 +0,0 @@ -# Mirror Modifier - -![MirrorModifier controls](../../../assets/screenshots/MirrorModifier.png) - -![MirrorModifier preview](../../../assets/screenshots/MirrorModifier.gif) - -Static modifier. Mirrors the logical space around centre axes, -producing a kaleidoscope effect. Each enabled axis doubles the -physical output from the same logical data. - -## Controls - -- `mirrorX` (Bool, default true) — mirror around vertical centre -- `mirrorY` (Bool, default true) — mirror around horizontal centre -- `mirrorZ` (Bool, default true) — mirror around depth centre - -## Effect on Pipeline - -- **Logical dimensions reduced**: 128x128 with mirrorX+Y → 64x64 - logical buffer (25% of physical). -- **LUT produces 1:N mappings**: each logical light maps to 2/4/8 - physical positions depending on how many axes are mirrored. -- **Deduplication**: lights exactly on the centre axis don't get - doubled (e.g. x=63 in 128-wide with mirrorX: 128-1-63=64 ≠ 63, - not duplicated. But in 127-wide: 127-1-63=63 = duplicate, skipped). - -## Key function: mapToPhysical - -Given a logical coordinate (lx, ly, lz) and physical dimensions, -produces all physical positions this logical light maps to. -Uses nested loops over enabled axes, deduplicates, returns count. - -## What worked - -- Kaleidoscope-style mirror is the correct approach (not coordinate - flip). Logical buffer is smaller, 1:N mapping fills all quadrants. -- Deduplication of centre-axis lights prevents double-brightness. -- `markDirty()` on onChange triggers LUT rebuild. -- Works with both Rainbow and Noise effects. - -## What needs improvement - -- `onChange` sets dirty, but the rebuild only happens when the main - loop checks the dirty flag. If the HTTP handler triggers onChange, - the actual rebuild is deferred to next frame. This is correct but - not obvious. -- With Z mirror on depth=1, multiplier is 8 but actual mapping is 4 - (Z deduplicates). This overallocates the LUT destinations array. - Harmless but wasteful. - -## Tests - -[Unit tests: MirrorModifier](../../../tests/unit-tests.md#mirrormodifier) — logical dimensions, corner/centre pixel mapping, deduplication, axis combinations. - -[Scenario: scenario_MirrorModifier_pipeline](../../../tests/scenario-tests.md#scenario_mirrormodifier_pipeline) — full pipeline with mirror kaleidoscope, performance bounds. - -## Prior art - -### MoonLight — M_MoonLight.h Mirror ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) - -Mirror, ReverseX/Y/Z, Transpose, Kaleidoscope as separate modifier nodes. Each uses `modifyPosition()` virtual to transform coordinates during layout pass. diff --git a/docs/moonmodules/light/modifiers/MultiplyModifier.md b/docs/moonmodules/light/modifiers/MultiplyModifier.md new file mode 100644 index 00000000..a18e6781 --- /dev/null +++ b/docs/moonmodules/light/modifiers/MultiplyModifier.md @@ -0,0 +1,37 @@ +# Multiply Modifier + +Static modifier. Tiles the logical image across the physical box `multiply` times per axis, optionally reflecting alternate tiles. With a multiplier of 2 and mirror enabled on an axis, that axis folds in half — the classic kaleidoscope mirror. Multiply subsumes the old MirrorModifier: a pure mirror is `multiply = 2, mirror = true` on the chosen axes. + +## Controls + +- `multiplyX` (Uint8, 1–64, default 2) — tiles along X (1 = no tiling) +- `multiplyY` (Uint8, 1–64, default 2) — tiles along Y +- `multiplyZ` (Uint8, 1–64, default 1) — tiles along Z +- `mirrorX` (Bool, default true) — reflect alternate (odd) tiles along X +- `mirrorY` (Bool, default true) — reflect alternate tiles along Y +- `mirrorZ` (Bool, default true) — reflect alternate tiles along Z + +The defaults (`multiply 2/2/1`, `mirror all on`) reproduce the canonical mirror-XY pipeline: a 128×128 physical grid folds to a 64×64 logical buffer, each logical light fanning out to its four reflected quadrants. (`mirrorZ` on is a no-op on a 2D/depth-1 layout.) + +## Effect on the pipeline + +- **Logical box shrinks by the multiplier**: `logW = physW / multiplyX` (etc.). 128×128 with multiply 2/2 → 64×64 logical (the effect renders a quarter of the lights). The effective multiplier clamps to the axis extent — `multiplyZ` on a depth-1 layout clamps to 1 (no-op), never blanking the layer. +- **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). +- **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 would matter, e.g. multiply-then-checkerboard ≠ checkerboard-then-multiply — is not yet 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. + +## Tests + +[Unit tests: MultiplyModifier](../../../tests/unit-tests.md#multiplymodifier) — logical dimensions, tile fan-out, per-axis mirror reflection, the pure-fold equivalence to the old Mirror, the multiplyZ-on-2D no-op, and the extent clamp. + +[Scenario: scenario_MultiplyModifier_pipeline](../../../tests/scenario-tests.md#scenario_multiplymodifier_pipeline) — full pipeline with the multiply/mirror kaleidoscope, performance bounds. + +## Prior art + +### MoonLight — M_MoonLight.h Multiply ([source](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Modifiers/M_MoonLight.h)) + +MoonLight's Multiply node tiles via `position % modifierSize` and reflects odd tiles when its `mirror` flag is set (`position = size − 1 − position`). We expose **per-axis** mirror bools and per-axis multipliers instead of MoonLight's single multiplier coord + single mirror flag, so X/Y/Z fold and tile independently. MoonLight keeps Mirror, Multiply, Transpose, and Kaleidoscope as separate nodes; we fold mirror into Multiply since mirror is just multiply-2-with-reflection. diff --git a/docs/performance.md b/docs/performance.md index 41affd21..473cb6c9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -6,9 +6,9 @@ This document holds what scenarios can't carry: structural sizes (`sizeof`), bui --- -## Desktop (macOS, Apple Silicon) +## Desktop (64-bit) -Desktop ArtNet sends to a non-existent IP so packets complete instantly; `freeHeap` returns 0 (unlimited). Per-step tick budgets live in `contract.pc-macos` blocks across the scenarios. +Desktop ArtNet sends to a non-existent IP so packets complete instantly; `freeHeap` returns 0 (unlimited). Per-step tick budgets live in per-host `contract.pc-` blocks across the scenarios — `pc-macos` for macOS arm64, `pc-windows` for Windows x64, `pc-linux` for Linux. The `sizeof` and dynamic-memory numbers below apply to all 64-bit desktop targets; tick numbers differ by host CPU and live in the scenario contracts. ### sizeof (desktop, 64-bit) @@ -24,7 +24,12 @@ Desktop ArtNet sends to a non-existent IP so packets complete instantly; `freeHe `Drivers` grew from 120 → 408 with the per-driver `Correction` stage (256-entry brightness LUT + channel-order table). Other classes grew ~16-32 bytes each as `MoonModule` itself grew (rolling-range observed slot + wired-by-code flag + per-child `tickChildren` accounting fields). -Binary: **358 KB** (debug-arm64; release-strip yields a smaller number). +Binary sizes: + +| Target | Size | Build | +|--------|------|-------| +| macOS arm64 | 358 KB | debug-arm64 (release-strip is smaller) | +| Windows x64 | 432 KB | MSVC Release, static CRT | ### Memory at 128×128 with mirror @@ -43,6 +48,71 @@ Per-step tick/heap live in `contract.esp32-eth-wifi` and `contract.esp32-eth` ac Individual measurements vary ~5–10% on the Olimex board with no configuration change — inherent ESP32/Ethernet timing jitter (lwIP `tcpip_thread` scheduling, EMAC DMA, Ethernet ACK pacing). Scenarios use 10% default ESP32 tolerance to absorb this; when a step trips, re-run before treating it as a real regression. The `collect_kpi.py --commit` gate parses a single `tick:` line from `esp32/monitor.log` and can flag an unlucky sample — same rule applies. +### All-effects sweep (every effect, no modifier, Ethernet + ArtNet) + +`scenario_AllEffects_grid_sizes` runs each effect alone over a Layer (no modifier) at four square grids, through the real ArtNet + Preview drivers — the per-effect cost of the same pipeline the README's single-effect headline row measures. Numbers are `observed.esp32-eth` from a live run; apply the ~5–10% variance above. + +**FPS** (= 1,000,000 / tick µs): + +| Effect | 16² | 32² | 64² | 128² | +|--------|----:|----:|----:|-----:| +| Lines | 12658 | 7633 | 2304 | 23 | +| Rainbow | 3831 | 968 | 143 | 22 | +| Noise | 1117 | 324 | 71 | 17 | +| Plasma | 3194 | 829 | 135 | 18 | +| PlasmaPalette | 6024 | 1733 | 267 | 21 | +| Metaballs | 2016 | 521 | 102 | 18 | +| Fire | 2762 | 784 | 159 | 21 | +| Particles | 4716 | 1848 | 424 | 30 | +| GlowParticles | 1706 | 586 | 128 | 14 | +| Checkerboard | 8474 | 2617 | 397 | 21 | +| Spiral | 2403 | 571 | 87 | 15 | +| Ripples | 1118 | 284 | 45 | 12 | +| LavaLamp | 3030 | 756 | 113 | 18 | +| GameOfLife | 6802 | 1519 | 226 | 13 | + +At 128² nearly every effect converges to ~12–23 FPS: the board is **ArtNet-output-bound** there (the ~38 ms synchronous send dominates the tick), so effect-compute differences wash out — the same physics the README narrates for the S3 over WiFi. Effect cost is visible at 64² and below, where Ripples / Noise / Spiral are the heaviest and Lines / Checkerboard the lightest. + +**Free internal heap** (KB) — the scarce resource on a no-PSRAM board; drops as the grid grows because the Layer buffer + LUT and the driver output buffer live in internal RAM: + +| Effect | 16² | 32² | 64² | 128² | +|--------|----:|----:|----:|-----:| +| Lines | 220 | 214 | 195 | 126 | +| Rainbow | 173 | 167 | 158 | 126 | +| Noise | 171 | 168 | 159 | 126 | +| Plasma | 173 | 171 | 162 | 126 | +| PlasmaPalette | 170 | 168 | 160 | 126 | +| Metaballs | 173 | 171 | 162 | 126 | +| Fire | 173 | 170 | 157 | 110 | +| Particles | 172 | 167 | 149 | 77 | +| GlowParticles | 173 | 171 | 162 | 126 | +| Checkerboard | 173 | 168 | 159 | 123 | +| Spiral | 170 | 169 | 160 | 123 | +| Ripples | 170 | 168 | 160 | 124 | +| LavaLamp | 170 | 169 | 160 | 124 | +| GameOfLife | 171 | 165 | 150 | 90 | + +**Largest free internal block** (KB) — the memory-pressure signal that matters: free heap can be ample while fragmentation leaves no single block big enough for the next allocation: + +| Effect | 16² | 32² | 64² | 128² | +|--------|----:|----:|----:|-----:| +| Lines | 108 | 108 | 108 | 62 | +| Rainbow | 92 | 88 | 76 | 62 | +| Noise | 92 | 88 | 76 | 62 | +| Plasma | 92 | 92 | 84 | 62 | +| PlasmaPalette | 92 | 88 | 80 | 62 | +| Metaballs | 92 | 92 | 84 | 62 | +| Fire | 96 | 92 | 76 | 62 | +| Particles | 80 | 80 | 68 | 34 | +| GlowParticles | 84 | 84 | 80 | 62 | +| Checkerboard | 96 | 88 | 72 | 62 | +| Spiral | 88 | 88 | 76 | 62 | +| Ripples | 92 | 88 | 80 | 62 | +| LavaLamp | 92 | 88 | 72 | 62 | +| GameOfLife | 88 | 84 | 68 | 46 | + +Most effects hold the same ~126 KB free / 62 KB block at 128² — their per-cell state is negligible next to the buffers. The exceptions carry real per-cell state: **Particles** (77 KB / 34 KB) and **GameOfLife** (90 KB / 46 KB) allocate a parallel grid-sized array, and **Fire** (110 KB) a heat map. Those three are the ones to watch for fragmentation headroom on a no-PSRAM board at large grids. + ### ArtNet over WiFi vs Ethernet | | Ethernet | WiFi STA | @@ -71,7 +141,7 @@ The Olimex `esp32` build is 4× slower at ArtNet than `esp32-eth-wifi` on the sa | Drivers | 48 KB | output buffer (128×128×3) | | System + Network | 0 | char buffers in class, no heap | -LUT is half desktop size (uint16_t vs uint32_t per entry). The 1:1 (no-modifier) path skips the LUT entirely; see `scenario_Layer_memory_1to1` vs `scenario_MirrorModifier_memory_lut`. +LUT is half desktop size (uint16_t vs uint32_t per entry). The 1:1 (no-modifier) path skips the LUT entirely; see `scenario_Layer_memory_1to1` vs `scenario_MultiplyModifier_memory_lut`. ### Heap breakdown (128×128, mirror, RainbowEffect, Ethernet + mDNS) diff --git a/docs/testing.md b/docs/testing.md index 7569f7cb..aee87e94 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -52,7 +52,7 @@ A test lives under the subfolder of its **primary** `@module`'s source domain (e ### Naming convention -- **Unit tests:** `unit_[_].cpp` — `` is the **CamelCase** class name as it appears in `// @module` (and in the source: `Layer`, `MoonModule`, `MirrorModifier`, `ArtNetSendDriver`). The optional `` collapses when the file's the only test for its module (`unit_Color.cpp` is fine if `@module Color`); add it when one module has several test files (`unit_Layer_extrude.cpp`, `unit_Layer_zero_grid.cpp`, …) or when the topic genuinely clarifies what the file covers (`unit_FilesystemModule_persistence.cpp`). +- **Unit tests:** `unit_[_].cpp` — `` is the **CamelCase** class name as it appears in `// @module` (and in the source: `Layer`, `MoonModule`, `MultiplyModifier`, `ArtNetSendDriver`). The optional `` collapses when the file's the only test for its module (`unit_Color.cpp` is fine if `@module Color`); add it when one module has several test files (`unit_Layer_extrude.cpp`, `unit_Layer_zero_grid.cpp`, …) or when the topic genuinely clarifies what the file covers (`unit_FilesystemModule_persistence.cpp`). - **Scenarios:** `scenario__.json` — same module-naming rule; the topic is always present because scenarios always cross multiple modules and the topic distinguishes the focus. - The **`"name"` field inside each scenario JSON** matches the filename stem exactly (e.g. `"name": "scenario_Layer_base_pipeline"`). The runner, the MoonDeck dropdown, the generated docs and `--name` on the CLI all use this single identifier. @@ -109,12 +109,12 @@ A `mutate` scenario that needs platform-bound modules (Network mDNS, WiFi, OTA) ### Reset block: idempotent scenarios -`mutate` scenarios mutate shared controls (Grid size, Mirror toggles, Preview detail). Without restoring those controls to known values at scenario start, measurements become coupled to *which other scenarios ran first* — last scenario's leftover state poisons this one's baseline. The fix is a top-level **`reset`** array, an `add_module`/`set_control`-shape list that runs *before* the scenario's own `steps`: +`mutate` scenarios mutate shared controls (Grid size, Multiply mirror toggles, Preview detail). Without restoring those controls to known values at scenario start, measurements become coupled to *which other scenarios ran first* — last scenario's leftover state poisons this one's baseline. The fix is a top-level **`reset`** array, an `add_module`/`set_control`-shape list that runs *before* the scenario's own `steps`: ```json "reset": [ { "name": "reset-grid-width", "op": "set_control", "id": "Grid", "key": "width", "value": 128 }, - { "name": "reset-mirrorX", "op": "set_control", "id": "Mirror", "key": "mirrorX", "value": true } + { "name": "reset-mirrorX", "op": "set_control", "id": "Multiply", "key": "mirrorX", "value": true } ], ``` @@ -214,22 +214,22 @@ Every `scenario_*.json` carries top-level metadata plus a `description` per step "name": "scenario_GridLayout_grid_sizes", "module": "GridLayout", "mode": "mutate", - "also": ["Layer", "MirrorModifier", "Drivers", "ArtNetSendDriver"], + "also": ["Layer", "MultiplyModifier", "Drivers", "ArtNetSendDriver"], "description": "Walk the grid through 16x16 → 32x32 → 64x64 → 128x128 and assert a per-size FPS floor.", "fixture": [ { "name": "fix-layouts", "op": "add_module", "id": "Layouts", "type": "Layouts" }, { "name": "fix-grid", "op": "add_module", "id": "Grid", "type": "GridLayout", "parent_id": "Layouts", "props": {"width": 16, "height": 16} }, { "name": "fix-layer", "op": "add_module", "id": "Layer", "type": "Layer", "props": {"layouts": "Layouts", "channelsPerLight": 3} }, { "name": "fix-noise", "op": "add_module", "id": "Noise", "type": "NoiseEffect", "parent_id": "Layer" }, - { "name": "fix-mirror", "op": "add_module", "id": "Mirror", "type": "MirrorModifier", "parent_id": "Layer" }, + { "name": "fix-mirror", "op": "add_module", "id": "Multiply", "type": "MultiplyModifier", "parent_id": "Layer" }, { "name": "fix-drivers", "op": "add_module", "id": "Drivers", "type": "Drivers", "props": {"layer": "Layer"} }, { "name": "fix-artnet", "op": "add_module", "id": "ArtNet", "type": "ArtNetSendDriver", "parent_id": "Drivers" } ], "reset": [ { "name": "reset-grid-width", "op": "set_control", "id": "Grid", "key": "width", "value": 128 }, { "name": "reset-grid-height", "op": "set_control", "id": "Grid", "key": "height", "value": 128 }, - { "name": "reset-mirrorX", "op": "set_control", "id": "Mirror", "key": "mirrorX", "value": true }, - { "name": "reset-mirrorY", "op": "set_control", "id": "Mirror", "key": "mirrorY", "value": true } + { "name": "reset-mirrorX", "op": "set_control", "id": "Multiply", "key": "mirrorX", "value": true }, + { "name": "reset-mirrorY", "op": "set_control", "id": "Multiply", "key": "mirrorY", "value": true } ], "steps": [ { @@ -296,6 +296,9 @@ Inventory: **[docs/tests/unit-tests.md](tests/unit-tests.md)** (auto-generated, Run them with: ```bash +# Replace build/macos with build/linux or build/windows per host. The +# MoonDeck path below and `uv run scripts/test/test_desktop.py` resolve the +# host build dir automatically; the raw ctest / mm_tests calls don't. ctest --test-dir build/macos --output-on-failure # all ./build/macos/test/mm_tests -tc="" # one test case uv run scripts/test/test_desktop.py --module Layer # filtered by module @@ -324,7 +327,10 @@ Or via MoonDeck (PC tab → Scenarios card). The module dropdown is shared with **Step ops** the in-process runner understands today: - `add_module` — instantiate a module by `type`, register it under `id`. Top-level when no `parent_id`. Mid-scenario adds (after the first `measure` step) run setup() + buildState() immediately, mirroring the live `/api/modules` POST shape. -- `set_control` — write a control on an already-added module (`id` + `key` + `value`). Mirrors `handleSetControl`: applies the typed write, calls `onUpdate()`, and triggers `Scheduler::buildState()` if `controlChangeTriggersBuildState` returns true. Today supports Uint8 / Uint16 / Int16 / Bool / Text / Password / Select. +- `remove_module` / `delete_module` — delete a child module from its parent (teardown + recursive free + buildState). The two names are aliases (the in-process and live runners must never diverge on op names, or a scenario silently no-ops on one tier). Refuses non-editable submodules (`userEditable()==false`) and top-level modules. +- `replace_module` — swap a child for a fresh module of another `type` at the same slot. A default-named module relabels to the new type; a custom/scenario id is preserved so later steps can still address it. Mirrors `/api/modules//replace`. +- `clear_children` — delete every deletable child of a container (`id`), leaving the container. The "prepare my own canvas" primitive: a scenario assumes nothing about the device's starting tree, clears a container, then adds what it needs. Non-editable children (Board, Preview, Improv) are skipped. +- `set_control` — write a control on an already-added module (`id` + `key` + `value`). Mirrors `handleSetControl`: applies the typed write, calls `onUpdate()`, and triggers `Scheduler::buildState()` if `controlChangeTriggersBuildState` returns true. Today supports Uint8 / Uint16 / Int16 / Bool / Text / Password / Select. A step may carry `"optional": true` — a best-effort write (e.g. shrink a grid that may not exist) that's skipped, not failed, when the target is absent. - `measure` — pure measurement step. Runs warmup + measure frames, prints per-step tick / FPS / lights / heap-delta, applies any `bounds` assertions for this step. A step can also set `"measure": true` on a non-measure op (e.g. mark the last `add_module` as the one to measure after); the runner treats either shape identically. diff --git a/docs/tests/scenario-tests.md b/docs/tests/scenario-tests.md index 7d14489c..bef20d53 100644 --- a/docs/tests/scenario-tests.md +++ b/docs/tests/scenario-tests.md @@ -10,7 +10,7 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e `test/scenarios/light/scenario_GridLayout_grid_sizes.json` — Walk the grid through 16x16 → 32x32 → 64x64 → 128x128 and assert a per-size FPS floor. -**Mode**: `mutate` · **Also touches**: Layer, MirrorModifier, NoiseEffect, Drivers, ArtNetSendDriver, PreviewDriver +**Mode**: `mutate` · **Also touches**: Layer, MultiplyModifier, NoiseEffect, Drivers, ArtNetSendDriver, PreviewDriver #### `size-16x16` (set_control) 📏 @@ -31,12 +31,14 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32-eth-wifi` | ≥ 1,429 / 1,821 | ≥ 146KB / 139KB | ≥ 49KB / 52KB | | `esp32s3-n16r8` | — / 1,672 | — / 8360KB | — / 160KB | | `pc-macos` | ≥ 200,000 / 200,000-1,000,000 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 142,857-333,333 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `esp32s3-n16r8`: observed 2026-06-04 - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-windows`: observed 2026-06-07 #### `size-32x32` (set_control) 📏 @@ -56,130 +58,1263 @@ Scenario tests are the integration tier in the [test strategy](../testing.md): e | `esp32-eth` | ≥ 303 / 379-381 | ≥ 161KB / 172KB | ≥ 78KB / 92KB | | `esp32-eth-wifi` | ≥ 400 / 390 | ≥ 142KB / 132KB | ≥ 49KB / 50KB | | `esp32s3-n16r8` | — / 288 | — / 8349KB | — / 140KB | -| `pc-macos` | ≥ 100,000 / 111,111-166,667 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 100,000 / 111,111-200,000 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 71,429-90,909 | — / unlimited | — / unlimited | + +- `esp32`: observed 2026-06-02 +- `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 +- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `esp32s3-n16r8`: observed 2026-06-04 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-06 +- `pc-windows`: observed 2026-06-07 + +#### `size-64x64` (set_control) 📏 + +64x64 measured. Real-world mid size. Target: 60 FPS on a fast Ethernet device. + +**Setup** (preceding non-measured steps): +- `size-64x64-width` (set_control) — 64x64 (4096 lights). + +**Bounds**: +- FPS ≥ 80% of baseline + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32` | — / 17.5 | — / 97KB | — / 48KB | +| `esp32-eth` | ≥ 55.6 / 74.5-74.7 | ≥ 137KB / 147KB | ≥ 54KB / 62KB | +| `esp32-eth-wifi` | ≥ 76.9 / 85.7 | ≥ 117KB / 108KB | ≥ 44KB / 48KB | +| `esp32s3-n16r8` | — / 25.9 | — / 8310KB | — / 152KB | +| `pc-macos` | ≥ 33,333 / 30,303-43,478 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 17,857-22,727 | — / unlimited | — / unlimited | + +- `esp32`: observed 2026-06-02 +- `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 +- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `esp32s3-n16r8`: observed 2026-06-04 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 + +#### `size-128x128` (set_control) 📏 + +128x128 measured. Real-world full-room size. Target: 20 FPS on a typical Ethernet device. Looser bound (min_pct 70) reflects the wider variance at the largest payload. + +**Setup** (preceding non-measured steps): +- `size-128x128-width` (set_control) — 128x128 (16384 lights) — maximum supported size. + +**Bounds**: +- FPS ≥ 70% of baseline + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32` | — / 4.4 | — / 83KB | — / 52KB | +| `esp32-eth` | ≥ 9.1 / 10.5-10.6 | ≥ 122KB / 132KB | ≥ 47KB / 48KB | +| `esp32-eth-wifi` | ≥ 10.0 / 54.5 | ≥ 103KB / 129KB | ≥ 44KB / 52KB | +| `esp32s3-n16r8` | — / 6.1 | — / 8163KB | — / 164KB | +| `pc-macos` | ≥ 8,333 / 4,975-10,204 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 3,676-4,505 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `esp32s3-n16r8`: observed 2026-06-04 - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 + +### scenario_GridLayout_resize + +`test/scenarios/light/scenario_GridLayout_resize.json` — Resize the grid while the pipeline is running and verify it reallocates cleanly under memory pressure. Lowers to 128x64 (release memory), increases to 128x128 (heaviest config: mirror + LUT). Each measured step captures tick/FPS/heap so the runner reports the degrade behaviour. + +**Mode**: `mutate` · **Also touches**: MultiplyModifier, Layer + +#### `size-128x128` (set_control) 📏 + +Set grid height to 128 (alongside default width 128). Measures the heaviest config as the baseline for the next two steps. + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32` | — / 4.5 | — / 83KB | — / 48KB | +| `esp32-eth` | — / 10.7-10.8 | — / 132KB | — / 48KB-52KB | +| `esp32-eth-wifi` | ≥ 10.0 / 12.4 | ≥ 103KB / 93KB | — / 48KB | +| `pc-macos` | ≥ 8,333 / 3,534-10,526 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 3,413-4,566 | — / unlimited | — / unlimited | + +- `esp32`: observed 2026-06-02 +- `esp32-eth`: observed 2026-06-02 +- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-windows`: observed 2026-06-07 + +#### `shrink-to-128x64` (set_control) 📏 + +Shrink to 128x64. Measured: FPS must stay within 20% of the baseline (proves the pipeline reallocs cleanly and there's no leak path). + +**Bounds**: +- FPS ≥ 80% of baseline + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32` | — / 11.1 | — / 63KB | — / 17KB | +| `esp32-eth` | — / 26.4-26.5 | — / 114KB | — / 48KB | +| `esp32-eth-wifi` | ≥ 22.2 / 31.8 | ≥ 83KB / 75KB | — / 24KB | +| `pc-macos` | ≥ 16,667 / 5,208-21,739 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 7,299-10,638 | — / unlimited | — / unlimited | + +- `esp32`: observed 2026-06-02 +- `esp32-eth`: observed 2026-06-02 +- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-windows`: observed 2026-06-07 + +#### `grow-to-128x128` (set_control) 📏 + +Grow back to 128x128. Measured: confirms the heap can return to the heavy baseline after a shrink. + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32` | — / 4.0 | — / 83KB | — / 52KB | +| `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB | +| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 52KB | +| `pc-macos` | ≥ 8,333 / 3,257-10,204 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 3,436-4,608 | — / unlimited | — / unlimited | + +- `esp32`: observed 2026-06-02 +- `esp32-eth`: observed 2026-06-02 +- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-windows`: observed 2026-06-07 + +## Layer + +### scenario_AllEffects_grid_sizes + +`test/scenarios/light/scenario_AllEffects_grid_sizes.json` — Sweep every effect (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + ArtNet, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe). + +**Mode**: `mutate` · **Also touches**: Layouts, GridLayout, Drivers, ArtNetSendDriver, PreviewDriver, LinesEffect, RainbowEffect, NoiseEffect, PlasmaEffect, PlasmaPaletteEffect, MetaballsEffect, FireEffect, ParticlesEffect, GlowParticlesEffect, CheckerboardEffect, SpiralEffect, RipplesEffect, LavaLampEffect, GameOfLifeEffect + +#### `LinesEffect-16x16` (set_control) 📏 + +LinesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `shrink-grid-w` (set_control) +- `shrink-grid-h` (set_control) +- `clear-layers` (clear_children) +- `clear-layouts` (clear_children) +- `clear-drivers` (clear_children) +- `build-grid` (add_module) +- `build-layer` (add_module) +- `build-fx` (add_module) +- `build-artnet` (add_module) +- `LinesEffect-pre-w` (set_control) +- `LinesEffect-pre-h` (set_control) +- `fx-LinesEffect` (replace_module) +- `LinesEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 12,658 | — / 221KB | — / 108KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / — | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `LinesEffect-32x32` (set_control) 📏 + +LinesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `LinesEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 7,634 | — / 215KB | — / 108KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / — | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `LinesEffect-64x64` (set_control) 📏 + +LinesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `LinesEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 2,304 | — / 195KB | — / 108KB | +| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | +| `pc-windows` | — / 1,000,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `LinesEffect-128x128` (set_control) 📏 + +LinesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `LinesEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 23.3 | — / 126KB | — / 62KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 37,037-250,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RainbowEffect-16x16` (set_control) 📏 + +RainbowEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RainbowEffect-pre-w` (set_control) +- `RainbowEffect-pre-h` (set_control) +- `fx-RainbowEffect` (replace_module) +- `RainbowEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 3,831 | — / 173KB | — / 92KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RainbowEffect-32x32` (set_control) 📏 + +RainbowEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RainbowEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 968 | — / 168KB | — / 88KB | +| `pc-macos` | — / 500,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 90,909-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RainbowEffect-64x64` (set_control) 📏 + +RainbowEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RainbowEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 143 | — / 159KB | — / 76KB | +| `pc-macos` | — / 111,111-125,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 34,483-40,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RainbowEffect-128x128` (set_control) 📏 + +RainbowEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RainbowEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 22.6 | — / 126KB | — / 62KB | +| `pc-macos` | — / 25,641-28,571 | — / unlimited | — / unlimited | +| `pc-windows` | — / 6,098-8,929 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `NoiseEffect-16x16` (set_control) 📏 + +NoiseEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `NoiseEffect-pre-w` (set_control) +- `NoiseEffect-pre-h` (set_control) +- `fx-NoiseEffect` (replace_module) +- `NoiseEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,117 | — / 172KB | — / 92KB | +| `pc-macos` | — / 250,000-333,333 | — / unlimited | — / unlimited | +| `pc-windows` | — / 83,333-111,111 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `NoiseEffect-32x32` (set_control) 📏 + +NoiseEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `NoiseEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 324 | — / 168KB | — / 88KB | +| `pc-macos` | — / 62,500-71,429 | — / unlimited | — / unlimited | +| `pc-windows` | — / 25,000-29,412 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `NoiseEffect-64x64` (set_control) 📏 + +NoiseEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `NoiseEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 71.8 | — / 159KB | — / 76KB | +| `pc-macos` | — / 13,514-15,625 | — / unlimited | — / unlimited | +| `pc-windows` | — / 4,739-6,757 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `NoiseEffect-128x128` (set_control) 📏 + +NoiseEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `NoiseEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 17.1 | — / 126KB | — / 62KB | +| `pc-macos` | — / 2,924-3,268 | — / unlimited | — / unlimited | +| `pc-windows` | — / 1,190-1,437 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaEffect-16x16` (set_control) 📏 + +PlasmaEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaEffect-pre-w` (set_control) +- `PlasmaEffect-pre-h` (set_control) +- `fx-PlasmaEffect` (replace_module) +- `PlasmaEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 3,195 | — / 174KB | — / 92KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaEffect-32x32` (set_control) 📏 + +PlasmaEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 830 | — / 171KB | — / 92KB | +| `pc-macos` | — / 333,333 | — / unlimited | — / unlimited | +| `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaEffect-64x64` (set_control) 📏 + +PlasmaEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 135 | — / 162KB | — / 84KB | +| `pc-macos` | — / 66,667-90,909 | — / unlimited | — / unlimited | +| `pc-windows` | — / 35,714-43,478 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaEffect-128x128` (set_control) 📏 + +PlasmaEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 18.3 | — / 126KB | — / 62KB | +| `pc-macos` | — / 19,231-22,727 | — / unlimited | — / unlimited | +| `pc-windows` | — / 7,874-9,709 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaPaletteEffect-16x16` (set_control) 📏 + +PlasmaPaletteEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaPaletteEffect-pre-w` (set_control) +- `PlasmaPaletteEffect-pre-h` (set_control) +- `fx-PlasmaPaletteEffect` (replace_module) +- `PlasmaPaletteEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 6,024 | — / 170KB | — / 92KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaPaletteEffect-32x32` (set_control) 📏 + +PlasmaPaletteEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaPaletteEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,733 | — / 168KB | — / 88KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 250,000-333,333 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaPaletteEffect-64x64` (set_control) 📏 + +PlasmaPaletteEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaPaletteEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 268 | — / 161KB | — / 80KB | +| `pc-macos` | — / 142,857-200,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 50,000-71,429 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `PlasmaPaletteEffect-128x128` (set_control) 📏 + +PlasmaPaletteEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `PlasmaPaletteEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 21.8 | — / 126KB | — / 62KB | +| `pc-macos` | — / 41,667-50,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 12,346-18,868 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `MetaballsEffect-16x16` (set_control) 📏 + +MetaballsEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `MetaballsEffect-pre-w` (set_control) +- `MetaballsEffect-pre-h` (set_control) +- `fx-MetaballsEffect` (replace_module) +- `MetaballsEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 2,016 | — / 174KB | — / 92KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 200,000-250,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `MetaballsEffect-32x32` (set_control) 📏 + +MetaballsEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `MetaballsEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 522 | — / 171KB | — / 92KB | +| `pc-macos` | — / 200,000-250,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 50,000-62,500 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `MetaballsEffect-64x64` (set_control) 📏 + +MetaballsEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `MetaballsEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 103 | — / 162KB | — / 84KB | +| `pc-macos` | — / 55,556-62,500 | — / unlimited | — / unlimited | +| `pc-windows` | — / 12,500-15,385 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `MetaballsEffect-128x128` (set_control) 📏 + +MetaballsEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `MetaballsEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 18.7 | — / 126KB | — / 62KB | +| `pc-macos` | — / 13,158-15,873 | — / unlimited | — / unlimited | +| `pc-windows` | — / 2,786-3,636 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `FireEffect-16x16` (set_control) 📏 + +FireEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `FireEffect-pre-w` (set_control) +- `FireEffect-pre-h` (set_control) +- `fx-FireEffect` (replace_module) +- `FireEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 2,762 | — / 173KB | — / 96KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 333,333-500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `FireEffect-32x32` (set_control) 📏 + +FireEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `FireEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 784 | — / 170KB | — / 92KB | +| `pc-macos` | — / 250,000-333,333 | — / unlimited | — / unlimited | +| `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `FireEffect-64x64` (set_control) 📏 + +FireEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `FireEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 160 | — / 158KB | — / 76KB | +| `pc-macos` | — / 55,556-76,923 | — / unlimited | — / unlimited | +| `pc-windows` | — / 27,027-33,333 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `FireEffect-128x128` (set_control) 📏 + +FireEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `FireEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 21.5 | — / 110KB | — / 62KB | +| `pc-macos` | — / 16,949-19,231 | — / unlimited | — / unlimited | +| `pc-windows` | — / 6,452-7,194 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `ParticlesEffect-16x16` (set_control) 📏 + +ParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `ParticlesEffect-pre-w` (set_control) +- `ParticlesEffect-pre-h` (set_control) +- `fx-ParticlesEffect` (replace_module) +- `ParticlesEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 4,717 | — / 172KB | — / 80KB | +| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | +| `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `ParticlesEffect-32x32` (set_control) 📏 + +ParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `ParticlesEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,848 | — / 168KB | — / 80KB | +| `pc-macos` | — / 333,333-500,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 166,667-250,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `ParticlesEffect-64x64` (set_control) 📏 + +ParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `ParticlesEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 425 | — / 150KB | — / 68KB | +| `pc-macos` | — / 111,111-142,857 | — / unlimited | — / unlimited | +| `pc-windows` | — / 52,632-71,429 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `ParticlesEffect-128x128` (set_control) 📏 + +ParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `ParticlesEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 30.8 | — / 78KB | — / 34KB | +| `pc-macos` | — / 27,027-34,483 | — / unlimited | — / unlimited | +| `pc-windows` | — / 12,987-15,873 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `GlowParticlesEffect-16x16` (set_control) 📏 + +GlowParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GlowParticlesEffect-pre-w` (set_control) +- `GlowParticlesEffect-pre-h` (set_control) +- `fx-GlowParticlesEffect` (replace_module) +- `GlowParticlesEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,706 | — / 174KB | — / 84KB | +| `pc-macos` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `GlowParticlesEffect-32x32` (set_control) 📏 + +GlowParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GlowParticlesEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 586 | — / 171KB | — / 84KB | +| `pc-macos` | — / 52,632-250,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 35,714-45,455 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `GlowParticlesEffect-64x64` (set_control) 📏 + +GlowParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GlowParticlesEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 128 | — / 162KB | — / 80KB | +| `pc-macos` | — / 37,037-55,556 | — / unlimited | — / unlimited | +| `pc-windows` | — / 8,850-10,638 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `GlowParticlesEffect-128x128` (set_control) 📏 + +GlowParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GlowParticlesEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 14.3 | — / 126KB | — / 62KB | +| `pc-macos` | — / 7,752-14,286 | — / unlimited | — / unlimited | +| `pc-windows` | — / 1,949-2,370 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `CheckerboardEffect-16x16` (set_control) 📏 + +CheckerboardEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `CheckerboardEffect-pre-w` (set_control) +- `CheckerboardEffect-pre-h` (set_control) +- `fx-CheckerboardEffect` (replace_module) +- `CheckerboardEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 8,475 | — / 173KB | — / 96KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / 500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `CheckerboardEffect-32x32` (set_control) 📏 + +CheckerboardEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `CheckerboardEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 2,618 | — / 168KB | — / 88KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 142,857-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `CheckerboardEffect-64x64` (set_control) 📏 + +CheckerboardEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `CheckerboardEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 397 | — / 159KB | — / 72KB | +| `pc-macos` | — / 250,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 34,483-45,455 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `CheckerboardEffect-128x128` (set_control) 📏 + +CheckerboardEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `CheckerboardEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 21.2 | — / 123KB | — / 62KB | +| `pc-macos` | — / 45,455-62,500 | — / unlimited | — / unlimited | +| `pc-windows` | — / 8,475-10,638 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `SpiralEffect-16x16` (set_control) 📏 + +SpiralEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `SpiralEffect-pre-w` (set_control) +- `SpiralEffect-pre-h` (set_control) +- `fx-SpiralEffect` (replace_module) +- `SpiralEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 2,404 | — / 170KB | — / 88KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `SpiralEffect-32x32` (set_control) 📏 + +SpiralEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `SpiralEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 572 | — / 170KB | — / 88KB | +| `pc-macos` | — / 250,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `SpiralEffect-64x64` (set_control) 📏 + +SpiralEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `SpiralEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 87.0 | — / 161KB | — / 76KB | +| `pc-macos` | — / 22,222-62,500 | — / unlimited | — / unlimited | +| `pc-windows` | — / 23,810-27,027 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `SpiralEffect-128x128` (set_control) 📏 + +SpiralEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `SpiralEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 15.5 | — / 123KB | — / 62KB | +| `pc-macos` | — / 9,901-13,889 | — / unlimited | — / unlimited | +| `pc-windows` | — / 5,102-6,579 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RipplesEffect-16x16` (set_control) 📏 + +RipplesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RipplesEffect-pre-w` (set_control) +- `RipplesEffect-pre-h` (set_control) +- `fx-RipplesEffect` (replace_module) +- `RipplesEffect-16x16-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,119 | — / 170KB | — / 92KB | +| `pc-macos` | — / 333,333 | — / unlimited | — / unlimited | +| `pc-windows` | — / 100,000-125,000 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RipplesEffect-32x32` (set_control) 📏 + +RipplesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `RipplesEffect-32x32-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 284 | — / 168KB | — / 88KB | +| `pc-macos` | — / 83,333-125,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 38,462-47,619 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `RipplesEffect-64x64` (set_control) 📏 -#### `size-64x64` (set_control) 📏 +RipplesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. -64x64 measured. Real-world mid size. Target: 60 FPS on a fast Ethernet device. +**Setup** (preceding non-measured steps): +- `RipplesEffect-64x64-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 45.0 | — / 161KB | — / 80KB | +| `pc-macos` | — / 30,303-35,714 | — / unlimited | — / unlimited | +| `pc-windows` | — / 12,048-15,152 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 +- `pc-windows`: observed 2026-06-07 + +#### `RipplesEffect-128x128` (set_control) 📏 + +RipplesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `size-64x64-width` (set_control) — 64x64 (4096 lights). +- `RipplesEffect-128x128-w` (set_control) -**Bounds**: -- FPS ≥ 80% of baseline +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 12.2 | — / 125KB | — / 62KB | +| `pc-macos` | — / 8,403-9,259 | — / unlimited | — / unlimited | +| `pc-windows` | — / 3,067-3,831 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `LavaLampEffect-16x16` (set_control) 📏 + +LavaLampEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `LavaLampEffect-pre-w` (set_control) +- `LavaLampEffect-pre-h` (set_control) +- `fx-LavaLampEffect` (replace_module) +- `LavaLampEffect-16x16-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | — / 17.5 | — / 97KB | — / 48KB | -| `esp32-eth` | ≥ 55.6 / 74.5-74.7 | ≥ 137KB / 147KB | ≥ 54KB / 62KB | -| `esp32-eth-wifi` | ≥ 76.9 / 85.7 | ≥ 117KB / 108KB | ≥ 44KB / 48KB | -| `esp32s3-n16r8` | — / 25.9 | — / 8310KB | — / 152KB | -| `pc-macos` | ≥ 33,333 / 30,303-43,478 | unlimited / unlimited | — / unlimited | +| `esp32-eth` | — / 3,030 | — / 170KB | — / 92KB | +| `pc-macos` | — / 1,000,000-— | — / unlimited | — / unlimited | +| `pc-windows` | — / 250,000-500,000 | — / unlimited | — / unlimited | -- `esp32`: observed 2026-06-02 -- `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `esp32s3-n16r8`: observed 2026-06-04 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 -#### `size-128x128` (set_control) 📏 +#### `LavaLampEffect-32x32` (set_control) 📏 -128x128 measured. Real-world full-room size. Target: 20 FPS on a typical Ethernet device. Looser bound (min_pct 70) reflects the wider variance at the largest payload. +LavaLampEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. **Setup** (preceding non-measured steps): -- `size-128x128-width` (set_control) — 128x128 (16384 lights) — maximum supported size. +- `LavaLampEffect-32x32-w` (set_control) -**Bounds**: -- FPS ≥ 70% of baseline +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 756 | — / 170KB | — / 88KB | +| `pc-macos` | — / 333,333-500,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 66,667-111,111 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `LavaLampEffect-64x64` (set_control) 📏 + +LavaLampEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `LavaLampEffect-64x64-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | — / 4.4 | — / 83KB | — / 52KB | -| `esp32-eth` | ≥ 9.1 / 10.5-10.6 | ≥ 122KB / 132KB | ≥ 47KB / 48KB | -| `esp32-eth-wifi` | ≥ 10.0 / 54.5 | ≥ 103KB / 129KB | ≥ 44KB / 52KB | -| `esp32s3-n16r8` | — / 6.1 | — / 8163KB | — / 164KB | -| `pc-macos` | ≥ 8,333 / 4,975-10,204 | unlimited / unlimited | — / unlimited | +| `esp32-eth` | — / 113 | — / 161KB | — / 72KB | +| `pc-macos` | — / 100,000-125,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 23,810-29,412 | — / unlimited | — / unlimited | -- `esp32`: observed 2026-06-02 -- `esp32-eth`: contract set 2026-06-02 "anti-regression floor; LUT-fit telemetry baseline" · observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `esp32s3-n16r8`: observed 2026-06-04 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 -### scenario_GridLayout_resize +#### `LavaLampEffect-128x128` (set_control) 📏 -`test/scenarios/light/scenario_GridLayout_resize.json` — Resize the grid while the pipeline is running and verify it reallocates cleanly under memory pressure. Lowers to 128x64 (release memory), increases to 128x128 (heaviest config: mirror + LUT). Each measured step captures tick/FPS/heap so the runner reports the degrade behaviour. +LavaLampEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. -**Mode**: `mutate` · **Also touches**: MirrorModifier, Layer +**Setup** (preceding non-measured steps): +- `LavaLampEffect-128x128-w` (set_control) -#### `size-128x128` (set_control) 📏 +**Performance** (contract / observed) — tick stored, FPS shown: -Set grid height to 128 (alongside default width 128). Measures the heaviest config as the baseline for the next two steps. +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 18.2 | — / 125KB | — / 62KB | +| `pc-macos` | — / 28,571-32,258 | — / unlimited | — / unlimited | +| `pc-windows` | — / 4,926-6,757 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 + +#### `GameOfLifeEffect-16x16` (set_control) 📏 + +GameOfLifeEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GameOfLifeEffect-pre-w` (set_control) +- `GameOfLifeEffect-pre-h` (set_control) +- `fx-GameOfLifeEffect` (replace_module) +- `GameOfLifeEffect-16x16-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | — / 4.5 | — / 83KB | — / 48KB | -| `esp32-eth` | — / 10.7-10.8 | — / 132KB | — / 48KB-52KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.4 | ≥ 103KB / 93KB | — / 48KB | -| `pc-macos` | ≥ 8,333 / 3,534-10,526 | unlimited / unlimited | — / unlimited | +| `esp32-eth` | — / 6,803 | — / 171KB | — / 88KB | +| `pc-macos` | — / — | — / unlimited | — / unlimited | +| `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 -#### `shrink-to-128x64` (set_control) 📏 +#### `GameOfLifeEffect-32x32` (set_control) 📏 -Shrink to 128x64. Measured: FPS must stay within 20% of the baseline (proves the pipeline reallocs cleanly and there's no leak path). +GameOfLifeEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block. -**Bounds**: -- FPS ≥ 80% of baseline +**Setup** (preceding non-measured steps): +- `GameOfLifeEffect-32x32-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | — / 11.1 | — / 63KB | — / 17KB | -| `esp32-eth` | — / 26.4-26.5 | — / 114KB | — / 48KB | -| `esp32-eth-wifi` | ≥ 22.2 / 31.8 | ≥ 83KB / 75KB | — / 24KB | -| `pc-macos` | ≥ 16,667 / 5,208-21,739 | unlimited / unlimited | — / unlimited | +| `esp32-eth` | — / 1,520 | — / 166KB | — / 84KB | +| `pc-macos` | — / 1,000,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 166,667-200,000 | — / unlimited | — / unlimited | -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 -#### `grow-to-128x128` (set_control) 📏 +#### `GameOfLifeEffect-64x64` (set_control) 📏 -Grow back to 128x128. Measured: confirms the heap can return to the heavy baseline after a shrink. +GameOfLifeEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GameOfLifeEffect-64x64-w` (set_control) **Performance** (contract / observed) — tick stored, FPS shown: | Board | FPS | heap | block | |---|---|---|---| -| `esp32` | — / 4.0 | — / 83KB | — / 52KB | -| `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB | -| `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 93KB | — / 52KB | -| `pc-macos` | ≥ 8,333 / 3,257-10,204 | unlimited / unlimited | — / unlimited | +| `esp32-eth` | — / 227 | — / 151KB | — / 68KB | +| `pc-macos` | — / 200,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 38,462-47,619 | — / unlimited | — / unlimited | -- `esp32`: observed 2026-06-02 -- `esp32-eth`: observed 2026-06-02 -- `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 -## Layer +#### `GameOfLifeEffect-128x128` (set_control) 📏 + +GameOfLifeEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block. + +**Setup** (preceding non-measured steps): +- `GameOfLifeEffect-128x128-w` (set_control) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 13.9 | — / 91KB | — / 46KB | +| `pc-macos` | — / 19,608-26,316 | — / unlimited | — / unlimited | +| `pc-windows` | — / 8,696-9,174 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 +- `pc-macos`: observed 2026-06-07 +- `pc-windows`: observed 2026-06-07 ### scenario_Layer_base_pipeline @@ -207,14 +1342,16 @@ Add ArtNetSendDriver and run the bounded FPS measurement (expected to stay at >= | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 20,000 / 7,576-— | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 7,874-8,475 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 ### scenario_Layer_buildup `test/scenarios/light/scenario_Layer_buildup.json` — Start empty, add modules step by step, measure tick + heap after each meaningful pipeline state. Surfaces 'how much does each module cost?' so a regression in any one module shows up as a per-step delta instead of a single end-to-end number. Heap bounds catch unintended allocations: each step's delta vs the previous step is asserted against max_delta_bytes (only meaningful on ESP32 where freeHeap() returns a real value). -**Mode**: `construct` · **Also touches**: Layouts, GridLayout, RainbowEffect, MirrorModifier, Drivers, ArtNetSendDriver +**Mode**: `construct` · **Also touches**: Layouts, GridLayout, RainbowEffect, MultiplyModifier, Drivers, ArtNetSendDriver #### `measure-minimum` (measure) 📏 @@ -231,8 +1368,10 @@ Baseline: 16x16 grid + Rainbow only. No Drivers yet (Layer renders into its own | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 20,000 / 8,197-— | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 333,333-1,000,000 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 #### `measure-full-16x16` (measure) 📏 @@ -250,15 +1389,17 @@ Full pipeline at 16x16. Heap delta vs previous measure-minimum step should stay | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 20,000 / 5,464-— | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 200,000-500,000 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 #### `measure-with-lut-16x16` (measure) 📏 Mirror is on: Layer has a LUT, Drivers has an output buffer. min_fps_led_product asserts the throughput floor scales correctly to the logical grid size (post-mirror). **Setup** (preceding non-measured steps): -- `add-mirror` (add_module) — MirrorModifier under Layer. Triggers a LUT build + Drivers output buffer allocation (the heavy memory path). +- `add-mirror` (add_module) — MultiplyModifier under Layer. Triggers a LUT build + Drivers output buffer allocation (the heavy memory path). **Bounds**: - FPS × lights ≥ 100,000 @@ -268,8 +1409,10 @@ Mirror is on: Layer has a LUT, Drivers has an output buffer. min_fps_led_product | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 16,667 / 6,667-— | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 333,333-1,000,000 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 #### `measure-full-128x128` (measure) 📏 @@ -288,8 +1431,10 @@ Production-size grid with the full pipeline. Final tick + cumulative heap delta | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 16,667 / 5,882-23,256 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 10,000-13,158 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-windows`: observed 2026-06-07 ### scenario_Layer_memory_1to1 @@ -316,8 +1461,74 @@ Add ArtNetSendDriver and run the bounded FPS measurement on the no-LUT path. | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | ≥ 20,000 / 12,500-— | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 500,000-1,000,000 | — / unlimited | — / unlimited | - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 + +### 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. + +**Mode**: `mutate` · **Also touches**: MultiplyModifier, CheckerboardModifier, NoiseEffect, Layouts, GridLayout, Drivers, ArtNetSendDriver, PreviewDriver + +#### `multiply-1` (measure) 📏 + +Multiply modifier active — pipeline live, LUT folds the grid. + +**Setup** (preceding non-measured steps): +- `shrink-w` (set_control) +- `shrink-h` (set_control) +- `clear-layers` (clear_children) +- `clear-layouts` (clear_children) +- `build-grid` (add_module) +- `build-layer` (add_module) +- `build-fx` (add_module) +- `build-mod` (add_module) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,580-7,752 | — / 172KB-204KB | — / 76KB-108KB | +| `pc-macos` | — / 142,857-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 + +#### `checkerboard` (measure) 📏 + +Checkerboard modifier active — masks half the lights; pipeline stays live (driver buffer non-null). + +**Setup** (preceding non-measured steps): +- `swap-to-checker` (replace_module) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 778-990 | — / 170KB-203KB | — / 76KB-108KB | +| `pc-macos` | — / 50,000-58,824 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 + +#### `multiply-2` (measure) 📏 + +Back to Multiply — replace round-trips cleanly, pipeline live again. + +**Setup** (preceding non-measured steps): +- `swap-to-multiply` (replace_module) + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `esp32-eth` | — / 1,587-2,278 | — / 169KB-204KB | — / 76KB-108KB | +| `pc-macos` | — / 125,000-166,667 | — / unlimited | — / unlimited | + +- `esp32-eth`: observed 2026-06-07 → 2026-06-08 +- `pc-macos`: observed 2026-06-07 → 2026-06-08 ## Layouts @@ -339,8 +1550,10 @@ Baseline: a single 64x64 grid layout drives the pipeline. | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | — / 29,412-125,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 32,258-37,037 | — / unlimited | — / unlimited | - `pc-macos`: observed 2026-06-05 +- `pc-windows`: observed 2026-06-07 #### `measure-two-layouts` (measure) 📏 @@ -357,8 +1570,10 @@ Pipeline still renders with two layouts wired (buffer non-null, fps measurable). | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | — / 33,333-111,111 | — / unlimited | — / unlimited | +| `pc-windows` | — / 16,393-23,810 | — / unlimited | — / unlimited | - `pc-macos`: observed 2026-06-05 +- `pc-windows`: observed 2026-06-07 #### `measure-after-replace` (measure) 📏 @@ -374,9 +1589,11 @@ Pipeline still renders after replacing a grid with a sphere (different layout ty | Board | FPS | heap | block | |---|---|---|---| -| `pc-macos` | — / 11,494-100,000 | — / unlimited | — / unlimited | +| `pc-macos` | — / 8,621-100,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 5,848-9,009 | — / unlimited | — / unlimited | -- `pc-macos`: observed 2026-06-05 +- `pc-macos`: observed 2026-06-05 → 2026-06-07 +- `pc-windows`: observed 2026-06-07 #### `measure-after-remove` (measure) 📏 @@ -393,76 +1610,18 @@ Pipeline renders with the single remaining grid, same as the baseline. | Board | FPS | heap | block | |---|---|---|---| | `pc-macos` | — / 16,949-125,000 | — / unlimited | — / unlimited | +| `pc-windows` | — / 33,333-38,462 | — / unlimited | — / unlimited | - `pc-macos`: observed 2026-06-05 - -## MirrorModifier - -### scenario_MirrorModifier_memory_lut - -`test/scenarios/light/scenario_MirrorModifier_memory_lut.json` — Verify that adding a MirrorModifier allocates both the mapping LUT and the driver buffer (the heavy memory path). Companion to scenario_Layer_memory_1to1, which verifies the no-LUT path. - -**Mode**: `construct` · **Also touches**: Layer, MappingLUT, BlendMap - -#### `add-artnet` (add_module) 📏 - -Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path. - -**Setup** (preceding non-measured steps): -- `add-layout-group` (add_module) — Create the top-level Layouts container. -- `add-grid` (add_module) — Add a 16x16 GridLayout. -- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). -- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. -- `add-mirror` (add_module) — Add MirrorModifier — triggers LUT and driver-buffer allocation. -- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 8,333 / 3,322-1,000,000 | unlimited / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 - -### scenario_MirrorModifier_pipeline - -`test/scenarios/light/scenario_MirrorModifier_pipeline.json` — Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MirrorModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MirrorModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound. - -**Mode**: `construct` · **Also touches**: Layer, NoiseEffect, ArtNetSendDriver - -#### `add-artnet` (add_module) 📏 - -Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS). - -**Setup** (preceding non-measured steps): -- `add-layout-group` (add_module) — Create the top-level Layouts container. -- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is measurable above the host's microsecond clock. -- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). -- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. -- `add-mirror` (add_module) — Add MirrorModifier so logical pixels reflect across X and Y in the physical grid. -- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer's output buffer. - -**Bounds**: -- FPS ≥ 80% of baseline - -**Performance** (contract / observed) — tick stored, FPS shown: - -| Board | FPS | heap | block | -|---|---|---|---| -| `pc-macos` | ≥ 8,333 / 4,065-1,000,000 | unlimited / unlimited | — / unlimited | - -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 ## MoonModule ### scenario_MoonModule_control_change -`test/scenarios/core/scenario_MoonModule_control_change.json` — Measure the cost of control changes on a running pipeline. Toggles MirrorModifier's mirrorX/Y at different points and verifies each change is applied without freezing the render loop. Companion to the MoonModule control-change gate unit tests (unit_MoonModule_control_change_gate.cpp) — this is the live equivalent. +`test/scenarios/core/scenario_MoonModule_control_change.json` — Measure the cost of control changes on a running pipeline. Toggles MultiplyModifier's mirrorX/Y at different points and verifies each change is applied without freezing the render loop. Companion to the MoonModule control-change gate unit tests (unit_MoonModule_control_change_gate.cpp) — this is the live equivalent. -**Mode**: `mutate` · **Also touches**: MirrorModifier, NoiseEffect +**Mode**: `mutate` · **Also touches**: MultiplyModifier, NoiseEffect #### `baseline` (set_control) 📏 @@ -478,12 +1637,14 @@ Set NoiseEffect.scale=4 and measure baseline FPS (mirror on). Effect controls do | `esp32` | — / 3.9 | — / 88KB | — / 48KB | | `esp32-eth` | — / 10.5-10.6 | — / 133KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.2 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 8,333 / 4,785-10,309 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 8,333 / 4,505-10,309 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 4,000-4,405 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-07 +- `pc-windows`: observed 2026-06-07 #### `disable-mirrorX` (set_control) 📏 @@ -496,12 +1657,14 @@ Disable mirrorX. Modifier control triggers a pipeline rebuild — measures the r | `esp32` | — / 4.8 | — / 88KB | — / 48KB | | `esp32-eth` | — / 10.4 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.0 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 5,000 / 3,636-5,525 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 5,000 / 3,636-9,174 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 2,024-2,392 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-08 +- `pc-windows`: observed 2026-06-07 #### `disable-mirrorY` (set_control) 📏 @@ -514,12 +1677,14 @@ Disable mirrorY. Mirror is now fully off — should land on the no-LUT path. | `esp32` | — / 4.4 | — / 88KB | — / 48KB | | `esp32-eth` | — / 8.9-9.0 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 11.1 | ≥ 103KB / 94KB | — / 48KB | -| `pc-macos` | ≥ 2,500 / 1,916-2,890 | unlimited / unlimited | — / unlimited | +| `pc-macos` | ≥ 2,500 / 1,916-9,009 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 1,082-1,305 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 -- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-08 +- `pc-windows`: observed 2026-06-07 #### `re-enable-mirrorY` (set_control) 📏 @@ -539,11 +1704,77 @@ Re-enable mirrorY and measure — the heavy LUT path must recover (FPS within 50 | `esp32-eth` | — / 10.5-10.6 | — / 132KB | — / 48KB-50KB | | `esp32-eth-wifi` | ≥ 10.0 / 12.1 | ≥ 103KB / 94KB | — / 48KB | | `pc-macos` | ≥ 8,333 / 5,348-10,417 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 4,065-4,854 | — / unlimited | — / unlimited | - `esp32`: observed 2026-06-02 - `esp32-eth`: observed 2026-06-02 - `esp32-eth-wifi`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 - `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-03 +- `pc-windows`: observed 2026-06-07 + +## MultiplyModifier + +### scenario_MultiplyModifier_memory_lut + +`test/scenarios/light/scenario_MultiplyModifier_memory_lut.json` — Verify that adding a MultiplyModifier allocates both the mapping LUT and the driver buffer (the heavy memory path). Companion to scenario_Layer_memory_1to1, which verifies the no-LUT path. + +**Mode**: `construct` · **Also touches**: Layer, MappingLUT, BlendMap + +#### `add-artnet` (add_module) 📏 + +Add ArtNetSendDriver and run the bounded FPS measurement on the LUT path. + +**Setup** (preceding non-measured steps): +- `add-layout-group` (add_module) — Create the top-level Layouts container. +- `add-grid` (add_module) — Add a 16x16 GridLayout. +- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). +- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. +- `add-mirror` (add_module) — Add MultiplyModifier — triggers LUT and driver-buffer allocation. +- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer. + +**Bounds**: +- FPS ≥ 80% of baseline + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | ≥ 8,333 / 3,322-1,000,000 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 166,667-333,333 | — / unlimited | — / unlimited | + +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 + +### scenario_MultiplyModifier_pipeline + +`test/scenarios/light/scenario_MultiplyModifier_pipeline.json` — Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MultiplyModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MultiplyModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound. + +**Mode**: `construct` · **Also touches**: Layer, NoiseEffect, ArtNetSendDriver + +#### `add-artnet` (add_module) 📏 + +Add ArtNetSendDriver and run the bounded FPS measurement (mirror + LUT path must stay at >=80% of the rated FPS). + +**Setup** (preceding non-measured steps): +- `add-layout-group` (add_module) — Create the top-level Layouts container. +- `add-grid` (add_module) — Add a 128x128 GridLayout child to Layouts. Set explicitly (the module default is 16x16x1) so the tick is measurable above the host's microsecond clock. +- `add-layer` (add_module) — Add a Layer wired to Layouts (RGB). +- `add-noise` (add_module) — Add NoiseEffect as the Layer's effect. +- `add-mirror` (add_module) — Add MultiplyModifier so logical pixels reflect across X and Y in the physical grid. +- `add-driver-group` (add_module) — Add a Drivers container wired to the Layer's output buffer. + +**Bounds**: +- FPS ≥ 80% of baseline + +**Performance** (contract / observed) — tick stored, FPS shown: + +| Board | FPS | heap | block | +|---|---|---|---| +| `pc-macos` | ≥ 8,333 / 4,065-1,000,000 | unlimited / unlimited | — / unlimited | +| `pc-windows` | — / 3,953-4,444 | — / unlimited | — / unlimited | + +- `pc-macos`: contract set 2026-06-02 "initial contract" · observed 2026-06-02 → 2026-06-05 +- `pc-windows`: observed 2026-06-07 ## NetworkModule diff --git a/docs/tests/unit-tests.md b/docs/tests/unit-tests.md index b8170d62..9508d5a3 100644 --- a/docs/tests/unit-tests.md +++ b/docs/tests/unit-tests.md @@ -69,6 +69,16 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - RipplesEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Ripples paints at least one non-zero byte (effect actually renders). - At least two distinct pixels exist somewhere in the buffer (ripples are localised, so corner-pair would be too strict). +## CheckerboardModifier + +`test/unit/light/unit_CheckerboardModifier.cpp` + +- Identity dimensions — a mask doesn't resize the logical box. +- size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. +- invert flips which parity passes — the cell that was dropped now passes and vice versa. +- size>1 groups cells into squares: with size=2, the 2×2 block at the origin is all one square (parity of 0/2=0), so all four pass; the next block over drops. +- Never fans out — at most one destination. + ## Color `test/unit/core/unit_Color.cpp` @@ -198,6 +208,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast path is preserved exactly (the grid short-circuit). - Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box. - Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount). +- REGRESSION: a high fan-out Multiply (8×8×4 = 256) on a 128×128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount × maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 × 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range — the symptom that black-screened the device. `test/unit/light/unit_Layer_zero_grid.cpp` *Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, CheckerboardEffect, SpiralEffect, MetaballsEffect, PlasmaPaletteEffect, RipplesEffect, GlowParticlesEffect, LavaLampEffect, FireEffect, ParticlesEffect.* @@ -262,19 +273,6 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - One tick on a 16×16 grid leaves at least one non-zero byte in the layer buffer (proves the effect rendered). - Pixels at opposite corners of a 32×32 grid differ in colour (the effect is not flat-filling the buffer). -## MirrorModifier - -`test/unit/light/unit_MirrorModifier.cpp` - -- MirrorModifier reports D3 dimensions (handles all three axes via mirrorX/Y/Z toggles). -- A 128×128 physical grid with mirrorXY has 64×64 logical lights (effect only renders one quadrant). -- An odd-axis physical grid (127×127) rounds up: 64×64 logical lights with one centre row/column shared. -- mirrorZ on a 128×128×4 grid yields 64×64×2 logical lights (mirroring also halves the Z axis). -- With mirrorXY enabled, the corner pixel (0,0) maps to all four corners of the physical grid. -- The centre pixel on an odd-axis grid deduplicates: all four mirror reflections land on the same position so count=1. -- With no mirror axis enabled, mapToPhysical returns one position (identity pass-through). -- mirrorX-only yields two positions per logical pixel (original + horizontal reflection). - ## ModuleFactory `test/unit/core/unit_ModuleFactory.cpp` @@ -303,7 +301,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - addBool binds a bool field — toggling the field updates control.ptr's view. `test/unit/core/unit_MoonModule_control_change_gate.cpp` -*Also touches: GridLayout, MirrorModifier, NoiseEffect, Drivers.* +*Also touches: GridLayout, MultiplyModifier, NoiseEffect, Drivers.* - Layout and Modifier modules opt in to rebuild on a control change (their controls reshape the pipeline). - Effects and Drivers opt out — their controls are values read directly in the hot path, no rebuild needed (prevents slider stutter). @@ -335,6 +333,24 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - A nullptr replacement returns nullptr and leaves the tree intact. - After replace, the caller follows the lifecycle order: onBuildControls → setup → onBuildState on the fresh module, then teardown on the old. +## MultiplyModifier + +`test/unit/light/unit_MultiplyModifier.cpp` + +- Reports D3 — handles all three axes. Pins the ModifierBase default too. +- Defaults (multiply 2/2/1, mirror true/true/false) reproduce the canonical mirror-XY pipeline: a 128×128 physical grid → 64×64 logical (each axis folds). +- multiplyZ tiles the Z axis too: 128×128×4 with multiply 2/2/2 → 64×64×2. +- PURE-FOLD EQUIVALENCE: with the defaults (mult 2, mirror XY), the corner logical pixel (0,0) fans out to all four physical corners — byte-identical to the old MirrorModifier corner test. This is the canonical-pipeline guarantee. +- PURE-FOLD EQUIVALENCE: an interior pixel folds to the same two columns the old mirrorX-only produced — original + horizontal reflection. +- No multiplication on any axis (all multipliers 1) → identity pass-through. +- Tiling WITHOUT mirror repeats (does not reflect) — multiply 2 on X, mirror off: logical x=0 lands at physical x=0 (tile 0) and x=64 (tile 1, identity offset), NOT x=127. This is the difference from a fold. +- multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so logD stays 1 and the layer isn't blanked. Before the clamp, multiplyZ=4 made logD = 1/4 = 0 → empty layer. +- A multiplier larger than the axis extent clamps to the extent (can't tile more times than there are pixels). +- maxMultiplier is the product of the raw controls (the fan-out upper bound). +- REGRESSION: maxMultiplier() must NOT wrap when all axes are maxed. The product 64×64×16 = 65536 overflows nrOfLightsType (uint16 on no-PSRAM) and would wrap to 0 — feeding the uint64 maxDest math in Layer::rebuildLUT an already-wrapped (possibly 0) multiplier → empty LUT → black display. It must saturate to the type max instead. (Single-axis tests above stay under the wrap; this one crosses it.) On uint32 (PSRAM) the product fits and isn't saturated — assert only the non-wrap, non-zero invariant that holds on both widths. +- REGRESSION: an 8×8 multiply must emit all 64 tile positions, not be truncated to 8. The Layer's scratch buffer is sized to ModifierBase::kMaxFanout (64); a smaller buffer (the original physicals[8]) silently dropped 56 of the 64 tiles, so a 128×128 grid showed only 8 tiles instead of the full 8×8 = 64. +- Fan-out never exceeds maxOut even if asked for more than the buffer holds. + ## NetworkModule `test/unit/core/unit_NetworkModule.cpp` @@ -385,6 +401,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - A small grid sends every light at its grid position (stride 1, exact). - A large layout is index-downsampled (stride > 1) so the payload fits the send-buffer cap — but at REAL positions, not a padded box. - Default fps is the rate-limited preview stream rate. +- Regression: deleting the active Layer must not leave a driver holding a dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned when the active Layer was null, leaving PreviewDriver's layer_ pointing at the freed Layer; the next onBuildState read layer_->layouts() on freed memory and crashed the device (LoadProhibited → boot loop, since the broken tree persists). Now passBufferToDrivers clears the drivers' layer_/sourceBuffer_ to null, a safe idle state. This drives the real path: Drivers bound to a Layers CONTAINER (self-healing), the Layer removed, then buildState re-resolves activeLayer()=null. ## RainbowEffect @@ -400,7 +417,7 @@ Unit tests are the fastest tier in the [test strategy](../testing.md): they run - A name with no collision is returned unchanged. - The second module with a duplicate name gets " 2" suffixed; the first keeps its original name. -- Suffix counting increments past existing " 2" / " 3" suffixes ("Layer", "Layer 2", "Layer" → "Layer 3"). +- Suffix counting increments past existing "-2" / "-3" suffixes ("Layer", "Layer-2", "Layer" → "Layer-3"). - deduplicateNamesInTree() walks the entire module tree in one pass and disambiguates every duplicate (used after persistence load). - firstByName(name) returns the first match in DFS order, or nullptr if no module carries that name. - If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented). diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 97e07a33..2babf911 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -65,11 +65,19 @@ if(MM_NO_ETH) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_ETH) endif() -# Embed UI files — regenerate on every build +# Embed UI files — regenerate on every build. ESP-IDF's bundled Python venv +# is named `python.exe` on Windows and `python3` on macOS/Linux; bare `python3` +# fails on the Windows IDF venv. find_package(Python3) lets CMake locate the +# interpreter via the same logic IDF uses (PATH, IDF env), so the same code +# works on every host. The desktop CMakeLists routes through `uv run python` +# for the same reason — only for the IDF build path do we let CMake pick the +# interpreter, because adding uv to ESP-IDF docker would be a bigger CI lift +# than the portability win pays for. +find_package(Python3 REQUIRED COMPONENTS Interpreter) set(UI_DIR ${COMPONENT_DIR}/../../src/ui) add_custom_command( OUTPUT ${UI_DIR}/ui_embedded.h - COMMAND ${CMAKE_COMMAND} -DUI_DIR=${UI_DIR} -DOUT=${UI_DIR}/ui_embedded.h -P ${UI_DIR}/embed_ui.cmake + COMMAND ${CMAKE_COMMAND} -DUI_DIR=${UI_DIR} -DOUT=${UI_DIR}/ui_embedded.h -DPYTHON_CMD=${Python3_EXECUTABLE} -P ${UI_DIR}/embed_ui.cmake DEPENDS ${UI_DIR}/index.html ${UI_DIR}/app.js ${UI_DIR}/style.css ${UI_DIR}/install-picker.js ${UI_DIR}/preview3d.js ${UI_DIR}/moonlight-logo.png ${UI_DIR}/embed_ui.cmake COMMENT "Embedding UI files" ) @@ -79,7 +87,7 @@ add_custom_target(ui_embed DEPENDS ${UI_DIR}/ui_embedded.h) set(BUILD_INFO_DIR ${COMPONENT_DIR}/../../src/core) add_custom_command( OUTPUT ${BUILD_INFO_DIR}/build_info.h - COMMAND python3 ${COMPONENT_DIR}/../../scripts/build/generate_build_info.py + COMMAND ${Python3_EXECUTABLE} ${COMPONENT_DIR}/../../scripts/build/generate_build_info.py DEPENDS ${COMPONENT_DIR}/../../library.json ${COMPONENT_DIR}/../../scripts/build/generate_build_info.py COMMENT "Generating build_info.h" ) diff --git a/scripts/build/_idf_win_shim.py b/scripts/build/_idf_win_shim.py new file mode 100644 index 00000000..57d16269 --- /dev/null +++ b/scripts/build/_idf_win_shim.py @@ -0,0 +1,52 @@ +"""Windows-only shim: set the C locale to UTF-8 before running idf.py. + +ESP-IDF's idf.py refuses to start when `locale.getlocale()` reports a non- +UTF-8 encoding ("Support for Unicode is required, locale … with 1252 …"). +On Dutch / German / French Windows installs the default C locale is cp1252. +PYTHONUTF8=1 fixes Python's I/O encoding but does NOT change getlocale() — +that comes from the C library and only `locale.setlocale()` updates it within +the running process. + +Windows offers no env-var equivalent that Python honours at interpreter +startup, so the workaround is: set the locale FIRST inside the same Python +process, THEN execute idf.py via runpy. The locale change persists for the +remainder of the process, so IDF's check passes. + +invoked as `python _idf_win_shim.py ` from build_esp32.py. +""" + +import locale +import os +import runpy +import sys + +# Try UTF-8 locale names in order of preference. The first one the C library +# accepts wins. en_US.UTF-8 has been supported on Windows 10 1803+ via the +# CRT's UTF-8 codepage; older systems get C.UTF-8 (also Windows-supported in +# recent UCRT) or fall through. +for loc in ("en_US.UTF-8", "en_US.utf8", "C.UTF-8", ".UTF-8"): + try: + locale.setlocale(locale.LC_ALL, loc) + break + except locale.Error: + continue +else: + sys.stderr.write( + "_idf_win_shim: no UTF-8 locale could be set. Run idf.py from a " + "Windows install with UTF-8 region support enabled, or set " + "Control Panel → Region → Administrative → \"Use " + "Unicode UTF-8 for worldwide language support\".\n" + ) + sys.exit(2) + +idf_tools_dir = os.path.join(os.environ["IDF_PATH"], "tools") +idf_py = os.path.join(idf_tools_dir, "idf.py") + +# runpy.run_path does NOT add the script's directory to sys.path the way +# `python idf.py` does, but idf.py imports `python_version_checker` as a +# sibling module. Prepend the tools dir so those sibling imports resolve. +if idf_tools_dir not in sys.path: + sys.path.insert(0, idf_tools_dir) + +sys.argv = [idf_py] + sys.argv[1:] +runpy.run_path(idf_py, run_name="__main__") diff --git a/scripts/build/build_desktop.py b/scripts/build/build_desktop.py index 5c763438..071754de 100644 --- a/scripts/build/build_desktop.py +++ b/scripts/build/build_desktop.py @@ -31,7 +31,11 @@ def host_build_dir() -> str: def main(): bdir = host_build_dir() + is_windows = platform.system() == "Windows" print(f"Building desktop target into {bdir}/ ...") + # CMAKE_BUILD_TYPE is honoured by single-config generators (Ninja, Make). + # Visual Studio is multi-config and ignores it — we pass --config Release at + # build time below. Setting CMAKE_BUILD_TYPE on multi-config is harmless. r = subprocess.run( ["cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release"], cwd=ROOT, @@ -39,10 +43,10 @@ def main(): if r.returncode != 0: sys.exit(r.returncode) - r = subprocess.run( - ["cmake", "--build", bdir], - cwd=ROOT, - ) + build_cmd = ["cmake", "--build", bdir] + if is_windows: + build_cmd += ["--config", "Release"] + r = subprocess.run(build_cmd, cwd=ROOT) sys.exit(r.returncode) diff --git a/scripts/build/build_esp32.py b/scripts/build/build_esp32.py index 10a61572..69ef3b1c 100644 --- a/scripts/build/build_esp32.py +++ b/scripts/build/build_esp32.py @@ -96,6 +96,15 @@ def find_idf() -> Path | None: return None +# Python venv layout differs between platforms — POSIX puts the interpreter in +# `/bin/python`; Windows in `/Scripts/python.exe`. Same for the +# toolchain `bin` dirs ESP-IDF unpacks under ~/.espressif/tools (POSIX) vs +# %USERPROFILE%\.espressif\tools\…\Scripts (Windows for some tools, `bin` for +# the GCC toolchains). The constants below capture the per-platform split. +_VENV_BIN = "Scripts" if sys.platform == "win32" else "bin" +_PYTHON_EXE = "python.exe" if sys.platform == "win32" else "python" + + def find_idf_python() -> Path | None: """Find the ESP-IDF Python venv. Prefers most recently modified.""" venv_dir = Path.home() / ".espressif" / "python_env" @@ -103,7 +112,7 @@ def find_idf_python() -> Path | None: return None candidates = [] for d in venv_dir.iterdir(): - python = d / "bin" / "python" + python = d / _VENV_BIN / _PYTHON_EXE if python.exists(): candidates.append((d.stat().st_mtime, d)) if candidates: @@ -116,7 +125,7 @@ def idf_version(idf_path: Path) -> str: """Extract a clean semver from the IDF version string.""" version_file = idf_path / "version.txt" if version_file.exists(): - raw = version_file.read_text().strip() + raw = version_file.read_text(encoding="utf-8").strip() # Extract major.minor.patch from strings like "v6.1-dev-399-gd1b91b79b5" m = re.match(r"v?(\d+\.\d+)(?:\.(\d+))?", raw) if m: @@ -129,6 +138,13 @@ def idf_env(idf_path: Path) -> dict: env = dict(os.environ) env["IDF_PATH"] = str(idf_path) env["ESP_IDF_VERSION"] = idf_version(idf_path) + # ESP-IDF's Python tooling refuses to run on a non-UTF-8 locale. Windows + # defaults to cp1252 (locale "English_Netherlands.1252" etc.), so idf.py + # bails with "Support for Unicode is required". PYTHONUTF8=1 (PEP 540) + # forces Python into UTF-8 mode regardless of the system locale, and + # PYTHONIOENCODING covers the stdin/stdout/stderr streams. + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" venv_path = find_idf_python() if venv_path: @@ -138,31 +154,47 @@ def idf_env(idf_path: Path) -> dict: extra_paths = [] if venv_path: - extra_paths.append(str(venv_path / "bin")) + extra_paths.append(str(venv_path / _VENV_BIN)) extra_paths.append(str(idf_path / "tools")) - # Add toolchain paths from ~/.espressif/tools + # Add toolchain paths from ~/.espressif/tools. Tool layout varies — POSIX + # tools have `bin/` subdirs (xtensa-esp-elf, cmake), Windows tools often + # don't (ninja, ccache, idf-exe ship the .exe at the version-dir root, or + # inside a single product-named subdir). Add both: the version dir itself + # (catches flat layouts) plus any nested `bin/` subdir (catches POSIX + # layouts). Together this covers every tool IDF installs on either host. tools_dir = Path.home() / ".espressif" / "tools" if tools_dir.exists(): for tool in tools_dir.iterdir(): if tool.is_dir(): for version_dir in sorted(tool.iterdir(), reverse=True): + extra_paths.append(str(version_dir)) bin_dirs = list(version_dir.rglob("bin")) if bin_dirs: extra_paths.append(str(bin_dirs[0])) - break + break env["PATH"] = os.pathsep.join(extra_paths + [env.get("PATH", "")]) return env def idf_cmd(idf_path: Path) -> list[str]: - """Return the command to invoke idf.py via the venv Python.""" + """Return the command to invoke idf.py via the venv Python. + + On Windows the entry point is `_idf_win_shim.py` instead of idf.py + directly — the shim calls `locale.setlocale(LC_ALL, "en_US.UTF-8")` + BEFORE idf.py runs, which is the only way to make IDF's locale check + (`locale.getlocale()`) pass on Windows installs whose system locale + is non-UTF-8 (e.g. Dutch / German / French). See the shim's docstring. + """ venv_path = find_idf_python() - if venv_path: - return [str(venv_path / "bin" / "python"), str(idf_path / "tools" / "idf.py")] - return [str(idf_path / "tools" / "idf.py")] + python_exe = (str(venv_path / _VENV_BIN / _PYTHON_EXE) + if venv_path else "python") + if sys.platform == "win32": + shim = Path(__file__).resolve().parent / "_idf_win_shim.py" + return [python_exe, str(shim)] + return [python_exe, str(idf_path / "tools" / "idf.py")] def firmware_cmake_args(firmware: str, release: str = "") -> list[str]: diff --git a/scripts/build/package_desktop.py b/scripts/build/package_desktop.py index 3d16ddfc..14e19fde 100644 --- a/scripts/build/package_desktop.py +++ b/scripts/build/package_desktop.py @@ -12,7 +12,8 @@ + macOS + Windows desktop only. Linux desktop is on the 2.0 roadmap. Both archives are unsigned; macOS users will see the Gatekeeper "downloaded -from internet" prompt on first run. Documented in the release notes. +from internet" prompt and Windows users will see a SmartScreen warning on +first run. Documented in the README and the per-archive README.txt. """ import json @@ -102,7 +103,10 @@ def package_macos(binary: Path, version: str) -> Path: DIST_DIR.mkdir(exist_ok=True) out = DIST_DIR / f"projectMM-macos-arm64-v{version}.tar.gz" readme = DIST_DIR / "_README.txt" - readme.write_text(readme_text(version, "macOS arm64")) + # encoding="utf-8" — the README contains "→" and "—"; Windows' default + # write_text encoding is cp1252 and rejects them. Explicit utf-8 matches + # what tar/zip readers expect today. + readme.write_text(readme_text(version, "macOS arm64"), encoding="utf-8") try: with tarfile.open(out, "w:gz") as tar: tar.add(binary, arcname="projectMM") @@ -117,7 +121,7 @@ def package_windows(binary: Path, version: str) -> Path: DIST_DIR.mkdir(exist_ok=True) out = DIST_DIR / f"projectMM-windows-x64-v{version}.zip" readme = DIST_DIR / "_README.txt" - readme.write_text(readme_text(version, "Windows x64")) + readme.write_text(readme_text(version, "Windows x64"), encoding="utf-8") try: with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf: zf.write(binary, arcname="projectMM.exe") diff --git a/scripts/build/setup_esp_idf.py b/scripts/build/setup_esp_idf.py index a3d35c1e..b4495332 100644 --- a/scripts/build/setup_esp_idf.py +++ b/scripts/build/setup_esp_idf.py @@ -8,6 +8,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from build_esp32 import find_idf, IDF_SEARCH_PATHS +# Espressif ships install.sh for POSIX hosts and install.bat / install.ps1 for +# Windows. Both create the same `~/.espressif/python_env/...` venv and download +# the same toolchains — only the wrapper differs. install.bat is the broadest +# entry point on Windows (works in cmd.exe and PowerShell); .ps1 needs the +# execution policy unlocked, which we'd rather not assume. +INSTALL_SCRIPT_NAME = "install.bat" if sys.platform == "win32" else "install.sh" + + def main(): idf_path = find_idf() if not idf_path: @@ -21,17 +29,30 @@ def main(): # Check version version_file = idf_path / "version.txt" if version_file.exists(): - print(f"Version: {version_file.read_text().strip()}") + print(f"Version: {version_file.read_text(encoding='utf-8').strip()}") + + # The shallow `git clone --depth 1` users typically run skips submodules, + # but install.{sh,bat} needs the vendored tooling under `tools/idf_tools.py` + # and the `components/*/` submodules. Run an idempotent submodule init — + # already-initialized submodules are a fast no-op. + print("Initializing ESP-IDF submodules (idempotent)...") + r = subprocess.run( + ["git", "submodule", "update", "--init", "--recursive", "--depth", "1"], + cwd=str(idf_path)) + if r.returncode != 0: + print("Submodule init failed.") + sys.exit(r.returncode) - # Run install.sh to set up Python venv - install_script = idf_path / "install.sh" + install_script = idf_path / INSTALL_SCRIPT_NAME if not install_script.exists(): - print(f"install.sh not found at {install_script}") + print(f"{INSTALL_SCRIPT_NAME} not found at {install_script}") sys.exit(1) - print("Running ESP-IDF install (creates Python venv)...") + print(f"Running ESP-IDF {INSTALL_SCRIPT_NAME} (creates Python venv)...") + # shell=True on Windows so cmd.exe interprets the .bat correctly. r = subprocess.run([str(install_script), "esp32"], - cwd=str(idf_path)) + cwd=str(idf_path), + shell=(sys.platform == "win32")) if r.returncode != 0: print("Install failed.") sys.exit(r.returncode) diff --git a/scripts/check/check_specs.py b/scripts/check/check_specs.py index 24f5d435..0a01839f 100644 --- a/scripts/check/check_specs.py +++ b/scripts/check/check_specs.py @@ -28,7 +28,9 @@ def find_moonmodules(): if str(rel).startswith("platform/") or str(rel).startswith("ui/"): continue - content = h_file.read_text() + # Explicit utf-8: source files contain non-ASCII (→, µ, ×). Windows' + # default read_text encoding is cp1252 and rejects those bytes. + content = h_file.read_text(encoding="utf-8") # Check if file defines a class inheriting from MoonModule or its subclasses if re.search(r'class\s+\w+\s*:\s*public\s+\w*(MoonModule|EffectBase|DriverBase|ModifierBase|LayoutBase)', content): # Skip abstract base classes (pure virtual methods + no controls) @@ -52,8 +54,8 @@ def find_spec(module_path): def check_spec_freshness(source_path, spec_path): """Check if spec mentions key elements from the source.""" issues = [] - source = source_path.read_text() - spec = spec_path.read_text() + source = source_path.read_text(encoding="utf-8") + spec = spec_path.read_text(encoding="utf-8") # Extract control names from source controls = re.findall(r'controls_\.add\w+\("(\w+)"', source) diff --git a/scripts/check/collect_kpi.py b/scripts/check/collect_kpi.py index ca4ddce9..45872fa9 100644 --- a/scripts/check/collect_kpi.py +++ b/scripts/check/collect_kpi.py @@ -50,7 +50,12 @@ sys.path.insert(0, str(ROOT / "scripts" / "build")) def run(cmd, cwd=None, timeout=30): - r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, timeout=timeout) + # encoding="utf-8" + errors="replace" — subprocess defaults to the locale + # codec when text=True, which is cp1252 on Windows and crashes on bytes + # >= 0x80 from tools like idf.py size that emit utf-8 (deg/box-drawing). + r = subprocess.run(cmd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + cwd=cwd, timeout=timeout) return r.stdout + r.stderr, r.returncode # --------------------------------------------------------------------------- @@ -189,6 +194,7 @@ def collect_esp32(): "-DSDKCONFIG=" + str(esp32_build / "sdkconfig"), "size"], capture_output=True, text=True, + encoding="utf-8", errors="replace", cwd=ESP32_DIR, env=env, timeout=60) out = r.stdout + r.stderr @@ -242,7 +248,7 @@ def collect_esp32(): def _extract_esp32_tick(log, kpi): if not log.exists(): return False - for line in reversed(log.read_text().splitlines()): + for line in reversed(log.read_text(encoding="utf-8", errors="replace").splitlines()): if "tick:" not in line: continue # Format: "tick: 108us (FPS: 9259) free: 215180 maxBlock: 63488" @@ -264,7 +270,7 @@ def _live_capture(log, seconds=15): if not cfg.exists(): return False try: - state = json.loads(cfg.read_text()) + state = json.loads(cfg.read_text(encoding="utf-8")) # Port lives inside the active network record (post-networks refactor # in moondeck.py). Fall back to the legacy top-level `port` so an # un-migrated moondeck.json still works. @@ -306,11 +312,13 @@ def collect_code(): src_files = list(src_dir.rglob("*.h")) + list(src_dir.rglob("*.cpp")) kpi["src_files"] = len(src_files) - kpi["src_lines"] = sum(f.read_text().count("\n") for f in src_files) + # encoding="utf-8" — sources contain non-ASCII (→, µ, ×) in comments. + # errors="replace" so any garbled file in the build dir doesn't crash KPI. + kpi["src_lines"] = sum(f.read_text(encoding="utf-8", errors="replace").count("\n") for f in src_files) test_files = [f for f in test_dir.rglob("*.cpp") if f.name != "doctest.h"] kpi["test_files"] = len(test_files) - kpi["test_lines"] = sum(f.read_text().count("\n") for f in test_files) + kpi["test_lines"] = sum(f.read_text(encoding="utf-8", errors="replace").count("\n") for f in test_files) kpi["specs"] = len(list((ROOT / "docs" / "moonmodules").rglob("*.md"))) kpi["scenarios"] = len(list((ROOT / "test" / "scenarios").rglob("*.json"))) diff --git a/scripts/moondeck.py b/scripts/moondeck.py index 85d63b0a..eb344e08 100644 --- a/scripts/moondeck.py +++ b/scripts/moondeck.py @@ -33,7 +33,7 @@ def _app_version(): """Read the project version from library.json. '?' if unavailable.""" try: - return json.loads((ROOT / "library.json").read_text()).get("version", "?") + return json.loads((ROOT / "library.json").read_text(encoding="utf-8")).get("version", "?") except Exception: return "?" @@ -54,7 +54,7 @@ def _load_boards(): Step 2 picker will share this file. """ try: - return json.loads(BOARDS_FILE.read_text()) + return json.loads(BOARDS_FILE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return [] @@ -301,7 +301,7 @@ def _consume_last_flash() -> dict | None: if not _LAST_FLASH_FILE.exists(): return None try: - data = json.loads(_LAST_FLASH_FILE.read_text()) + data = json.loads(_LAST_FLASH_FILE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None import time @@ -641,24 +641,36 @@ def is_process_running(name: str) -> bool: # --------------------------------------------------------------------------- def list_serial_ports() -> list[str]: - """List available serial ports.""" - ports = [] - # macOS + """List available serial ports. + + POSIX hosts: glob the conventional /dev/tty* device files (no deps). + Windows: read HKLM\\HARDWARE\\DEVICEMAP\\SERIALCOMM via winreg (stdlib). + SERIALCOMM is the authoritative table the OS itself maintains for + present COM ports — what pyserial reads under the hood — so a registry + walk is both correct and dependency-free. The previous brute-force + COM0..COM255 open-and-close loop required pyserial, which MoonDeck did + not declare, so on Windows the list silently came back empty. + """ + ports: list[str] = [] import glob ports.extend(glob.glob("/dev/tty.usb*")) ports.extend(glob.glob("/dev/ttyUSB*")) ports.extend(glob.glob("/dev/ttyACM*")) - # Windows COM ports if sys.platform == "win32": - for i in range(256): - port = f"COM{i}" - try: - import serial - s = serial.Serial(port) - s.close() - ports.append(port) - except Exception: - pass + import winreg + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"HARDWARE\DEVICEMAP\SERIALCOMM") as key: + i = 0 + while True: + try: + _, port, _ = winreg.EnumValue(key, i) + ports.append(port) + i += 1 + except OSError: + break + except FileNotFoundError: + pass # no SERIALCOMM key — no ports present return sorted(ports) diff --git a/scripts/scenario/run_live_scenario.py b/scripts/scenario/run_live_scenario.py index b7cc95ac..51074fd8 100644 --- a/scripts/scenario/run_live_scenario.py +++ b/scripts/scenario/run_live_scenario.py @@ -11,8 +11,16 @@ import time import urllib.request import urllib.error +import urllib.parse from pathlib import Path + +def _mod_path(name: str) -> str: + """`/api/modules/` with the name URL-encoded. Module names can contain + spaces (ensureUniqueName disambiguates duplicates as "Layer 2"), which urllib + rejects in a raw URL — encode so delete/replace/clear can address them.""" + return "/api/modules/" + urllib.parse.quote(name, safe="") + ROOT = Path(__file__).resolve().parent.parent.parent SCENARIOS_DIR = ROOT / "test" / "scenarios" BASELINE_FILE = ROOT / "test" / "scenario-baseline.json" @@ -25,25 +33,46 @@ class Client: + # Mutating ops (add/delete/replace/control) trigger a full buildState on the + # device — at 128x128 that frees/reallocates a large buffer + LUT and can + # take several seconds on a busy ESP32. 5s was too tight (deletes timed out + # mid-teardown, leaving a half-mutated tree). 15s clears the worst case while + # still catching a genuinely hung device. + TIMEOUT_S = 15 + def __init__(self, host: str): self.base = f"http://{host}" + def _send(self, req): + # A mutating call triggers buildState; while the device is mid-rebuild it + # can drop the TCP connection (ConnectionResetError / "remote end closed") + # or briefly refuse one. The device recovers in well under a second, so a + # single transient drop shouldn't cascade-fail the run — retry once after + # a short settle. A genuine HTTPError (4xx/5xx from the handler) is a real + # result and is NOT retried; it propagates to the caller. + for attempt in range(2): + try: + with urllib.request.urlopen(req, timeout=self.TIMEOUT_S) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError: + raise # a real handler response — let the caller decide + except (urllib.error.URLError, ConnectionError, OSError): + if attempt == 0: + time.sleep(1.0) + continue + raise + def get(self, path: str): - req = urllib.request.Request(f"{self.base}{path}") - with urllib.request.urlopen(req, timeout=5) as resp: - return json.loads(resp.read()) + return self._send(urllib.request.Request(f"{self.base}{path}")) def post(self, path: str, data: dict): body = json.dumps(data).encode() - req = urllib.request.Request(f"{self.base}{path}", data=body, - headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=5) as resp: - return json.loads(resp.read()) + return self._send(urllib.request.Request( + f"{self.base}{path}", data=body, + headers={"Content-Type": "application/json"})) def delete(self, path: str): - req = urllib.request.Request(f"{self.base}{path}", method="DELETE") - with urllib.request.urlopen(req, timeout=5) as resp: - return json.loads(resp.read()) + return self._send(urllib.request.Request(f"{self.base}{path}", method="DELETE")) def _today_iso() -> str: @@ -164,6 +193,21 @@ def walk(modules): return names +def _child_names_of(state: dict, container_name: str) -> list: + """Direct-child names of the named container in the live tree (depth-1 only). + Used by clear_children to enumerate what to delete; the device tears down each + child's whole subtree, so only the immediate children need naming.""" + def find(modules): + for m in modules: + if m.get("name") == container_name: + return [c.get("name") for c in m.get("children", []) if c.get("name")] + hit = find(m.get("children", [])) + if hit is not None: + return hit + return None + return find(state.get("modules", [])) or [] + + def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, update_contract: bool = False, update_reason: str | None = None) -> dict: @@ -206,27 +250,39 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, results["passed"] = False return results - # Pre-flight: every id touched by the steps must already exist on the device. - # A scenario in mutate mode is meant to tweak the live pipeline; a missing id - # means the scenario was written against a different wiring than the device - # has, and the old silent-skip path produced meaningless passes. + # Pre-flight: every id touched by a step must be reachable — either already + # on the device, OR added by an earlier add_module in this scenario. A + # canvas-preparing scenario clears the containers and builds its own tree, so + # its set_control/replace ids won't exist on the device yet; they're created + # mid-run. A still-unreachable id is a real typo / wrong-wiring bug. target = "unknown" try: live_state = client.get("/api/state") target = _detect_target(live_state) - live_names = _collect_module_names(live_state) - # Include reset block ids in the pre-flight — a reset step that - # references a missing module would silently no-op and the baseline - # would measure unintended state. - referenced = {step["id"] for step in scenario.get("steps", []) - if step.get("op") in ("set_control", "delete_module") and step.get("id")} - referenced |= {r["id"] for r in scenario.get("reset", []) - if r.get("op") == "set_control" and r.get("id")} - missing = sorted(referenced - live_names) + # Walk the steps in order, growing the reachable set as add_module steps + # create ids. The containers (Layouts/Layers/Drivers) are always present. + reachable = _collect_module_names(live_state) + missing = [] + for step in scenario.get("steps", []): + sid = step.get("id") + opn = step.get("op") + if opn == "add_module" and sid: + reachable.add(sid) + elif opn in ("set_control", "delete_module", "remove_module", "replace_module", "clear_children") and sid: + # `optional` steps are best-effort (e.g. shrink the grid before a + # clear, if a grid exists) — the executor skips them on a missing + # target, so they don't count as a wiring bug in the pre-flight. + if sid not in reachable and not step.get("optional"): + missing.append(sid) + # Reset-block ids must exist before steps run (no add can precede them). + for r in scenario.get("reset", []): + if r.get("op") == "set_control" and r.get("id") and r["id"] not in reachable: + missing.append(r["id"]) + missing = sorted(set(missing)) if missing: - print(f"\n FAIL — mutate scenario references ids not on the live device: " - f"{', '.join(missing)}. Re-flash, or write the scenario against the " - f"actual wiring.") + print(f"\n FAIL — scenario references ids that are neither on the live " + f"device nor added by an earlier step: {', '.join(missing)}. " + f"Fix the wiring or add the module first.") results["passed"] = False return results except Exception as e: @@ -271,7 +327,7 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # Live runs `steps` only — `fixture` is the in-process equivalent of what # main.cpp already wired on the device. - for step in scenario.get("steps", []): + for step_index, step in enumerate(scenario.get("steps", [])): step_name = step.get("name", "?") op = step.get("op", "") step_result = {"name": step_name, "op": op} @@ -291,9 +347,27 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, elif op == "set_control": data = {"module": step["id"], "control": step["key"], "value": step["value"]} - resp = client.post("/api/control", data) - step_result["status"] = "ok" if resp.get("ok") else "error" - print(f" SET {step.get('id', '?')}.{step.get('key', '?')} = {step.get('value', '?')}") + try: + resp = client.post("/api/control", data) + step_result["status"] = "ok" if resp.get("ok") else "error" + print(f" SET {step.get('id', '?')}.{step.get('key', '?')} = {step.get('value', '?')}") + except urllib.error.HTTPError as ce: + if step.get("optional") and ce.code == 404: + # An `optional` set_control on a missing module (e.g. shrink + # a grid a prior run's cleanup removed) is a no-op, not a fail. + step_result["status"] = "ok" + print(f" SET {step.get('id','?')}.{step.get('key','?')} — skipped (optional, not present)") + elif ce.code == 404: + # Transient: a set_control issued right after a structural + # change (replace/add) can race the device's buildState and + # briefly see "module not found" while the tree rebuilds. + # Settle and retry once before treating it as a real failure. + time.sleep(1.0) + resp = client.post("/api/control", data) + step_result["status"] = "ok" if resp.get("ok") else "error" + print(f" SET {step.get('id','?')}.{step.get('key','?')} = {step.get('value','?')} (retried)") + else: + raise # If this step doesn't measure (so `collect_metrics` won't wait # for us), still give the device a moment — a set_control that # triggers buildState briefly mutates the module tree, and the @@ -302,11 +376,54 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, if not (step.get("measure") or op == "measure"): time.sleep(0.5) - elif op == "delete_module": - resp = client.delete(f"/api/modules/{step['id']}") + elif op in ("delete_module", "remove_module"): + # Both names mean the same thing — accept either so a scenario + # reads identically on the in-process runner (which uses + # `remove_module`) and here. The two runners must never diverge + # on op names, or a scenario silently no-ops on one tier. + resp = client.delete(_mod_path(step["id"])) step_result["status"] = "ok" if resp.get("ok") else "error" print(f" - {step.get('id', '?')}") + elif op == "clear_children": + # Delete every child of a container, leaving the container. + # The "prepare my own canvas" primitive — a scenario assumes + # nothing about the device's starting tree. Enumerate children + # from /api/state, DELETE each by name. The device tears down the + # whole subtree per delete (handleDeleteModule), so clearing a + # Layer's effect also drops any modifier under it. + container_id = step["id"] + state = client.get("/api/state") + child_names = _child_names_of(state, container_id) + cleared = skipped = 0 + for cn in child_names: + try: + client.delete(_mod_path(cn)) + cleared += 1 + except urllib.error.HTTPError as de: + # Non-deletable submodules (Preview, Board, Improv) return + # 400 "module not deletable" — that's expected, skip them. + # Mirrors the in-process op, which skips !userEditable(). + # Re-raise anything that isn't a clean deletability refusal. + if de.code == 400: + skipped += 1 + else: + raise + step_result["status"] = "ok" + tail = f", {skipped} kept" if skipped else "" + print(f" clr {container_id} ({cleared} cleared{tail})") + time.sleep(0.5) # let buildState settle before the next add + + elif op == "replace_module": + # Swap a child for a fresh module of another type at the same + # slot, keeping its name — mirrors the in-process op and the + # device's POST /api/modules//replace endpoint. + resp = client.post(_mod_path(step["id"]) + "/replace", + {"type": step["type"]}) + step_result["status"] = "ok" if resp.get("ok") else "error" + print(f" ~ {step.get('id', '?')} → {step.get('type', '?')}") + time.sleep(0.5) + elif op == "measure": # Pure measurement step (introduced for the build-up scenario shape). # No REST call; the measure block below picks it up via step["measure"] @@ -487,10 +604,13 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # commit a renegotiated promise. if update_contract: # Preserve any per-step tolerance overrides already in place. + # Key by step INDEX, not the step dict — a dict is unhashable, so + # `(step, target)` as a key raised TypeError (this whole path is + # only reached with --update-contract, which the gates don't pass). existing = step.get("contract", {}).get(target, {}) - if (step, target) not in pending_contract_originals: + if (step_index, target) not in pending_contract_originals: # Deep enough copy: existing is a flat dict of scalars. - pending_contract_originals[(step, target)] = ( + pending_contract_originals[(step_index, target)] = ( dict(existing) if existing else None ) new_block = { @@ -555,7 +675,7 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # Cleanup: delete modules that were created by this scenario for module_id in reversed(created_modules): try: - client.delete(f"/api/modules/{module_id}") + client.delete(_mod_path(module_id)) print(f" - {module_id} (cleanup)") except Exception: pass @@ -572,11 +692,11 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, if not contract_safe_to_write and update_contract: # Revert any contract mutations we made to the in-memory tree so the # JSON write below (for observed) doesn't leak them to disk. - for step in scenario.get("steps", []): + for step_index, step in enumerate(scenario.get("steps", [])): contract = step.get("contract") if contract and target in contract: - if (step, target) in pending_contract_originals: - orig = pending_contract_originals[(step, target)] + if (step_index, target) in pending_contract_originals: + orig = pending_contract_originals[(step_index, target)] if orig is None: del contract[target] else: diff --git a/scripts/scenario/run_scenario.py b/scripts/scenario/run_scenario.py index fb47311f..12e20f91 100644 --- a/scripts/scenario/run_scenario.py +++ b/scripts/scenario/run_scenario.py @@ -32,8 +32,24 @@ import _observed # noqa: E402 _HOST = {"darwin": "macos", "win32": "windows"}.get(sys.platform, "linux") -_RUNNER_BASE = ROOT / "build" / _HOST / "test" / "mm_scenarios" -RUNNER = _RUNNER_BASE.with_suffix(".exe") if sys.platform == "win32" else _RUNNER_BASE + + +def _resolve_runner() -> Path: + """Find mm_scenarios. MSVC multi-config drops it in test/Release/; Ninja and + single-config generators drop it in test/. Check both so the script works + with either layout.""" + base = ROOT / "build" / _HOST / "test" / "mm_scenarios" + suffix = ".exe" if sys.platform == "win32" else "" + candidates = [base.with_suffix(suffix)] + if sys.platform == "win32": + candidates.insert(0, base.parent / "Release" / f"mm_scenarios{suffix}") + for c in candidates: + if c.exists(): + return c + return candidates[-1] # fall through with the simplest path for the error message + + +RUNNER = _resolve_runner() # Format emitted by scenario_runner.cpp's measure block: # MEASURE : tick=Nus FPS=N lights=N heap=N (step: ±N) block=N diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 48721a1e..baa77025 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -194,7 +194,7 @@ ApplyResult applyControlValue(const ControlDescriptor& c, // Password parses identically to Text — only serialization 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 = c.max > 0 ? c.max : 16; + uint8_t maxLen = static_cast(c.max > 0 ? c.max : 16); mm::json::parseString(json, key, static_cast(c.ptr), maxLen); return ApplyResult::Ok; } diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 2ec39623..2369abad 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -688,6 +688,15 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c return; } + // Non-editable submodules (Board, Preview, Improv) are apparatus, not + // swappable pipeline content — refuse here so the API enforces it, not just + // the UI's hidden delete button. They can still be disabled via their enable + // toggle; they just can't be removed from the tree. + if (!mod->userEditable()) { + sendResponse(conn, 400, "application/json", "{\"error\":\"module not deletable\"}"); + return; + } + // Remove from parent parent->removeChild(mod); @@ -720,6 +729,14 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const sendResponse(conn, 400, "application/json", "{\"error\":\"top-level modules cannot be replaced\"}"); return; } + // Non-editable submodules (Board, Preview, Improv) are apparatus — replacing + // one swaps it for a different type, which is as much a removal as a delete. + // Refuse, mirroring handleDeleteModule's guard, so the editability contract + // holds across both endpoints. + if (!mod->userEditable()) { + sendResponse(conn, 400, "application/json", "{\"error\":\"module not editable\"}"); + return; + } char typeName[32] = {}; mm::json::parseString(body, "type", typeName, sizeof(typeName)); if (typeName[0] == 0) { @@ -746,6 +763,19 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const return; } + // Name on replace: keep a CUSTOM name (a scenario id like "MOD", or a + // user-renamed slot) so callers can keep addressing the slot by it. But if + // the old name was just the old type's factory display name ("Multiply" for + // a MultiplyModifier), let the fresh module keep its own factory name + // ("Checkerboard") — otherwise a Multiply→Checkerboard replace leaves a + // Checkerboard mislabelled "Multiply". `fresh` already arrives with its + // correct default name from ModuleFactory::create, so we only override for a + // custom name; then re-run uniqueness so two same-type siblings don't collide. + const char* oldDefault = ModuleFactory::displayNameFor(mod->typeName(), mod->role()); + if (std::strcmp(mod->name(), oldDefault) != 0) { + fresh->setName(mod->name()); // custom name — preserve the slot identity + } + // Swap in place; replaceChildAt returns the old module, which we own. MoonModule* old = parent->replaceChildAt(index, fresh); @@ -761,6 +791,13 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const Scheduler::deleteTree(old); } + // Disambiguate only now that the tree is in its final shape: `fresh` is in + // place and `old` is gone. Run before this and firstByName wouldn't find + // `fresh` (not yet linked) and would append a spurious " 2"; run after the + // old module is removed and a genuine same-named sibling is the only thing + // that triggers a suffix. No-op for a preserved custom name that's unique. + if (scheduler_) scheduler_->ensureUniqueName(fresh); + // Re-run onBuildState across the tree so Layer LUT / Drivers buffer // wiring re-forms — a replaced effect/driver re-wires like a freshly added one. if (scheduler_) scheduler_->buildState(); diff --git a/src/core/ImprovProvisioningModule.h b/src/core/ImprovProvisioningModule.h index 0cf3300e..1dd71a29 100644 --- a/src/core/ImprovProvisioningModule.h +++ b/src/core/ImprovProvisioningModule.h @@ -37,6 +37,10 @@ class ImprovProvisioningModule : public MoonModule { // Diagnostics keep ticking; matches FirmwareUpdateModule / SystemModule. bool respectsEnabled() const override { return false; } + // Apparatus, not swappable content — provisioning is a fixed device service. + // Not deletable (matches Board / Preview); can still be disabled. + bool userEditable() const override { return false; } + void setup() override { if constexpr (platform::hasImprov) { // Strings borrowed; platform task copies them into its own storage diff --git a/src/core/JsonSink.h b/src/core/JsonSink.h index 82b6311a..8397f25a 100644 --- a/src/core/JsonSink.h +++ b/src/core/JsonSink.h @@ -7,6 +7,16 @@ #include #include +// Printf-format checking: GCC/Clang have __attribute__((format)); MSVC parses +// it as an "unknown override specifier" under /WX and the whole class fails to +// parse downstream. Define a portable macro that expands to the attribute on +// the GNU family and to nothing on MSVC. +#if defined(__GNUC__) || defined(__clang__) + #define MM_PRINTF_FORMAT(fmt_arg, va_arg) __attribute__((format(printf, fmt_arg, va_arg))) +#else + #define MM_PRINTF_FORMAT(fmt_arg, va_arg) +#endif + namespace mm { // Writes JSON with no fixed-buffer size ceiling. Three modes: @@ -70,7 +80,7 @@ class JsonSink { // module header) fits the FRAG_MAX stack buffer; a fragment that would // exceed it — e.g. an unusually long text-control value — is re-formatted // into a heap buffer so the output is never silently truncated. - void appendf(const char* fmt, ...) __attribute__((format(printf, 2, 3))) { + void appendf(const char* fmt, ...) MM_PRINTF_FORMAT(2, 3) { char frag[FRAG_MAX]; va_list ap; va_start(ap, fmt); diff --git a/src/core/ModuleFactory.h b/src/core/ModuleFactory.h index 80948b1b..12784406 100644 --- a/src/core/ModuleFactory.h +++ b/src/core/ModuleFactory.h @@ -79,7 +79,7 @@ class ModuleFactory { // a flash literal from registerType("…"), valid for the program lifetime. mod->setTypeName(types_[i].name); // Display name: typeName with the role-noun suffix stripped - // (NoiseEffect → Noise, MirrorModifier → Mirror, GridLayout → Grid, + // (NoiseEffect → Noise, MultiplyModifier → Multiply, GridLayout → Grid, // PreviewDriver → Preview). The role chip already conveys what kind of // module it is; the card label shouldn't repeat it. typeName stays // intact for persistence / lookup / replace. @@ -96,7 +96,7 @@ class ModuleFactory { // Returns a pointer into a small static buffer — caller copies it (setName does). // Role-based stripping: // Effect → strip "Effect" (NoiseEffect → Noise) - // Modifier → strip "Modifier" (MirrorModifier → Mirror) + // Modifier → strip "Modifier" (MultiplyModifier → Multiply) // Layout → strip "Layout" (GridLayout → Grid) // Driver → strip "Driver" (PreviewDriver → Preview) // Generic → strip "Module" (FilesystemModule → Filesystem) diff --git a/src/core/Scheduler.cpp b/src/core/Scheduler.cpp index 9d267c15..d914e47b 100644 --- a/src/core/Scheduler.cpp +++ b/src/core/Scheduler.cpp @@ -166,15 +166,20 @@ void Scheduler::ensureUniqueName(MoonModule* mod) { // computing a longer name than setName can store. The snprintf check // below refuses to truncate, which means the practical cap depends on // the base length: 99 for ≤ 5-char bases, 9 for 12–13-char bases like - // "GlowParticles" or "PlasmaPalette" (where "GlowParticles 10" = 16 + // "GlowParticles" or "PlasmaPalette" (where "GlowParticles-10" = 16 // chars + NUL doesn't fit). When the cap is hit we keep the duplicate // name rather than truncate; first-match DFS lookups become ambiguous // for that name but the engine doesn't crash. This is unlikely in // practice (10+ same-typed siblings on one tree) — bump name_/candidate // together if it ever bites. + // + // Separator is '-', not a space: the name becomes a URL path segment in the + // module API (DELETE / replace / move `/api/modules/`); a space there + // needs URL-encoding and breaks the device's raw-path name lookup, so a + // device-created "Grid 2" couldn't be deleted. '-' is URL-safe and readable. char candidate[16]; for (int suffix = 2; suffix < 100; suffix++) { - int n = std::snprintf(candidate, sizeof(candidate), "%s %d", base, suffix); + int n = std::snprintf(candidate, sizeof(candidate), "%s-%d", base, suffix); if (n < 0 || n >= static_cast(sizeof(candidate))) return; // doesn't fit name_ if (firstByName(candidate) == nullptr) { mod->setName(candidate); diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index 70df6be9..11790e36 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -67,8 +67,8 @@ class SystemModule : public MoonModule { void onBuildControls() override { // Platform-derived totals queried here (idempotent, no I/O) so the conditionals that // gate the Progress controls see real values rather than waiting on setup(). - totalInternalVal_ = platform::totalInternalHeap(); - totalHeapVal_ = platform::totalHeap(); + totalInternalVal_ = static_cast(platform::totalInternalHeap()); + totalHeapVal_ = static_cast(platform::totalHeap()); firmwareSizeVal_ = static_cast(platform::firmwareSize()); totalFlashVal_ = static_cast(platform::firmwarePartition()); chipFlashVal_ = static_cast(platform::flashChipSize()); diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index dd5b206f..f7a3db0f 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -3,6 +3,7 @@ #include "core/MoonModule.h" #include "light/layers/Buffer.h" #include "light/layers/Layer.h" +#include "light/layers/Layers.h" #include "light/layers/BlendMap.h" #include "light/drivers/Correction.h" #include "platform/platform.h" @@ -24,6 +25,9 @@ class DriverBase : public MoonModule { // Drivers container hands it one Layer for dimensions regardless of how many // layers feed the output buffer. void setLayer(Layer* layer) { layer_ = layer; } + // The active Layer this driver reads dimensions from — null when no Layer is + // wired (e.g. the last Layer was deleted). Drivers must tolerate null here. + Layer* layer() const { return layer_; } // Shared output correction (brightness LUT + channel order + white) owned by the // Drivers container. Default no-op so Preview (which shows the raw logical buffer) @@ -49,7 +53,19 @@ class Drivers : public MoonModule { uint8_t brightness = 255; uint8_t lightPreset = 0; // index into kLightPresetOptions; 0 = RGB + // Two ways to wire the source Layer: + // - setLayers(Layers*): bind the container; layer_ is re-resolved from + // activeLayer() at every buildState. This makes the link self-healing — + // a Layer cleared and rebuilt via the API (clear_children + add_module) + // is picked up on the next buildState without re-running main.cpp wiring. + // - setLayer(Layer*): pin a specific Layer directly (test rigs that build a + // Layer outside a Layers container). Skips re-resolution. + void setLayers(Layers* layers) { + layers_ = layers; + if (layers_) layer_ = layers_->activeLayer(); + } void setLayer(Layer* layer) { + layers_ = nullptr; // explicit pin overrides container resolution layer_ = layer; } @@ -83,6 +99,10 @@ class Drivers : public MoonModule { } void onBuildState() override { + // Re-resolve the active Layer from the bound container so a Layer that + // was cleared and rebuilt via the API is picked up here (self-healing). + // setLayer() pins a Layer directly and leaves layers_ null — skip then. + if (layers_) layer_ = layers_->activeLayer(); // Output buffer needed if any layer has a LUT (currently single layer). // Multi-layer: check all layers, allocate if at least one has a LUT. // If allocation fails (no contiguous heap large enough — a real risk on @@ -119,17 +139,24 @@ class Drivers : public MoonModule { } private: + Layers* layers_ = nullptr; // bound container; layer_ re-resolved from it at buildState Layer* layer_ = nullptr; Buffer outputBuffer_; Correction correction_; void passBufferToDrivers() { - if (!layer_) return; - Buffer* buf = layer_->lut().hasLUT() ? &outputBuffer_ : &layer_->buffer(); + // No active Layer (e.g. the last Layer was just deleted): clear every + // driver's Layer + source-buffer pointers rather than leaving them at + // their previous values. An early return here left drivers holding a + // dangling layer_ pointing at the freed Layer — PreviewDriver then read + // layer_->layouts() on freed memory and crashed (LoadProhibited). A + // driver with a null layer/buffer is a well-defined idle state. + Buffer* buf = layer_ ? (layer_->lut().hasLUT() ? &outputBuffer_ : &layer_->buffer()) + : nullptr; for (uint8_t i = 0; i < childCount(); i++) { auto* drv = static_cast(child(i)); drv->setSourceBuffer(buf); - drv->setLayer(layer_); // so PreviewDriver can read current physical dimensions + drv->setLayer(layer_); // null when no active Layer; drivers must tolerate it drv->setCorrection(&correction_); // physical drivers apply it; Preview ignores } } diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 9dff6cb6..171b6075 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -111,6 +111,19 @@ class Layer : public MoonModule { rebuildLUT(); + // Neutral status: the LOGICAL box the effects render into (width_×height_× + // depth_) — this is what start/end region carving and modifiers reshape, + // so it can differ from the physical box (shown on Layouts). Only set it + // when rebuildLUT left the status clear; a degrade path (LUT skipped / + // buffer reduced) sets its own Warning, which must win over this line. + if (status() == nullptr) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "%u×%u×%u", + static_cast(width_), + static_cast(height_), + static_cast(depth_)); + setStatus(statusBuf_); + } + // Children allocate after LUT is built (effects need buffer dimensions) MoonModule::onBuildState(); } @@ -258,8 +271,15 @@ class Layer : public MoonModule { // maxDest in DRIVER-index terms. The modifier's multiplier bounds how // many destinations a logical cell fans out to; clamp to 2× the real // light count (was box count — driverCount is the true ceiling now). - nrOfLightsType maxDest = logicalCount * mod->maxMultiplier(); - if (maxDest > driverCount * 2) maxDest = driverCount * 2; + // Compute in 64-bit: on a no-PSRAM board nrOfLightsType is uint16, and + // logicalCount × maxMultiplier (e.g. 256 × 256) overflows it — which + // wrapped maxDest to a tiny value, built a near-empty LUT, and blanked + // the display (the multiplyZ-on-2D black-screen bug). Clamp the ceiling + // before narrowing back to nrOfLightsType. + uint64_t maxDestWide = static_cast(logicalCount) * mod->maxMultiplier(); + const uint64_t ceiling = static_cast(driverCount) * 2; + if (maxDestWide > ceiling) maxDestWide = ceiling; + nrOfLightsType maxDest = static_cast(maxDestWide); // MappingLUT::build owns the allocation decision: it tries a single // contiguous block, falls back to fixed-size pages when no single block @@ -287,8 +307,25 @@ class Layer : public MoonModule { // Layouts::forEachCoord. nullptr → dense grid → no translation needed. nrOfLightsType* boxToDriver = sparse ? buildBoxToDriver(boxCount) : nullptr; - // Fill LUT by iterating all logical coordinates - nrOfLightsType physicals[8]; + // Per-light scratch buffer for the modifier's fan-out destinations. Sized + // to the modifier's maxMultiplier(), but clamped to boxCount: a logical + // light can't map to more positions than the physical box has cells, so + // that's the true ceiling. The clamp avoids a pathological over-alloc + // when maxMultiplier() saturates high (e.g. a 64×64×16 multiply reports + // 65535 but a small grid has far fewer cells). Heap-allocated on the cold + // path (like boxToDriver); an OOM here degrades to the identity LUT. + nrOfLightsType fanout = mod->maxMultiplier() > 0 ? mod->maxMultiplier() : 1; + if (fanout > boxCount && boxCount > 0) fanout = boxCount; + auto* physicals = static_cast( + platform::alloc(static_cast(fanout) * sizeof(nrOfLightsType))); + if (!physicals) { + if (boxToDriver) platform::free(boxToDriver); + lut_.free(); + lutSkipped_ = true; + setStatus("modifier scratch alloc failed — not enough memory", Severity::Warning); + degradeIdentity(); + return; + } nrOfLightsType count; nrOfLightsType logIdx = 0; @@ -297,7 +334,7 @@ class Layer : public MoonModule { for (lengthType x = 0; x < width_; x++) { count = 0; mod->mapToPhysical(x, y, z, physicalWidth_, physicalHeight_, physicalDepth_, - physicals, count, 8); + physicals, count, fanout); if (boxToDriver) { // Translate box indices → driver indices, dropping cells // that map to no real light (kNoDriver). @@ -313,6 +350,7 @@ class Layer : public MoonModule { } } } + platform::free(physicals); if (boxToDriver) platform::free(boxToDriver); lut_.finalize(); @@ -375,6 +413,7 @@ class Layer : public MoonModule { lengthType height_ = 0; lengthType depth_ = 0; uint32_t elapsed_ = 0; + char statusBuf_[20] = {}; // "999×999×999" fits; owned (setStatus borrows the pointer) // Check if heap can afford an allocation (returns true if unlimited or enough budget) static bool canAllocate(size_t bytesNeeded) { diff --git a/src/light/layers/Layers.h b/src/light/layers/Layers.h index 87868d76..92420b06 100644 --- a/src/light/layers/Layers.h +++ b/src/light/layers/Layers.h @@ -35,6 +35,14 @@ class Layers : public MoonModule { Layouts* layouts() const { return layouts_; } + // Re-wire children before they build their state, so a Layer added via the + // API (clear_children + add_module) gets the shared Layouts without anyone + // re-running main.cpp's setLayouts. Then chain to base to build the children. + void onBuildState() override { + setLayouts(layouts_); + MoonModule::onBuildState(); + } + // Role-filtered loop propagation: only tick children that are Layers. // The factory / UI shouldn't allow non-Layer children of a Layers // container, but if one slips in (test fixture, hand-crafted config), diff --git a/src/light/layouts/Layouts.h b/src/light/layouts/Layouts.h index d0cdeadb..142334af 100644 --- a/src/light/layouts/Layouts.h +++ b/src/light/layouts/Layouts.h @@ -3,6 +3,8 @@ #include "core/MoonModule.h" #include "light/light_types.h" // lengthType, nrOfLightsType +#include // std::snprintf for the status line + namespace mm { // Callback for layout coordinate iteration — a layout walks its positions and @@ -70,6 +72,36 @@ class Layouts : public MoonModule { offset += layout->lightCount(); } } + + // Status line: total physical lights + the physical bounding box (the extent + // of all light coordinates). Both are derived facts the container owns — the + // count is the driver buffer size, the box is the dense render extent. Shown + // via the status slot (not controls) so it costs no spec-check entry and + // renders generically. Recomputed only on a rebuild (cold path). A degenerate + // setup (no lights / zero box) flags Warning so the UI shows it's empty. + void onBuildState() override { + const nrOfLightsType lights = totalLightCount(); + // One forEachCoord pass for the bounding box: max coordinate + 1 per axis. + struct Extent { lengthType x, y, z; bool any; } e{0, 0, 0, false}; + forEachCoord([](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + auto* ex = static_cast(ctx); + if (x > ex->x) ex->x = x; + if (y > ex->y) ex->y = y; + if (z > ex->z) ex->z = z; + ex->any = true; + }, &e); + const lengthType w = e.any ? e.x + 1 : 0; + const lengthType h = e.any ? e.y + 1 : 0; + const lengthType d = e.any ? e.z + 1 : 0; + std::snprintf(statusBuf_, sizeof(statusBuf_), "%u lights · %u×%u×%u", + static_cast(lights), + static_cast(w), static_cast(h), static_cast(d)); + setStatus(statusBuf_, lights == 0 ? Severity::Warning : Severity::Status); + MoonModule::onBuildState(); + } + +private: + char statusBuf_[40] = {}; // "65535 lights · 999×999×999" fits; owned (setStatus borrows) }; } // namespace mm diff --git a/src/light/modifiers/CheckerboardModifier.h b/src/light/modifiers/CheckerboardModifier.h new file mode 100644 index 00000000..9cfa6575 --- /dev/null +++ b/src/light/modifiers/CheckerboardModifier.h @@ -0,0 +1,60 @@ +#pragma once + +#include "light/modifiers/ModifierBase.h" + +namespace mm { + +// Masks the layer in a checkerboard pattern: lights in the "off" squares are +// dropped (map to no physical position), lights in the "on" squares pass through +// unchanged. `size` sets the square edge in lights; `invert` flips which squares +// are on. The logical box is unchanged (identity dimensions) — this is a mask, +// not a remap. +// +// Implemented by emitting outCount=0 for dropped lights, which Layer::rebuildLUT +// already records as "this logical light has no destination" (the same zero- +// destination path the sparse box→driver translation uses). No ModifierBase +// contract change. +// +// Prior art: MoonLight's Checkerboard modifier (M_MoonLight.h) drops lights by +// setting position.x = UINT16_MAX; outCount=0 is our equivalent. +class CheckerboardModifier : public ModifierBase { +public: + const char* tags() const override { return "💫"; } // MoonLight origin + + uint8_t size = 2; // checker square edge, in lights (≥1) + bool invert = false; // flip which squares pass through + + nrOfLightsType maxMultiplier() const override { return 1; } // 1:1 or 1:0, never fans out + + void onBuildControls() override { + controls_.addUint8("size", size, 1, 64); + controls_.addBool("invert", invert); + } + + // Identity: a mask doesn't resize the logical box. + void logicalDimensions(lengthType physW, lengthType physH, lengthType physD, + lengthType& logW, lengthType& logH, lengthType& logD) const override { + logW = physW; + logH = physH; + logD = physD; + } + + void mapToPhysical(lengthType lx, lengthType ly, lengthType lz, + lengthType physW, lengthType physH, lengthType /*physD*/, + nrOfLightsType* outPhysicals, nrOfLightsType& outCount, + nrOfLightsType maxOut) const override { + outCount = 0; + if (maxOut == 0) return; + const lengthType s = size ? size : 1; + // Parity of the square this light sits in. Even parity = "on" square by + // default; `invert` swaps which parity passes. + const bool on = (((lx / s) + (ly / s) + (lz / s)) & 1) == (invert ? 1u : 0u); + if (!on) return; // dropped — no physical destination (the mask) + outPhysicals[0] = static_cast(lz) * static_cast(physW) * static_cast(physH) + + static_cast(ly) * static_cast(physW) + + static_cast(lx); + outCount = 1; + } +}; + +} // namespace mm diff --git a/src/light/modifiers/MirrorModifier.h b/src/light/modifiers/MirrorModifier.h deleted file mode 100644 index c9115af2..00000000 --- a/src/light/modifiers/MirrorModifier.h +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include "light/modifiers/ModifierBase.h" - -namespace mm { - -class MirrorModifier : public ModifierBase { -public: - const char* tags() const override { return "💫"; } // MoonLight origin - - bool mirrorX = true; - bool mirrorY = true; - bool mirrorZ = true; - - uint8_t maxMultiplier() const override { - return (mirrorX ? 2 : 1) * (mirrorY ? 2 : 1) * (mirrorZ ? 2 : 1); - } - - void onBuildControls() override { - controls_.addBool("mirrorX", mirrorX); - controls_.addBool("mirrorY", mirrorY); - controls_.addBool("mirrorZ", mirrorZ); - } - - void logicalDimensions(lengthType physW, lengthType physH, lengthType physD, - lengthType& logW, lengthType& logH, lengthType& logD) const override { - logW = mirrorX ? (physW + 1) / 2 : physW; - logH = mirrorY ? (physH + 1) / 2 : physH; - logD = mirrorZ ? (physD + 1) / 2 : physD; - } - - void mapToPhysical(lengthType lx, lengthType ly, lengthType lz, - lengthType physW, lengthType physH, lengthType physD, - nrOfLightsType* outPhysicals, nrOfLightsType& outCount, - nrOfLightsType maxOut) const override { - outCount = 0; - - // Generate mirror coordinates for each axis - lengthType xs[2] = {lx, static_cast(physW - 1 - lx)}; - lengthType ys[2] = {ly, static_cast(physH - 1 - ly)}; - lengthType zs[2] = {lz, static_cast(physD - 1 - lz)}; - - int xCount = (mirrorX && xs[1] != xs[0]) ? 2 : 1; - int yCount = (mirrorY && ys[1] != ys[0]) ? 2 : 1; - int zCount = (mirrorZ && zs[1] != zs[0]) ? 2 : 1; - - for (int zi = 0; zi < zCount && outCount < maxOut; zi++) { - for (int yi = 0; yi < yCount && outCount < maxOut; yi++) { - for (int xi = 0; xi < xCount && outCount < maxOut; xi++) { - nrOfLightsType idx = static_cast(zs[zi]) * - static_cast(physW) * - static_cast(physH) + - static_cast(ys[yi]) * - static_cast(physW) + - static_cast(xs[xi]); - outPhysicals[outCount++] = idx; - } - } - } - } -}; - -} // namespace mm diff --git a/src/light/modifiers/ModifierBase.h b/src/light/modifiers/ModifierBase.h index 885496a4..12191fef 100644 --- a/src/light/modifiers/ModifierBase.h +++ b/src/light/modifiers/ModifierBase.h @@ -23,15 +23,20 @@ class ModifierBase : public MoonModule { // purely an advisory chip, never read in the render path. virtual Dim dimensions() const { return Dim::D3; } - // Max physical destinations per logical light (for LUT allocation estimate) - virtual uint8_t maxMultiplier() const { return 8; } + // Max physical destinations a single logical light fans out to. The Layer + // sizes its per-light scratch buffer to exactly this (heap, cold path), so + // there is no fixed ceiling — a modifier may declare any fan-out and the + // only limit is memory (an alloc failure degrades to the identity LUT). + // Returned as nrOfLightsType (not uint8_t) so large fan-outs (e.g. an 8×8×8 + // multiply = 512) are expressible without overflow. + virtual nrOfLightsType maxMultiplier() const { return 8; } // Compute logical dimensions given physical dimensions virtual void logicalDimensions(lengthType physW, lengthType physH, lengthType physD, lengthType& logW, lengthType& logH, lengthType& logD) const = 0; - // Map a logical coordinate to physical positions - // outPhysicals: caller-provided array (stack, max 8 for XYZ mirror) + // Map a logical coordinate to physical positions. + // outPhysicals: caller-provided array sized kMaxFanout; write at most maxOut. // outCount: set to number of physical positions written virtual void mapToPhysical(lengthType lx, lengthType ly, lengthType lz, lengthType physW, lengthType physH, lengthType physD, diff --git a/src/light/modifiers/MultiplyModifier.h b/src/light/modifiers/MultiplyModifier.h new file mode 100644 index 00000000..08db3c73 --- /dev/null +++ b/src/light/modifiers/MultiplyModifier.h @@ -0,0 +1,128 @@ +#pragma once + +#include "light/modifiers/ModifierBase.h" + +namespace mm { + +// Tiles the logical image across the physical box `multiply` times per axis, +// optionally mirroring alternate tiles. The logical box is the physical box +// divided by the per-axis multiplier; each logical light fans out to one +// physical position per tile. With a multiplier of 2 and mirror on, an axis +// folds in half — the classic kaleidoscope mirror (this subsumes the old +// MirrorModifier: mirror = multiply 2 + mirror true). +// +// Prior art: MoonLight's Multiply modifier (M_MoonLight.h) — same tile+mirror +// shape (`position % modifierSize`, odd tiles reflected). We expose per-axis +// mirror bools (3) instead of MoonLight's single mirror flag, and per-axis +// multipliers, so X/Y/Z can fold and tile independently. +class MultiplyModifier : public ModifierBase { +public: + const char* tags() const override { return "💫"; } // MoonLight origin + + // Tiles per axis. 1 = no multiplication on that axis. + uint8_t multiplyX = 2; + uint8_t multiplyY = 2; + uint8_t multiplyZ = 1; + // Reflect alternate (odd-numbered) tiles on this axis. All default on — a + // mirror on an axis the layout doesn't use (e.g. Z on a 2D grid) is a no-op, + // so defaulting them true gives a kaleidoscope on whatever axes exist. + bool mirrorX = true; + bool mirrorY = true; + bool mirrorZ = true; + + // Upper bound on fan-out = product of the raw control multipliers. Computed + // without the grid extents (not known here), so it's an over-estimate when a + // multiplier exceeds its axis (the effective multiplier clamps to the extent + // in mapToPhysical). The Layer sizes its per-light scratch buffer to this, so + // over-estimating is safe (a few unused slots); under-estimating would + // truncate. No fixed cap — limited only by memory. e.g. 8×8 = 64. + nrOfLightsType maxMultiplier() const override { + const uint8_t cx = multiplyX ? multiplyX : 1; + const uint8_t cy = multiplyY ? multiplyY : 1; + const uint8_t cz = multiplyZ ? multiplyZ : 1; + // Compute in uint64 and saturate to the return type's max. The controls + // cap each axis at 64, so the product can reach 64³ = 262144 — which + // overflows nrOfLightsType (uint16 on no-PSRAM). A wrapped value here + // would defeat the uint64 maxDest math in Layer::rebuildLUT (it'd be fed + // an already-wrapped, possibly-0 multiplier → empty LUT → black display). + // The Layer clamps maxDest to driverCount×2 anyway, so saturating the + // upper bound only over-allocates the scratch buffer slightly. + const uint64_t product = static_cast(cx) * cy * cz; + constexpr uint64_t kTypeMax = static_cast(-1); + return static_cast(product < kTypeMax ? product : kTypeMax); + } + + void onBuildControls() override { + // 1–64 tiles per axis. The fan-out (product) sizes the LUT scratch buffer + // dynamically, so the cap is generous, not a buffer constraint — more + // tiles than the grid has pixels just yields 1-pixel tiles. + controls_.addUint8("multiplyX", multiplyX, 1, 64); + controls_.addUint8("multiplyY", multiplyY, 1, 64); + controls_.addUint8("multiplyZ", multiplyZ, 1, 64); + controls_.addBool("mirrorX", mirrorX); + controls_.addBool("mirrorY", mirrorY); + controls_.addBool("mirrorZ", mirrorZ); + } + + void logicalDimensions(lengthType physW, lengthType physH, lengthType physD, + lengthType& logW, lengthType& logH, lengthType& logD) const override { + // Logical box is the physical box divided by the EFFECTIVE multiplier + // (clamped to the axis extent — see eff()). On a 2D grid (physD=1) any + // multiplyZ clamps to 1, so logD stays 1 and Z multiplication is a no-op + // — you can't tile an axis more times than it has pixels. + logW = physW / eff(multiplyX, physW); + logH = physH / eff(multiplyY, physH); + logD = physD / eff(multiplyZ, physD); + } + + void mapToPhysical(lengthType lx, lengthType ly, lengthType lz, + lengthType physW, lengthType physH, lengthType physD, + nrOfLightsType* outPhysicals, nrOfLightsType& outCount, + nrOfLightsType maxOut) const override { + outCount = 0; + const lengthType mX = eff(multiplyX, physW); + const lengthType mY = eff(multiplyY, physH); + const lengthType mZ = eff(multiplyZ, physD); + const lengthType tileW = physW / mX; + const lengthType tileH = physH / mY; + const lengthType tileD = physD / mZ; + + // One physical position per tile on each axis. For a mirror-enabled axis, + // odd tiles reflect within their tile (MoonLight's `size-1-pos`). + for (lengthType tz = 0; tz < mZ; tz++) { + const lengthType pz = tileOrigin(tz, tileD) + axisOffset(lz, tileD, tz, mirrorZ); + for (lengthType ty = 0; ty < mY; ty++) { + const lengthType py = tileOrigin(ty, tileH) + axisOffset(ly, tileH, ty, mirrorY); + for (lengthType tx = 0; tx < mX; tx++) { + if (outCount >= maxOut) return; + const lengthType px = tileOrigin(tx, tileW) + axisOffset(lx, tileW, tx, mirrorX); + outPhysicals[outCount++] = + static_cast(pz) * static_cast(physW) * static_cast(physH) + + static_cast(py) * static_cast(physW) + + static_cast(px); + } + } + } + } + +private: + // Effective multiplier for an axis: the control value clamped to [1, extent]. + // ≥1 avoids divide-by-zero; ≤extent because tiling more times than the axis + // has pixels is meaningless (and would blank the layer via logD=0). So a + // multiplyZ on a depth-1 (2D) layout clamps to 1 — no effect, as expected. + static lengthType eff(uint8_t mult, lengthType extent) { + lengthType m = mult ? mult : 1; + if (extent > 0 && m > extent) m = extent; + return m; + } + + static lengthType tileOrigin(uint8_t tile, lengthType tileSize) { + return static_cast(tile) * tileSize; + } + // Offset within a tile: identity, or reflected for odd tiles when mirroring. + static lengthType axisOffset(lengthType l, lengthType tileSize, uint8_t tile, bool mirror) { + return (mirror && (tile & 1)) ? static_cast(tileSize - 1 - l) : l; + } +}; + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index c6c4dd0a..63e2e145 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,7 +16,8 @@ #include "light/effects/RipplesEffect.h" #include "light/effects/LavaLampEffect.h" #include "light/effects/GameOfLifeEffect.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/MultiplyModifier.h" +#include "light/modifiers/CheckerboardModifier.h" #include "light/drivers/ArtNetSendDriver.h" #include "light/drivers/PreviewDriver.h" #include "core/HttpServerModule.h" @@ -59,7 +60,8 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("RipplesEffect", "light/effects/RipplesEffect.md"); mm::ModuleFactory::registerType("LavaLampEffect", "light/effects/LavaLampEffect.md"); mm::ModuleFactory::registerType("GameOfLifeEffect", "light/effects/GameOfLifeEffect.md"); - mm::ModuleFactory::registerType("MirrorModifier", "light/modifiers/MirrorModifier.md"); + mm::ModuleFactory::registerType("MultiplyModifier", "light/modifiers/MultiplyModifier.md"); + mm::ModuleFactory::registerType("CheckerboardModifier", "light/modifiers/CheckerboardModifier.md"); mm::ModuleFactory::registerType("ArtNetSendDriver", "light/drivers/ArtNetSendDriver.md"); mm::ModuleFactory::registerType("PreviewDriver", "light/drivers/PreviewDriver.md"); mm::ModuleFactory::registerType("HttpServerModule", "core/HttpServerModule.md"); @@ -191,23 +193,29 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) { auto* noise = mm::ModuleFactory::create("NoiseEffect"); layer->addChild(noise); - auto* mirror = mm::ModuleFactory::create("MirrorModifier"); - layer->addChild(mirror); - - // Drivers: top-level container; one or more Driver children. Today wired to - // the single active Layer (placeholder); the composition follow-up will read - // from the Layers container directly and blend across N Layer buffers. + // MultiplyModifier with its default mult=2 / mirror=true on X,Y reproduces + // the old MirrorModifier-XY canonical pipeline (fold each axis in half). + auto* multiply = mm::ModuleFactory::create("MultiplyModifier"); + layer->addChild(multiply); + + // Drivers: top-level container; one or more Driver children. Bound to the + // Layers container — Drivers re-resolves the active Layer from it at every + // buildState, so a Layer cleared+rebuilt via the API self-heals without + // re-running this wiring. Binding the container (not a single Layer) is what + // lets a driver read across N Layer buffers from one place — the hook + // multi-layer blending uses. auto* drivers = static_cast(mm::ModuleFactory::create("Drivers")); - drivers->setLayer(layersContainer->activeLayer()); + drivers->setLayers(layersContainer); auto* artnet = mm::ModuleFactory::create("ArtNetSendDriver"); drivers->addChild(artnet); // name = "ArtNetSend" (factory default) — disambiguates from a future ArtNetReceive artnet->markWiredByCode(); auto* preview = static_cast(mm::ModuleFactory::create("PreviewDriver")); - // PreviewDriver reads the active Layer (via Drivers' setLayer wiring) for the - // light positions and the sparse driver buffer it streams; it owns its own - // scratch buffers, so no externally-owned frame is needed. + // PreviewDriver reads the active Layer (resolved by the Drivers container's + // setLayers(layersContainer) wiring above) for the light positions and the + // sparse driver buffer it streams; it owns its own scratch buffers, so no + // externally-owned frame is needed. drivers->addChild(preview); // These two drivers are wired by code here (preview gets its broadcaster set // below, once httpServer exists; ArtNet its IP). Mark them wired-by-code so a diff --git a/src/platform/desktop/main_desktop.cpp b/src/platform/desktop/main_desktop.cpp index 3d1459a0..eda6a072 100644 --- a/src/platform/desktop/main_desktop.cpp +++ b/src/platform/desktop/main_desktop.cpp @@ -1,9 +1,15 @@ #include +#include // mm_main() takes uint16_t; used to pull this in on POSIX #include #include #include #include + +#ifdef _WIN32 +#include // _write +#else #include +#endif extern void mm_main(volatile bool& keepRunning, uint16_t httpPort); @@ -14,7 +20,11 @@ static bool cleanExit = false; static void safeWrite(const char* s) { size_t len = std::strlen(s); while (len > 0) { +#ifdef _WIN32 + int n = ::_write(2 /* stderr fd */, s, static_cast(len)); +#else ssize_t n = ::write(STDERR_FILENO, s, len); +#endif if (n <= 0) break; s += n; len -= static_cast(n); } @@ -24,11 +34,15 @@ static void crashHandler(int sig) { const char* name = sig == SIGSEGV ? "SIGSEGV" : sig == SIGABRT ? "SIGABRT" : sig == SIGFPE ? "SIGFPE" - : sig == SIGBUS ? "SIGBUS" : "SIGNAL"; +#ifdef SIGBUS + : sig == SIGBUS ? "SIGBUS" +#endif + : "SIGNAL"; safeWrite("\n*** CRASH: "); safeWrite(name); safeWrite(" ***\n"); - // SA_RESETHAND already restored SIG_DFL; re-raise for OS coredump. + // SA_RESETHAND (POSIX) or signal() one-shot semantics (Windows) already + // restored SIG_DFL; re-raise for OS coredump. raise(sig); } @@ -49,6 +63,20 @@ int main() { std::setvbuf(stdout, nullptr, _IONBF, 0); std::setvbuf(stderr, nullptr, _IONBF, 0); +#ifdef _WIN32 + // Winsock is initialized by a static RAII guard in platform_desktop.cpp, + // so it covers test binaries too (they link mm_platform but have their own + // main()). Nothing to do here. + + // Windows has no sigaction — use signal() with one-shot semantics (the + // handler is restored to SIG_DFL on entry, so re-raise produces the OS + // crash dialog). No SIGPIPE on Windows; broken socket writes return an + // error from send(), which the code already checks. + signal(SIGSEGV, crashHandler); + signal(SIGFPE, crashHandler); + signal(SIGABRT, crashHandler); + signal(SIGINT, [](int) { running = false; }); +#else struct sigaction sa{}; sa.sa_handler = crashHandler; sigemptyset(&sa.sa_mask); @@ -67,6 +95,7 @@ int main() { // which kills the process silently (no crash report) when a browser tab closes // mid-response. We handle broken writes via return-value checks instead. signal(SIGPIPE, SIG_IGN); +#endif std::atexit(atExitHandler); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 9723186c..b2613ecc 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -6,17 +6,81 @@ #include #include #include +#include #include +#include + +#ifdef _WIN32 +// Winsock + Win32 socket APIs. SOCKET is an unsigned handle (INVALID_SOCKET = ~0), +// but `fd_` stays `int` in the cross-platform header — the narrowing is well-defined +// for handle values in the practical range and is the standard Win32 pattern. +#include +#include +#include // _fileno, _commit (POSIX fileno/fsync equivalents) +#else #include #include #include #include #include #include -#include +#endif namespace mm::platform { +namespace { +// Tiny portability shims so each call site reads as plain code, not `#ifdef` noise. +// POSIX uses int FDs + errno + read/write/close; Winsock uses SOCKET handles + +// WSAGetLastError + recv/send/closesocket. Map to a small common surface. +#ifdef _WIN32 +// SOCKET is unsigned (UINT_PTR). `sock(fd)` casts to it at API boundaries so +// /W4 doesn't warn about signed→unsigned at every call site. +inline SOCKET sock(int fd) { return static_cast(fd); } +inline int close_sock(int fd) { return ::closesocket(sock(fd)); } +// WSAEWOULDBLOCK: non-blocking call had no buffer/data. WSAETIMEDOUT: blocking +// recv hit SO_RCVTIMEO without data. Both translate to POSIX EAGAIN semantics +// (the read/write path returns -1 / WouldBlock and the caller retries). +inline bool sockWouldBlock() { + int err = ::WSAGetLastError(); + return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT; +} +inline int open_sock(int domain, int type, int protocol) { + SOCKET s = ::socket(domain, type, protocol); + return (s == INVALID_SOCKET) ? -1 : static_cast(s); +} +inline int make_nonblocking(int fd) { + u_long mode = 1; + return ::ioctlsocket(sock(fd), FIONBIO, &mode); +} +#else +inline int sock(int fd) { return fd; } +inline int close_sock(int fd) { return ::close(fd); } +inline bool sockWouldBlock() { return errno == EAGAIN || errno == EWOULDBLOCK; } +inline int open_sock(int domain, int type, int protocol) { + return ::socket(domain, type, protocol); +} +inline int make_nonblocking(int fd) { + int flags = ::fcntl(fd, F_GETFL, 0); + return ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} +#endif +#ifdef _WIN32 +// Winsock 2.2 must be initialized once per process before any socket call. +// A static RAII guard runs at library load (covers both the app and the test +// binaries, which have their own main() but link mm_platform). WSAStartup is +// reference-counted so this is safe alongside any future caller-side init. +struct WinsockInit { + WinsockInit() { + WSADATA d; + ::WSAStartup(MAKEWORD(2, 2), &d); + } + ~WinsockInit() { ::WSACleanup(); } +}; +static WinsockInit g_winsockInit; +#endif + +} // namespace + static auto startTime = std::chrono::steady_clock::now(); // Test-only override for millis(); 0 means "use the real clock". std::atomic so // a test can set it from one thread while a tested module reads from another. @@ -104,20 +168,20 @@ const char* hostIp() { // host's LAN IP. Cached after the first call. "" if offline. static char ip[INET_ADDRSTRLEN] = {}; if (ip[0]) return ip; - int fd = ::socket(AF_INET, SOCK_DGRAM, 0); + int fd = open_sock(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return ""; sockaddr_in probe{}; probe.sin_family = AF_INET; probe.sin_port = htons(80); inet_pton(AF_INET, "8.8.8.8", &probe.sin_addr); - if (::connect(fd, reinterpret_cast(&probe), sizeof(probe)) == 0) { + if (::connect(sock(fd), reinterpret_cast(&probe), sizeof(probe)) == 0) { sockaddr_in local{}; socklen_t len = sizeof(local); - if (::getsockname(fd, reinterpret_cast(&local), &len) == 0) { + if (::getsockname(sock(fd), reinterpret_cast(&local), &len) == 0) { inet_ntop(AF_INET, &local.sin_addr, ip, sizeof(ip)); } } - ::close(fd); + close_sock(fd); return ip; } @@ -195,7 +259,10 @@ bool fsRemove(const char* path) { int fsRead(const char* path, char* buf, size_t maxLen) { if (!buf || maxLen == 0) return -1; - FILE* f = std::fopen(toFsPath(path).c_str(), "rb"); + // path::c_str() returns wchar_t* on Windows; std::fopen needs char*. Go via + // .string() so the call compiles on both. Costs one std::string allocation + // per read — acceptable for /.config/*.json reads (rare, small). + FILE* f = std::fopen(toFsPath(path).string().c_str(), "rb"); if (!f) return -1; size_t n = std::fread(buf, 1, maxLen - 1, f); std::fclose(f); @@ -208,7 +275,7 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { auto tmp = target; tmp += ".tmp"; - FILE* f = std::fopen(tmp.c_str(), "wb"); + FILE* f = std::fopen(tmp.string().c_str(), "wb"); if (!f) return false; size_t written = std::fwrite(data, 1, len, f); if (written != len) { @@ -218,8 +285,13 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { return false; } std::fflush(f); +#ifdef _WIN32 + int fd = ::_fileno(f); + if (fd >= 0) ::_commit(fd); // Windows equivalent of fsync +#else int fd = ::fileno(f); if (fd >= 0) ::fsync(fd); +#endif std::fclose(f); std::error_code ec; @@ -238,7 +310,10 @@ void fsList(const char* dir, FsListCb cb, void* user) { if (!std::filesystem::exists(p, ec)) return; for (auto& entry : std::filesystem::directory_iterator(p, ec)) { if (ec) break; - cb(entry.path().filename().c_str(), entry.is_directory(ec), user); + // path::filename().c_str() returns wchar_t* on Windows; the callback + // wants char*. Round-trip through .string() to get a portable view. + std::string name = entry.path().filename().string(); + cb(name.c_str(), entry.is_directory(ec), user); } } @@ -339,7 +414,7 @@ UdpSocket::~UdpSocket() { bool UdpSocket::open() { if (fd_ >= 0) return true; - fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + fd_ = open_sock(AF_INET, SOCK_DGRAM, 0); return fd_ >= 0; } @@ -349,17 +424,17 @@ bool UdpSocket::connect(const char* ip, uint16_t port) { addr.sin_family = AF_INET; addr.sin_port = htons(port); if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) return false; - return ::connect(fd_, reinterpret_cast(&addr), sizeof(addr)) == 0; + return ::connect(sock(fd_), reinterpret_cast(&addr), sizeof(addr)) == 0; } bool UdpSocket::sendTo(const uint8_t* data, size_t len) { if (fd_ < 0) return false; - return ::send(fd_, data, len, 0) >= 0; + return ::send(sock(fd_), reinterpret_cast(data), static_cast(len), 0) >= 0; } void UdpSocket::close() { if (fd_ >= 0) { - ::close(fd_); + close_sock(fd_); fd_ = -1; } } @@ -372,10 +447,14 @@ TcpConnection::~TcpConnection() { int TcpConnection::read(uint8_t* buf, size_t maxLen) { if (fd_ < 0) return -1; - auto n = ::read(fd_, buf, maxLen); + // recv() works the same on POSIX and Winsock — the socket is blocking with + // SO_RCVTIMEO set in TcpServer::accept (Windows takes DWORD ms, POSIX takes + // struct timeval). After the timeout, recv returns -1 with EAGAIN/EWOULDBLOCK + // (POSIX) or WSAEWOULDBLOCK (Windows); we translate both to -1 for the caller. + auto n = ::recv(sock(fd_), reinterpret_cast(buf), static_cast(maxLen), 0); if (n > 0) return static_cast(n); if (n == 0) return 0; // peer closed - if (errno == EAGAIN || errno == EWOULDBLOCK) return -1; // nothing available + if (sockWouldBlock()) return -1; // read timed out, nothing available return 0; // error → treat as closed } @@ -383,14 +462,16 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { if (fd_ < 0) return false; size_t sent = 0; while (sent < len) { - auto n = ::write(fd_, data + sent, len - sent); + auto n = ::send(sock(fd_), reinterpret_cast(data + sent), + static_cast(len - sent), 0); if (n > 0) { sent += static_cast(n); - } else if (n < 0 && errno == EINTR) { + } else if (sockWouldBlock()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); +#ifndef _WIN32 + } else if (errno == EINTR) { continue; // interrupted by signal, retry - } else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - struct timespec ts = {0, 1000000}; // 1ms - nanosleep(&ts, nullptr); +#endif } else { return false; } @@ -401,6 +482,33 @@ bool TcpConnection::write(const uint8_t* data, size_t len) { WriteResult TcpConnection::writeChunks(const WriteChunk* chunks, int count) { if (fd_ < 0) return WriteResult::Error; if (count < 1 || count > MAX_WRITE_CHUNKS) return WriteResult::Error; +#ifdef _WIN32 + // WSASend takes a WSABUF[] — same scatter-gather shape as iovec[]. Windows + // has no MSG_DONTWAIT flag, so flip the socket to non-blocking just for the + // duration of this call (and back). The socket is blocking by default for + // recv()'s SO_RCVTIMEO behaviour (see TcpServer::accept). + WSABUF bufs[MAX_WRITE_CHUNKS]; + size_t total = 0; + for (int i = 0; i < count; i++) { + bufs[i].buf = reinterpret_cast(const_cast(chunks[i].data)); + bufs[i].len = static_cast(chunks[i].len); + total += chunks[i].len; + } + u_long nonblocking = 1; + ::ioctlsocket(sock(fd_), FIONBIO, &nonblocking); + DWORD sentBytes = 0; + int rc = ::WSASend(sock(fd_), bufs, static_cast(count), + &sentBytes, 0, nullptr, nullptr); + int err = (rc == SOCKET_ERROR) ? ::WSAGetLastError() : 0; + u_long blocking = 0; + ::ioctlsocket(sock(fd_), FIONBIO, &blocking); + if (rc == SOCKET_ERROR) { + return (err == WSAEWOULDBLOCK) ? WriteResult::WouldBlock : WriteResult::Error; + } + if (sentBytes == 0) return WriteResult::WouldBlock; + if (static_cast(sentBytes) == total) return WriteResult::Complete; + return WriteResult::Partial; +#else struct iovec iov[MAX_WRITE_CHUNKS]; size_t total = 0; for (int i = 0; i < count; i++) { @@ -416,17 +524,17 @@ WriteResult TcpConnection::writeChunks(const WriteChunk* chunks, int count) { msg.msg_iovlen = static_cast(count); ssize_t n = ::sendmsg(fd_, &msg, MSG_DONTWAIT); if (n < 0) { - return (errno == EAGAIN || errno == EWOULDBLOCK) - ? WriteResult::WouldBlock : WriteResult::Error; + return sockWouldBlock() ? WriteResult::WouldBlock : WriteResult::Error; } if (n == 0) return WriteResult::WouldBlock; if (static_cast(n) == total) return WriteResult::Complete; return WriteResult::Partial; +#endif } void TcpConnection::close() { if (fd_ >= 0) { - ::close(fd_); + close_sock(fd_); fd_ = -1; } } @@ -439,51 +547,61 @@ TcpServer::~TcpServer() { bool TcpServer::open(uint16_t port) { if (fd_ >= 0) return true; - fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + fd_ = open_sock(AF_INET, SOCK_STREAM, 0); if (fd_ < 0) return false; int opt = 1; - setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + setsockopt(sock(fd_), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&opt), sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(port); - if (::bind(fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { - ::close(fd_); + if (::bind(sock(fd_), reinterpret_cast(&addr), sizeof(addr)) < 0) { + close_sock(fd_); fd_ = -1; return false; } - if (::listen(fd_, 8) < 0) { - ::close(fd_); + if (::listen(sock(fd_), 8) < 0) { + close_sock(fd_); fd_ = -1; return false; } - // Set non-blocking - int flags = fcntl(fd_, F_GETFL, 0); - fcntl(fd_, F_SETFL, flags | O_NONBLOCK); + make_nonblocking(fd_); return true; } TcpConnection TcpServer::accept() { if (fd_ < 0) return TcpConnection(); +#ifdef _WIN32 + SOCKET client = ::accept(sock(fd_), nullptr, nullptr); + if (client == INVALID_SOCKET) return TcpConnection(); + int clientFd = static_cast(client); + // Match POSIX: socket stays blocking, SO_RCVTIMEO gives recv a 2-second + // timeout. Windows SO_RCVTIMEO takes a DWORD millisecond count (not a + // timeval). writeChunks toggles non-blocking around its WSASend call to + // emulate POSIX's MSG_DONTWAIT. + DWORD timeoutMs = 2000; + ::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeoutMs), sizeof(timeoutMs)); +#else int clientFd = ::accept(fd_, nullptr, nullptr); if (clientFd < 0) return TcpConnection(); - // Set read timeout (2 seconds) instead of non-blocking struct timeval tv = {2, 0}; setsockopt(clientFd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - +#endif return TcpConnection(clientFd); } void TcpServer::close() { if (fd_ >= 0) { - ::close(fd_); + close_sock(fd_); fd_ = -1; } } diff --git a/src/ui/app.js b/src/ui/app.js index d567f809..c09d7d48 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -675,7 +675,7 @@ function createCard(mod, depth) { // -- Children block + footer -- // The .card-children wrapper lives inside this card so the parent's border - // encloses its children; renderModuleTree recurses into it. The "+ add child" + // encloses its children; renderModuleTree recurses into it. The "+ add module" // footer only appears on parents that accept user-created children — a parent // hosting only code-wired children (e.g. Network → Improv) renders the // children block but no add button. @@ -687,12 +687,12 @@ function createCard(mod, depth) { card.appendChild(childrenEl); if (acceptsNewChildren(mod)) { - // -- Footer: + add child -- + // -- Footer: + add module -- const footer = document.createElement("div"); footer.className = "card-footer"; const addBtn = document.createElement("button"); addBtn.className = "add-btn"; - addBtn.textContent = "+ add child"; + addBtn.textContent = "+ add module"; addBtn.addEventListener("click", () => { // Hide the button while the picker is open (the picker takes its // place); restore it once the picker is removed (cancel/create/Esc). @@ -838,7 +838,7 @@ function findParent(childName) { return walk(null, state.modules); } -// Whether this module renders any nested children at all (a "+ add child" +// Whether this module renders any nested children at all (a "+ add module" // button included if it also accepts new ones via the UI). True whenever the // module has at least one child today OR is one of the light-pipeline // containers that users can add to. This lets code-wired children (e.g. @@ -858,7 +858,7 @@ function rolesAcceptedBy(parentMod) { return csv ? csv.split(",") : []; } -// Whether the "+ add child" affordance applies — derived from acceptsChildRoles +// Whether the "+ add module" affordance applies — derived from acceptsChildRoles // being non-empty, so there's a single source of truth (no separate list). function acceptsNewChildren(mod) { return rolesAcceptedBy(mod).length > 0; @@ -1777,9 +1777,22 @@ function attachDragHandlers(card, mod) { }); card.addEventListener("drop", (e) => { e.preventDefault(); + // Innermost card wins — without stopPropagation the drop bubbles to every + // ancestor card that also has a drop handler, firing a SECOND move onto + // the grandparent's child list (e.g. dropping onto Mirror also dropped + // onto the Layer card → move into Layers, index 0 → undoing the first + // move). Same reason dragstart stops propagation above. + e.stopPropagation(); card.classList.remove("drag-over"); const srcName = e.dataTransfer.getData("text/plain"); if (!srcName || srcName === mod.name) return; + // Insert semantics (not swap): the dropped item takes the target row's + // slot and the others shift to fill — the standard reorderable-list + // behaviour (Finder, Trello, VS Code, SortableJS). Because we drop ONTO a + // row (not into a between-rows gap), the landing is the target's absolute + // index: dragging down lands after the target, dragging up lands before + // it. That's consistent ("take the target's slot"), just not always-before. + // // Compute target absolute index within parent's children. Identify the // drop-target by name, not by object identity — state is replaced on // every WS push, so `mod` captured at render time is stale within ~1s. diff --git a/src/ui/embed_ui.cmake b/src/ui/embed_ui.cmake index cd62f01d..0ec169fd 100644 --- a/src/ui/embed_ui.cmake +++ b/src/ui/embed_ui.cmake @@ -5,10 +5,31 @@ # (HttpServerModule); the browser inflates natively, so the firmware carries the # UI ~3.5x smaller with no device-side or front-end decompressor. Standard # embedded-web-UI practice (WLED, ESPHome, ESP-IDF examples all pre-gzip assets). -# Compression runs on the BUILD HOST via python3's gzip module — stdlib, -# guaranteed on every ESP-IDF/desktop build host (chosen over a `gzip` binary -# that Windows toolchains may lack). The PNG is already compressed, so it stays -# raw. +# Compression runs on the BUILD HOST via Python's gzip module — stdlib, so any +# Python interpreter works (chosen over a `gzip` binary that Windows toolchains +# may lack). The PNG is already compressed, so it stays raw. +# +# Interpreter resolution — callers pick ONE of: +# PYTHON_CMD — a CMake list giving the exact argv prefix (ESP32 path +# passes `${Python3_EXECUTABLE}`). Wins if both are set. +# UV_EXECUTABLE — path to `uv`; built into ` run python` here (desktop +# path). The list is built in-script so the desktop +# CMakeLists doesn't have to pass semicolons through +# MSBuild / make's command-line splitter. +# The two build entry points always pass one of these (root CMakeLists passes +# UV_EXECUTABLE via find_program(... REQUIRED); the ESP32 path passes PYTHON_CMD). +# A standalone `cmake -P` invocation that supplies neither is a configuration +# error — fail loudly here rather than fall back to a bare `python3` that doesn't +# exist on Windows (where it's `python`/`py`), which would surface as a cryptic +# mid-build "python3 not found" inside the gzip custom command. +if(DEFINED UV_EXECUTABLE AND NOT DEFINED PYTHON_CMD) + set(PYTHON_CMD ${UV_EXECUTABLE} run python) +elseif(NOT DEFINED PYTHON_CMD) + message(FATAL_ERROR + "embed_ui.cmake: no Python interpreter. Pass -DUV_EXECUTABLE= " + "or -DPYTHON_CMD=. (The normal build supplies one; this only " + "fires on a standalone `cmake -P embed_ui.cmake` with neither set.)") +endif() # Gzip ${UI_DIR}/ to ${OUT_DIR}/.gz and HEX-read it into OUT_VAR. function(gzip_file_hex NAME OUT_VAR) @@ -17,10 +38,10 @@ function(gzip_file_hex NAME OUT_VAR) get_filename_component(out_dir "${OUT}" DIRECTORY) set(gz "${out_dir}/${NAME}.gz") execute_process( - COMMAND python3 -c "import sys,gzip; open(sys.argv[2],'wb').write(gzip.compress(open(sys.argv[1],'rb').read(),9))" "${src}" "${gz}" + COMMAND ${PYTHON_CMD} -c "import sys,gzip; open(sys.argv[2],'wb').write(gzip.compress(open(sys.argv[1],'rb').read(),9))" "${src}" "${gz}" RESULT_VARIABLE rc) if(NOT rc EQUAL 0) - message(FATAL_ERROR "gzip of ${src} failed (python3 rc=${rc})") + message(FATAL_ERROR "gzip of ${src} failed (PYTHON_CMD=${PYTHON_CMD} rc=${rc})") endif() file(READ "${gz}" hex HEX) file(REMOVE "${gz}") diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 558bf5b0..f05e020b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -40,7 +40,8 @@ add_executable(mm_tests unit/light/unit_Layouts_mutation.cpp unit/light/unit_Layouts_toggle_cycle.cpp unit/light/unit_MetaballsEffect.cpp - unit/light/unit_MirrorModifier.cpp + unit/light/unit_MultiplyModifier.cpp + unit/light/unit_CheckerboardModifier.cpp unit/light/unit_NoiseEffect.cpp unit/light/unit_ParticlesEffect.cpp unit/light/unit_PlasmaEffect.cpp diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index 203aa0ed..eb3b0d0a 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -11,9 +11,22 @@ #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" #include "light/layers/Layers.h" +#include "light/effects/LinesEffect.h" #include "light/effects/RainbowEffect.h" #include "light/effects/NoiseEffect.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/effects/PlasmaEffect.h" +#include "light/effects/PlasmaPaletteEffect.h" +#include "light/effects/MetaballsEffect.h" +#include "light/effects/FireEffect.h" +#include "light/effects/ParticlesEffect.h" +#include "light/effects/GlowParticlesEffect.h" +#include "light/effects/CheckerboardEffect.h" +#include "light/effects/SpiralEffect.h" +#include "light/effects/RipplesEffect.h" +#include "light/effects/LavaLampEffect.h" +#include "light/effects/GameOfLifeEffect.h" +#include "light/modifiers/MultiplyModifier.h" +#include "light/modifiers/CheckerboardModifier.h" #include "light/drivers/Drivers.h" #include "light/drivers/ArtNetSendDriver.h" #include "light/drivers/PreviewDriver.h" @@ -156,9 +169,22 @@ static void registerScenarioTypes() { mm::ModuleFactory::registerType("SphereLayout"); mm::ModuleFactory::registerType("Layers"); mm::ModuleFactory::registerType("Layer"); + mm::ModuleFactory::registerType("LinesEffect"); mm::ModuleFactory::registerType("RainbowEffect"); mm::ModuleFactory::registerType("NoiseEffect"); - mm::ModuleFactory::registerType("MirrorModifier"); + mm::ModuleFactory::registerType("PlasmaEffect"); + mm::ModuleFactory::registerType("PlasmaPaletteEffect"); + mm::ModuleFactory::registerType("MetaballsEffect"); + mm::ModuleFactory::registerType("FireEffect"); + mm::ModuleFactory::registerType("ParticlesEffect"); + mm::ModuleFactory::registerType("GlowParticlesEffect"); + mm::ModuleFactory::registerType("CheckerboardEffect"); + mm::ModuleFactory::registerType("SpiralEffect"); + mm::ModuleFactory::registerType("RipplesEffect"); + mm::ModuleFactory::registerType("LavaLampEffect"); + mm::ModuleFactory::registerType("GameOfLifeEffect"); + mm::ModuleFactory::registerType("MultiplyModifier"); + mm::ModuleFactory::registerType("CheckerboardModifier"); mm::ModuleFactory::registerType("Drivers"); mm::ModuleFactory::registerType("ArtNetSendDriver"); mm::ModuleFactory::registerType("PreviewDriver"); @@ -240,6 +266,19 @@ struct ScenarioContext { return mm::ModuleFactory::create(type); } + // Erase every `modules` entry whose pointer lies in `root`'s subtree (root + // and all descendants), so deleting a module that has registered children + // (e.g. a Layer with an effect child) doesn't leave their ids pointing at + // freed memory. Call BEFORE deleteTree(root). Walks the live tree, so it + // must run while the subtree is still intact. + void purgeSubtree(mm::MoonModule* root) { + if (!root) return; + for (uint8_t i = 0; i < root->childCount(); i++) purgeSubtree(root->child(i)); + for (auto it = modules.begin(); it != modules.end();) { + it = (it->second == root) ? modules.erase(it) : std::next(it); + } + } + void wireModule(const char* type, const char* id, const JsonVal& step) { auto* mod = modules[id]; if (!mod) return; @@ -256,7 +295,16 @@ struct ScenarioContext { // Wire props (only when the step has any). if (step.has("props")) { auto& props = step["props"]; - if (std::strcmp(type, "Layer") == 0) { + if (std::strcmp(type, "Layers") == 0) { + // Wire the container's Layouts (mirrors main.cpp's + // layersContainer->setLayouts). Layers re-propagates this to its + // child Layers at every buildState, so a Layer added later picks + // it up — the self-healing path the device relies on. + if (props.has("layouts")) { + auto* layoutsModule = static_cast(modules[props["layouts"].str]); + if (layoutsModule) static_cast(mod)->setLayouts(layoutsModule); + } + } else if (std::strcmp(type, "Layer") == 0) { auto* layer = static_cast(mod); if (props.has("layouts")) { auto* layoutsModule = static_cast(modules[props["layouts"].str]); @@ -266,7 +314,14 @@ struct ScenarioContext { layer->setChannelsPerLight(static_cast(props["channelsPerLight"].num)); } } else if (std::strcmp(type, "Drivers") == 0) { - if (props.has("layer")) { + // Prefer binding the Layers container (self-healing: the active + // Layer is re-resolved at every buildState, so a Layer cleared + // and rebuilt mid-scenario is picked up — mirrors main.cpp). + // Fall back to pinning a specific Layer for older fixtures. + if (props.has("layers")) { + auto* layersModule = static_cast(modules[props["layers"].str]); + if (layersModule) static_cast(mod)->setLayers(layersModule); + } else if (props.has("layer")) { auto* layerModule = static_cast(modules[props["layer"].str]); if (layerModule) static_cast(mod)->setLayer(layerModule); } @@ -485,24 +540,61 @@ static int runScenario(const char* path) { } else { std::printf(" SET %s (%s.%s)\n", name, targetId, key); } - } else if (std::strcmp(op, "remove_module") == 0) { + } else if (std::strcmp(op, "remove_module") == 0 || std::strcmp(op, "delete_module") == 0) { + // `remove_module` and `delete_module` are aliases — accept both so a + // scenario reads identically here and on the live runner (which uses + // `delete_module`). The two runners must never diverge on op names, + // or a scenario silently no-ops on one tier. // Remove a child module from its parent — mirrors // HttpServerModule::handleDeleteModule (remove from parent, // teardown + recursive delete, rebuild pipeline state). Only child // modules can be removed; top-level modules are policy-fixed. const char* targetId = step["id"].c_str(); auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr; - if (!target || !target->parent()) { - std::printf(" - %s — %s not found or top-level, skipped\n", name, targetId); + if (!target || !target->parent() || !target->userEditable()) { + // Mirror the live API (handleDeleteModule): top-level and + // non-editable submodules (Board, Preview, Improv) can't be removed. + std::printf(" - %s — %s not found / top-level / not editable, skipped\n", name, targetId); continue; } auto* parent = target->parent(); parent->removeChild(target); target->teardown(); + ctx.purgeSubtree(target); // erase target + any registered descendants before freeing mm::Scheduler::deleteTree(target); - ctx.modules.erase(targetId); if (schedulerStarted) ctx.scheduler.buildState(); std::printf(" - %s (%s)\n", name, targetId); + } else if (std::strcmp(op, "clear_children") == 0) { + // Delete every child of a container, leaving the container itself. + // The "prepare my own canvas" primitive: a scenario assumes nothing + // about the device's starting tree, clears a container, then adds + // what it needs. Children are deleted including ones the scenario + // never added (a live device's pre-existing effects/modifiers). + // Mirrors remove_module's teardown, looped over all children. Walk + // back-to-front since removeChild compacts the array in place. + const char* targetId = step["id"].c_str(); + auto* container = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr; + if (!container) { + std::printf(" clr %s — container %s not found, skipped\n", name, targetId); + continue; + } + int cleared = 0; + for (uint8_t i = container->childCount(); i > 0; i--) { + mm::MoonModule* childMod = container->child(i - 1); + // Mirror handleDeleteModule: non-editable submodules (Board, + // Preview, Improv) are apparatus, not deletable — skip them so + // the in-process clear matches what the live device does. + if (!childMod->userEditable()) continue; + container->removeChild(childMod); + childMod->teardown(); + // Purge the child AND any registered descendants (e.g. a Layer's + // effect child) before freeing, so no id is left dangling. + ctx.purgeSubtree(childMod); + mm::Scheduler::deleteTree(childMod); + cleared++; + } + if (schedulerStarted) ctx.scheduler.buildState(); + std::printf(" clr %s (%s: %d cleared)\n", name, targetId, cleared); } else if (std::strcmp(op, "replace_module") == 0) { // Replace a child with a fresh module of another type at the same // slot — mirrors HttpServerModule::handleReplaceModule. The new @@ -511,8 +603,10 @@ static int runScenario(const char* path) { const char* targetId = step["id"].c_str(); const char* newType = step["type"].c_str(); auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr; - if (!target || !target->parent()) { - std::printf(" ~ %s — %s not found or top-level, skipped\n", name, targetId); + if (!target || !target->parent() || !target->userEditable()) { + // Mirror the live API (handleReplaceModule): top-level and + // non-editable submodules can't be replaced. + std::printf(" ~ %s — %s not found / top-level / not editable, skipped\n", name, targetId); continue; } auto* parent = target->parent(); @@ -531,7 +625,16 @@ static int runScenario(const char* path) { fresh->onBuildControls(); fresh->setup(); fresh->onBuildState(); - if (old) { old->teardown(); mm::Scheduler::deleteTree(old); } + if (old) { + // Mirror the remove_module / clear_children branches: purge any + // ctx.modules entries pointing at old or its descendants before + // freeing. Otherwise a later step addressing an old descendant + // by id reads a dangling pointer. purgeSubtree also removes the + // targetId mapping; we re-register it to fresh on the next line. + ctx.purgeSubtree(old); + old->teardown(); + mm::Scheduler::deleteTree(old); + } ctx.modules[targetId] = fresh; if (schedulerStarted) ctx.scheduler.buildState(); std::printf(" ~ %s (%s → %s)\n", name, targetId, newType); @@ -789,7 +892,9 @@ int main(int argc, char* argv[]) { for (auto& entry : std::filesystem::recursive_directory_iterator("test/scenarios")) { if (entry.path().extension() == ".json") { total++; - if (runScenario(entry.path().c_str()) != 0) failed++; + // path::c_str() is wchar_t* on Windows; round-trip through + // .string() to get a portable narrow-char view for runScenario. + if (runScenario(entry.path().string().c_str()) != 0) failed++; std::printf("\n"); } } diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index 01f0989d..9eae600f 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -3,10 +3,10 @@ "module": "MoonModule", "mode": "mutate", "also": [ - "MirrorModifier", + "MultiplyModifier", "NoiseEffect" ], - "description": "Measure the cost of control changes on a running pipeline. Toggles MirrorModifier's mirrorX/Y at different points and verifies each change is applied without freezing the render loop. Companion to the MoonModule control-change gate unit tests (unit_MoonModule_control_change_gate.cpp) — this is the live equivalent.", + "description": "Measure the cost of control changes on a running pipeline. Toggles MultiplyModifier's mirrorX/Y at different points and verifies each change is applied without freezing the render loop. Companion to the MoonModule control-change gate unit tests (unit_MoonModule_control_change_gate.cpp) — this is the live equivalent.", "fixture": [ { "name": "fix-layouts", @@ -45,8 +45,8 @@ { "name": "fix-mirror", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -84,14 +84,14 @@ { "name": "reset-mirrorX", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": true }, { "name": "reset-mirrorY", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": true }, @@ -135,7 +135,7 @@ "pc-macos": { "tick_us": [ 97, - 209 + 222 ], "free_heap": [ 0, @@ -147,7 +147,7 @@ ], "at": [ "2026-06-02", - "2026-06-05" + "2026-06-07" ] }, "esp32-eth-wifi": { @@ -203,6 +203,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 227, + 250 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -210,7 +228,7 @@ "name": "disable-mirrorX", "description": "Disable mirrorX. Modifier control triggers a pipeline rebuild — measures the rebuilt path.", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": false, "measure": true, @@ -231,7 +249,7 @@ "observed": { "pc-macos": { "tick_us": [ - 181, + 109, 275 ], "free_heap": [ @@ -244,7 +262,7 @@ ], "at": [ "2026-06-02", - "2026-06-05" + "2026-06-08" ] }, "esp32-eth-wifi": { @@ -300,6 +318,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 418, + 494 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -307,7 +343,7 @@ "name": "disable-mirrorY", "description": "Disable mirrorY. Mirror is now fully off — should land on the no-LUT path.", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": false, "measure": true, @@ -328,7 +364,7 @@ "observed": { "pc-macos": { "tick_us": [ - 346, + 111, 522 ], "free_heap": [ @@ -341,7 +377,7 @@ ], "at": [ "2026-06-02", - "2026-06-05" + "2026-06-08" ] }, "esp32-eth-wifi": { @@ -397,6 +433,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 766, + 924 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -404,7 +458,7 @@ "name": "re-enable-mirrors", "description": "Re-enable mirrorX (rebuild back to LUT path).", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": true }, @@ -412,7 +466,7 @@ "name": "re-enable-mirrorY", "description": "Re-enable mirrorY and measure — the heavy LUT path must recover (FPS within 50% of baseline) without staying degraded.", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": true, "measure": true, @@ -507,6 +561,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 206, + 246 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json index 95620e29..fd4e4226 100644 --- a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json +++ b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json @@ -22,14 +22,14 @@ { "name": "reset-mirrorX", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": true }, { "name": "reset-mirrorY", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": true }, diff --git a/test/scenarios/light/scenario_AllEffects_grid_sizes.json b/test/scenarios/light/scenario_AllEffects_grid_sizes.json new file mode 100644 index 00000000..e6c37bb9 --- /dev/null +++ b/test/scenarios/light/scenario_AllEffects_grid_sizes.json @@ -0,0 +1,4475 @@ +{ + "name": "scenario_AllEffects_grid_sizes", + "module": "Layer", + "mode": "mutate", + "also": [ + "Layouts", + "GridLayout", + "Drivers", + "ArtNetSendDriver", + "PreviewDriver", + "LinesEffect", + "RainbowEffect", + "NoiseEffect", + "PlasmaEffect", + "PlasmaPaletteEffect", + "MetaballsEffect", + "FireEffect", + "ParticlesEffect", + "GlowParticlesEffect", + "CheckerboardEffect", + "SpiralEffect", + "RipplesEffect", + "LavaLampEffect", + "GameOfLifeEffect" + ], + "description": "Sweep every effect (no modifier) across 16/32/64/128 square grids and measure tick/FPS, free internal heap, max internal block per (effect, size). The scenario prepares its own canvas: clear_children wipes whatever layouts/layers/drivers the device had, then it rebuilds exactly one Layout(Grid) + one Layer + one effect (no modifier) + ArtNet, so the measurement is each effect's raw cost over the full grid through the real output driver, on any starting device state. PreviewDriver is apparatus (non-deletable) so it survives the clear. Effects are swapped via replace_module at a fixed Layer child slot; grid resized via set_control (width then height, measuring after height so we never measure an N x 128 stripe).", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 16, + "height": 16 + } + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Layers", + "type": "Layers", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "fix-fx", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layers": "Layers" + } + }, + { + "name": "fix-artnet", + "op": "add_module", + "id": "ArtNet", + "type": "ArtNetSendDriver", + "parent_id": "Drivers" + }, + { + "name": "fix-preview", + "op": "add_module", + "id": "Preview", + "type": "PreviewDriver", + "parent_id": "Drivers" + } + ], + "steps": [ + { + "name": "shrink-grid-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "shrink-grid-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "clear-layers", + "op": "clear_children", + "id": "Layers" + }, + { + "name": "clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "clear-drivers", + "op": "clear_children", + "id": "Drivers" + }, + { + "name": "build-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 16, + "height": 16 + } + }, + { + "name": "build-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "build-fx", + "op": "add_module", + "id": "FX", + "type": "LinesEffect", + "parent_id": "Layer" + }, + { + "name": "build-artnet", + "op": "add_module", + "id": "ArtNet", + "type": "ArtNetSendDriver", + "parent_id": "Drivers" + }, + { + "name": "LinesEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "LinesEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-LinesEffect", + "op": "replace_module", + "id": "FX", + "type": "LinesEffect" + }, + { + "name": "LinesEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "LinesEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "LinesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 67, + 79 + ], + "free_heap": [ + 225884, + 225916 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LinesEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "LinesEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "LinesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 100, + 131 + ], + "free_heap": [ + 219708, + 221308 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LinesEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "LinesEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "LinesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 363, + 434 + ], + "free_heap": [ + 199716, + 202876 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LinesEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "LinesEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "LinesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 37272, + 42911 + ], + "free_heap": [ + 129116, + 129148 + ], + "max_alloc_block": [ + 63488, + 63488 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 4, + 27 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RainbowEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "RainbowEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-RainbowEffect", + "op": "replace_module", + "id": "FX", + "type": "RainbowEffect" + }, + { + "name": "RainbowEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "RainbowEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "RainbowEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 254, + 261 + ], + "free_heap": [ + 177396, + 177532 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RainbowEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "RainbowEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "RainbowEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 2, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 980, + 1033 + ], + "free_heap": [ + 171968, + 175228 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 6, + 11 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RainbowEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "RainbowEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "RainbowEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 8, + 9 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 5691, + 6980 + ], + "free_heap": [ + 162748, + 166012 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 25, + 29 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RainbowEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "RainbowEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "RainbowEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 35, + 39 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 44281, + 49423 + ], + "free_heap": [ + 129152, + 129380 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 112, + 164 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "NoiseEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "NoiseEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-NoiseEffect", + "op": "replace_module", + "id": "FX", + "type": "NoiseEffect" + }, + { + "name": "NoiseEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "NoiseEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "NoiseEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 3, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 853, + 895 + ], + "free_heap": [ + 176020, + 177516 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 9, + 12 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "NoiseEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "NoiseEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "NoiseEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 14, + 16 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 3082, + 3622 + ], + "free_heap": [ + 172144, + 175212 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 34, + 40 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "NoiseEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "NoiseEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "NoiseEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 64, + 74 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 13923, + 22059 + ], + "free_heap": [ + 162936, + 165996 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 148, + 211 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "NoiseEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "NoiseEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "NoiseEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 306, + 342 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 58413, + 81254 + ], + "free_heap": [ + 129132, + 129372 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 696, + 840 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "PlasmaEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-PlasmaEffect", + "op": "replace_module", + "id": "FX", + "type": "PlasmaEffect" + }, + { + "name": "PlasmaEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "PlasmaEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "PlasmaEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 313, + 331 + ], + "free_heap": [ + 177504, + 177756 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "PlasmaEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "PlasmaEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 3, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 1159, + 1205 + ], + "free_heap": [ + 175200, + 175452 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 6, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "PlasmaEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "PlasmaEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 11, + 15 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 5510, + 7394 + ], + "free_heap": [ + 165984, + 166236 + ], + "max_alloc_block": [ + 86016, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 23, + 28 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "PlasmaEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "PlasmaEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 44, + 52 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 41150, + 54579 + ], + "free_heap": [ + 129128, + 129372 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 103, + 127 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaPaletteEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "PlasmaPaletteEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-PlasmaPaletteEffect", + "op": "replace_module", + "id": "FX", + "type": "PlasmaPaletteEffect" + }, + { + "name": "PlasmaPaletteEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "PlasmaPaletteEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "PlasmaPaletteEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 157, + 166 + ], + "free_heap": [ + 174456, + 177516 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 1, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaPaletteEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "PlasmaPaletteEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "PlasmaPaletteEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 447, + 577 + ], + "free_heap": [ + 172152, + 175212 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 3, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaPaletteEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "PlasmaPaletteEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "PlasmaPaletteEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 5, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 2081, + 3736 + ], + "free_heap": [ + 164508, + 165996 + ], + "max_alloc_block": [ + 81920, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 14, + 20 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "PlasmaPaletteEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "PlasmaPaletteEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "PlasmaPaletteEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 20, + 24 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 31106, + 45974 + ], + "free_heap": [ + 129132, + 129372 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 53, + 81 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "MetaballsEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "MetaballsEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-MetaballsEffect", + "op": "replace_module", + "id": "FX", + "type": "MetaballsEffect" + }, + { + "name": "MetaballsEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "MetaballsEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "MetaballsEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 485, + 496 + ], + "free_heap": [ + 177504, + 177756 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 4, + 5 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "MetaballsEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "MetaballsEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "MetaballsEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 4, + 5 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 1375, + 1916 + ], + "free_heap": [ + 175200, + 175452 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 16, + 20 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "MetaballsEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "MetaballsEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "MetaballsEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 16, + 18 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 9712, + 11199 + ], + "free_heap": [ + 165984, + 166236 + ], + "max_alloc_block": [ + 86016, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 65, + 80 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "MetaballsEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "MetaballsEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "MetaballsEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 63, + 76 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 53599, + 61174 + ], + "free_heap": [ + 129120, + 129372 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 275, + 359 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "FireEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "FireEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-FireEffect", + "op": "replace_module", + "id": "FX", + "type": "FireEffect" + }, + { + "name": "FireEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "FireEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "FireEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 362, + 369 + ], + "free_heap": [ + 177284, + 177496 + ], + "max_alloc_block": [ + 98304, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "FireEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "FireEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "FireEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 3, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 1275, + 1474 + ], + "free_heap": [ + 174216, + 174432 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 8, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "FireEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "FireEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "FireEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 13, + 18 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 6258, + 8228 + ], + "free_heap": [ + 161364, + 161924 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 30, + 37 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "FireEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "FireEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "FireEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 52, + 59 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 46487, + 55207 + ], + "free_heap": [ + 112772, + 112992 + ], + "max_alloc_block": [ + 63488, + 63488 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 139, + 155 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "ParticlesEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "ParticlesEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-ParticlesEffect", + "op": "replace_module", + "id": "FX", + "type": "ParticlesEffect" + }, + { + "name": "ParticlesEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "ParticlesEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "ParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 212, + 219 + ], + "free_heap": [ + 176256, + 176472 + ], + "max_alloc_block": [ + 81920, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "ParticlesEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "ParticlesEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "ParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 2, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 541, + 675 + ], + "free_heap": [ + 171640, + 171864 + ], + "max_alloc_block": [ + 81920, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 4, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "ParticlesEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "ParticlesEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "ParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 7, + 9 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 2353, + 3821 + ], + "free_heap": [ + 153216, + 153432 + ], + "max_alloc_block": [ + 69632, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 14, + 19 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "ParticlesEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "ParticlesEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "ParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 29, + 37 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 32515, + 43764 + ], + "free_heap": [ + 79488, + 79704 + ], + "max_alloc_block": [ + 34816, + 63488 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 63, + 77 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GlowParticlesEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "GlowParticlesEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-GlowParticlesEffect", + "op": "replace_module", + "id": "FX", + "type": "GlowParticlesEffect" + }, + { + "name": "GlowParticlesEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "GlowParticlesEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "GlowParticlesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 586, + 592 + ], + "free_heap": [ + 177472, + 177700 + ], + "max_alloc_block": [ + 86016, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 6, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GlowParticlesEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "GlowParticlesEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "GlowParticlesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 4, + 19 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 1706, + 2433 + ], + "free_heap": [ + 175176, + 175396 + ], + "max_alloc_block": [ + 86016, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 22, + 28 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GlowParticlesEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "GlowParticlesEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "GlowParticlesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 18, + 27 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 7792, + 12832 + ], + "free_heap": [ + 165960, + 166168 + ], + "max_alloc_block": [ + 81920, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 94, + 113 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GlowParticlesEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "GlowParticlesEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "GlowParticlesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 70, + 129 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 66613, + 70035 + ], + "free_heap": [ + 129096, + 129304 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 422, + 513 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "CheckerboardEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "CheckerboardEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-CheckerboardEffect", + "op": "replace_module", + "id": "FX", + "type": "CheckerboardEffect" + }, + { + "name": "CheckerboardEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "CheckerboardEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "CheckerboardEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 111, + 118 + ], + "free_heap": [ + 177532, + 177584 + ], + "max_alloc_block": [ + 98304, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "CheckerboardEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "CheckerboardEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "CheckerboardEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 356, + 382 + ], + "free_heap": [ + 172140, + 175228 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 6, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "CheckerboardEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "CheckerboardEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "CheckerboardEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 4, + 5 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 1776, + 2517 + ], + "free_heap": [ + 162940, + 166012 + ], + "max_alloc_block": [ + 73728, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 22, + 29 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "CheckerboardEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "CheckerboardEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "CheckerboardEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 16, + 22 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 31750, + 47168 + ], + "free_heap": [ + 126072, + 129152 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 94, + 118 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "SpiralEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "SpiralEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-SpiralEffect", + "op": "replace_module", + "id": "FX", + "type": "SpiralEffect" + }, + { + "name": "SpiralEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "SpiralEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "SpiralEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 412, + 416 + ], + "free_heap": [ + 174456, + 177524 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "SpiralEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "SpiralEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "SpiralEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 4, + 5 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 1448, + 1749 + ], + "free_heap": [ + 173700, + 175220 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 8, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "SpiralEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "SpiralEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "SpiralEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 16, + 45 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 7137, + 11492 + ], + "free_heap": [ + 164496, + 166004 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 37, + 42 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "SpiralEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "SpiralEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "SpiralEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 72, + 101 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 43962, + 64536 + ], + "free_heap": [ + 126084, + 129140 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 152, + 196 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RipplesEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "RipplesEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-RipplesEffect", + "op": "replace_module", + "id": "FX", + "type": "RipplesEffect" + }, + { + "name": "RipplesEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "RipplesEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "RipplesEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 3, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 886, + 894 + ], + "free_heap": [ + 174404, + 177500 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 8, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RipplesEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "RipplesEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "RipplesEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 8, + 12 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 2422, + 3520 + ], + "free_heap": [ + 172116, + 175196 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 21, + 26 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RipplesEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "RipplesEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "RipplesEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 28, + 33 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 10357, + 22203 + ], + "free_heap": [ + 164368, + 165980 + ], + "max_alloc_block": [ + 81920, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 66, + 83 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "RipplesEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "RipplesEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "RipplesEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 108, + 119 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 76826, + 81784 + ], + "free_heap": [ + 127500, + 129116 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 261, + 326 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LavaLampEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "LavaLampEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-LavaLampEffect", + "op": "replace_module", + "id": "FX", + "type": "LavaLampEffect" + }, + { + "name": "LavaLampEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "LavaLampEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "LavaLampEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 322, + 330 + ], + "free_heap": [ + 174356, + 177524 + ], + "max_alloc_block": [ + 94208, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 2, + 4 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LavaLampEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "LavaLampEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "LavaLampEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 2, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 1249, + 1322 + ], + "free_heap": [ + 173620, + 175220 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 9, + 15 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LavaLampEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "LavaLampEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "LavaLampEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 8, + 10 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 7338, + 8846 + ], + "free_heap": [ + 164400, + 166004 + ], + "max_alloc_block": [ + 73728, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 34, + 42 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "LavaLampEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "LavaLampEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "LavaLampEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 31, + 35 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 52505, + 55036 + ], + "free_heap": [ + 127544, + 129140 + ], + "max_alloc_block": [ + 63488, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 148, + 203 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GameOfLifeEffect-pre-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "GameOfLifeEffect-pre-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "fx-GameOfLifeEffect", + "op": "replace_module", + "id": "FX", + "type": "GameOfLifeEffect" + }, + { + "name": "GameOfLifeEffect-16x16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "GameOfLifeEffect-16x16", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "measure": true, + "description": "GameOfLifeEffect at 16x16 (256 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 0, + 0 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 141, + 147 + ], + "free_heap": [ + 175368, + 176980 + ], + "max_alloc_block": [ + 90112, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 1, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GameOfLifeEffect-32x32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32 + }, + { + "name": "GameOfLifeEffect-32x32", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "measure": true, + "description": "GameOfLifeEffect at 32x32 (1024 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 1, + 1 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 605, + 658 + ], + "free_heap": [ + 169968, + 173140 + ], + "max_alloc_block": [ + 86016, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 5, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GameOfLifeEffect-64x64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "GameOfLifeEffect-64x64", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "measure": true, + "description": "GameOfLifeEffect at 64x64 (4096 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 5, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 4258, + 4415 + ], + "free_heap": [ + 154608, + 157776 + ], + "max_alloc_block": [ + 69632, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 21, + 26 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + }, + { + "name": "GameOfLifeEffect-128x128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "GameOfLifeEffect-128x128", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "measure": true, + "description": "GameOfLifeEffect at 128x128 (16384 lights) — measure tick/FPS, free internal heap, max internal block.", + "observed": { + "pc-macos": { + "tick_us": [ + 35, + 51 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 72173, + 87201 + ], + "free_heap": [ + 93164, + 96348 + ], + "max_alloc_block": [ + 47104, + 63488 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "pc-windows": { + "tick_us": [ + 109, + 115 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + } + } + } + ] +} diff --git a/test/scenarios/light/scenario_GridLayout_grid_sizes.json b/test/scenarios/light/scenario_GridLayout_grid_sizes.json index a0b116ed..5854090e 100644 --- a/test/scenarios/light/scenario_GridLayout_grid_sizes.json +++ b/test/scenarios/light/scenario_GridLayout_grid_sizes.json @@ -4,7 +4,7 @@ "mode": "mutate", "also": [ "Layer", - "MirrorModifier", + "MultiplyModifier", "NoiseEffect", "Drivers", "ArtNetSendDriver", @@ -49,8 +49,8 @@ { "name": "fix-mirror", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -95,14 +95,14 @@ { "name": "reset-mirrorX", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": true }, { "name": "reset-mirrorY", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": true } @@ -241,6 +241,24 @@ "2026-06-04", "2026-06-04" ] + }, + "pc-windows": { + "tick_us": [ + 3, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -377,6 +395,24 @@ "2026-06-04", "2026-06-04" ] + }, + "pc-windows": { + "tick_us": [ + 11, + 14 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -513,6 +549,24 @@ "2026-06-04", "2026-06-04" ] + }, + "pc-windows": { + "tick_us": [ + 44, + 56 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -649,6 +703,24 @@ "2026-06-04", "2026-06-04" ] + }, + "pc-windows": { + "tick_us": [ + 222, + 272 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index 5ad313c3..0fadc190 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -3,7 +3,7 @@ "module": "GridLayout", "mode": "mutate", "also": [ - "MirrorModifier", + "MultiplyModifier", "Layer" ], "description": "Resize the grid while the pipeline is running and verify it reallocates cleanly under memory pressure. Lowers to 128x64 (release memory), increases to 128x128 (heaviest config: mirror + LUT). Each measured step captures tick/FPS/heap so the runner reports the degrade behaviour.", @@ -45,8 +45,8 @@ { "name": "fix-mirror", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -84,14 +84,14 @@ { "name": "reset-mirrorX", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorX", "value": true }, { "name": "reset-mirrorY", "op": "set_control", - "id": "Mirror", + "id": "Multiply", "key": "mirrorY", "value": true } @@ -191,6 +191,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 219, + 293 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -293,6 +311,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 94, + 137 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -390,6 +426,24 @@ "2026-06-02", "2026-06-02" ] + }, + "pc-windows": { + "tick_us": [ + 217, + 291 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json index b94805f1..408fb77f 100644 --- a/test/scenarios/light/scenario_Layer_base_pipeline.json +++ b/test/scenarios/light/scenario_Layer_base_pipeline.json @@ -98,6 +98,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 118, + 127 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_Layer_buildup.json b/test/scenarios/light/scenario_Layer_buildup.json index 671eb7b9..42451564 100644 --- a/test/scenarios/light/scenario_Layer_buildup.json +++ b/test/scenarios/light/scenario_Layer_buildup.json @@ -6,7 +6,7 @@ "Layouts", "GridLayout", "RainbowEffect", - "MirrorModifier", + "MultiplyModifier", "Drivers", "ArtNetSendDriver" ], @@ -81,6 +81,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 1, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -138,15 +156,33 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 2, + 5 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, { "name": "add-mirror", - "description": "MirrorModifier under Layer. Triggers a LUT build + Drivers output buffer allocation (the heavy memory path).", + "description": "MultiplyModifier under Layer. Triggers a LUT build + Drivers output buffer allocation (the heavy memory path).", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -185,6 +221,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 1, + 3 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } }, @@ -243,6 +297,24 @@ "2026-06-02", "2026-06-03" ] + }, + "pc-windows": { + "tick_us": [ + 76, + 100 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_Layer_memory_1to1.json b/test/scenarios/light/scenario_Layer_memory_1to1.json index 2a47ff7b..c90eb665 100644 --- a/test/scenarios/light/scenario_Layer_memory_1to1.json +++ b/test/scenarios/light/scenario_Layer_memory_1to1.json @@ -95,6 +95,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 1, + 2 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index 161fb0ae..60645fcc 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -93,6 +93,42 @@ "2026-06-05", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 27, + 31 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 24, + 24 + ], + "free_heap": [ + 228908, + 228908 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-08", + "2026-06-08" + ] } } }, @@ -136,6 +172,42 @@ "2026-06-05", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 42, + 61 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 27, + 27 + ], + "free_heap": [ + 228740, + 228740 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-08", + "2026-06-08" + ] } } }, @@ -160,7 +232,7 @@ "pc-macos": { "tick_us": [ 10, - 87 + 116 ], "free_heap": [ 0, @@ -172,7 +244,43 @@ ], "at": [ "2026-06-05", - "2026-06-05" + "2026-06-07" + ] + }, + "pc-windows": { + "tick_us": [ + 111, + 171 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 26, + 26 + ], + "free_heap": [ + 228740, + 228740 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-08", + "2026-06-08" ] } } @@ -211,6 +319,42 @@ "2026-06-05", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 26, + 30 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 24, + 24 + ], + "free_heap": [ + 228908, + 228908 + ], + "max_alloc_block": [ + 110592, + 110592 + ], + "at": [ + "2026-06-08", + "2026-06-08" + ] } } } diff --git a/test/scenarios/light/scenario_MirrorModifier_memory_lut.json b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json similarity index 74% rename from test/scenarios/light/scenario_MirrorModifier_memory_lut.json rename to test/scenarios/light/scenario_MultiplyModifier_memory_lut.json index 0d435afe..e7431411 100644 --- a/test/scenarios/light/scenario_MirrorModifier_memory_lut.json +++ b/test/scenarios/light/scenario_MultiplyModifier_memory_lut.json @@ -1,13 +1,13 @@ { - "name": "scenario_MirrorModifier_memory_lut", - "module": "MirrorModifier", + "name": "scenario_MultiplyModifier_memory_lut", + "module": "MultiplyModifier", "mode": "construct", "also": [ "Layer", "MappingLUT", "BlendMap" ], - "description": "Verify that adding a MirrorModifier allocates both the mapping LUT and the driver buffer (the heavy memory path). Companion to scenario_Layer_memory_1to1, which verifies the no-LUT path.", + "description": "Verify that adding a MultiplyModifier allocates both the mapping LUT and the driver buffer (the heavy memory path). Companion to scenario_Layer_memory_1to1, which verifies the no-LUT path.", "steps": [ { "name": "add-layout-group", @@ -49,10 +49,10 @@ }, { "name": "add-mirror", - "description": "Add MirrorModifier — triggers LUT and driver-buffer allocation.", + "description": "Add MultiplyModifier — triggers LUT and driver-buffer allocation.", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -104,6 +104,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 3, + 6 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_MirrorModifier_pipeline.json b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json similarity index 76% rename from test/scenarios/light/scenario_MirrorModifier_pipeline.json rename to test/scenarios/light/scenario_MultiplyModifier_pipeline.json index 18721603..288ceebf 100644 --- a/test/scenarios/light/scenario_MirrorModifier_pipeline.json +++ b/test/scenarios/light/scenario_MultiplyModifier_pipeline.json @@ -1,13 +1,13 @@ { - "name": "scenario_MirrorModifier_pipeline", - "module": "MirrorModifier", + "name": "scenario_MultiplyModifier_pipeline", + "module": "MultiplyModifier", "mode": "construct", "also": [ "Layer", "NoiseEffect", "ArtNetSendDriver" ], - "description": "Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MirrorModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MirrorModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound.", + "description": "Pipeline with a mirror modifier: NoiseEffect renders one quadrant, MultiplyModifier reflects across X and Y to produce a kaleidoscope. Used to verify the MultiplyModifier wires into Layer cleanly and that the full pipeline still meets its FPS bound.", "steps": [ { "name": "add-layout-group", @@ -49,10 +49,10 @@ }, { "name": "add-mirror", - "description": "Add MirrorModifier so logical pixels reflect across X and Y in the physical grid.", + "description": "Add MultiplyModifier so logical pixels reflect across X and Y in the physical grid.", "op": "add_module", - "id": "Mirror", - "type": "MirrorModifier", + "id": "Multiply", + "type": "MultiplyModifier", "parent_id": "Layer" }, { @@ -104,6 +104,24 @@ "2026-06-02", "2026-06-05" ] + }, + "pc-windows": { + "tick_us": [ + 225, + 253 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] } } } diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json new file mode 100644 index 00000000..a9b74946 --- /dev/null +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -0,0 +1,291 @@ +{ + "name": "scenario_modifier_swap", + "module": "Layer", + "mode": "mutate", + "also": [ + "MultiplyModifier", + "CheckerboardModifier", + "NoiseEffect", + "Layouts", + "GridLayout", + "Drivers", + "ArtNetSendDriver", + "PreviewDriver" + ], + "description": "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.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 32, + "height": 32 + } + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Layers", + "type": "Layers", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "fix-fx", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "fix-mod", + "op": "add_module", + "id": "MOD", + "type": "MultiplyModifier", + "parent_id": "Layer" + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layers": "Layers" + } + }, + { + "name": "fix-artnet", + "op": "add_module", + "id": "ArtNet", + "type": "ArtNetSendDriver", + "parent_id": "Drivers" + } + ], + "steps": [ + { + "name": "shrink-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "shrink-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "clear-layers", + "op": "clear_children", + "id": "Layers" + }, + { + "name": "clear-layouts", + "op": "clear_children", + "id": "Layouts" + }, + { + "name": "build-grid", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 32, + "height": 32 + } + }, + { + "name": "build-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "build-fx", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "build-mod", + "op": "add_module", + "id": "MOD", + "type": "MultiplyModifier", + "parent_id": "Layer" + }, + { + "name": "multiply-1", + "op": "measure", + "measure": true, + "description": "Multiply modifier active — pipeline live, LUT folds the grid.", + "observed": { + "pc-macos": { + "tick_us": [ + 6, + 7 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-07" + ] + }, + "esp32-eth": { + "tick_us": [ + 129, + 633 + ], + "free_heap": [ + 176224, + 226064 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + } + } + }, + { + "name": "swap-to-checker", + "op": "replace_module", + "id": "MOD", + "type": "CheckerboardModifier" + }, + { + "name": "checkerboard", + "op": "measure", + "measure": true, + "description": "Checkerboard modifier active — masks half the lights; pipeline stays live (driver buffer non-null).", + "observed": { + "pc-macos": { + "tick_us": [ + 17, + 20 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 1010, + 1301 + ], + "free_heap": [ + 173744, + 225172 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + } + } + }, + { + "name": "swap-to-multiply", + "op": "replace_module", + "id": "MOD", + "type": "MultiplyModifier" + }, + { + "name": "multiply-2", + "op": "measure", + "measure": true, + "description": "Back to Multiply — replace round-trips cleanly, pipeline live again.", + "observed": { + "pc-macos": { + "tick_us": [ + 6, + 8 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + }, + "esp32-eth": { + "tick_us": [ + 439, + 630 + ], + "free_heap": [ + 173092, + 226072 + ], + "max_alloc_block": [ + 77824, + 110592 + ], + "at": [ + "2026-06-07", + "2026-06-08" + ] + } + } + } + ] +} diff --git a/test/unit/core/unit_FilesystemModule_persistence.cpp b/test/unit/core/unit_FilesystemModule_persistence.cpp index beba534f..df96a54a 100644 --- a/test/unit/core/unit_FilesystemModule_persistence.cpp +++ b/test/unit/core/unit_FilesystemModule_persistence.cpp @@ -8,7 +8,7 @@ #include "core/SystemModule.h" #include "light/effects/NoiseEffect.h" #include "light/effects/RainbowEffect.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/MultiplyModifier.h" #include "light/layers/Layer.h" #include "platform/platform.h" @@ -103,7 +103,7 @@ TEST_CASE("FilesystemModule structural reconciliation") { mm::ModuleFactory::registerType("Layer"); mm::ModuleFactory::registerType("NoiseEffect"); mm::ModuleFactory::registerType("RainbowEffect"); - mm::ModuleFactory::registerType("MirrorModifier"); + mm::ModuleFactory::registerType("MultiplyModifier"); // Write a Layer.json that asks for one child (RainbowEffect at position 0). { @@ -117,15 +117,15 @@ TEST_CASE("FilesystemModule structural reconciliation") { fs->setTypeName("FilesystemModule"); fs->setScheduler(&scheduler); - // Build a live tree: Layer with NoiseEffect at pos 0 and MirrorModifier at pos 1. + // Build a live tree: Layer with NoiseEffect at pos 0 and MultiplyModifier at pos 1. // The JSON wants RainbowEffect at pos 0 and nothing at pos 1 — so we expect a swap // and a trim. auto* layer = new mm::Layer(); layer->setTypeName("Layer"); auto* noise = new mm::NoiseEffect(); noise->setTypeName("NoiseEffect"); - auto* mirror = new mm::MirrorModifier(); - mirror->setTypeName("MirrorModifier"); + auto* mirror = new mm::MultiplyModifier(); + mirror->setTypeName("MultiplyModifier"); layer->addChild(noise); layer->addChild(mirror); @@ -262,7 +262,7 @@ TEST_CASE("FilesystemModule writes valid JSON with children") { mm::ModuleFactory::registerType("Layer"); mm::ModuleFactory::registerType("NoiseEffect"); - mm::ModuleFactory::registerType("MirrorModifier"); + mm::ModuleFactory::registerType("MultiplyModifier"); mm::Scheduler scheduler; auto* fs = new mm::FilesystemModule(); @@ -270,8 +270,8 @@ TEST_CASE("FilesystemModule writes valid JSON with children") { fs->setScheduler(&scheduler); auto* layer = new mm::Layer(); layer->setTypeName("Layer"); - auto* mirror = new mm::MirrorModifier(); - mirror->setTypeName("MirrorModifier"); + auto* mirror = new mm::MultiplyModifier(); + mirror->setTypeName("MultiplyModifier"); auto* noise = new mm::NoiseEffect(); noise->setTypeName("NoiseEffect"); layer->addChild(mirror); @@ -287,10 +287,11 @@ TEST_CASE("FilesystemModule writes valid JSON with children") { // Read back the raw file and verify both child "type" fields are followed by // a comma before the next field — the previously-broken serializer emitted - // "0.type":"MirrorModifier""0.mirrorX":true with no separator. + // "0.type":"MultiplyModifier""0.mirrorX":true with no separator. std::ifstream f(std::string(tmpRoot) + "/.config/Layer.json"); std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - CHECK(content.find("\"MirrorModifier\",") != std::string::npos); + f.close(); // Windows holds an exclusive lock on open files — close before remove_all. + CHECK(content.find("\"MultiplyModifier\",") != std::string::npos); CHECK(content.find("\"NoiseEffect\",") != std::string::npos); // And the catastrophic "}{ or "X""Y syntactic shape must not appear. CHECK(content.find("\"\"") == std::string::npos); @@ -351,6 +352,7 @@ TEST_CASE("FilesystemModule singleton survives probe construct+destruct") { std::ifstream f(std::string(tmpRoot) + "/.config/Layer.json"); CHECK(f.is_open()); + f.close(); // Windows holds an exclusive lock on open files — close before remove_all. scheduler.teardown(); std::filesystem::remove_all(tmpRoot); diff --git a/test/unit/core/unit_MoonModule_control_change_gate.cpp b/test/unit/core/unit_MoonModule_control_change_gate.cpp index 06cc0dde..055ce7ba 100644 --- a/test/unit/core/unit_MoonModule_control_change_gate.cpp +++ b/test/unit/core/unit_MoonModule_control_change_gate.cpp @@ -1,5 +1,5 @@ // @module MoonModule -// @also GridLayout, MirrorModifier, NoiseEffect, Drivers +// @also GridLayout, MultiplyModifier, NoiseEffect, Drivers // Pins the selective control-change rebuild gate. `handleSetControl` rebuilds // the pipeline only when `controlChangeTriggersBuildState()` returns true. @@ -11,13 +11,13 @@ #include "doctest.h" #include "light/drivers/Drivers.h" #include "light/layouts/GridLayout.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/MultiplyModifier.h" #include "light/effects/NoiseEffect.h" // Layout and Modifier modules opt in to rebuild on a control change (their controls reshape the pipeline). TEST_CASE("controlChangeTriggersBuildState: Layout and Modifier opt in") { mm::GridLayout layout; - mm::MirrorModifier modifier; + mm::MultiplyModifier modifier; CHECK(layout.controlChangeTriggersBuildState("width")); CHECK(modifier.controlChangeTriggersBuildState("mirrorX")); } diff --git a/test/unit/core/unit_MoonModule_movechild.cpp b/test/unit/core/unit_MoonModule_movechild.cpp index f1dd0446..4914e0fd 100644 --- a/test/unit/core/unit_MoonModule_movechild.cpp +++ b/test/unit/core/unit_MoonModule_movechild.cpp @@ -3,6 +3,8 @@ #include "doctest.h" #include "core/MoonModule.h" +#include // MSVC STL doesn't pull this in via doctest.h + namespace { class StubModule : public mm::MoonModule {}; diff --git a/test/unit/core/unit_Scheduler_unique_names.cpp b/test/unit/core/unit_Scheduler_unique_names.cpp index 193c9c9f..959f2527 100644 --- a/test/unit/core/unit_Scheduler_unique_names.cpp +++ b/test/unit/core/unit_Scheduler_unique_names.cpp @@ -56,27 +56,27 @@ TEST_CASE("Scheduler::ensureUniqueName suffixes the second occurrence") { s.ensureUniqueName(second); CHECK(std::strcmp(first->name(), "Layer") == 0); - CHECK(std::strcmp(second->name(), "Layer 2") == 0); + CHECK(std::strcmp(second->name(), "Layer-2") == 0); // '-' separator (URL-safe) s.deleteTree(parent); } -// Suffix counting increments past existing " 2" / " 3" suffixes ("Layer", "Layer 2", "Layer" → "Layer 3"). -TEST_CASE("Scheduler::ensureUniqueName keeps suffixing past 'Foo 2'") { +// Suffix counting increments past existing "-2" / "-3" suffixes ("Layer", "Layer-2", "Layer" → "Layer-3"). +TEST_CASE("Scheduler::ensureUniqueName keeps suffixing past 'Foo-2'") { mm::Scheduler s; auto* parent = new Stub(); parent->setName("Layers"); s.addModule(parent); auto* a = new Stub(); a->setName("Layer"); parent->addChild(a); - auto* b = new Stub(); b->setName("Layer 2"); parent->addChild(b); + auto* b = new Stub(); b->setName("Layer-2"); parent->addChild(b); auto* c = new Stub(); c->setName("Layer"); parent->addChild(c); s.ensureUniqueName(c); - // First "Layer" intact, hand-named "Layer 2" intact, new collision lands at "Layer 3". + // First "Layer" intact, existing "Layer-2" intact, new collision lands at "Layer-3". CHECK(std::strcmp(a->name(), "Layer") == 0); - CHECK(std::strcmp(b->name(), "Layer 2") == 0); - CHECK(std::strcmp(c->name(), "Layer 3") == 0); + CHECK(std::strcmp(b->name(), "Layer-2") == 0); + CHECK(std::strcmp(c->name(), "Layer-3") == 0); s.deleteTree(parent); } @@ -109,11 +109,11 @@ TEST_CASE("Scheduler::deduplicateNamesInTree walks the whole tree") { s.deduplicateNamesInTree(); - // First occurrences keep their names; second ones get " 2" suffix. + // First occurrences keep their names; second ones get a "-2" suffix. CHECK(std::strcmp(layerA->name(), "Layer") == 0); - CHECK(std::strcmp(layerB->name(), "Layer 2") == 0); + CHECK(std::strcmp(layerB->name(), "Layer-2") == 0); CHECK(std::strcmp(effectA1->name(), "Noise") == 0); - CHECK(std::strcmp(effectB1->name(), "Noise 2") == 0); + CHECK(std::strcmp(effectB1->name(), "Noise-2") == 0); s.deleteTree(layers); } @@ -136,7 +136,7 @@ TEST_CASE("Scheduler::firstByName returns the first match in tree-walk order") { // If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented). TEST_CASE("Scheduler::ensureUniqueName leaves the colliding name alone when the suffixed result wouldn't fit") { // MoonModule::name_ is 16 bytes (15 chars + NUL). A 13-char base name like - // "GlowParticles" reaches the buffer ceiling at suffix "10" — "GlowParticles 10" + // "GlowParticles" reaches the buffer ceiling at suffix "10" — "GlowParticles-10" // is 16 chars, doesn't fit. ensureUniqueName must refuse to truncate // (keep the duplicate, return) rather than silently produce a different // result than the caller asked for. This test pins that refusal. @@ -145,8 +145,8 @@ TEST_CASE("Scheduler::ensureUniqueName leaves the colliding name alone when the parent->setName("Layers"); s.addModule(parent); - // Create one base "GlowParticles" plus eight "GlowParticles 2".."GlowParticles 9". - // The next ensureUniqueName needs "GlowParticles 10" which doesn't fit. + // Create one base "GlowParticles" plus eight "GlowParticles-2".."GlowParticles-9". + // The next ensureUniqueName needs "GlowParticles-10" which doesn't fit. auto* first = new Stub(); first->setName("GlowParticles"); parent->addChild(first); @@ -154,7 +154,7 @@ TEST_CASE("Scheduler::ensureUniqueName leaves the colliding name alone when the for (int n = 2; n <= 9; n++) { auto* m = new Stub(); char nm[16]; - std::snprintf(nm, sizeof(nm), "GlowParticles %d", n); + std::snprintf(nm, sizeof(nm), "GlowParticles-%d", n); m->setName(nm); parent->addChild(m); } diff --git a/test/unit/light/unit_CheckerboardModifier.cpp b/test/unit/light/unit_CheckerboardModifier.cpp new file mode 100644 index 00000000..6ff2fa65 --- /dev/null +++ b/test/unit/light/unit_CheckerboardModifier.cpp @@ -0,0 +1,90 @@ +// @module CheckerboardModifier + +#include "doctest.h" +#include "light/modifiers/CheckerboardModifier.h" + +// CheckerboardModifier masks the layer: lights in "off" squares are dropped +// (mapToPhysical returns outCount=0), lights in "on" squares pass through +// unchanged. The logical box is unchanged (identity dimensions). + +static mm::nrOfLightsType mapOne(mm::CheckerboardModifier& c, + mm::lengthType x, mm::lengthType y, mm::lengthType z, + mm::lengthType w, mm::lengthType h, mm::lengthType d, + mm::nrOfLightsType& count) { + mm::nrOfLightsType phys[8]; + count = 0; + c.mapToPhysical(x, y, z, w, h, d, phys, count, 8); + return count ? phys[0] : 0; +} + +// Identity dimensions — a mask doesn't resize the logical box. +TEST_CASE("CheckerboardModifier logicalDimensions are identity") { + mm::CheckerboardModifier c; + mm::lengthType logW, logH, logD; + c.logicalDimensions(64, 32, 4, logW, logH, logD); + CHECK(logW == 64); + CHECK(logH == 32); + CHECK(logD == 4); +} + +// size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert +// false) keeps even-parity cells, drops odd-parity. +TEST_CASE("CheckerboardModifier size 1 keeps even-parity, drops odd") { + mm::CheckerboardModifier c; + c.size = 1; + mm::nrOfLightsType count; + + // (0,0,0): parity 0 → on. Passes through at identity index 0. + CHECK(mapOne(c, 0, 0, 0, 8, 8, 1, count) == 0); + CHECK(count == 1); + + // (1,0,0): parity 1 → off. Dropped. + mapOne(c, 1, 0, 0, 8, 8, 1, count); + CHECK(count == 0); + + // (1,1,0): parity 0 → on. Identity index 1*8+1 = 9. + CHECK(mapOne(c, 1, 1, 0, 8, 8, 1, count) == 9); + CHECK(count == 1); +} + +// invert flips which parity passes — the cell that was dropped now passes and +// vice versa. +TEST_CASE("CheckerboardModifier invert flips the kept squares") { + mm::CheckerboardModifier c; + c.size = 1; + c.invert = true; + mm::nrOfLightsType count; + + // (0,0,0): parity 0, but inverted → off. Dropped. + mapOne(c, 0, 0, 0, 8, 8, 1, count); + CHECK(count == 0); + + // (1,0,0): parity 1, inverted → on. Passes at index 1. + CHECK(mapOne(c, 1, 0, 0, 8, 8, 1, count) == 1); + CHECK(count == 1); +} + +// size>1 groups cells into squares: with size=2, the 2×2 block at the origin is +// all one square (parity of 0/2=0), so all four pass; the next block over drops. +TEST_CASE("CheckerboardModifier size 2 groups into squares") { + mm::CheckerboardModifier c; + c.size = 2; + mm::nrOfLightsType count; + + // Origin 2×2 block (x,y in 0..1): square (0,0) parity 0 → on. + for (mm::lengthType y = 0; y < 2; y++) + for (mm::lengthType x = 0; x < 2; x++) { + mapOne(c, x, y, 0, 8, 8, 1, count); + CHECK(count == 1); // whole block passes + } + + // Adjacent block (x in 2..3): square (1,0) parity 1 → off. + mapOne(c, 2, 0, 0, 8, 8, 1, count); + CHECK(count == 0); +} + +// Never fans out — at most one destination. +TEST_CASE("CheckerboardModifier maxMultiplier is 1") { + mm::CheckerboardModifier c; + CHECK(c.maxMultiplier() == 1); +} diff --git a/test/unit/light/unit_Layer_sparse_mapping.cpp b/test/unit/light/unit_Layer_sparse_mapping.cpp index 2eca583c..5fc16d58 100644 --- a/test/unit/light/unit_Layer_sparse_mapping.cpp +++ b/test/unit/light/unit_Layer_sparse_mapping.cpp @@ -5,7 +5,7 @@ #include "light/layouts/Layouts.h" #include "light/layouts/GridLayout.h" #include "light/layouts/SphereLayout.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/MultiplyModifier.h" // The driver/output buffer must hold ONLY the real lights, never the dense // bounding box. A sphere defines a 9x9x9 (=729) render grid but only 210 shell @@ -88,7 +88,7 @@ TEST_CASE("Layer: sphere + mirror maps into driver-index space") { mm::Layer layer; layer.setLayouts(&group); layer.setChannelsPerLight(3); - mm::MirrorModifier mirror; + mm::MultiplyModifier mirror; mirror.mirrorX = true; layer.addChild(&mirror); layer.onBuildControls(); @@ -104,3 +104,45 @@ TEST_CASE("Layer: sphere + mirror maps into driver-index space") { }); } } + +// REGRESSION: a high fan-out Multiply (8×8×4 = 256) on a 128×128 grid must build +// a NON-EMPTY LUT that covers every physical light. The maxDest estimate +// (logicalCount × maxMultiplier) is computed in 64-bit; before that fix it +// overflowed uint16 on no-PSRAM boards (256 × 256 = 65536 wraps to 0), sized the +// LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps +// the full light set, in range — the symptom that black-screened the device. +TEST_CASE("Layer: high fan-out Multiply builds a full, in-range LUT (no overflow)") { + mm::GridLayout g; + g.width = 128; g.height = 128; g.depth = 1; // 16384 physical lights + mm::Layouts group; + group.addChild(&g); + mm::Layer layer; + layer.setLayouts(&group); + layer.setChannelsPerLight(3); + mm::MultiplyModifier mult; + mult.multiplyX = 8; mult.multiplyY = 8; mult.multiplyZ = 4; // raw product 256 + layer.addChild(&mult); + layer.onBuildControls(); + layer.onBuildState(); + + const mm::nrOfLightsType N = layer.physicalLightCount(); // 16384 + CHECK(N == 16384); + CHECK(layer.lut().hasLUT()); + + // multiplyZ clamps to depth-1 → effective 8×8×1; logical box 16×16 = 256. + CHECK(layer.lut().logicalCount() == 16 * 16); + + // Count destinations: the LUT must NOT be empty (the black-screen bug) and + // every destination must be a valid driver index. 256 logical × 64 tiles = + // 16384 destinations = full coverage. + std::size_t total = 0; + bool inRange = true; + for (mm::nrOfLightsType li = 0; li < layer.lut().logicalCount(); li++) { + layer.lut().forEachDestination(li, [&](mm::nrOfLightsType d) { + total++; + if (d >= N) inRange = false; + }); + } + CHECK(total == 16384); // full physical coverage, not a collapsed/empty LUT + CHECK(inRange); +} diff --git a/test/unit/light/unit_Layouts_toggle_cycle.cpp b/test/unit/light/unit_Layouts_toggle_cycle.cpp index 6fb243f1..c97da3db 100644 --- a/test/unit/light/unit_Layouts_toggle_cycle.cpp +++ b/test/unit/light/unit_Layouts_toggle_cycle.cpp @@ -7,7 +7,7 @@ #include "light/layouts/Layouts.h" #include "light/layouts/GridLayout.h" #include "light/effects/RainbowEffect.h" -#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/MultiplyModifier.h" #include "light/drivers/Drivers.h" namespace { @@ -68,7 +68,7 @@ TEST_CASE("Toggle a single layout off then on: pipeline survives and renders aga // Include a modifier so the LUT is actually populated (hasLUT == true); the // crash only occurs in this path because the no-LUT path doesn't read the // output buffer. - mm::MirrorModifier mirror; + mm::MultiplyModifier mirror; layer.addChild(&mirror); layersC.addChild(&layer); layersC.setLayouts(&layouts); diff --git a/test/unit/light/unit_MirrorModifier.cpp b/test/unit/light/unit_MirrorModifier.cpp deleted file mode 100644 index 29d2db32..00000000 --- a/test/unit/light/unit_MirrorModifier.cpp +++ /dev/null @@ -1,103 +0,0 @@ -// @module MirrorModifier - -#include "doctest.h" -#include "light/modifiers/MirrorModifier.h" - -// MirrorModifier reports D3 dimensions (handles all three axes via mirrorX/Y/Z toggles). -TEST_CASE("MirrorModifier advertises D3 dimensions") { - // MirrorModifier handles all three axes via mirrorX/Y/Z toggles, so the - // factory dim chip should be 🧊. Pins the default for ModifierBase too. - mm::MirrorModifier mirror; - CHECK(mirror.dimensions() == mm::Dim::D3); -} - -// A 128×128 physical grid with mirrorXY has 64×64 logical lights (effect only renders one quadrant). -TEST_CASE("MirrorModifier logicalDimensions even grid") { - mm::MirrorModifier mirror; - mm::lengthType logW, logH, logD; - - mirror.logicalDimensions(128, 128, 1, logW, logH, logD); - CHECK(logW == 64); - CHECK(logH == 64); - CHECK(logD == 1); -} - -// An odd-axis physical grid (127×127) rounds up: 64×64 logical lights with one centre row/column shared. -TEST_CASE("MirrorModifier logicalDimensions odd grid") { - mm::MirrorModifier mirror; - mm::lengthType logW, logH, logD; - - mirror.logicalDimensions(127, 127, 1, logW, logH, logD); - CHECK(logW == 64); // (127+1)/2 = 64 - CHECK(logH == 64); - CHECK(logD == 1); -} - -// mirrorZ on a 128×128×4 grid yields 64×64×2 logical lights (mirroring also halves the Z axis). -TEST_CASE("MirrorModifier logicalDimensions mirrorZ") { - mm::MirrorModifier mirror; - mirror.mirrorZ = true; - mm::lengthType logW, logH, logD; - - mirror.logicalDimensions(128, 128, 4, logW, logH, logD); - CHECK(logW == 64); - CHECK(logH == 64); - CHECK(logD == 2); -} - -// With mirrorXY enabled, the corner pixel (0,0) maps to all four corners of the physical grid. -TEST_CASE("MirrorModifier corner pixel produces 4 positions with mirrorXY") { - mm::MirrorModifier mirror; - mm::nrOfLightsType physicals[8]; - mm::nrOfLightsType count = 0; - - // Grid 128x128, pixel (0,0) should map to 4 corners - mirror.mapToPhysical(0, 0, 0, 128, 128, 1, physicals, count, 8); - CHECK(count == 4); - - // Verify the 4 positions: (0,0), (127,0), (0,127), (127,127) - // In row-major: idx = y*128 + x - CHECK(physicals[0] == 0); // (0, 0) - CHECK(physicals[1] == 127); // (127, 0) - CHECK(physicals[2] == 127 * 128); // (0, 127) - CHECK(physicals[3] == 127 * 128 + 127); // (127, 127) -} - -// The centre pixel on an odd-axis grid deduplicates: all four mirror reflections land on the same position so count=1. -TEST_CASE("MirrorModifier centre pixel deduplication on odd grid") { - mm::MirrorModifier mirror; - mm::nrOfLightsType physicals[8]; - mm::nrOfLightsType count = 0; - - // Grid 127x127, pixel (63,63): mirrored x = 127-1-63 = 63 (same!) - mirror.mapToPhysical(63, 63, 0, 127, 127, 1, physicals, count, 8); - CHECK(count == 1); // All mirrors deduplicate to same position -} - -// With no mirror axis enabled, mapToPhysical returns one position (identity pass-through). -TEST_CASE("MirrorModifier no mirrors produces 1 position") { - mm::MirrorModifier mirror; - mirror.mirrorX = false; - mirror.mirrorY = false; - mirror.mirrorZ = false; - mm::nrOfLightsType physicals[8]; - mm::nrOfLightsType count = 0; - - mirror.mapToPhysical(5, 10, 0, 128, 128, 1, physicals, count, 8); - CHECK(count == 1); - CHECK(physicals[0] == 10 * 128 + 5); // row-major: y*W + x -} - -// mirrorX-only yields two positions per logical pixel (original + horizontal reflection). -TEST_CASE("MirrorModifier mirrorX only produces 2 positions") { - mm::MirrorModifier mirror; - mirror.mirrorX = true; - mirror.mirrorY = false; - mm::nrOfLightsType physicals[8]; - mm::nrOfLightsType count = 0; - - mirror.mapToPhysical(5, 10, 0, 128, 128, 1, physicals, count, 8); - CHECK(count == 2); - CHECK(physicals[0] == 10 * 128 + 5); // (5, 10) - CHECK(physicals[1] == 10 * 128 + 122); // (122, 10) = (127-5, 10) -} diff --git a/test/unit/light/unit_MultiplyModifier.cpp b/test/unit/light/unit_MultiplyModifier.cpp new file mode 100644 index 00000000..affaf148 --- /dev/null +++ b/test/unit/light/unit_MultiplyModifier.cpp @@ -0,0 +1,187 @@ +// @module MultiplyModifier + +#include "doctest.h" +#include "light/modifiers/MultiplyModifier.h" + +// MultiplyModifier tiles the logical image across the physical box `multiply` +// times per axis, optionally reflecting alternate tiles (the kaleidoscope +// mirror). With multiply=2 + mirror on an axis it folds in half — exactly the +// behaviour the old MirrorModifier provided, which several of these cases pin. + +// Reports D3 — handles all three axes. Pins the ModifierBase default too. +TEST_CASE("MultiplyModifier advertises D3 dimensions") { + mm::MultiplyModifier m; + CHECK(m.dimensions() == mm::Dim::D3); +} + +// Defaults (multiply 2/2/1, mirror true/true/false) reproduce the canonical +// mirror-XY pipeline: a 128×128 physical grid → 64×64 logical (each axis folds). +TEST_CASE("MultiplyModifier default logicalDimensions = mirror-XY fold") { + mm::MultiplyModifier m; + mm::lengthType logW, logH, logD; + m.logicalDimensions(128, 128, 1, logW, logH, logD); + CHECK(logW == 64); // 128 / 2 + CHECK(logH == 64); + CHECK(logD == 1); // multiplyZ default 1 → unchanged +} + +// multiplyZ tiles the Z axis too: 128×128×4 with multiply 2/2/2 → 64×64×2. +TEST_CASE("MultiplyModifier logicalDimensions on Z") { + mm::MultiplyModifier m; + m.multiplyZ = 2; + mm::lengthType logW, logH, logD; + m.logicalDimensions(128, 128, 4, logW, logH, logD); + CHECK(logW == 64); + CHECK(logH == 64); + CHECK(logD == 2); +} + +// PURE-FOLD EQUIVALENCE: with the defaults (mult 2, mirror XY), the corner +// logical pixel (0,0) fans out to all four physical corners — byte-identical to +// the old MirrorModifier corner test. This is the canonical-pipeline guarantee. +TEST_CASE("MultiplyModifier corner pixel produces 4 corners (mirror fold)") { + mm::MultiplyModifier m; // defaults: mult 2/2/1, mirror true/true/false + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + + m.mapToPhysical(0, 0, 0, 128, 128, 1, physicals, count, 8); + CHECK(count == 4); + // tile (0,0) identity → (0,0); tile (1,0) mirror x → (127,0); + // tile (0,1) mirror y → (0,127); tile (1,1) → (127,127). Row-major y*128+x. + CHECK(physicals[0] == 0); // (0,0) + CHECK(physicals[1] == 127); // (127,0) + CHECK(physicals[2] == 127 * 128); // (0,127) + CHECK(physicals[3] == 127 * 128 + 127); // (127,127) +} + +// PURE-FOLD EQUIVALENCE: an interior pixel folds to the same two columns the +// old mirrorX-only produced — original + horizontal reflection. +TEST_CASE("MultiplyModifier mirrorX fold matches old Mirror") { + mm::MultiplyModifier m; + m.multiplyX = 2; m.mirrorX = true; + m.multiplyY = 1; m.mirrorY = false; + m.multiplyZ = 1; + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + + m.mapToPhysical(5, 10, 0, 128, 128, 1, physicals, count, 8); + CHECK(count == 2); + CHECK(physicals[0] == 10 * 128 + 5); // (5,10) + CHECK(physicals[1] == 10 * 128 + 122); // (127-5,10) = (122,10) +} + +// No multiplication on any axis (all multipliers 1) → identity pass-through. +TEST_CASE("MultiplyModifier identity when all multipliers are 1") { + mm::MultiplyModifier m; + m.multiplyX = 1; m.multiplyY = 1; m.multiplyZ = 1; + mm::lengthType logW, logH, logD; + m.logicalDimensions(128, 128, 1, logW, logH, logD); + CHECK(logW == 128); CHECK(logH == 128); CHECK(logD == 1); + + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + m.mapToPhysical(5, 10, 0, 128, 128, 1, physicals, count, 8); + CHECK(count == 1); + CHECK(physicals[0] == 10 * 128 + 5); +} + +// Tiling WITHOUT mirror repeats (does not reflect) — multiply 2 on X, mirror off: +// logical x=0 lands at physical x=0 (tile 0) and x=64 (tile 1, identity offset), +// NOT x=127. This is the difference from a fold. +TEST_CASE("MultiplyModifier tiles without mirror (repeat, not fold)") { + mm::MultiplyModifier m; + m.multiplyX = 2; m.mirrorX = false; + m.multiplyY = 1; m.mirrorY = false; + m.multiplyZ = 1; + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + + // 128 wide, tileW = 64. logical x=0 → tile0 x=0, tile1 x=64. + m.mapToPhysical(0, 0, 0, 128, 128, 1, physicals, count, 8); + CHECK(count == 2); + CHECK(physicals[0] == 0); // tile 0: x=0 + CHECK(physicals[1] == 64); // tile 1 (no mirror): x = 64+0 +} + +// multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier +// clamps to the axis extent (1), so logD stays 1 and the layer isn't blanked. +// Before the clamp, multiplyZ=4 made logD = 1/4 = 0 → empty layer. +TEST_CASE("MultiplyModifier multiplyZ on 2D does nothing") { + mm::MultiplyModifier m; + m.multiplyX = 1; m.multiplyY = 1; m.multiplyZ = 4; // Z multiply on a flat grid + mm::lengthType logW, logH, logD; + m.logicalDimensions(64, 64, 1, logW, logH, logD); + CHECK(logW == 64); + CHECK(logH == 64); + CHECK(logD == 1); // NOT 0 — Z multiply clamped to the depth-1 extent + + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + m.mapToPhysical(5, 10, 0, 64, 64, 1, physicals, count, 8); + CHECK(count == 1); // single position, no Z tiling + CHECK(physicals[0] == 10 * 64 + 5); // identity +} + +// A multiplier larger than the axis extent clamps to the extent (can't tile more +// times than there are pixels). +TEST_CASE("MultiplyModifier clamps a multiplier above the axis extent") { + mm::MultiplyModifier m; + m.multiplyX = 64; m.multiplyY = 1; m.multiplyZ = 1; + m.mirrorX = false; + mm::lengthType logW, logH, logD; + m.logicalDimensions(16, 16, 1, logW, logH, logD); // 64× on a 16-wide axis + CHECK(logW == 1); // clamped to extent 16 → 16/16 = 1, not 16/64 = 0 + CHECK(logH == 16); +} + +// maxMultiplier is the product of the raw controls (the fan-out upper bound). +TEST_CASE("MultiplyModifier maxMultiplier is the product of axes") { + mm::MultiplyModifier m; + m.multiplyX = 2; m.multiplyY = 2; m.multiplyZ = 2; + CHECK(m.maxMultiplier() == 8); + m.multiplyZ = 1; + CHECK(m.maxMultiplier() == 4); // the default-ish XY fold +} + +// REGRESSION: maxMultiplier() must NOT wrap when all axes are maxed. The product +// 64×64×16 = 65536 overflows nrOfLightsType (uint16 on no-PSRAM) and would wrap +// to 0 — feeding the uint64 maxDest math in Layer::rebuildLUT an already-wrapped +// (possibly 0) multiplier → empty LUT → black display. It must saturate to the +// type max instead. (Single-axis tests above stay under the wrap; this one +// crosses it.) On uint32 (PSRAM) the product fits and isn't saturated — assert +// only the non-wrap, non-zero invariant that holds on both widths. +TEST_CASE("MultiplyModifier maxMultiplier saturates, never wraps to 0") { + mm::MultiplyModifier m; + m.multiplyX = 64; m.multiplyY = 64; m.multiplyZ = 16; // 65536 — wraps uint16 + CHECK(m.maxMultiplier() > 0); // never the wrapped 0 + // The product (65536) is ≥ the uint16 ceiling, so on a uint16 build it + // saturates to 65535; on uint32 it's the true 65536. Either way it's a large + // positive upper bound, never a small/zero value that would starve the LUT. + CHECK(m.maxMultiplier() >= 65535); +} + +// REGRESSION: an 8×8 multiply must emit all 64 tile positions, not be truncated +// to 8. The Layer's scratch buffer is sized to ModifierBase::kMaxFanout (64); a +// smaller buffer (the original physicals[8]) silently dropped 56 of the 64 tiles, +// so a 128×128 grid showed only 8 tiles instead of the full 8×8 = 64. +TEST_CASE("MultiplyModifier 8x8 emits all 64 tiles") { + mm::MultiplyModifier m; + m.multiplyX = 8; m.multiplyY = 8; m.multiplyZ = 1; + m.mirrorX = false; m.mirrorY = false; // pure tiling → 64 distinct positions + CHECK(m.maxMultiplier() == 64); + mm::nrOfLightsType physicals[64]; + mm::nrOfLightsType count = 0; + // 128 wide → tile edge 16; logical (0,0) maps to one position per 16×16 tile. + m.mapToPhysical(0, 0, 0, 128, 128, 1, physicals, count, 64); + CHECK(count == 64); +} + +// Fan-out never exceeds maxOut even if asked for more than the buffer holds. +TEST_CASE("MultiplyModifier respects maxOut clamp") { + mm::MultiplyModifier m; + m.multiplyX = 2; m.multiplyY = 2; m.multiplyZ = 2; // wants 8 + mm::nrOfLightsType physicals[8]; + mm::nrOfLightsType count = 0; + m.mapToPhysical(0, 0, 0, 128, 128, 8, physicals, count, 4); // cap at 4 + CHECK(count == 4); +} diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp index b18c7d86..5bfd6767 100644 --- a/test/unit/light/unit_PreviewDriver.cpp +++ b/test/unit/light/unit_PreviewDriver.cpp @@ -1,9 +1,11 @@ // @module PreviewDriver #include "doctest.h" +#include "core/Scheduler.h" #include "light/drivers/PreviewDriver.h" #include "light/drivers/Drivers.h" #include "light/layers/Layer.h" +#include "light/layers/Layers.h" #include "light/layouts/Layouts.h" #include "light/layouts/GridLayout.h" #include "light/layouts/SphereLayout.h" @@ -127,3 +129,48 @@ TEST_CASE("PreviewDriver fps default") { mm::PreviewDriver driver; CHECK(driver.fps == 24); } + +// Regression: deleting the active Layer must not leave a driver holding a +// dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned +// when the active Layer was null, leaving PreviewDriver's layer_ pointing at the +// freed Layer; the next onBuildState read layer_->layouts() on freed memory and +// crashed the device (LoadProhibited → boot loop, since the broken tree persists). +// Now passBufferToDrivers clears the drivers' layer_/sourceBuffer_ to null, a safe +// idle state. This drives the real path: Drivers bound to a Layers CONTAINER +// (self-healing), the Layer removed, then buildState re-resolves activeLayer()=null. +TEST_CASE("PreviewDriver tolerates the active Layer being deleted") { + mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; + mm::Layouts group; group.addChild(&g); + mm::Layers layers; + auto* layer = new mm::Layer(); + layer->setChannelsPerLight(3); + layers.addChild(layer); + layers.setLayouts(&group); + layers.onBuildControls(); + + mm::Drivers drivers; + auto* preview = new mm::PreviewDriver(); + CaptureBroadcaster cap; + preview->setBroadcaster(&cap); + drivers.addChild(preview); + drivers.setLayers(&layers); // container-bound: layer_ re-resolved at buildState + drivers.onBuildControls(); + + layers.onBuildState(); + drivers.onBuildState(); + REQUIRE(preview->layer() == layer); // wired to the active Layer + + // Remove the only Layer, then rebuild — activeLayer() now returns null. + layers.removeChild(layer); + layer->teardown(); + mm::Scheduler::deleteTree(layer); // free it — a stale pointer would now dangle + + layers.onBuildState(); + drivers.onBuildState(); // must NOT deref the freed Layer + CHECK(preview->layer() == nullptr); // cleared, not dangling + + // And producing a frame on the empty pipeline is a safe no-op (no crash). + preview->buildAndSendCoordTable(); + preview->sendFrame(); + CHECK(cap.frameMsgs == 0); // nothing to send with no layer +}