diff --git a/.actrc b/.actrc new file mode 100644 index 000000000..5c08277ae --- /dev/null +++ b/.actrc @@ -0,0 +1 @@ +-P ubuntu-latest=catthehacker/ubuntu:act-latest diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..8671016f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,32 @@ +# Generated by `uv run moondeck/docs/gen_moonwalk.py --issue-form` from the log-issue process in moonwalk/; edit the process, not this file. +name: Bug report +description: A bug report with the device's state attached. +labels: +- bug +body: +- type: markdown + attributes: + value: 'Describe what you saw and hand over the state of the device it happened on; diagnosing it is ours. Not sure it is a bug? Ask on [Discord](https://discord.gg/TC8NSUSCdV) first. A feature request needs no template and no diagnostics: describe the use case. + + + [Log an issue](https://moonmodules.org/MoonLight/moonwalk.html#log-issue)' +- type: textarea + id: version + attributes: + label: Which version and firmware does the device run? + description: Check the version on the Firmware card. +- type: textarea + id: diagnostics + attributes: + label: The diagnostics of System, Firmware and the card that went wrong + description: Copy each card's diagnostics. +- type: textarea + id: describe + attributes: + label: Steps to reproduce, what should happen, and what happens instead + description: Describe the steps, the expected and the actual. +- type: textarea + id: photo + attributes: + label: A photo or screenshot of what you see + description: Add a photo of what you see. diff --git a/.github/actions/ccache/action.yml b/.github/actions/ccache/action.yml index 77ea99348..052319b33 100644 --- a/.github/actions/ccache/action.yml +++ b/.github/actions/ccache/action.yml @@ -20,7 +20,9 @@ runs: - if: inputs.install == 'true' && runner.os == 'macOS' shell: bash run: brew install ccache - - uses: actions/cache@v4 + # act sets ACT, and runs no JavaScript action nested in a composite one; a cache only speeds a run, so a local run goes without it. + - if: ${{ !env.ACT }} + uses: actions/cache@v4 with: path: .ccache key: ccache-${{ inputs.key }}-${{ github.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 04adfbbed..20983ded2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ on: # Both branches are listed on purpose. A workflow whose trigger names a branch nobody is # pushing to runs never, and reports nothing while looking healthy — this job sat idle for # two commits when the working branch was called `next` instead of `next-iteration` - # (docs/work/past/lessons.md). `main` keeps it firing once this branch is merged and retired. + # (docs/contributing/lessons.md). `main` keeps it firing once this branch is merged and retired. push: branches: - main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd58f9ca8..0a6f96b37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,10 +102,9 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - uses: astral-sh/setup-uv@v3 - id: gen - run: | - set -euo pipefail - echo "list=$(jq -c '[.firmwares[] | select(.ships and .platform == "esp32") | {firmware: .name, chip}]' mooninstaller/firmwares.json)" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py firmwares build-esp32: needs: [verify-version, firmwares] @@ -146,13 +145,7 @@ jobs: id: tag env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" # The semver burned into the binary + stamped on the assets/manifest. A # `latest` build gets `-dev.` (N = commits since the last v* tag) @@ -168,10 +161,7 @@ jobs: # injection from a crafted tag/ref. env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" # The espressif/idf image ships ccache and turns it on; the workspace mount carries the cache in and out. - uses: ./.github/actions/ccache @@ -192,50 +182,12 @@ jobs: command: python ../moondeck/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" --version "${{ steps.ver.outputs.version }}" && ccache --show-stats extra_docker_args: -e CCACHE_DIR=/app/${{ github.repository }}/.ccache + # Per-firmware build dir build/esp32-/ (build_esp32.py points idf.py at it with -B), so several firmwares coexist: CI uses one per matrix job, dev machines as many as built. The version is the one computed above, matching the binary's MM_VERSION. - name: Stage release artifacts - run: | - set -euo pipefail - mkdir -p dist - V="${{ steps.ver.outputs.version }}" # computed once above; matches the binary's MM_VERSION - # Per-firmware build dir under build/esp32-/ (plan-19.1). - # build_esp32.py points idf.py at this dir via -B, so the build - # tree lives outside esp32/ and multiple firmwares can coexist: - # CI uses one per matrix job, dev machines use as many as built. - B=build/esp32-${{ matrix.firmware }} - PREFIX="firmware-${{ matrix.firmware }}-v$V" - # App, bootloader and partition table are unique per firmware (one flash size - # holds several layouts), so staged with the prefix. - cp "$B/MoonLight.bin" "dist/${PREFIX}.bin" - cp "$B/bootloader/bootloader.bin" "dist/${PREFIX}-bootloader.bin" - cp "$B/partition_table/partition-table.bin" "dist/${PREFIX}-partition-table.bin" - # ota-data is byte-identical for ALL firmwares, so it is staged under a SHARED - # name and the release uploads it once. Every matrix job writes the same file; - # the flatten step keeps one copy (byte-identical, so last-writer-wins is - # correct). The generated manifests point at this name (generate_manifest.py). - cp "$B/ota_data_initial.bin" "dist/shared-ota-data.bin" - # Per-firmware flasher_args.json: the release job feeds it to - # generate_manifest.py so offsets come from the real build. - cp "$B/flasher_args.json" "dist/flasher-${{ matrix.firmware }}.json" - # MoonBase, the second boot image on the 4 MB tables (built alongside those firmwares - # by build_esp32.py): chip-shared, so staged under a shared name like the partition - # table. The slot-0 otadata is what makes a fresh install boot the APP with MoonBase - # standing by: blank otadata would boot MoonBase. Their manifests reference both - # (generate_manifest.py), and install-picker.js excludes them from OTA offers. - for MB in build/moonbase-*/MoonLight-moonbase.bin; do - [ -f "$MB" ] || continue - CHIP=$(basename "$(dirname "$MB")"); CHIP=${CHIP#moonbase-} - cp "$MB" "dist/shared-moonbase-$CHIP.bin" - uv run python -c "import sys; sys.path.insert(0,'moondeck/build'); \ - import build_esp32, pathlib; \ - pathlib.Path('dist/shared-ota-data-slot0.bin').write_bytes(build_esp32.otadata_slot0_bytes())" - done - # The migration image, which a board running WLED installs through WLED's own update page to move to MoonLight (moonbase/migrate/): one per chip, so shared like MoonBase. - for RP in build/migrate-*/MoonLight-migrate.bin; do - [ -f "$RP" ] || continue - CHIP=$(basename "$(dirname "$RP")"); CHIP=${CHIP#migrate-} - case "$CHIP" in *-pause) continue ;; esac # the rehearsal image never ships - cp "$RP" "dist/shared-migrate-$CHIP.bin" - done + env: + FIRMWARE: ${{ matrix.firmware }} + VERSION: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/release_stage.py stage-esp32 --firmware "$FIRMWARE" --version "$VERSION" - uses: actions/upload-artifact@v4 with: @@ -265,27 +217,20 @@ jobs: shell: bash env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" - name: Compute version id: ver shell: bash env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" - uses: ./.github/actions/ccache with: key: macos-arm64 - name: Build + package macOS arm64 - run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" && ccache --show-stats + env: + VERSION: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/package_desktop.py --version "$VERSION" - uses: actions/upload-artifact@v4 with: name: desktop-macos @@ -311,22 +256,13 @@ jobs: shell: bash env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" - name: Compute version id: ver shell: bash env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" # The runner image does NOT ship NSIS (the first release run after the installer merged # proved it: "no makensis on this CI runner"). choco installs it into # Program Files (x86)\NSIS, one of the two locations find_makensis already searches. @@ -359,7 +295,9 @@ jobs: } Write-Host "NSIS present: $found" - name: Build + package Windows x64 - run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" + env: + VERSION: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/package_desktop.py --version "$VERSION" - uses: actions/upload-artifact@v4 with: name: desktop-windows @@ -404,27 +342,20 @@ jobs: shell: bash env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" - name: Compute version id: ver shell: bash env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" - uses: ./.github/actions/ccache with: key: linux-x64 - name: Build + package Linux x64 - run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" && ccache --show-stats + env: + VERSION: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/package_desktop.py --version "$VERSION" - name: Check the glibc floor run: uv run moondeck/ci/check_glibc_floor.py dist/moonlight_*_amd64.deb - uses: actions/upload-artifact@v4 @@ -469,27 +400,20 @@ jobs: shell: bash env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" - name: Compute version id: ver shell: bash env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" - uses: ./.github/actions/ccache with: key: linux-arm64 - name: Build + package Linux arm64 - run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" && ccache --show-stats + env: + VERSION: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/package_desktop.py --version "$VERSION" - name: Check the glibc floor run: uv run moondeck/ci/check_glibc_floor.py dist/moonlight_*_arm64.deb - uses: actions/upload-artifact@v4 @@ -509,7 +433,7 @@ jobs: - uses: astral-sh/setup-uv@v3 - name: the Arm toolchain's version, which keys its cache id: gcc - run: echo "version=$(sed -n 's/^GCC_VERSION = "\(.*\)"/\1/p' moondeck/build/setup_teensy.py)" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py gcc-version - uses: actions/cache@v4 with: path: ~/teensy/arm-gnu-toolchain-* @@ -523,36 +447,20 @@ jobs: shell: bash env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" - else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" - name: Compute version id: ver shell: bash env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" - name: Install the Teensy SDK run: uv run moondeck/build/setup_teensy.py --no-loader - name: Build the Teensy 4.1 firmwares env: TAG: ${{ steps.tag.outputs.tag }} V: ${{ steps.ver.outputs.version }} - run: | - set -euo pipefail - mkdir -p dist - for fw in $(jq -r '.firmwares[] | select(.ships and .platform == "teensy") | .name' mooninstaller/firmwares.json); do - uv run moondeck/build/build_teensy.py --firmware "$fw" --release "$TAG" --version "$V" - cp "build/$fw/MoonLight.hex" "dist/firmware-$fw-v$V.hex" - cp "build/$fw/MoonLight.bin" "dist/firmware-$fw-v$V.bin" - done + run: uv run moondeck/ci/release_stage.py stage-teensy --tag "$TAG" --version "$V" - uses: actions/upload-artifact@v4 with: name: firmware-teensy41 @@ -598,41 +506,19 @@ jobs: path: artifacts - name: Flatten artifacts into dist/ - run: | - set -euo pipefail - mkdir -p dist - find artifacts -type f -exec mv {} dist/ \; - ls -la dist/ + run: uv run moondeck/ci/release_stage.py gather # The Open Stage Control surface, so someone who owns a device can download it beside the # firmware instead of being sent to a path inside the repo. It is a plain session file with - # no build step: copied, not generated. - - name: Stage the control-surface session - run: | - set -euo pipefail - cp docs/reference/examples/open-stage-control.json \ - dist/MoonLight-control-surface.json - ls -la dist/MoonLight-control-surface.json - - # The module help this release's app shows, so a device reads the help of the firmware it runs: a newer file names controls it lacks. deploy-pages mirrors it beside the release's binaries, where the device's page can read it. - - name: Stage the module help - run: cp docs/help/help.json dist/MoonLight-help.json + # no build step: copied, not generated. The module help beside it is the one this release's app shows, so a device reads the help of the firmware it runs (a newer file names controls it lacks); deploy-pages mirrors it beside the release's binaries, where the device's page can read it. + - name: Stage the control-surface session and the module help + run: uv run moondeck/ci/release_stage.py extras - name: Resolve effective tag id: tag env: INPUT_TAG: ${{ inputs.tag }} - REF_NAME: ${{ github.ref_name }} - IS_MAIN: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - if [ -n "$INPUT_TAG" ]; then - echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" - elif [ "$IS_MAIN" = "true" ]; then - echo "tag=latest" >> "$GITHUB_OUTPUT" - else - echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT" - fi + run: uv run moondeck/ci/release_stage.py tag --input-tag "$INPUT_TAG" --ref "$GITHUB_REF" --ref-name "$GITHUB_REF_NAME" # Same computation as the build job's "Compute version" — the manifest's # version must match the binary's MM_VERSION + the asset names. Channel + @@ -644,36 +530,14 @@ jobs: # no shell injection from a crafted tag/ref. env: TAG: ${{ steps.tag.outputs.tag }} - run: | - set -euo pipefail - V=$(uv run python moondeck/build/compute_version.py --tag "$TAG") - echo "version=$V" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py version --tag "$TAG" + # Absolute GitHub release-asset URLs, uploaded as release assets and read by the on-device OTA picker (the device fetches the .bin directly, no CORS). The Pages-relative manifests are generated in the deploy-pages job, where the web installer (CORS-bound) consumes them. The shipping list is the mooninstaller/firmwares.json the build matrix reads, so manifests and builds cannot drift. - name: Generate the installer manifests (release-asset URLs) env: TAG: ${{ steps.tag.outputs.tag }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - V="${{ steps.ver.outputs.version }}" # computed once above; matches binary + asset names - # Absolute GitHub release-asset URLs. Uploaded as release assets; - # read by the on-device OTA picker (device fetches the .bin directly - # — no CORS). The Pages-relative manifests are generated in the - # deploy-pages job, where the web installer (CORS-bound) consumes them. - BASE="https://github.com/${REPO}/releases/download/$TAG" - # The shipping firmware list — the same mooninstaller/firmwares.json the - # build matrix reads, so manifests and builds can't drift. - for F in $(jq -r '.firmwares[] | select(.ships and .platform != "desktop") | .name' mooninstaller/firmwares.json); do - # An ESP32 manifest takes its offsets from IDF's flasher args; a Teensy one is the flat image at 0. - ARGS=() - [ -f "dist/flasher-$F.json" ] && ARGS=(--flasher-args "dist/flasher-$F.json") - uv run python moondeck/build/generate_manifest.py \ - --firmware "$F" \ - --version "$V" \ - --release-url "$BASE" \ - "${ARGS[@]}" \ - --out "dist/manifest-$F.json" - done + V: ${{ steps.ver.outputs.version }} + run: uv run moondeck/ci/release_stage.py manifests --tag "$TAG" --version "$V" --repo "$GITHUB_REPOSITORY" - name: Re-create the `latest` release fresh # The `latest` tag is a moving prerelease: every main-branch build @@ -771,6 +635,7 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + - uses: astral-sh/setup-uv@v3 # The same .debs the release published, from the same run: one per architecture. - uses: actions/download-artifact@v4 @@ -797,65 +662,14 @@ jobs: # would be rejected where `moonmodules/moonlight` is accepted. - name: Resolve image name id: img - env: - REPO: ${{ github.repository }} - run: | - set -euo pipefail - echo "name=ghcr.io/$(echo "$REPO" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + run: uv run moondeck/ci/release_stage.py image-name --repo "$GITHUB_REPOSITORY" # A build context of just the .deb, so the image installs THIS run's package instead of # resolving one from the API the way a local `docker build` does. Written here rather than # kept as a second Dockerfile in the repo: it is four lines, and two Dockerfiles that must # agree about the runtime is exactly the duplication that drifts. - name: Write the release Dockerfile - run: | - set -euo pipefail - mkdir -p ctx - # One .deb per architecture, named by the arch BuildKit will ask for. TARGETARCH is - # amd64 or arm64, the same suffix package_desktop.py writes, so the stage below picks - # its own package by name and neither list has to be kept in step with the other. - for a in amd64 arm64; do - deb=$(ls dist/moonlight_*_"$a".deb | head -1) - test -n "$deb" - cp "$deb" "ctx/moonlight_$a.deb" - done - ls -la ctx/ - cat > ctx/Dockerfile <<'DOCKERFILE' - # --platform=$TARGETPLATFORM: the unpack must happen on the TARGET's architecture, or - # buildx would run the amd64 stage for both and ship an amd64 binary inside an arm64 - # image. ARG TARGETARCH is set by BuildKit, no --build-arg needed. - FROM --platform=$TARGETPLATFORM debian:trixie-slim AS fetch - ARG TARGETARCH - COPY moonlight_$TARGETARCH.deb /tmp/moonlight.deb - # libcurl4t64 is installed because the binary LINKS it (the one outbound HTTPS call), and - # libpulse0 because its audio input LOADS it at runtime, which ldd cannot see; the sweep - # then collects both plus everything they need. See ../../Dockerfile. - RUN apt-get update \ - && apt-get install -y --no-install-recommends libcurl4t64 libpulse0 \ - && rm -rf /var/lib/apt/lists/* \ - && dpkg-deb -x /tmp/moonlight.deb /rootfs \ - && mkdir -p /deps \ - && pulse=$(find /usr/lib -name libpulse.so.0 | head -1) \ - && test -n "$pulse" \ - && { ldd /rootfs/usr/bin/MoonLight; ldd "$pulse"; echo "libpulse.so.0 => $pulse (0)"; } | awk '/=> \//{print $3}' | sort -u | grep -vE '/(libc|libm|libstdc\+\+|libgcc_s)\.so' | xargs -I{} cp -L {} /deps/ - # Both binaries are built on Ubuntu 22.04 and need glibc 2.35 at most, which - # check_glibc_floor.py enforces, so debian13 (2.41) runs them. See ../../Dockerfile. - # Pinned by digest, not by tag: `cc-debian13` is mutable, so an unpinned base means two - # builds of the same commit can ship different runtimes. Re-pin deliberately when picking - # up base updates (docker buildx imagetools inspect gcr.io/distroless/cc-debian13:latest). - # This digest is the multi-arch INDEX, not one platform's manifest, so the same pin serves - # amd64 and arm64; buildx resolves the right manifest per platform. - FROM gcr.io/distroless/cc-debian13@sha256:9b615fff20e1a4fad29c2b30562580b212c7dd5e2225236735cca0070ed11c78 - COPY --from=fetch /rootfs/usr/bin/MoonLight /usr/bin/MoonLight - # The libraries the loader resolved, collected in the fetch stage. Without them the - # container dies at startup on "libcurl.so.4: cannot open shared object file", which the - # run before publishing now catches. - COPY --from=fetch /deps/ /usr/lib/ - ENV XDG_DATA_HOME=/data - VOLUME /data - EXPOSE 8080 - ENTRYPOINT ["/usr/bin/MoonLight"] - DOCKERFILE + run: uv run moondeck/ci/release_stage.py container-context # Each architecture's image runs before any is published, since an image whose binary dies at # startup (a missing library, a glibc its base lacks) installs cleanly and fails on a user's board. @@ -876,27 +690,7 @@ jobs: # loop makes a response time a matter of the runner, so it must survive its start: the loader # resolves every library and glibc symbol in the first second or the process dies. - name: Run each image - run: | - set -euo pipefail - docker run -d --name smoke-amd64 --platform linux/amd64 -p 18080:8080 moonlight-smoke:amd64 - ok=0 - for i in $(seq 1 60); do - if curl -fs http://localhost:18080/api/system > /dev/null; then ok=1; break; fi - sleep 1 - done - docker logs smoke-amd64 | tail -20 - docker rm -f smoke-amd64 - test "$ok" = 1 || { echo "the amd64 image did not answer within 60 s" >&2; exit 1; } - echo "the amd64 image answers" - docker run -d --name smoke-arm64 --platform linux/arm64 moonlight-smoke:arm64 - sleep 15 - running=$(docker inspect -f '{{.State.Running}}' smoke-arm64) - docker logs smoke-arm64 2>&1 | tail -20 | tee arm64.log - docker rm -f smoke-arm64 - if [ "$running" != true ] || grep -qE "GLIBC_|cannot open shared object" arm64.log; then - echo "the arm64 image did not survive its start" >&2; exit 1 - fi - echo "the arm64 image runs" + run: uv run moondeck/ci/release_stage.py smoke-images # Two tags: the exact version, which never moves, and `latest`, which follows this workflow's # own notion of latest (the rolling prerelease from main, or a tagged release). A user pins @@ -944,11 +738,7 @@ jobs: path: artifacts - name: Flatten artifacts into dist/ - run: | - set -euo pipefail - mkdir -p dist - find artifacts -type f -exec mv {} dist/ \; - ls -la dist/ + run: uv run moondeck/ci/release_stage.py gather - name: Stage cumulative release content env: @@ -1008,48 +798,15 @@ jobs: echo "Staged binaries + relative manifests into releases/$T" done + # The install page and the picker modules at the root; each release's binaries and manifests live under releases//. - name: Stage GitHub Pages site - run: | - set -euo pipefail - # Install page + the shared install-picker module sit at the root. - # Each release's binaries + manifests live under releases//. - mkdir -p pages/install - cp -r mooninstaller/. pages/install/ - cp src/ui/install-picker.js pages/install/ - # The device-catalog / chip-detection half of the picker, mooninstaller - # only (not embedded in firmware), imported by index.html. Must ship to - # Pages alongside install-picker.js or the ES-module import 404s. - cp src/ui/install-picker-devices.js pages/install/ - # library.json — install page reads the project version from it. - cp library.json pages/install/ - # Board picker images live in docs/assets/deviceModels/ (the project's asset - # home, also a library for boards not yet in the catalog). Stage ONLY the - # images a deviceModels.json entry actually references, under install/assets/, - # so an "image": "assets/deviceModels/.jpg" resolves same-origin from - # /install/ without shipping the unused library to Pages. - mkdir -p pages/install/assets/deviceModels - # rel is "assets/deviceModels/." (the path served from /install/); - # the source file lives in docs/ (i.e. docs/assets/deviceModels/...). - jq -r '.[].image // empty' mooninstaller/deviceModels.json | while read -r rel; do - src="docs/$rel" - [ -f "$src" ] && cp "$src" "pages/install/$rel" \ - || echo "WARNING: deviceModels.json image not found: $src" - done - ls -la pages/ pages/install/ + run: uv run moondeck/ci/release_stage.py pages-site - - name: Build docs site into Pages root - run: | - set -euo pipefail - # Render the docs/ tree (Material for MkDocs) as the Pages ROOT - # (moonmodules.github.io/MoonLight/) — the project's front door. - # Config: mkdocs.yml; deps declared inline in the build script (uv - # provisions them). Build to a temp dir, then copy INTO pages/ so the - # installer staged above under pages/install/ survives (a plain - # --site-dir pages would wipe it — mkdocs cleans its output dir). - # history/ and backlog/ are excluded in mkdocs.yml (internal docs). - uv run moondeck/docs/build_docs.py --strict --site-dir "$RUNNER_TEMP/docs-site" - cp -r "$RUNNER_TEMP/docs-site/." pages/ - ls -la pages/ + # The docs/ tree (Material for MkDocs) is the Pages ROOT (moonmodules.github.io/MoonLight/), the project's front door. Config: mkdocs.yml; the build script declares its deps inline. It builds to a temp dir that is then copied INTO pages/, so the installer staged above under pages/install/ survives (a plain --site-dir pages would wipe it, since mkdocs cleans its output dir). + - name: Render the docs site + run: uv run moondeck/docs/build_docs.py --strict --site-dir "$RUNNER_TEMP/docs-site" + - name: Merge the docs site into the Pages root + run: uv run moondeck/ci/release_stage.py merge-docs --site-dir "$RUNNER_TEMP/docs-site" - uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bc0d1cb4..0901a4dd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -164,6 +164,9 @@ jobs: - uses: ./.github/actions/ccache with: key: sanitizer-${{ matrix.kind }} + # The job declares the build tools it needs rather than leaning on the runner's preinstalls, so it runs on any Ubuntu image, act's included. + - name: install the build tools + run: sudo apt-get install -y cmake # The whole suite under one sanitizer, as check_sanitizers.py runs it locally. RTSan is clang-only, so that lane names clang-20; # the others take the runner's default compiler. The address lane writes the unit results as JUnit for the annotations: one lane is enough. - name: build + run unit tests under ${{ matrix.kind }}sanitizer diff --git a/.vale.ini b/.vale.ini index 3712ec008..f47bc6bf6 100644 --- a/.vale.ini +++ b/.vale.ini @@ -46,13 +46,9 @@ BasedOnStyles = BasedOnStyles = # Exempt for the reasons check_prose.py gives: quoted material, generated pages, vendor code. -# All of docs/work/ is exempt: past and future are quoted or inherited material, and a plan in -# present is a working document that ends at its PR rather than a page anyone reads twice. -[docs/work/past/*.md] -BasedOnStyles = -[docs/work/present/*.md] -BasedOnStyles = -[docs/work/future/*.md] +# All of docs/work/ is exempt: a plan is a working document that ends at its PR rather than a page +# anyone reads twice, and backlog_mirror/ is generated from the Project's cards. +[docs/work/**] BasedOnStyles = [docs/friend-repos/*.md] BasedOnStyles = @@ -63,8 +59,7 @@ BasedOnStyles = # CLOSING THE NET. A page the sweep has finished is held to every rule on the whole file: a # suggestion becomes an error there, so a regression fails the gate instead of annotating a PR. -# Add a page here when `vale ` reports nothing; the sweep plan in docs/work/present/ is the -# list. When every page is here, the two prose scripts go and this section becomes the default. +# Add a page here when `vale ` reports nothing. When every page is here, the two prose scripts go and this section becomes the default. [docs/reference/installing-on-linux.md] BasedOnStyles = MoonLight MoonLight.Weasel = error diff --git a/CLAUDE.md b/CLAUDE.md index 3eea8fd7b..f37db9d60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The product owner is the critical success factor. They review every line before **An agent posts on GitHub only what the product owner has read**: a comment, a review reply or an issue goes out after they said so. Text in their name to another project or community carries no agent attribution line or trailer. -**Report the real situation.** A reason to stop is a real one (a failing theory, a broken tree, a decision needed), never the time of day, and an item reported as open exists under `docs/work/future/`. +**Report the real situation.** A reason to stop is a real one (a failing theory, a broken tree, a decision needed), never the time of day, and an item reported as open exists as a card on [the Project](https://github.com/users/MoonModules/projects/5). **Reverting is the product owner's call**, whatever prompted it: a contradicting doc, a reviewer finding, a failing check, or the agent's own second thoughts. State the case and wait. @@ -84,7 +84,7 @@ Every activity is a [MoonWalk](docs/moonwalk.md) process, the one home of its st ### Main and branch -**The product owner creates every branch**, and main stays releasable: [the branch](docs/moonwalk.md#branch). A change goes from [a spec and a plan](docs/moonwalk.md#pick-spec-plan) to a pull request the plan describes. **Moving the plan to `docs/work/past/plans/` is the product owner's call**, because "the code is written" is not "the plan is realized". +**The product owner creates every branch**, and main stays releasable: [the branch](docs/moonwalk.md#branch). A change goes from [a spec and a plan](docs/moonwalk.md#pick-spec-plan) to a pull request the plan describes. **Closing the plan, as a closed issue labeled `plan`, is the product owner's call**, because "the code is written" is not "the plan is realized". **A contributed pull request is one topic, max 400 changed lines and ten files**, tests included, based on main: [Contribute a pull request](docs/moonwalk.md#contribute-pr). The contributor vouches for every line, agent-written or not, answers reviewers in their own words, and says what an agent wrote and what was tested. **A contribution is reviewed and finished as [Review and finish a contribution](docs/moonwalk.md#review-contribution) describes**. The contributor fixes their own code first and the maintainer finishes, case by case. The merge keeps their commits and authorship. @@ -116,4 +116,4 @@ On "run pre-release": [Release](docs/moonwalk.md#release). Every commit and merg Published at [moonmodules.org/MoonLight](https://moonmodules.org/MoonLight/); sources under `docs/`, laid out in [the documentation hierarchy](docs/contributing/documentation-standards.md#the-hierarchy). Docs describe the system as it is; git is the history; specs precede implementation. -`docs/work/` is the exemption to present tense: `future` is what does not exist yet, `present` is being built, `past` is what shipped and the lessons it taught. Agents read it when planning, on request, and it shrinks under mandatory subtraction like everything else. +`docs/work/` is the exemption to present tense: it holds the plans being built and `backlog_mirror/`, the generated copy of [the Project](https://github.com/users/MoonModules/projects/5)'s cards, where the backlog lives; design studies are in [docs/design/](docs/design/index.md). A shipped or stopped plan is a [closed issue labeled `plan`](https://github.com/MoonModules/MoonLight/issues?q=label%3Aplan%20is%3Aclosed), and hard-won lessons are in [lessons.md](docs/contributing/lessons.md). Agents read the work when planning, on request, and it shrinks under mandatory subtraction like everything else. diff --git a/docs/assets/moonwalk.js b/docs/assets/moonwalk.js index eb99cfee7..64ff7d2ae 100644 --- a/docs/assets/moonwalk.js +++ b/docs/assets/moonwalk.js @@ -1,4 +1,4 @@ -// MoonWalk's journey map: every process from moonwalk/*.yaml, drawn from moonwalk.json as a story map, stages (or systems) as columns and each process's steps inside it. The model is docs/work/future/moonwalk.md. +// MoonWalk's journey map: every process from moonwalk/*.yaml, drawn from moonwalk.json as a story map, stages (or systems) as columns and each process's steps inside it. The model is docs/explanation/architecture/moonwalk.md. // Two hosts: on the docs site it adds a map button to every page's header and draws the map on the MoonWalk page, through Material's document$ so instant navigation mounts it too; the device's app imports it from the site and calls drawMap with its own state, so a device shows what it has done and what comes next. const ROOT = new URL("../", import.meta.url); @@ -485,6 +485,12 @@ function showPanel(panel, model, pid, done, onDone, host = {}) { } const meta = [model.stages.find((s) => s.id === p.stage)?.title, p.actor, ...(p.systems || [])].join(" · "); panel.append(control("h3", {}, p.title), control("p", { className: "mm-map-meta" }, meta), control("p", {}, p.goal)); + // The open backlog cards whose Process field names this process, each linking its card on the Project. + if (p.open?.length) { + const list = control("p", { className: "mm-map-meta" }, `Open work (${p.open.length}): `); + p.open.forEach((c, i) => list.append(i ? ", " : "", c.card ? control("a", { href: c.card, target: "_blank", rel: "noopener" }, c.title) : c.title)); + panel.append(list); + } // Done on the device is what the state shows, so it has no checkbox to undo and says on what it rests, at the top where a reader looks for it. if (host.device?.has(pid)) { const why = conditionText(p.steps.flatMap((s) => s.then || [])); diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md index 72bc58868..7b7c22e3a 100644 --- a/docs/contributing/coding-standards.md +++ b/docs/contributing/coding-standards.md @@ -132,7 +132,7 @@ Option B (children first on `tick`; parent first on `setup` / `defineControls` / ### Per-type behavior lives with the type -A struct or enum that is the semantic owner of some data owns the functions that interpret, serialize or validate it. A control descriptor, a packet and a module role are each such an owner. They live next to the type, never at the call sites. Free functions in the same `.cpp` count; member methods on the owning class are stronger; virtual methods on a base class are strongest. The wrong shape is the same `switch (type)` repeated in every consumer: adding a variant means hunting across N files, and the compiler cannot say when one is missed. +A struct or enum that is the semantic owner of some data owns the functions that interpret, serialize or validate it. A control descriptor, a packet and a module role are each such an owner. They live next to the type, never at the call sites. The wrong shape is the same `switch (type)` repeated in every consumer: adding a variant means hunting across N files, and the compiler cannot say when one is missed. Three concrete patterns, all already common in this codebase: @@ -140,9 +140,11 @@ Three concrete patterns, all already common in this codebase: - **Methods on the owning class.** [Buffer.h](../src/light/layers/Buffer.h)'s `allocate` / `free` / `clear`; [Scheduler.h](../src/core/module/Scheduler.h)'s `addModule` / `tick` / `prepareTree`; [ControlList](../src/core/module/Control.h)'s `addX` family. Best when the class has identity and the operations naturally form a small interface. - **Virtual methods on a base class.** [MoonModule.h](../src/core/module/MoonModule.h)'s lifecycle (`setup`, `tick`, `tick1s`, `defineControls`, `prepare`, …). Best when polymorphism is already in play. -Counter-example to avoid: a `switch (c.type)` on `ControlType` duplicated across the HTTP server, the filesystem module and the scenario runner. That shape forces a new ControlType to be added in four places, and the compiler can't catch a missed switch on a non-exhaustive enum. The per-type dispatch instead lives next to `ControlType` in [Control.cpp](../src/core/module/Control.cpp); consumers call `writeControlValue(sink, c)` and don't need to know the enum's shape. +Counter-example to avoid: a `switch (c.type)` on `ControlType` duplicated across the HTTP server, the filesystem module and the scenario runner, so a new ControlType is added in four places and the compiler can't catch a missed one. The dispatch lives next to `ControlType` in [Control.cpp](../src/core/module/Control.cpp) instead; consumers call `writeControlValue(sink, c)`. -When a `switch (type)` outside the type's home file is legitimate: the caller has a genuinely different concern. The HTTP server mapping `ApplyResult` to status codes is a transport policy, not per-type behavior, and scenario_runner's `switch (JsonVal::type)` dispatches on *its own* discriminator. The rule is "per-type dispatch lives with the type", not "switches are banned". +A `switch (type)` outside the type's home file is legitimate when the caller has a different concern: the HTTP server mapping `ApplyResult` to status codes is transport policy, and scenario_runner's `switch (JsonVal::type)` dispatches on its own discriminator. + +**The UI follows the same rule**: `app.js` dispatches on control types, never on module names, and a module that needs more than the generic controls brings its own widget from its own file. **A flat parser that returns a zero sentinel for a missing key must be paired with a presence check before the value is applied as authoritative.** `json::parseInt(json, key)` returns 0 for an absent key, indistinguishable from a real 0. On a persistence-overlay load path that clobbers a non-zero default when an older or partial file omits the key. Guard with `json::hasKey()` first: an absent key leaves the control untouched (its default stands); a present key (even value 0) applies. Any "control resets to its default/0 after reboot" symptom is this overlay smell, not a control-init bug. @@ -208,7 +210,7 @@ Which checks run at which lifecycle event is defined once, in the [commit](../mo ## Debugging and verification -Hard-won discipline for diagnosing hardware and infrastructure failures, distilled from the war stories in [lessons.md](../work/past/lessons.md). +Hard-won discipline for diagnosing hardware and infrastructure failures, distilled from the war stories in [lessons.md](lessons.md). - **Prove the failure is *about* the change before editing code.** When something fails right after a change, re-run it isolated and probe the actual end state. Confirm the artifact under test is the one you built: process uptime, the `build` timestamp, what is bound to the port. A stale process, a loaded machine, or an async-confirmation timeout reads as a regression it isn't. - **A status/dimension assertion does not prove the pipeline renders.** A correctness test for a mapping or effect asserts the buffer or LUT is non-empty with the expected coverage, beyond the declared dimensions looking right. diff --git a/docs/contributing/documentation-standards.md b/docs/contributing/documentation-standards.md index 4219d85b7..084efb778 100644 --- a/docs/contributing/documentation-standards.md +++ b/docs/contributing/documentation-standards.md @@ -126,13 +126,13 @@ Every page serves one of four reader needs, and only one. This is [Diátaxis](ht The test for any page is the cell it sits in. A tutorial that stops to explain, or a reference that starts to teach, is two pages: move the other half to where it belongs. -Two kinds of page sit outside the grid on purpose. **The rules**, [coding-standards.md](coding-standards.md) and this page in `contributing/` and [CLAUDE.md](../../CLAUDE.md) at the repo root, are for contributors and bind every change. `legal/` sits outside it too. **Work on its way to the code** lives under `docs/work/`, apart from the pages that describe the system. `future` holds what is planned, `present` what is being built, which moves to `past` at its PR, and `past` the closed plans, shipped or cancelled. +Two kinds of page sit outside the grid on purpose. **The rules**, [coding-standards.md](coding-standards.md) and this page in `contributing/` and [CLAUDE.md](../../CLAUDE.md) at the repo root, are for contributors and bind every change. `legal/` sits outside it too. **Work on its way to the code** lives under `docs/work/`, apart from the pages that describe the system. It holds the plans being built; the backlog is [the Project](https://github.com/users/MoonModules/projects/5)'s cards, mirrored to `backlog_mirror`, a survey before a plan is a [design study](../design/index.md) in `design/`, and a plan that ships or stops becomes a [closed issue labeled `plan`](https://github.com/MoonModules/MoonLight/issues?q=label%3Aplan%20is%3Aclosed). Two scales below a page. **A module** has one reference card and one header (see [Module pages](#module-pages)); **a line of code** carries its own reason in a comment (see [Comments](#comments)). **The split that decides everything else.** A hand-written page says what the code cannot; the header is the code's own documentation. So a fact about behavior belongs in the `.h`, beside the code. A fact about intent, cost or sequence has no member to sit on, and is written. -**A shipped plan is a working document, not testimony.** It may be edited, trimmed, or deleted: whatever its PR already carries is duplication. Only a dated record stating what was true at a moment (release notes, an inventory quoted from another project) is kept unrewritten. +**A shipped plan is a working document, not testimony.** Its closed issue keeps what no PR carries, and may be edited or trimmed: whatever its PR already carries is duplication. A dated record of a past state, such as release notes or an inventory quoted from another project, stays as written. ## Writing diff --git a/docs/work/past/lessons.md b/docs/contributing/lessons.md similarity index 84% rename from docs/work/past/lessons.md rename to docs/contributing/lessons.md index 95fae7669..bf69e438a 100644 --- a/docs/work/past/lessons.md +++ b/docs/contributing/lessons.md @@ -1,9 +1,9 @@ # Lessons -Hard-won debugging lessons and gotchas, recorded with the code that proved them. The PR-merge *carry-forward* gate writes new entries here (CLAUDE.md § Lifecycle Events). This is the **lesson** record: a bug, its root cause, and the fix. Two neighbours hold the other genres, and a lesson belongs in whichever fits: +Hard-won debugging lessons and gotchas, recorded with the code that proved them. The [pre-merge](../moonwalk.md#pre-merge) process writes new entries here. This is the **lesson** record: a bug, its root cause, and the fix. Two neighbors hold the other genres, and a lesson belongs in whichever fits: -- A durable **rule** that graduated from a lesson lives in [CLAUDE.md](../../../CLAUDE.md) or [coding-standards.md](../../contributing/coding-standards.md), not here. -- The forward-looking **design intent** of a feature is a [plan](index.md). +- A durable **rule** that graduated from a lesson lives in [CLAUDE.md](../../CLAUDE.md) or [coding-standards.md](coding-standards.md), not here. +- The forward-looking **design intent** of a feature is a plan: one [being built](https://github.com/MoonModules/MoonLight/tree/main/docs/work), or [a closed plan issue](https://github.com/MoonModules/MoonLight/issues?q=label%3Aplan%20is%3Aclosed) once it ships. Entries run oldest-first by branch. A lesson fully absorbed into a rule doc or the code is pruned (per *Mandatory subtraction*): the git history is the permanent record, this file is the working narrative on top. @@ -57,7 +57,7 @@ Bench notes: a board that drops STA mid-session falls back to softAP silently, p ## Diagnosing LED flicker: eliminate firmware with hardware tests before blaming (or fixing) the wire -A classic ESP32 driving WS2812 on RMT showed random wrong colors on LEDs the effect left black. Instead of guessing (WiFi, buffering, timing), a four-step elimination each a *measurement*: (1) capture the source/preview buffer, clean, so corruption is downstream of the logical buffer; (2) whole-frame loopback self-test through a jumper, bit-exact PASS even under WiFi load, so the firmware/peripheral is innocent; (3) sweep `txPowerSetting` 20→1 dBm, flicker constant, so it's NOT WiFi coupling; (4) check pulse timing, 350/700/1250 ns spec-exact, not a timing-margin bug. Every firmware cause eliminated by test, the remaining cause is the physical data path, and "constant regardless of TX power" fingers signal integrity, a missing 3.3→5 V level shift. Fix is hardware (documented in [LED signal integrity](../../reference/troubleshooting.md#stray-pixels-or-wrong-colors-on-lights-that-should-be-off)). +A classic ESP32 driving WS2812 on RMT showed random wrong colors on LEDs the effect left black. Instead of guessing (WiFi, buffering, timing), a four-step elimination each a *measurement*: (1) capture the source/preview buffer, clean, so corruption is downstream of the logical buffer; (2) whole-frame loopback self-test through a jumper, bit-exact PASS even under WiFi load, so the firmware/peripheral is innocent; (3) sweep `txPowerSetting` 20→1 dBm, flicker constant, so it's NOT WiFi coupling; (4) check pulse timing, 350/700/1250 ns spec-exact, not a timing-margin bug. Every firmware cause eliminated by test, the remaining cause is the physical data path, and "constant regardless of TX power" fingers signal integrity, a missing 3.3→5 V level shift. Fix is hardware (documented in [LED signal integrity](../reference/troubleshooting.md#stray-pixels-or-wrong-colors-on-lights-that-should-be-off)). **Red-herring note:** the flicker's *appearance* shifted (blue-only → random) after an unrelated `lightPreset` RGB→GRB default change plus a pin swap. The electrical fault was identical; only the color mapping changed. A changed symptom is not proof a code change caused it. @@ -89,7 +89,7 @@ The first audio-reactive capability: `AudioModule` (a SystemModule Peripheral) r - **Effects reach the producer via `AudioModule::latestFrame()` (a static accessor), not a boot setter**, because an audio effect can be added through the UI after boot and a setter only wired the boot instance. The active mic registers in `setup()`, clears in `teardown()`, returns a static silent frame when there's no mic. - **Two hardware-only bugs, now pinned:** a missing `registerType` made `create(...)->markWiredByCode()` deref null and boot-loop; the I²S read blocked the tick ~7.7 ms at a 20 ms timeout (fixed to non-blocking + a cross-tick sample accumulator, dropping to ~400 µs). The INMP441 is on the **left** I²S slot here (right reads empty), the first bench suspect when level floors with sound present. - **Shipped the manual core; the adaptive conditioner was prototyped and removed.** Auto noise-floor + AGC + smoothing needs tuning in a *quiet* room; the dev environment (a campground van with strong varying low-frequency rumble) was the adversarial worst case that kept it from settling. Land the manual core, treat adaptive auto-tuning as its own increment, tune it where the noise floor is real-quiet. Also: `level` is overall RMS (independent of the FFT), don't derive it from the bands or it stops fluctuating with volume. -- **Designed fresh from the datasheet + textbook DSP, not a prior project.** The datasheet made even a behavior-reference unnecessary: a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool; the textbook defaults sufficed. The DSP choices and *why* live in [AudioService.md](../../moonmodules/core/services.md#audio). +- **Designed fresh from the datasheet + textbook DSP, not a prior project.** The datasheet made even a behavior-reference unnecessary: a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool; the textbook defaults sufficed. The DSP choices and *why* live in [AudioService.md](../moonmodules/core/services.md#audio). - **A floating or strap-pin line fakes a signal.** It reads near full scale or bounces, so an RMS above zero never confirms a mic: confirm it by RMS tracking sound on one fixed pin, and re-read the silkscreen first (a 45 read as 15 put WS on a strap). ## ESP32-P4 round 3, WiFi via the C6: the abstraction the earlier round feared wasn't needed @@ -109,11 +109,11 @@ A deferred "fuller error handling" item for `rmtWs2812Show`/`rmtWs2812Wait` ( A mic-less **classic ESP32** boot-looped (TG1WDT_SYS_RESET at ~736 ms, silent hang). Bisect (clean-built known-good still looped; disabling AudioModule wiring booted clean) fingered AudioModule: auto-wired via `addChild` + `markWiredByCode()` (gated on `platform::hasI2sMic`), it ran `setup()` → `reinit()` → `audioMicInit()` → `i2s_channel_enable()`, which on the classic's older I²S driver **blocks forever** when no mic clocks the pins. The P4 was never affected (newer I²S returns silence or fails cleanly). -The fix (a design fix, not a band-aid): don't auto-wire AudioModule (register it in the factory so it's user-addable); default the mic pins to unset (0); `reinit()` no-ops on any unset pin (`setStatus("set …"); return;`). Classic then boots 191 FPS, 0 WDT resets. This drove the general rule now in [MoonInstaller, config provenance](../../explanation/architecture/mooninstaller.md#config-provenance-mcu-devicemodel) and [coding-standards.md § Defaults](../../contributing/coding-standards.md#defaults): chip-/board-fixed pins (RMII Ethernet) *must* default (omitting them is a chicken-and-egg lockout); user-soldered pins (mic, LED strands) stay empty until set. Never auto-run a peripheral whose init can block on absent hardware. +The fix (a design fix, not a band-aid): don't auto-wire AudioModule (register it in the factory so it's user-addable); default the mic pins to unset (0); `reinit()` no-ops on any unset pin (`setStatus("set …"); return;`). Classic then boots 191 FPS, 0 WDT resets. This drove the general rule now in [MoonInstaller, config provenance](../explanation/architecture/mooninstaller.md#config-provenance-mcu-devicemodel) and [coding-standards.md § Defaults](coding-standards.md#defaults): chip-/board-fixed pins (RMII Ethernet) *must* default (omitting them is a chicken-and-egg lockout); user-soldered pins (mic, LED strands) stay empty until set. Never auto-run a peripheral whose init can block on absent hardware. ## Live reconfiguration falls out of the prepare-pass for free, MoonLight's "initless" goal, a different mechanism -MoonLight reconfigures every module live the instant a control changes (pins, leds-per-pin, output protocol, mic pin/rate), no reboot. The design note lives in [architecture.md § Live reconfiguration](../../explanation/architecture/moonmodule.md#live-reconfiguration-every-change-applies-on-the-next-frame). The lineage is MoonLight's "initless drivers" (a driver with no `addLeds`/`initLed` step, reading a mutable Context at `show()` time). MoonLight reaches the same outcome by a different mechanism: our drivers *do* have an explicit rebuild (`RmtLedDriver::reinit()`, the i80/Parlio DMA-bus rebuild), but it's driven by the generic tier-3 `onBuildState()` sweep, not hand-built per driver, so any module returning `true` from `controlChangeTriggersBuildState` inherits live-reconfig, spanning drivers, audio, effects, layouts, modifiers, and network I/O alike. Credit the lineage for the idea, but name the property by what the user sees when the mechanism differs. +MoonLight reconfigures every module live the instant a control changes (pins, leds-per-pin, output protocol, mic pin/rate), no reboot. The design note lives in [architecture.md § Live reconfiguration](../explanation/architecture/moonmodule.md#live-reconfiguration-every-change-applies-on-the-next-frame). The lineage is MoonLight's "initless drivers" (a driver with no `addLeds`/`initLed` step, reading a mutable Context at `show()` time). MoonLight reaches the same outcome by a different mechanism: our drivers *do* have an explicit rebuild (`RmtLedDriver::reinit()`, the i80/Parlio DMA-bus rebuild), but it's driven by the generic tier-3 `onBuildState()` sweep, not hand-built per driver, so any module returning `true` from `controlChangeTriggersBuildState` inherits live-reconfig, spanning drivers, audio, effects, layouts, modifiers, and network I/O alike. Credit the lineage for the idea, but name the property by what the user sees when the mechanism differs. ## Lessons from the catalog-driven installer branch (3-layer device model) @@ -181,7 +181,7 @@ The catalog/summary pages hand-author each card image as a raw ` 4` wrote **past its 4-byte slot**, overflowing into adjacent lanes and off the array end → heap/stack corruption. `outChannels` is set from the preset's role count, so a fixture preset (Pan/Tilt/Dimmer/… = 8–16+ ch) — or RGBCCT (5) — walks straight off the end. The persisted preset re-applied every boot, so it crashed, rebooted, crashed = bootloop. - **The reproducing bootloop was the ideal debugger, not a disaster.** Same panic every boot → each hypothesis got a definitive yes/no. `addr2line` on the backtrace + a one-line `ESP_LOGW` dumping the state pre-crash (`st`, `io`, `buf`, `bytes`, fifo head/tail) turned "it crashes somewhere in tx_color" into hard facts. Two decisive isolation flashes (queue_depth 2→1, then PSRAM→internal buffer) **ruled OUT** the new Step 1.5 double-buffer as the cause — the crash was identical. Then flashing the **previous commit** in a git worktree crashed too, but landed in `tlsf_malloc` (`remove_free_block`) — the unmistakable signature of a **corrupted heap**, i.e. a buffer overflow, present *before* Step 1.5. General: a crash inside the allocator on a *later* malloc is almost never the allocator's fault — it's an earlier overflow that trashed the free-list; find the writer, not the reader. And a reliable repro earns instrumentation, not guesswork (the anti-stalling rule cuts *both* ways: >2 blind fix attempts = stop, but a repro you can *measure* is exactly when to keep going). -- **The fix is NOT a channel cap — it's sizing the scratch to the channel count (no maxed caps).** The first instinct (reject `outChannels > 4` with a status) was wrong: RGBCCT is already 5, and MoonLight's principle is *no arbitrary caps — the only limit is memory* ([CLAUDE.md](../../../CLAUDE.md)). So `wire` became a **heap buffer sized to `kMaxLanes × outChannels`** (parallel) / `outChannels` (RMT), allocated off the hot path (in `parseConfig`/`resizeSymbols`, grows-only, freed on release), and the encoder's lane stride became the `channels` parameter instead of a literal 4. Now any channel count lays out without overrun; the driver **drives** a 16-channel fixture, it doesn't refuse it. **Heap in the hot path?** No: allocation is off the hot path (once per config change), `tick()` only reads the member pointer — same pattern `NetworkSendDriver`'s `corrected_` and the DMA buffer already use, so no per-frame alloc and no cache penalty at these sizes (tens–hundreds of bytes stays in L1). Verified end-to-end: the SE16, still holding the multi-channel preset that bootlooped it, now boots clean and reports `driving 2304 of 16384 lights`. +- **The fix is NOT a channel cap; it's sizing the scratch to the channel count (no maxed caps).** The first instinct (reject `outChannels > 4` with a status) was wrong: RGBCCT is already 5, and MoonLight's principle is *no arbitrary caps — the only limit is memory* ([CLAUDE.md](../../CLAUDE.md)). So `wire` became a **heap buffer sized to `kMaxLanes × outChannels`** (parallel) / `outChannels` (RMT), allocated off the hot path (in `parseConfig`/`resizeSymbols`, grows-only, freed on release), and the encoder's lane stride became the `channels` parameter instead of a literal 4. Now any channel count lays out without overrun; the driver **drives** a 16-channel fixture, it doesn't refuse it. **Heap in the hot path?** No: allocation is off the hot path (once per config change), `tick()` only reads the member pointer — same pattern `NetworkSendDriver`'s `corrected_` and the DMA buffer already use, so no per-frame alloc and no cache penalty at these sizes (tens–hundreds of bytes stays in L1). Verified end-to-end: the SE16, still holding the multi-channel preset that bootlooped it, now boots clean and reports `driving 2304 of 16384 lights`. - **General: a fixed-stride scratch is a silent cap.** Any `buf[N * FIXED_STRIDE]` where the real element size is runtime-derived is a maxed cap hiding as a buffer — it works until an input exceeds the stride, then it's a memory-corruption bug, not a clean rejection. Either size it to the actual stride (off the hot path) or assert the input fits; never let it overrun. The tell that this class of bug exists: a driver that assumes RGB(W) but reads its channel count from a user-settable preset. ## Six dead hypotheses on one bug: what a debugging session looks like when the *method* is broken @@ -225,9 +225,10 @@ What actually went wrong, and the rules that fall out: - **Absence of evidence is not evidence.** The "silent underrun" story existed *only* because the S3 has no underrun interrupt. A chip that cannot report X is not telling you X is happening. The moment a control condition appeared (a P4, which *does* report), the theory evaporated. - **Check the datasheet number before theorising about it.** "PSRAM is too slow" cost real time and was refutable in ten seconds by looking up the octal-PSRAM bandwidth against the 26.67 MB/s the frame needs. - **A metric you never validated is not a meter.** The GDMA error count was used as the success signal for most of the session — and then the product owner reported *visually clean, flicker-free strands in a state that still logged 121 mount failures*. The proxy and the reality had come apart, which means every "confirmed" and "no change" read off that proxy was worthless. Validate a proxy against ground truth **once**, early, or don't lean on it. -- **The PO's eyes found in one sentence what six theories missed.** "I put asyncTransmit off and it's MUCH better" and, later, a blind sweep of `ledsPerPin` landing *exactly* on the allocator's internal/PSRAM boundary (96 good / 128 bad, against a ~38 KB largest free internal block): both were better data than anything the agent produced. When a human at the bench reports a reproducible observation, that is the primary instrument; the logs are supporting evidence. (This is the *[Invite the product owner to test, then stop](../../../CLAUDE.md#roles)* rule earning its place: the rule exists because the agent kept racing past the one measurement that mattered.) +- **The PO's eyes found in one sentence what six theories missed.** "I put asyncTransmit off and it's MUCH better" and, later, a blind sweep of `ledsPerPin` landing *exactly* on the allocator's internal/PSRAM boundary (96 good / 128 bad, against a ~38 KB largest free internal block): both were better data than anything the agent produced. When a human at the bench reports a reproducible observation, that is the primary instrument; the logs are supporting evidence. (This is the *[Invite the product owner to test, then stop](../../CLAUDE.md#roles)* rule earning its place: the rule exists because the agent kept racing past the one measurement that mattered.) - **A fix that shrinks the feature below its own baseline is not a fix.** Internal-RAM-first "worked" and was nearly shipped as the answer — while capping a shift display at ~1,500 lights, *fewer* than direct mode already drives. It ships as an explicitly-labelled **stopgap**, and the PO's push-back ("not scalable — check you're not running in a very bad circle") is what caught it. -- **Prune the record as hypotheses die.** `docs/work/future/shift-register-driver-analysis.md` § 7.5 had accumulated three successive "the mechanism is X" write-ups, each stated with confidence and each wrong — actively poisoning the next attempt. It is now split into **what is TRUE (measured)** and **what is FALSE (guessed, then refuted — do not re-derive)**. A design doc that records dead theories as findings is worse than no doc. +- **Build the floor test before optimizing.** Six encode optimizations ran before a diagnostic encoder that stores only a fixed pattern measured the floor at 19 µs a light, which showed the stall was a frame-close fault rather than speed. When an artifact can be correctness or speed, the cheap floor test comes first. +- **Prune the record as hypotheses die.** The shift-register design record had accumulated three successive "the mechanism is X" write-ups, each stated with confidence and each wrong — actively poisoning the next attempt. It is now split into **what is TRUE (measured)** and **what is FALSE (guessed, then refuted — do not re-derive)**. A design doc that records dead theories as findings is worse than no doc. The one thing that *is* solid after all that: the frame renders correctly from **internal RAM** and incorrectly from **PSRAM** on the S3 — established by the PO's blind sweep, not by any of the reasoning above — while the same PSRAM frame runs perfectly on a P4. The mechanism remains **unknown**, and the next attempt starts from the PO's `asyncTransmit` observation, not from a seventh theory. @@ -318,207 +319,92 @@ Streaming panel-card frames from an S31 at ~5 300 packets/s degraded with uptime ## Lessons from the power-function branch (shared toolbox, particles, shaders) -A branch that built a shared library and migrated existing effects onto it. The gotchas all -share a shape: a green build, plausible-looking output, and a defect only counting could see. +A branch that built a shared library and migrated existing effects onto it. The gotchas all share a shape: a green build, plausible-looking output, and a defect only counting could see. - **A ported function's SIGN is part of its contract, and getting it wrong is silent.** Our - `sin16`/`cos16` returned unsigned 0..65535; FastLED master (`i16 sin16lut`) and WLED main - (`int16_t sin16_t`) both return signed. WLED effects write `sin16_t(x) + 32768` when they - want an unsigned value — arithmetic that is exactly half a scale wrong against an unsigned - return, with no compile error and no crash. A ported effect just looks subtly off. **When - adopting a function that exists upstream, verify its range and sign against upstream's - `master`/`main`, not against a release and not against memory** — the contract to bind to is - the one users will have. Local checkouts of both live beside this repo for exactly this. + `sin16`/`cos16` returned unsigned 0..65535; FastLED master (`i16 sin16lut`) and WLED main (`int16_t sin16_t`) both return signed. WLED effects write `sin16_t(x) + 32768` when they want an unsigned value: arithmetic that is exactly half a scale wrong against an unsigned return, with no compile error and no crash. A ported effect just looks subtly off. **When adopting a function that exists upstream, verify its range and sign against upstream's `master`/`main`, not against a release and not against memory**: the contract to bind to is the one users will have. Local checkouts of both live beside this repo for exactly this. - **"It looks like noise" is not evidence that it IS noise.** `fbm16` summed its octaves after - shifting each sample down by 8 — which fits a 32-bit accumulator and produces output that - passes every visual check, while being an 8-bit field wearing a 16-bit type: 195 distinct - values over 20,000 samples, low byte never set. That is precisely the banding the 16-bit tier - exists to remove, shipped inside the fix for it. **For a function whose value IS its - distribution, assert on the distribution — count distinct values, check the range is - reached.** An eyeball, and a test that only checks bounds, both pass. + shifting each sample down by 8 — which fits a 32-bit accumulator and produces output that passes every visual check, while being an 8-bit field wearing a 16-bit type: 195 distinct values over 20,000 samples, low byte never set. That is precisely the banding the 16-bit tier exists to remove, shipped inside the fix for it. **For a function whose value IS its distribution, assert on the distribution — count distinct values, check the range is reached.** An eyeball, and a test that only checks bounds, both pass. - **Two framerate bugs can cancel, and fixing one alone looks like a regression.** Fireworks - counts frames for its launch roll (20x the shells at 1200 fps) *and* floors its trail fade at - 1 per render (erasing the trail 20x faster). Together they roughly cancel, so the effect - looked fine and the audit passed. Fixing only the launch made the measured ratio worse — - three times, before the second bug was found. **When a targeted fix makes a metric worse, - stop patching and look for the compensating bug** rather than tuning the fix. + counts frames for its launch roll (20x the shells at 1200 fps) *and* floors its trail fade at 1 per render (erasing the trail 20x faster). Together they roughly cancel, so the effect looked fine and the audit passed. Fixing only the launch made the measured ratio worse — three times, before the second bug was found. **When a targeted fix makes a metric worse, stop patching and look for the compensating bug** rather than tuning the fix. - **A "no-op" rewrite of a shared primitive needs a differential test, not a review.** - Replacing `wrap()`'s per-span loops with modulo changed its endpoint behavior: the loops are - asymmetric (reducing from above stops *at* `span`, climbing from below stops at `0`), which - no amount of reading the new code reveals. Diffing old against new across every span and - 2.8M inputs found it in seconds. **For a hot-path primitive whose replacement is meant to be - behavior-identical, prove it by exhaustive comparison against the original.** + Replacing `wrap()`'s per-span loops with modulo changed its endpoint behavior: the loops are asymmetric (reducing from above stops *at* `span`, climbing from below stops at `0`), which no amount of reading the new code reveals. Diffing old against new across every span and 2.8M inputs found it in seconds. **For a hot-path primitive whose replacement is meant to be behavior-identical, prove it by exhaustive comparison against the original.** ## Lessons from the MoonLive-on-Xtensa branch (the register-window frame bug) -MoonLive's JIT ran on desktop arm64 and on RISC-V, and reset every Xtensa board the moment a -script called a host function. Three days, because every check we had was looking at the -instructions and the defect was in the stack layout around them. +MoonLive's JIT ran on desktop arm64 and on RISC-V, and reset every Xtensa board the moment a script called a host function. Three days, because every check we had was looking at the instructions and the defect was in the stack layout around them. - **On Xtensa, the top 32 bytes of a call8 frame belong to the hardware, not to the routine.** - The window-overflow handler (`_WindowOverflow8`, esp-idf `components/xtensa/xtensa_vectors.S`) - writes two 16-byte bands into a spilled frame: the top 16 take an *older* frame's a0-a3 - (`s32e aN, a9, -16..-4`), and the next 16 take the routine's *own* a4-a7 - (`s32e aN, a0, -32..-20`, where a0 is that frame's top). GCC obeys the same rule — every - call8-making function it compiles reserves locals + exactly 32, at every size. Our emitter - reserved 16, so the parked control-arena pointer sat inside the second band and a window - spill overwrote it with an expression temp. **A frame-layout question is answered by the - platform's own spill handler and a compiler probe, not by the ISA prose.** + The window-overflow handler (`_WindowOverflow8`, esp-idf `components/xtensa/xtensa_vectors.S`) writes two 16-byte bands into a spilled frame: the top 16 take an *older* frame's a0-a3 (`s32e aN, a9, -16..-4`), and the next 16 take the routine's *own* a4-a7 (`s32e aN, a0, -32..-20`, where a0 is that frame's top). GCC obeys the same rule — every call8-making function it compiles reserves locals + exactly 32, at every size. Our emitter reserved 16, so the parked control-arena pointer sat inside the second band and a window spill overwrote it with an expression temp. **A frame-layout question is answered by the platform's own spill handler and a compiler probe, not by the ISA prose.** - **Window rotation is deterministic; the SPILL is not, so the contract is spatial.** Rotation - happens only at `call8`/`retw`, in our own instruction stream. The spill is lazy: it fires on - any interrupt that lands while a call is in flight, at an instruction we do not choose. No - ordering, no code sequence and no "quiet moment" can dodge it — only the layout can be right - or wrong. This is also the whole symptom explanation: deep call chains (plasma's `sin`/`beat` - into libm) gave every tick interrupt a wide window and died in under a second, brief leaf - calls almost never coincided, and call-free scripts never faulted at all. **A crash whose - frequency tracks how LONG a call runs, rather than what it computes, is an interrupt-timing - bug — look at what the hardware writes asynchronously, not at the code path.** + happens only at `call8`/`retw`, in our own instruction stream. The spill is lazy: it fires on any interrupt that lands while a call is in flight, at an instruction we do not choose. No ordering, no code sequence and no "quiet moment" can dodge it — only the layout can be right or wrong. This is also the whole symptom explanation: deep call chains (plasma's `sin`/`beat` into libm) gave every tick interrupt a wide window and died in under a second, brief leaf calls almost never coincided, and call-free scripts never faulted at all. **A crash whose frequency tracks how LONG a call runs, rather than what it computes, is an interrupt-timing bug — look at what the hardware writes asynchronously, not at the code path.** - **Byte-perfect encodings prove the instructions, and say nothing about the frame.** Round-trip - checks against `xtensa-esp32-elf-as` passed for every instruction we emit, including the - `entry` whose immediate was the bug. Two other backends running the identical IR pipeline were - flawless, which read as "the compiler is fine, the target is haunted". **When a defect is - target-specific and every static check is green, suspect the contract with the platform - (frame, ABI, alignment) rather than the code generator.** + checks against `xtensa-esp32-elf-as` passed for every instruction we emit, including the `entry` whose immediate was the bug. Two other backends running the identical IR pipeline were flawless, which read as "the compiler is fine, the target is haunted". **When a defect is target-specific and every static check is green, suspect the contract with the platform (frame, ABI, alignment) rather than the code generator.** - **The guardrail had to see the bug before the fix went in.** `MM_ISA_RESERVED_TOP` was raised - to 32 in the structural checker *first*, and it failed on the shipped emitter, naming the - offending frame offset. Only then did the emitter constant change. A test written after a fix - proves the fix compiles; a test that fails first proves it *detects*. The checker decodes the - real `entry` immediate out of the emitted bytes, so it pins the invariant rather than the - constant. + to 32 in the structural checker *first*, and it failed on the shipped emitter, naming the offending frame offset. Only then did the emitter constant change. A test written after a fix proves the fix compiles; a test that fails first proves it *detects*. The checker decodes the real `entry` immediate out of the emitted bytes, so it pins the invariant rather than the constant. - **A reserve constant is coupled to the widest call the emitter can produce.** 32 is exact for - `call8`; `call12` would need 48 (its extra save area holds a8-a11 as well). We only ever emit - `call8`, so the checker's 32 is an equality — but nothing in the code says so, and a future - wider call would leave the test green while the frames went wrong again. hpwit, who wrote the - new-parser Xtensa compiler, independently reached the same floor from experience ("I always - start at at least 32") and phrases it as a minimum for exactly this reason. The reserve is - therefore derived from the emitted call opcode rather than written down, so widening the call - moves the reserve with it or fails the build. + `call8`; `call12` would need 48 (its extra save area holds a8-a11 as well). We only ever emit `call8`, so the checker's 32 is an equality — but nothing in the code says so, and a future wider call would leave the test green while the frames went wrong again. hpwit, who wrote the new-parser Xtensa compiler, independently reached the same floor from experience ("I always start at at least 32") and phrases it as a minimum for exactly this reason. The reserve is therefore derived from the emitted call opcode rather than written down, so widening the call moves the reserve with it or fails the build. ## Lessons from the script-functions branch: a tidier disassembly can be the broken one -Local calls and recursion in MoonLive cost four stacked defects, and every one of them was -invisible to a green test suite. The theme is narrower than "test on hardware": it is that the -evidence which *looks* most authoritative here is the evidence that lies. +Local calls and recursion in MoonLive cost four stacked defects, and every one of them was invisible to a green test suite. The theme is narrower than "test on hardware": it is that the evidence which *looks* most authoritative here is the evidence that lies. - **A cleaner-looking emitted block can be the broken one.** Removing the spill pass's function - boundary remap makes `crosshair.mlv` disassemble BETTER on Xtensa (three tidy `entry`/`retw.n` - pairs instead of two and a stray) and keeps all 1282 host tests green. It also boot-loops an S3 - with `StoreProhibited` and the light-buffer pointer holding `0xff`: a store through a register - that a mid-statement frame boundary left holding a colour byte. Two things conspire — the host - backend never executes that path, and the disassembler decodes the zero padding between - functions as instructions. **A change to function-boundary or frame code gets a flash and a - soak, never a listing review.** + boundary remap makes `crosshair.mlv` disassemble BETTER on Xtensa (three tidy `entry`/`retw.n` pairs instead of two and a stray) and keeps all 1282 host tests green. It also boot-loops an S3 with `StoreProhibited` and the light-buffer pointer holding `0xff`: a store through a register that a mid-statement frame boundary left holding a colour byte. Two things conspire — the host backend never executes that path, and the disassembler decodes the zero padding between functions as instructions. **A change to function-boundary or frame code gets a flash and a soak, never a listing review.** - **The host backend cannot pin an argument-passing contract, because its register map hides - one.** A script-to-script call emitted no argument setup at all, so each callee parked whatever - the caller had left in the argument registers and its first control read faulted at - `EXCVADDR 0x9`, offset 9 into a null arena. On arm64 the same omission fails nothing: R0..R4 map - onto the ABI argument registers and `bl` leaves them alone, so the values survive by luck of the - mapping. **Where a backend's register map coincides with the ABI, a test on that backend proves - the contract holds by accident rather than by construction — say so in the test.** + one.** A script-to-script call emitted no argument setup at all, so each callee parked whatever the caller had left in the argument registers and its first control read faulted at `EXCVADDR 0x9`, offset 9 into a null arena. On arm64 the same omission fails nothing: R0..R4 map onto the ABI argument registers and `bl` leaves them alone, so the values survive by luck of the mapping. **Where a backend's register map coincides with the ABI, a test on that backend proves the contract holds by accident rather than by construction — say so in the test.** - **An index into an array a later pass rewrites is a bug with a delay.** `CallScript` first - carried the callee's IR index. The spill pass inserts a Reload before a read, so every index past - its first insertion shifts and the call named an op that no longer started a function. A function - NUMBER survives any rewrite. **When one pass hands a position to another, hand it a name.** + carried the callee's IR index. The spill pass inserts a Reload before a read, so every index past its first insertion shifts and the call named an op that no longer started a function. A function NUMBER survives any rewrite. **When one pass hands a position to another, hand it a name.** - **`swap()` must swap the whole object.** `IrProgram::swap` exchanged the ops, counts and slots - but not the function table, so the spill pass's carefully remapped boundaries went into the - discarded half and the lowering read the stale ones. The remap was correct; the transfer was not. + but not the function table, so the spill pass's carefully remapped boundaries went into the discarded half and the lowering read the stale ones. The remap was correct; the transfer was not. - **An empty function is a real case in a guard that brackets a body.** The recursion depth guard is emitted at a function's first real op, because it must follow the host arguments being parked. - A function whose entire body is that parking (`nop() {}` parses) never reached the emission point - while its epilogue still decremented, so every call to it drove the counter DOWN, two calls - wrapped the byte past zero, and the next legal call was refused as too deep. **Any guard with an - entry and an exit needs the zero-length body as a test, not just the deep one.** + A function whose entire body is that parking (`nop() {}` parses) never reached the emission point while its epilogue still decremented, so every call to it drove the counter DOWN, two calls wrapped the byte past zero, and the next legal call was refused as too deep. **Any guard with an entry and an exit needs the zero-length body as a test, not just the deep one.** - **The toolchain answers ISA questions faster than reasoning does.** "Must an Xtensa `entry` be - 4-byte aligned?" took one `xtensa-esp32-elf-as` invocation to settle: it refuses with - `Error: unaligned entry instruction`. The same question had already cost an afternoon of - hypotheses. hpwit's `new-parser` hits the identical wall and leaves it unhandled, which is - confirmation the question is real rather than self-inflicted. **Assemble the case before - theorising about it.** + 4-byte aligned?" took one `xtensa-esp32-elf-as` invocation to settle: it refuses with `Error: unaligned entry instruction`. The same question had already cost an afternoon of hypotheses. hpwit's `new-parser` hits the identical wall and leaves it unhandled, which is confirmation the question is real rather than self-inflicted. **Assemble the case before theorising about it.** ## Lessons from the particles branch - **A stateful effect is a jitter meter; a stateless one hides the same fault.** A shader recomputes - every pixel from `t`, so a late frame is simply skipped and the next one is correct. A particle's - position is the previous position plus velocity, and `FrameTime` deliberately spends a whole stall - at once to keep the trajectory true in real time, so one frame after an 80 ms gap moves every - particle **6.7x its usual distance** (measured). The first particle effect on the branch therefore - exposed a 1 Hz `esp_littlefs_info` scan running inline on the render thread that had been there all - along and that no shader had ever revealed. **When motion starts stuttering after a change that - should not have touched timing, suspect a pre-existing periodic cost, and measure the frame deltas - before theorising.** The reverse also holds: a smooth shader is not evidence that the render loop - is clean. + every pixel from `t`, so a late frame is simply skipped and the next one is correct. A particle's position is the previous position plus velocity, and `FrameTime` deliberately spends a whole stall at once to keep the trajectory true in real time, so one frame after an 80 ms gap moves every particle **6.7x its usual distance** (measured). The first particle effect on the branch therefore exposed a 1 Hz `esp_littlefs_info` scan running inline on the render thread that had been there all along and that no shader had ever revealed. **When motion starts stuttering after a change that should not have touched timing, suspect a pre-existing periodic cost, and measure the frame deltas before theorising.** The reverse also holds: a smooth shader is not evidence that the render loop is clean. - **Standardising a duplicated sentence requires deciding which version is TRUE first.** Three front - pages had drifted into four orderings of the same six platforms, so a check was written to hold - them to one wording. The wording picked was the most formal-looking existing one, which happened - to demote five of the six targets to secondary and was simply wrong. Enforcing it would have - spread a false claim to every file the check covered. **A consistency check is only as good as the - value it pins; establish the fact, then enforce it.** + pages had drifted into four orderings of the same six platforms, so a check was written to hold them to one wording. The wording picked was the most formal-looking existing one, which happened to demote five of the six targets to secondary and was simply wrong. Enforcing it would have spread a false claim to every file the check covered. **A consistency check is only as good as the value it pins; establish the fact, then enforce it.** - **A test that passes with the bug reintroduced is worse than no test.** Three separate tests on - this branch (the fade idle-gap, the collide spread, the filesystem throttle) were each written, - seen green, and then found to pass with their own defect deliberately restored. Two were deleted - and one was rewritten. The habit that catches it is cheap: **sabotage the fix and confirm the test - goes red before believing it.** Two of those three had also been failing for a reason unrelated to - what they claimed to assert, which the control check surfaced immediately. + this branch (the fade idle-gap, the collide spread, the filesystem throttle) were each written, seen green, and then found to pass with their own defect deliberately restored. Two were deleted and one was rewritten. The habit that catches it is cheap: **sabotage the fix and confirm the test goes red before believing it.** Two of those three had also been failing for a reason unrelated to what they claimed to assert, which the control check surfaced immediately. - **A build script that does not build what the next command tests produces a confident false - green.** `build_desktop.py` builds `MoonLight` but not `mm_tests`/`mm_scenarios` unless given - `--tests`, so `ctest` straight after it runs whatever binary was there before. It reported "1377 - passed" against a test binary two hours old, on a change whose new option string was not even - present in it. The tell is cheap and worth the habit: **after changing code, confirm the test - binary actually contains the change** (a `grep` for a new string, or a run of just the new case) - before believing a green suite. The staleness guards in `run_scenario.py` exist for exactly this - and are why the scenario half never went unnoticed; the unit half has no such guard. + green.** `build_desktop.py` builds `MoonLight` but not `mm_tests`/`mm_scenarios` unless given `--tests`, so `ctest` straight after it runs whatever binary was there before. It reported "1377 passed" against a test binary two hours old, on a change whose new option string was not even present in it. The tell is cheap and worth the habit: **after changing code, confirm the test binary actually contains the change** (a `grep` for a new string, or a run of just the new case) before believing a green suite. The staleness guards in `run_scenario.py` exist for exactly this and are why the scenario half never went unnoticed; the unit half has no such guard. - **A freshness guard keyed on mtime must exclude files the build itself rewrites.** The same guard - then cried wolf: `build_info.h` embeds a `+` when `git status` reports a dirty tree, and a gate run - dirties the tree by writing its own scenario baselines and metrics. So every gate run left the - next one reporting a stale runner, a self-inflicted loop that reads exactly like a real staleness - failure. **Generated build metadata is not source**, and a guard that cannot tell them apart - teaches people to ignore it. + then cried wolf: `build_info.h` embeds a `+` when `git status` reports a dirty tree, and a gate run dirties the tree by writing its own scenario baselines and metrics. So every gate run left the next one reporting a stale runner, a self-inflicted loop that reads exactly like a real staleness failure. **Generated build metadata is not source**, and a guard that cannot tell them apart teaches people to ignore it. - **An ISA-guarded test is not run by the machine that wrote it, and a JIT's bytes are only true - where they execute.** The MoonLive backends are `#if`-guarded per architecture, so an arm64 bench - compiles neither the x86-64 encoder tests nor the x86-64 emitted code — a whole backend can be - wrong while the local suite is green and confident. A Q16.16 multiply that borrowed a register the - allocator hands out returned garbage on every x86-64 desktop; 1458 local tests passed, and CI is - where it surfaced. Two smaller defects hid in the same blind spot: a one-byte arena where the code - now does a 32-bit load, and an unmigrated type keyword in a test file arm64 never compiles. + where they execute.** The MoonLive backends are `#if`-guarded per architecture, so an arm64 bench compiles neither the x86-64 encoder tests nor the x86-64 emitted code — a whole backend can be wrong while the local suite is green and confident. A Q16.16 multiply that borrowed a register the allocator hands out returned garbage on every x86-64 desktop; 1458 local tests passed, and CI is where it surfaced. Two smaller defects hid in the same blind spot: a one-byte arena where the code now does a 32-bit load, and an unmigrated type keyword in a test file arm64 never compiles. **On an Apple Silicon machine that blind spot is one command wide:** `cmake -B build/x86 -DCMAKE_OSX_ARCHITECTURES=x86_64` then `arch -x86_64 ./build/x86/test/mm_tests`. Rosetta runs the emitted x86-64 instructions for real, so the tests that only exist on that host actually execute. - Worth doing on any change to a backend, an encoder, or the register allocator — it is minutes, - and it is the difference between finding these locally and finding them in CI. + Worth doing on any change to a backend, an encoder, or the register allocator — it is minutes, and it is the difference between finding these locally and finding them in CI. ## A lossy channel never closes a client for slowness (2026-08-25) -The preview transport spent a bench day being patched before the pattern surfaced: every -symptom (reconnect storms, blank refreshes, renderWait spikes charged to the LEDs) traced to a -synchronous send path that treated a slow socket as a fault, spinning up to a stall budget on -the output core and then **closing the client**. That converts ordinary congestion into -disconnects, disconnects into re-primed state, and re-primed state into more congestion, a -feedback loop. +The preview transport spent a bench day being patched before the pattern surfaced: every symptom (reconnect storms, blank refreshes, renderWait spikes charged to the LEDs) traced to a synchronous send path that treated a slow socket as a fault, spinning up to a stall budget on the output core and then **closing the client**. That converts ordinary congestion into disconnects, disconnects into re-primed state, and re-primed state into more congestion, a feedback loop. -The rule that replaced it, and holds generally: **on a lossy stream, drop at the source and let -TCP pace the rest.** Write only what the socket takes now (`writeSome`, never a wait), skip the -frame when the previous one has not drained, report the skip to the receiver (its one honest -congestion signal), and close only on a real error or FIN. The receiver steers quality from the -drop reports. Every give-up budget that remains must bound *lack of progress*, never elapsed -total, or slow-but-healthy transfers get truncated. +The rule that replaced it, and holds generally: **on a lossy stream, drop at the source and let TCP pace the rest.** Write only what the socket takes now (`writeSome`, never a wait), skip the frame when the previous one has not drained, report the skip to the receiver (its one honest congestion signal), and close only on a real error or FIN. The receiver steers quality from the drop reports. Every give-up budget that remains must bound *lack of progress*, never elapsed total, or slow-but-healthy transfers get truncated. ## A control test must fail against the bug it claims to catch @@ -544,3 +430,18 @@ Three weeks without a Windows run left three build breaks waiting, while CI's `b - **Check the compiler's output before optimizing the algorithm.** Removing branches and a whole wasted transpose gained nothing measurable; the disassembly showed the store loop's variable shifts costing more than the transpose, and unrolling them gained 37%. - **A depth that runs clean is the measurement, not free RAM.** `ringAuto` took 28 buffers because RAM allowed it; 21 smaller ones run clean, and the RAM went back to WiFi and the UI. - **Two fix attempts, then stop.** The ring's 1.1 ms of jitter survived two; it is in the backlog with its measurements rather than chased further. + +## The 16-bit Layer: built, measured, reversed (2026-09-04) + +A decaying plane at 8 bits cannot fade: multiplying by 0.994 a frame either truncates to black too fast or rounds back and freezes. The plan answered by widening the Layer buffer to 16 bits per channel, end to end. It worked, and it cost too much: + +| 128x128 | 8-bit | 16-bit | cost | +|---|---|---|---| +| ESP32-S3 | 18.25 ms | 28.58 ms | +56.5% (effect +50%, driver read +63%) | +| desktop arm64 | 0.48 ms | 0.76 ms | +57.9% | + +The answer that shipped widens only the planes that accumulate: Trails, Nebula, Fluid and the scripted trail each own a 16-bit plane and narrow once on the way out through `draw::blit16` with temporal dithering, so the buffer and the driver read stay 8 bits. Only the effects that need it pay, in memory freed with the effect. + +- **Measure the whole path, not the kernel.** On the S3 the driver read was the larger half: reassembling two bytes per channel happens lights times channels per frame whatever the effect does, and an in-order core punishes it. No host benchmark would have found it. +- **Precision is a per-consumer property, not a pipeline one.** "16-bit end to end" is a tidy sentence and an expensive design; the code needed 16 bits in the four places that accumulate. +- **A measured reversal is worth more than the feature.** The number is what stops the wide buffer being proposed again, which is why it lives here rather than in a commit message. diff --git a/docs/work/future/code-hosting-comparison.md b/docs/design/code-hosting.md similarity index 93% rename from docs/work/future/code-hosting-comparison.md rename to docs/design/code-hosting.md index 1e20a5ee9..a42326cab 100644 --- a/docs/work/future/code-hosting-comparison.md +++ b/docs/design/code-hosting.md @@ -9,7 +9,7 @@ Their content rules come first, since they decide whether MoonLight may be hoste |---|---|---|---|---| | Allows an agent-written project | yes | no, Terms §2(1)7 | yes, our own rules | yes, no rule on it | | Run by | Microsoft | a German non-profit | us | GitLab Inc. | -| Hosted CI runners | Linux x86 and arm64, macOS, Windows | Linux x86 on request (Woodpecker) | none: self-hosted only | Linux x86; macOS and Windows in beta | +| Hosted CI runners | Linux x86 and arm64, macOS, Windows | Linux x86 on request (Woodpecker) | none: self-hosted only | Linux x86 and arm64; macOS and Windows in beta | | Release API as MoonLight reads it | yes | Gitea shape | Gitea shape | its own shape | | Static site with custom domain | Pages | Codeberg Pages | any static host beside it | GitLab Pages | | Container registry | ghcr.io | Forgejo packages | Forgejo packages | GitLab registry | @@ -27,7 +27,7 @@ Their content rules come first, since they decide whether MoonLight may be hoste The [blog post](https://blog.codeberg.org/protecting-our-floss-commons-from-llms.html) explaining the vote names as unwelcome *"projects written and maintained with heavy use of LLMs"* and *"projects created by LLM 'agents' in autonomous ways"*, and as welcome *"projects who have an active community"* and *"projects with a significant pre-LLM history"*. Enforcement is by human moderators, with no automatic scanning. -MoonLight is written by agents and says so, in the roles of [CLAUDE.md](../../../CLAUDE.md#roles) and in [why we write our own code](../../explanation/why-we-write-our-own.md#why-this-became-possible), which places it under §2(1)7. +MoonLight is written by agents and says so, in the roles of [CLAUDE.md](../../CLAUDE.md#roles) and in [why we write our own code](../explanation/why-we-write-our-own.md#why-this-became-possible), which places it under §2(1)7. The two exceptions are uncertain ground: the community is small, and the pre-LLM history belongs to WLED-MM rather than to this repository. The same terms bar cryptocurrency projects, which does not concern MoonLight. @@ -44,7 +44,7 @@ Plain git moves unchanged: the repo-health and KPI scripts, MoonCloud on Cloudfl | GitHub | MoonLight uses it for | Codeberg and Forgejo | GitLab.com | |---|---|---|---| | Actions, 4 workflows | build, test, release, docs deploy | Forgejo Actions reads GitHub-style workflow files; most `actions/*` steps run | `.gitlab-ci.yml`, a rewrite of the four workflows | -| Hosted runners: Linux x86 and arm64, macOS, Windows | the desktop builds: `.deb` on both architectures, `.dmg`, `setup.exe` | Codeberg's Woodpecker is `linux/amd64` only and *"provided as-is"*; the others need self-hosted runners | macOS and Windows in beta, outside the service-level agreement; arm64 needs a self-hosted runner | +| Hosted runners: Linux x86 and arm64, macOS, Windows | the desktop builds: `.deb` on both architectures, `.dmg`, `setup.exe` | Codeberg's Woodpecker is `linux/amd64` only and *"provided as-is"*; the others need self-hosted runners | macOS and Windows in beta, outside the service-level agreement; arm64 hosted on every tier, the small size on the free one | | Releases and `api.github.com` | the installer, the UI's update badge and OTA picker, MoonBase's recovery page and the Dockerfile read releases, the release `name` and the `prerelease` flag | the Gitea-shaped releases API: similar fields, different URLs | the Releases API, with assets as links to files in a package registry | | `raw.githubusercontent.com` | MoonLive script downloads, the Gallery | `/raw/` URLs, CORS to verify | `/-/raw/` URLs, CORS to verify | | Pages, custom domain | moonmodules.org/MoonLight and the installer at `/install/` | Codeberg Pages from a `pages` branch or an Actions build; with self-hosting, any static host | GitLab Pages | @@ -61,6 +61,6 @@ The URLs in the docs, the README, `mkdocs.yml` and the packaging metadata are a ## The deciding costs Any move pays the same three costs: -- **Runners:** macOS, Windows and arm64 for the desktop builds, hosted on GitHub alone. +- **Runners:** macOS and Windows for the desktop builds, hosted outside a beta on GitHub alone. - **The release API:** read by the firmware, MoonBase and the installer, with its shape followed exactly on GitHub alone. - **Review:** CodeRabbit, which reaches GitHub and GitLab. diff --git a/docs/work/future/effects-power-function-inventory.md b/docs/design/effects-power-functions-inventory.md similarity index 76% rename from docs/work/future/effects-power-function-inventory.md rename to docs/design/effects-power-functions-inventory.md index dc47efdd2..fbd4c602d 100644 --- a/docs/work/future/effects-power-function-inventory.md +++ b/docs/design/effects-power-functions-inventory.md @@ -1,12 +1,12 @@ -# Effects × power functions — inventory +# Effects on the power functions, an inventory -> **Forward-looking backlog document — exception to CLAUDE.md present-tense rule.** One row per effect in the tree on 2026-09-03 (58), recording what each uses today and which power functions it could run on: the ones that exist ([power-functions.md](../../moonmodules/light/power-functions.md)) and the ones the [generative-fields top-down](generative-fields-analysis-top-down.md) builds (gradient noise, `PolarLut`, `Oscillators`, `advect` and the velocity rules, `decay`, `lineAA`/`disc`; the 16-bit Layer was proposed and then reversed, top-down § 11). It is the input for the catalog sweep that top-down names as a follow-on plan, under the product owner's rule: **every effect runs on the power functions for whatever they cover; an effect that does not is rewritten on them; algorithmic effects keep their logic; a rewrite lands only if at least as beautiful as the effect ran before.** Rows shrink as effects are rewritten; a rewritten effect's row is deleted, not ticked. +One row per effect in the tree on 2026-09-03 (58), recording what each uses and which power functions it could run on, all of them in [power-functions.md](../moonmodules/light/power-functions.md): gradient noise, `PolarLut`, `Oscillators`, `advect` and the velocity rules, `decay`, `lineAA` and `disc` among them. It is the input for [the catalog sweep](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081620), under [the migration rule](../moonmodules/light/power-functions.md#migrating-an-effect). A rewritten effect's row is deleted, not ticked. **Kind.** N = natural motion (physics, fields, noise, trails, rotation, oscillation): rewritten on the kernels. A = algorithmic (a rule set or a game is the effect): keeps its logic, draws through the library. M = audio meter (bars, levels, spectra): the geometry and ballistics rows apply. U = utility or test: no rewrite owed. Anything in doubt is N. -**Uses today** is read from the source: `noise` (`inoise*`/`fbm`/`warp`), `polar` (`atan16`/`dist16`/`kaleido`), `beat` (`BeatPhase`/`beatsin`), `sin` (`sin16`/`sin8`), `fade` (`fadeToBlackBy`), `particles`, `splat`, `blur`, `geom` (`draw::line`/`rect`/`bar`), `shader`, `scratch` (`ScratchBuffer`), `rnd`, `pal` (`colorFromPalette`), `audio`, and **float** where the effect still computes per light in float. +**Uses** is read from the source: `noise` (`inoise*`/`fbm`/`warp`), `polar` (`atan16`/`dist16`/`kaleido`), `beat` (`BeatPhase`/`beatsin`), `sin` (`sin16`/`sin8`), `fade` (`fadeToBlackBy`), `particles`, `splat`, `blur`, `geom` (`draw::line`/`rect`/`bar`), `shader`, `scratch` (`ScratchBuffer`), `rnd`, `pal` (`colorFromPalette`), `audio`, and **float** where the effect still computes per light in float. -| Effect | Kind | Uses today | Could use, existing | Could use, to be built | Note | +| Effect | Kind | Uses | Could use, existing | Could use, to be built | Note | |---|---|---|---|---|---| | AudioSpectrum | M | geom pal audio | `bar`, `smoothFollow`, `peakHold` | 16-bit bars (smooth tops) | | | AudioVolume | M | pal audio | `smoothFollow` | `decay`, `Oscillators` for the idle breathe | writes raw bytes: migrates to `draw::` in the 16-bit phase | @@ -63,6 +63,6 @@ | WaterRipple | N | scratch rnd pal | already a wide-state kernel | renders into the 16-bit Layer without narrowing; `disc` for drops; `decay` | | | Wave | N | noise beat sin scratch pal | `sin16` | gradient noise, `Oscillators` | | -**Counts.** 54 rows remain of the 58 the table opened with, the difference being effects since rewritten on the kernels and deleted per the rule above. 11 still compute per light in float outside the FPU-gated raymarch (BouncingBalls, Blurz, DistortionWaves, PaintBrush, Ripples, RubiksCube, Sine, SphereMove, StarField, Tetrix, Raymarch by design). 17 effect headers write `buffer()` bytes directly rather than going through `draw::`; that is a measured OPTIMIZATION on the hot path rather than debt (a row pointer costs 0.26 ns/light where `draw::pixel` with known coordinates costs +170%), so it is not a migration target. The 16-bit phase that was to migrate them was reversed (top-down § 11). +**Counts.** 54 rows remain of the 58 the table opened with, the difference being effects since rewritten on the kernels and deleted per the rule above. 11 still compute per light in float: Raymarch by design, on the FPU-gated `raymarch.h`, and the 10 migration candidates BouncingBalls, Blurz, DistortionWaves, PaintBrush, Ripples, RubiksCube, Sine, SphereMove, StarField and Tetrix. 17 effect headers write `buffer()` bytes directly rather than going through `draw::`; that is a measured OPTIMIZATION on the hot path rather than debt (a row pointer costs 0.26 ns/light where `draw::pixel` with known coordinates costs +170%), so it is not a migration target. The 16-bit phase that was to migrate them was reversed (top-down § 11). **What the sweep would look like when it becomes a plan.** Batches by kernel, as each lands: the noise effects with phase 0; the polar and oscillation effects with phase 1; Echo, Lissajous, PaintBrush and the trail effects with `advect`/`decay`/`lineAA`; BouncingBalls onto `particles` on its own, since it is a rework with a bench judgment. Each batch under ~100 files, each effect compared against how it ran, the golden re-baselined with the reason. Effects a showcase supersedes (Lissajous by Trails, possibly Echo) are deleted rather than kept beside it. diff --git a/docs/design/index.md b/docs/design/index.md new file mode 100644 index 000000000..416fff30d --- /dev/null +++ b/docs/design/index.md @@ -0,0 +1,3 @@ +# Design studies + +A design study surveys a problem before it is planned: the field's prior art, the options, and the one this project would take and why. Each is cited by the backlog card or plan it informs, and when its topic ships, its reasoning moves into the explanation, the code or the lessons, and the study goes. diff --git a/docs/design/input-mapping.md b/docs/design/input-mapping.md new file mode 100644 index 000000000..02e52ebd0 --- /dev/null +++ b/docs/design/input-mapping.md @@ -0,0 +1,138 @@ +# Input mapping: how a physical control reaches a module control + +Analysis, not a decision. Written 2026-09-01 after `ButtonService` shipped with a single button and a single target, which does not scale to a board with four buttons or a remote with twelve keys. + +## The two-step interface + +The product owner's framing: **everything goes through ControlModule**. An input service learns to map its physical events onto the surface (switches, encoders, faders, pads); the surface maps onto whatever those drive. Two steps, each simple: + +```text +physical event step 1: input service step 2: ControlModule + button on GPIO 0 -> switch1 -> Drivers.on + IR code 0x40BF -> switch1 -> Drivers.on + encoder A turned -> encoder3 -> (whatever encoder3 targets) + OSC /mm/pad/3 -> pad3 -> preset 3 +``` + +**Why two steps rather than the direct `Module.control` that shipped.** A single step is shorter for +one button and wrong for everything else: + +- The surface is the **one place** to see what the device's controls do. With direct targets that + knowledge is scattered across every input service, and two inputs driving the same thing look unrelated. +- The surface already exists, is already persisted, and is already what OSC drives. A second wiring + model beside it is the split brain [the OSC plan](https://github.com/MoonModules/MoonLight/issues/183) forbids. +- Feedback needs it. A motorised fader or an LED-ringed encoder has to be *told* the current value; + that lives on the surface, and an input mapped straight to a module control has nowhere to read it back from. +- It makes inputs interchangeable: a rehearsal driven by IR and a show driven by a pedalboard hit + the same switches, so the mapping behind them is unchanged. + +The cost is one indirection for the simple case, and the direct form should stay available: +`target = "Drivers.on"` and `target = "Control.switch1"` are both strings, so nothing needs a special case. The surface is the recommended path, not an enforced one. + +## What the mapping table looks like + +**The trigger vocabulary is worth borrowing.** ESPHome models each input as its own entry (not a +central table, which matches the per-service list here) and its useful contribution is the *event* set: `on_press`, `on_release`, `on_click` with a length window, `on_double_click`, `on_multi_click`. +A long-press and a double-press are what let three physical buttons drive nine actions, which is the difference between a usable installation panel and a wall of switches. Start with press / release / long-press; double and multi-click are the same machinery with more timers. + +## Foot pedals: contact closures and MIDI + +Worth correcting an assumption recorded earlier in the backlog. Stage pedals are **1/4" jacks**, not USB, and they come in two kinds that need different handling: + +- A **footswitch** is a switch on a TS jack: electrically identical to a wall button, so + `ButtonService` covers it with no new work. Momentary mode is hold-to-activate; latching is press-to-toggle. (Some are latching in hardware, which is why the mode has to be configurable rather than assumed.) +- An **expression pedal** is a **potentiometer** on a TRS jack, giving a continuous 0..100%. That is + an **ADC read**, not a GPIO read, and it maps onto a `faderN`, not a switch. New seam (`adcRead`), new service, genuinely different from a button. + +So "foot pedal support" is two features: the footswitch is done, and the expression pedal is an analog-input service that does not exist yet. USB pedals do exist but are a niche (they enumerate as HID keyboards), and they are the gamepad problem below, not the pedal problem. + +## Two kinds of input, and only one of them is a mapping + +The two-step model above is right for **discrete** inputs and wrong for **continuous** ones, and conflating them is the mistake to avoid. + +**A discrete input is an event.** A button press, a remote code, a pedal stomp: it happens, it +drives a control, and nothing reads it again until the next one. Its natural rate is human (a few per second at most), a control write per event costs nothing, and the surface is the right destination because a person needs to see what it does. This is what the mapping table is for. + +**A continuous input is a stream.** A microphone at 22 kHz, an IMU at 50 Hz, a distance sensor an +effect samples every frame: an effect wants *the current value on the hot path*, not a notification. +Routing that through `setControl` would be wrong twice over. It would put a control write, a name lookup and a persistence dirty-flag on the render path at frame rate, which the [hot-path rules](../explanation/architecture/moonmodule.md#hot-path-discipline) forbid. And it would be lossy: a control is a setting a person edits, while a stream is data an effect reads. + +**The pattern for a stream already exists and is already domain-neutral.** `AudioService` publishes +an `AudioFrame` and effects reach it through the static `AudioService::latestFrame()`; `AudioSpectrumEffect`, `GEQEffect`, `SpectrumEffect` and `NoiseMeterEffect` all consume it that way. +[MoonModule, data exchange](../explanation/architecture/moonmodule.md#data-exchange-between-modules) states it as the shared-struct pull: a POD struct the producer overwrites in place each tick, a plain-data header both sides include, a const getter, no allocation and no subscription. It even names this case: +lock-free "is visually harmless for the gyro/sensor data this carries". + +So the rule is: + +| input | rate | how it reaches the light domain | +|---|---|---| +| button, remote code, footswitch, PIR | an event, human rate | mapping table -> `setControl` -> the surface | +| encoder detent | an event, human rate | mapping table -> `setControl` (a relative nudge) | +| microphone | continuous, per tick | `AudioService::latestFrame()`, shipped | +| IMU, distance, light level | continuous, per tick or per 20 ms | a published frame, the same pull | +| expression pedal | either | a fader (a setting) or a frame (a stream), by intent | + +**The expression pedal is the interesting boundary**, and it shows the split is about *use*, not +about the sensor: swelling brightness is a setting a person is adjusting, so a fader; driving an effect's parameter per frame is a stream. A pedal could reasonably do both, which argues the two paths should be selectable per input rather than fixed by the device type. + +**What this means for the modules.** A sensor service does both jobs, and they are cheap to combine: +it publishes its frame for effects to pull, *and* it may carry mapping rows for thresholds a person cares about ("closer than 50 cm" drives a switch). One module, two outputs, matching what the sensor is: a stream, with events derivable from it. + +### A third shape: a sensor that produces an image + +The VL53L8CX (and its VL53L5CX predecessor) is not a distance sensor with extra decimals. It reports an **8x8 grid of zones at up to 60 Hz**, over I2C or SPI at 3 MHz: a low-resolution depth image at render rate. That is a third shape beside event and stream; what it changes is step 4b of [the plan](../work/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(7%20of%2012).md). + +**It carries an 84 KB firmware blob**, measured from ST's own driver +([stm32duino/VL53L8CX](https://github.com/stm32duino/VL53L8CX), `vl53l8cx_buffers.h`): + +| | | +|---|---| +| firmware uploaded to the sensor at every boot | **86,017 bytes** (plus 972 B config, 776 B crosstalk) | +| upload time | 1.94 s at I2C 400 kHz, 0.77 s at 1 MHz, **0.23 s at SPI 3 MHz** | +| driver RAM | ~2.3 KB (`VL53L8CX_Configuration`) | +| result struct | ~1 KB per frame | + +**No prior art to lean on, checked rather than assumed.** `troyhacks/WLED` (the tracked friend repo) +carries `usermods/VL53L0X_gestures` on all three of its P4 branches, but that is upstream WLED's usermod for the *simple* VL53L0X, present identically in `wled/WLED`, and a code search for `VL53L8` across both that fork and the MoonModules org returns nothing. So the blob upload, the SPI path and the zone-frame publishing are ours to work out, with ST's ULD driver as the only reference. + +**Where it lands in the plan:** the only ToF on the bench, so it is both the second I2C device and +the hard case. See step 4b. + +## Bus sensors: I2C and I2S + +A GPIO carries one bit. The sensors an installation wants mostly do not: a motion sensor worth having reports distance or an occupancy zone, not a level, and that arrives over a bus. + +**I2C is the common case, and its seam is proposed rather than shipped**: the unmerged commit `11f8eb76` adds `i2cInit`, `i2cWriteReg` and `i2cReadRegs`, and step 4 of [the plan](../work/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(7%20of%2012).md) merges it. + +What it unlocks, in the order an installation asks for it: a **ToF distance sensor** (VL53L0X: a hand's distance drives a parameter, the well-behaved version of "motion"), a **light-level sensor** (BH1750: the piece dims itself to the room), an **IMU** (the MPU6050 that commit would add), and a **gesture/proximity sensor** (APDS-9960). All four are the same shape: init the bus, poll a register set on `tick20ms` or `tick1s`, publish a value. + +**Motion specifically is three different sensors**, and calling them all "motion" is what makes the +category confusing: + +- **PIR** is a digital pin. Not a bus device at all, so it needs nothing beyond the shipped GPIO + seam: presence as a level, which is how MoonLight models it (`pin_PIR`, "HIGH = lights on"). +- **Ultrasonic (HC-SR04)** is a trigger pulse plus an echo whose *width* is the distance. Neither + I2C nor a plain read: it needs a **pulse-timing seam** that does not exist. The textbook answer on ESP32 is the same PCNT/RMT machinery an encoder wants, so the two features share a dependency. +- **ToF** is I2C, so it rides the layer above. A **mmWave radar** such as the HLK-LD2450 is UART, so it needs the UART seam the [audio-reactive plan](../work/Plan-20261013%20-%20Audio%20reactive%20live%20performance%20(0%20of%207).md) names. + +## MoonLive: scripted sensors and mappings + +The direction is that MoonLive gains driver scripts whose hello-world is "read from GPIO, write to GPIO", with a precompiled sibling always available. Input mapping is where that pays off twice, because the two halves want different things from a script. + +**Half one: a script AS a sensor.** A sensor nobody has written a module for is exactly what a +script is good at, since the module is small and the value is in the register knowledge, which is data rather than architecture. A VL53L0X script is then a `defineControls` for the address and pins, a `setup` that writes the init registers, and a `tick20ms` that reads two bytes. That is a page of code, and a user with a datasheet can write it without a toolchain. **This is the strongest argument for scripted services**: +the long tail of sensors is unbounded, and no firmware can carry all of them, but a script per sensor costs nothing to ship and nothing to maintain. + +**Half two: a script AS the mapping.** Once a script can call `setControl`, the mapping table itself +becomes optional for anything unusual: "if distance < 50 cm then switch1 on, else off" is three lines, and expresses a rule no fixed table column could. The table stays the answer for the common case (a button drives a switch), because a table is inspectable and a script is not. + +## What the rules say + +- **[CLAUDE.md, industry standards](../CLAUDE.md)**: take the textbook construct. Quadrature via + PCNT rather than a hand-rolled edge decoder; a HID descriptor parser rather than a per-model byte table. +- **[CLAUDE.md, minimalism](../CLAUDE.md)**: every fact has one home. The surface is that home + for "what does this control do", which is the argument for the two-step model over direct targets. +- **[MoonModule, live reconfiguration](../explanation/architecture/moonmodule.md#live-reconfiguration-every-change-applies-on-the-next-frame)**: + a mapping must be editable on a running device. A compiled-in action table fails this, which is the concrete defect in `IrService`. +- **[MoonCore, services](../explanation/architecture/index.md)**: "Direction is per-module, not a role: a + service may read (gyro), write (relay), or both." Input services are already the sanctioned shape. diff --git a/docs/design/leddriver.md b/docs/design/leddriver.md new file mode 100644 index 000000000..b52a7f8dc --- /dev/null +++ b/docs/design/leddriver.md @@ -0,0 +1,164 @@ +# LED drivers: the designs ahead + +What shipped and where it lives: [Choosing and wiring an LED driver](../reference/hardware/led-drivers.md) for the drivers and their limits, [MoonLight, drivers](../explanation/architecture/moonlight.md#drivers) for the pipeline, [What bounds a parallel driver](../explanation/architecture/moonlight.md#what-bounds-a-parallel-driver) and [performance.md](../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid) for the measurements, and the headers under `src/light/drivers/` and `src/platform/esp32/` for each peripheral's design. This file holds the research behind four things that are not built, each with its backlog item. + +## LED output from a Raspberry Pi's own pins + +Backlog: [LED output from a Raspberry Pi's own pins](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086230). The prerequisite is a `src/platform/rpi/` backend; a Pi runs the desktop build and reaches LEDs through the network drivers. + +**Pi 4 and earlier (BCM2711 and before).** rpi_ws281x (BSD-2, ) drives the header by DMA through PWM (2 channels), PCM (1) or SPI (1), up to about 2,700 lights a string on PWM and 5,400 on PCM. It is the reference for that route; its last release is v1.0.0 of March 2023. + +**Pi 5 (BCM2712 with the RP1 southbridge).** GPIO, PWM, DMA, SPI and I2C sit behind RP1, so every driver that mapped BCM283x registers from userspace stops working (rpi_ws281x issues #528, #554, #555, #564). Three routes exist: + +- **SPI MOSI**, one lane per bus, each WS2812 bit encoded in SPI bits at 2.4 MHz: the rpi5-ws2812 package (). The one path every Pi shares, and the one to start with. +- **PWM through a kernel module**, rpi_ws281x's Pi 5 branch (): `rp1_ws281x_pwm.ko` plus a device-tree overlay, one channel working, concurrent channels not. +- **RP1's PIO**, the forward path. RP1 carries one PIO block of the RP2040's design, 4 state machines and 32 instruction slots. Its registers are not reachable over the PCIe link, only the state machines' FIFOs through DMA, so userspace goes through the kernel's `rp1-pio` driver, which piolib wraps (, BSD-3). WS2812 is piolib's own example (), one lane. Parallel lanes would feed pre-encoded slots through a FIFO by DMA, the shape `GpioDmaPeripheral` uses on the Teensy, and a '595 expander would hang off the same slots; nobody has shipped either on RP1. + +**HUB75 on a Pi.** On a Pi 1 to 4, hzeller's rpi-rgb-led-matrix () writes the GPIO registers directly, and its options catalog the panel variety a backend has to cover: multiplexing schemes, row-address types, driver-chip init sequences such as FM6126A, chained and parallel panels, PWM depth, a pixel mapper. On a Pi 5, Adafruit's PioMatter () drives up to 3 panels through the PIO, alpha. Both are study material: MoonLight writes its own from the chip datasheets, with credit. + +**Not a route.** WiringPi (a community fork since its author stopped) and libgpiod make a call per pin change, far too slow and jittery for WS2812's 150 ns edges or a HUB75 refresh; they serve buttons and relays. + +## The classic ESP32 past 2,048 lights + +Backlog: [Classic-ESP32 shift-register ring on raw I2S](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080365). + +### Verdict + +**Yes: PSRAM can lift the classic-ESP32 ceiling, and the mechanism is a memory-model change, not a shift-register feature.** Our 2048-light wall is a direct consequence of the *whole-frame* DMA model: `esp_lcd`'s i80 backend materializes every light's WS2812 waveform in one contiguous DMA-capable block before the transfer starts, and on the classic ESP32 that block **cannot be PSRAM**: the IDF source rejects it outright (`esp_lcd_panel_io_i2s.c`: `ESP_RETURN_ON_FALSE((caps & MALLOC_CAP_SPIRAM) == 0, NULL, TAG, "external memory is not supported")`), because the classic chip's I2S DMA physically cannot address the external-RAM window. So the DMA buffer scales linearly with the light count against a ~76 KB internal wall. + +The ring shipped as the `MoonI80` peripheral beside `i80`: [Choosing and wiring an LED driver](../reference/hardware/led-drivers.md#which-one). + +**A scoping fact that shapes the work: the ring is not available off the shelf.** This is not a matter of adopting a library. The mainstream ESP32 LED libraries use the same **whole-frame DMA** we do and therefore share our internal-RAM ceiling; the refill-ring model is effectively unique to hpwit's drivers, which is why § 2 studies **his source** rather than surveying alternatives. Two consequences: (1) building it is genuinely new capability for a classic-ESP32 LED controller, not catching up; (2) per [*Industry standards, our own code*](../CLAUDE.md#principles), we study his implementation hard and then **write our own against our architecture**: carry the idea forward, not the code. + +### The PSRAM question + +#### Two memory models + +**Whole-frame (MoonLight, via `esp_lcd` i80).** The DMA buffer *is* the frame: every light's expanded WS2812 waveform, 3 slots per bit, one byte (8-lane) or two (16-lane) per slot, materialized in one contiguous DMA-capable block, handed to the peripheral in a single chained transfer. The CPU is out of the loop for the whole frame, which is why this model is **underrun-immune by construction**: nothing remains to refill, so no ISR storm can starve it. The price is linear scaling: + +```text +internalBytes(whole-frame) = maxLaneLights × channels × 3 slots × slotBytes +``` + +**Refill ring (hpwit, under the raw I2S registers).** The DMA buffer holds only a small rolling window: a ring of `nbDmaBuffer` buffers, each **one pixel-row of slots** wide. On each buffer-EOF the I2S ISR calls `loadAndTranspose()`, which reads the next pixel row from the source array and SWAR-transposes it into the buffer the DMA is about to reach. Internal RAM: + +```text +internalBytes(ring) = (nbDmaBuffer + 2) × (nbComponents × 8 bits × 3 slots × 2 bytes/word) + = (nbDmaBuffer + 2) × 144 B [RGB] +``` + +**The formula has no LED-count term.** That is the decoupling, and it is the entire point. + +**Crucially, "ring buffer" names two different things and only one solves the ceiling.** The distinction is the whole decision, and it is easy to lose: + +| | **(A) Refill ring** | **(B) Ping-pong / double buffer** | +|---|---|---| +| What's in a buffer | one pixel row of slots | a **whole frame** | +| Who fills it, when | the **DMA-completion ISR**, mid-transfer | the CPU, **before** the transfer | +| Source array touched by | **CPU only** → **PSRAM-eligible** | DMA → must be internal | +| Internal RAM vs LED count | **constant** | **scales linearly** | +| Buys you | **lifts the memory ceiling** | hides wire time behind DMA | +| In our tree | *(this proposal)* | **`asyncTransmit`** (already shipped) | + +We already have (B). It is genuinely valuable, it is what took the S3 from ~13 to ~34 fps at 16 K, but **it does not and cannot lift the memory ceiling**, which is exactly why halving the DMA buffer by turning `asyncTransmit` off didn't help the WROVER. + +#### What hpwit's source says + +Read directly from [`src/I2SClocklessLedDriver.h`](https://github.com/hpwit/I2SClocklessLedDriver/blob/main/src/I2SClocklessLedDriver.h) / `.cpp` (branch `main`): + +- **A ring of linked `lldesc_t` descriptors.** `struct I2SClocklessLedDriverDMABuffer { lldesc_t descriptor; uint8_t* buffer; };`. `initDMABuffers()` allocates `nbDmaBuffer + 2` buffers of `nbComponents * 8 * 2 * 3` bytes each (**144 B RGB / 192 B RGBW**) plus one 4×-size terminator. Chained `descriptor.qe.stqe_next = &next->descriptor`, last wrapping to `[0]`: a genuine ring. +- **`uint8_t nbDmaBuffer = 6;`**: default 6, and **runtime-tunable** via `updateDriver(..., uint8_t dmaBuffer, ...)` → `nbDmaBuffer = dmaBuffer;`. (This is why the product owner's StarLight-era setting of **75** was reachable: it's a plain count, not a compile-time constant.) +- **The ISR refills and transposes.** `static void IRAM_ATTR interruptHandler(void*)`: on `I2S_OUT_EOF_INT` it increments `ledToDisplay` and calls `loadAndTranspose()`, which reads `driver->leds + ledToDisplay * nbComponents` per strip, applies brightness/gamma maps, and SWAR-transposes (`transpose16x1Noinline2`) into `dmaBuffersTampon[dmaBufferActive]->buffer`. **One buffer = one LED slot, so the ISR fires per LED (~30 µs at 800 kHz).** +- **Ring buffers are internal; the framebuffer is the caller's.** Ring: `heap_caps_malloc(bytes, MALLOC_CAP_DMA)`. Framebuffer: `initled(uint8_t* leds, ...)` takes a raw pointer the driver only ever reads **with the CPU**. Nothing DMA-touches it → **a PSRAM pointer works by construction.** +- **The ISR is deliberately arranged to be allowed to read PSRAM.** `esp_intr_alloc(interruptSource, ESP_INTR_FLAG_INTRDISABLED | ESP_INTR_FLAG_LEVEL3, ...)` with the in-code comment that `ESP_INTR_FLAG_IRAM` was *"removed to avoid Cache Disabled but Cached Memory Region Accessed"*, i.e. the refill ISR is explicitly permitted to touch cache-mapped (PSRAM) memory. That is the design decision that makes the PSRAM framebuffer legal, stated in hpwit's own source. +- **The whole-frame mode exists too, and is the one that scales.** `FULL_DMA_BUFFER` allocates `numLedPerStrip + 2` buffers, and is precisely the mode behind [issue #11, DMA mode taking 60 KB of RAM for a single 300-LED strip](https://github.com/hpwit/I2SClocklessLedDriver/issues/11). **hpwit's library contains both models, and the scaling complaint lands on the whole-frame one, the same wall we hit.** + +**Honest caveat:** hpwit never writes "put `leds[]` in PSRAM" in so many words *for the classic chip* (the explicit PSRAM-framebuffer statements are in the S3 variants). The classic conclusion is drawn from construction, CPU-only reads from a caller-supplied pointer, in a non-IRAM Level-3 ISR, which is sound but is **inference from source, not a vendor promise.** The bench spike in § 5 is what turns it into a measured fact. + +#### The memory math, concretely (8 lanes, RGB) + +Whole-frame, `slotBytes = 1` (8-lane bus), 3 channels, 3 slots/bit, our i80 model: + +| Lights/lane | Total (8 lanes) | Whole-frame DMA buffer | Fits ~76 KB internal? | +|---|---:|---:|---| +| 256 | 2,048 | ~18 KB | ✅ **measured working** | +| 512 | 4,096 | ~37 KB | ❌ **measured fail** (`i80 bus init failed`): the real wall sits below the arithmetic: ~76 KB is the *largest free block*, and `asyncTransmit` wants two, plus IDF descriptor overhead | +| 1024 | 8,192 | ~74 KB | ❌ | +| 2048 | 16,384 | ~147 KB | ❌ | + +Ring, same 8 lanes, RGB (hpwit's own sizing, 144 B/buffer): + +| `nbDmaBuffer` | Ring internal RAM | Lights its memory allows | +|---|---:|---| +| 6 (default) | (6+2) × 144 = **1,152 B** | **any**: 2 K, 16 K, 130 K; the count does not appear in the formula, so wire time and ISR headroom bind first | +| 32 | (32+2) × 144 ≈ **4.9 KB** | **any** | +| 75 (StarLight-era) | (75+2) × 144 ≈ **11 KB** | **any** | + +**Read those against each other.** The whole-frame model needs 147 KB of a ~76 KB budget to reach 16 K lights, so it cannot. The ring reaches 16 K, or 130 K, on **1.6–11 KB**, and even a 75-buffer cushion costs ~11 KB. The frame itself moves to PSRAM, where 16 K lights × 3 B = 48 KB is a rounding error against 4 MB. + +#### What the ring gives the WROVER, and what binds next + +With the internal-RAM wall gone, the binding constraints become, in order: + +1. **Wire time, the hard physical floor.** WS2812 is ~30 µs per light per lane (24 bits × 1.25 µs). At 8 lanes × 2048/lane = 16,384 lights: 2048 × 30 µs = **~61 ms → ~16 fps**, before any CPU cost. A protocol constant no driver escapes; it is why "more LEDs" past a point is a *distribution* problem. +2. **Encode CPU in interrupt context, the ring's true price.** Our whole-frame model transposes once with a SWAR kernel on the main path out of internal RAM (measured **~7.6 µs/light** on classic). The ring does comparable work **inside an ISR, per LED, reading cache-mediated PSRAM**. hpwit fires that ISR once per LED (~30 µs apart): so the refill must reliably complete within the wire time of one pixel. This is the number to measure first. +3. **PSRAM bandwidth / cache stalls**, the same story from the other side: an ISR that stalls on a cache miss is an ISR that is late, which is item 4. +4. **The underrun cushion** (§ 2.5). + +**Honest bottom line for a 4 MB-PSRAM WROVER with a ring driver:** the **memory ceiling disappears** (the frame is kilobytes against megabytes) and the practical ceiling becomes **wire time and ISR headroom**: comfortably into the **8–16 K** range at single-digit-to-low-teens fps. That is a **4–8× lift over our measured 2048**, and the right framing is *"the ceiling stops being about RAM."* **This is not theory:** a prior firmware built on hpwit's ring (the product owner's own MoonLight / WLED-MM lineage) raises its light boundary to **130 K on PSRAM against 4096 without it**: a ~32× gap between the PSRAM and non-PSRAM configuration of the *same* firmware, which is the empirical shape of ring-vs-whole-frame. + +#### The cost: underrun, and why `nbDmaBuffer` is tunable + +The ring's weakness is the exact mirror of whole-frame's strength. Every buffer completion needs the ISR to refill *before* the DMA consumes the next one. If a higher-priority ISR (WiFi), or a flash-cache-off window, or a PSRAM stall delays it, the DMA runs off the end of valid data and **the strip glitches**: WS2812 latches on a gap of only ~150 µs. + +`nbDmaBuffer` **is** the cushion: it buys `nbDmaBuffer × (one pixel's wire time)` of slack. hpwit says so directly in the shift-register driver's README, *"Sometimes interrupts can disturb the pixel buffer calculations hence making some artifacts. A solution … is to calculate several buffers in advance. By default we have 2 dma buffers. this can be increased"*, and notes the ceiling case: pushing it to `NUM_LEDS_PER_STRIP` is the full frame, "but it requires too much memory" (i.e. it degenerates back into our model). Mitigations visible in his source: a deeper ring, `_DMA_EXTENSTION` idle padding, **core pinning** (`enableShowPixelsOnCore()`), and **Level-3 interrupt priority** so the refill preempts ordinary WiFi ISRs. + +This is not a niche worry: every ESP32 LED library that streams rather than whole-frames fights it, and they all expose the same remedy: a **tunable count of DMA buffers**, raised when interrupt load (typically WiFi) causes flicker. So the cushion depth is a **recognized, standard tuning parameter, not a bespoke hack**: which matters under [*Common patterns first*](../CLAUDE.md#principles): if we build the ring, `nbDmaBuffer` is the knob users already expect. + +### Sources + +**hpwit (primary, source read line-by-line):** +- [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) · [`src/I2SClocklessLedDriver.h`](https://github.com/hpwit/I2SClocklessLedDriver/blob/main/src/I2SClocklessLedDriver.h): `lldesc_t` ring; `nbDmaBuffer = 6` default, runtime-tunable via `updateDriver()`; `initDMABuffers()` → `nbDmaBuffer + 2` buffers of `nbComponents * 8 * 2 * 3` B (144 RGB / 192 RGBW) + 4× terminator; `IRAM_ATTR interruptHandler()` → `loadAndTranspose()` reading `driver->leds`; ring buffers `heap_caps_malloc(..., MALLOC_CAP_DMA)`; `initled(uint8_t* leds, ...)` caller-supplied framebuffer; `esp_intr_alloc(..., ESP_INTR_FLAG_INTRDISABLED | ESP_INTR_FLAG_LEVEL3, ...)` with `ESP_INTR_FLAG_IRAM` "removed to avoid Cache Disabled but Cached Memory Region Accessed"; optional `FULL_DMA_BUFFER` whole-frame mode. +- [issue #11, DMA mode taking 60 KB of RAM for a single 300-LED strip](https://github.com/hpwit/I2SClocklessLedDriver/issues/11): the whole-frame mode's scaling complaint. +- [PR #47, `__NB_DMA_BUFFER` as var & PSRAM-prefer](https://github.com/hpwit/I2SClocklessLedDriver/pull/47). +- [I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2SClocklessVirtualLedDriver): "8 strips out of one single pin … 8x15=120 strips"; 74HC595 per virtual pin + 74HC245 level shifter; `__NB_DMA_BUFFER` default 2, buffers `(NUM_VIRT_PINS+1) * nb_components * 8 * 3 * 2` ≈ 1152 B; "Artifacts due to interrupts" → "calculate several buffers in advance"; `enableShowPixelsOnCore()`; 12,000-LED worked example; 75 → 129 fps second-core figures. +- [I2SClockLessLedDriveresp32s3](https://github.com/hpwit/I2SClockLessLedDriveresp32s3): the S3 lineage (PSRAM buffers; S3 EDMA can DMA from PSRAM). + +- [The predecessor MoonLight](https://github.com/ewowi/MoonLight) / [WLED-MM](https://github.com/MoonModules/WLED-MM): a prior firmware riding hpwit's ring on PSRAM: LEDs pre-allocated in PSRAM, light boundary **130 K** (vs **4096** non-PSRAM). The empirical demonstration that the ring's ceiling lift is real. + +**Espressif (the load-bearing hardware constraint):** +- [`esp_lcd/i80/esp_lcd_panel_io_i2s.c`](https://github.com/espressif/esp-idf/blob/master/components/esp_lcd/i80/esp_lcd_panel_io_i2s.c): `ESP_RETURN_ON_FALSE((caps & MALLOC_CAP_SPIRAM) == 0, NULL, TAG, "external memory is not supported");` +- [Support for External RAM, ESP-IDF (ESP32)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/external-ram.html): external RAM "cannot be used as a place to store DMA transaction descriptors or as a buffer for a DMA transfer to read from or write into"; prescribed workaround = internal DMA-able buffer + copy. **Confirms: no IDF API feeds PSRAM into classic-ESP32 I2S DMA.** +- [Support for External RAM, ESP-IDF (ESP32-S3)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/external-ram.html): the S3 *does* have hardware DMA-to-PSRAM (descriptors still internal): why our S3 i80 reaches 16 K and classic cannot. + +**MoonLight's own measurements:** +- [performance.md § Multi-pin LED driving](../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid): classic i80 2048-light ceiling + `esp_lcd_i80_alloc_draw_buffer` rejecting `MALLOC_CAP_SPIRAM`; S3 16,384 @ ~34 fps; P4 Parlio 4096, 139 fps @ 1024; the `multicore` +44 % table. +- [backlog/drivers.md](https://github.com/users/MoonModules/projects/5?filterQuery=area%3ADrivers): the superseded chunk-streaming-ring decision; the shift-register driver's **48 × 256 = 12,288** acceptance floor. + +## The pin expander on the other peripherals + +The 74HCT595 expander ships on `i80` and `MoonI80`, the LCD_CAM peripheral of the S3, P4 and S31 ([Wiring a 74HCT595 pin expander](../reference/hardware/led-drivers.md#wiring-a-74hct595-pin-expander)). What it needs from a peripheral: a shift clock on a pin, the latch as one bit of every bus word, and eight bus words per slot, so the DMA frame grows eightfold. RMT has no clock line and never qualifies. The other two parallel peripherals could: + +- **Parlio (P4).** `ParlioPeripheral::supportsPinExpander()` is false. The transmit unit generates its pixel clock itself, and `platform_esp32_parlio.cpp` leaves `clk_out_gpio_num` unconnected because WS2812 ignores it; connected, it is the '595's shift clock. Whether Parlio's clock output and bit packing carry the expander's protocol is a bench question, and the eightfold frame meets the single-transaction ceiling the [chunked transfer](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080935) lifts. Backlog: [A '595 expander on Parlio](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079383). +- **GPIO-DMA (Teensy 4.1).** `GpioDmaPeripheral::supportsPinExpander()` is false. The port write carries no clock of its own, so the shift clock has to come from the writes themselves, at a DMA rate still to measure. FlexIO clocks by itself, and [the Teensy plan](../work/Plan-20261010%20-%20Teensy%204.1%20(3%20of%206).md) set it aside because no 8 contiguous FlexIO pins stay free beside the W5500. TriantaduoWS2811 () is the prior art: 32 strands from 3 Teensy pins through FlexIO clocking '595s at zero CPU cost. Backlog: [A '595 expander on the Teensy's GPIO-DMA bus](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078012). +- **RP1 PIO (Pi 5).** Research grade, above. + +The expander multiplies slots, not pins, so each peripheral's frame or ring grows eightfold too: the memory question arrives with the clock question. + +## The sigrok cross-check + +Backlog: [sigrok/fx2lafw cross-check + MoonDeck LED driver test script](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130440). + +The loopback test trusts the chip's own clock, and on an ESP32 it runs on the chip whose WiFi causes the flicker it looks for. An analyzer with its own clock crosses it: where the two disagree there is a real bug. The reference device is a NanoDLA v1.3 (CY7C68013A, fx2lafw firmware, 24 MS/s on 8 channels, 10 to 15 dollars); any board sigrok's `fx2lafw` driver enumerates works the same. 24 MS/s is 41 ns a sample, enough to tell a 350 ns pulse from a 700 ns one and marginal for a timing-margin test (320 against 380 ns). A DSLogic Plus (16 channels at 100 MS/s) is the upgrade when a flicker reproduces that 24 MS/s cannot resolve, and the scripts carry over unchanged. + +Wiring: the analyzer's GND to a board GND over the shortest jumper, and channel D0 (labeled CH0) to the data GPIO, beside the loopback jumper. The analyzer is high impedance and loads nothing; 3.3 V GPIO into a 3.3 V, 5 V tolerant input needs no shifter or resistor. Never power the board from the analyzer's rail, and give a real strand its own 5 V with its ground on the same common ground. + +```bash +sigrok-cli --scan # one fx2lafw entry +sigrok-cli -d fx2lafw -c samplerate=24m --samples 100000 \ + -P ws2812:data=D0 -A ws2812=rgb +``` + +The decoder (libsigrokdecode's `ws2812`) prints one line per pixel in transmit order, to compare against the bytes the firmware sent. At 24 MS/s, 100,000 samples is about 4 ms, a 30-light frame with its latch; a longer strand raises `--samples` or triggers on the first edge. Gotchas in the order they come: no common ground reads as random toggling; the wrong channel; a sample rate left at the driver's lower default, where a 350 ns pulse is three samples and zeros read as ones (always pass `samplerate=24m`); and when no frame decodes, PulseView on the same capture shows the missing idle gap or the stuck bit by eye. + +The real flicker test is a sustained capture of seconds with WiFi associated and a packet flood, every frame decoded for a slipped byte or a short latch gap. diff --git a/docs/design/moonlive.md b/docs/design/moonlive.md new file mode 100644 index 000000000..46eb95fe3 --- /dev/null +++ b/docs/design/moonlive.md @@ -0,0 +1,171 @@ +# MoonLive: the language roadmap + +[MoonLive](../explanation/architecture/moonlive.md) is the engine as built (grammar, IR, codegen), and [power-functions.md](../moonmodules/light/power-functions.md) is the builtin surface it calls into. This analysis is the ordered plan for closing the gap between them: which language features to add next, and why that order. Every limit quoted was measured against the shipped compiler, not read off the source. The steps are one backlog item, [The next language steps, in order](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082542), and the code cap is [its own item](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082596). + +## What this is for + +MoonLive compiles a C-subset to native code on the device. The subset is deliberately small: it started at "fill a buffer with a color" and grew a feature at a time. What follows is the ordered list of what to grow next, and why that order. + +**The bar is a corpus, not one effect.** The predecessor MoonLight publishes a body of live scripts at +[ewowi/MoonLight/livescripts](https://github.com/ewowi/MoonLight/tree/main/livescripts), and the goal is that MoonLive can **run them**, not that we migrate them into this repo. They are written against a language with floats, structs and a wide builtin surface, so they are an honest external measure of how far the subset still has to grow: each one that compiles unchanged is a feature below that landed, and each one that does not names the next gap. + +The measure throughout is **what a real effect needs**. A simulation effect (particles, physics, anything where this frame depends on the last) is the honest test, because it exercises state, arithmetic and control flow together rather than one at a time. `moonlive/effects/particles/balls.mle` is the worked example: bouncing balls, written against the language as it stands, and every compromise it had to make is a line item below. + +## The ceilings + +The limits a script meets are the constants in [MoonLiveBuiltins.h](../../src/core/moonlive/MoonLiveBuiltins.h) and [MoonLiveIr.h](../../src/core/moonlive/MoonLiveIr.h), each with its reason beside it. + +## What a simulation effect gives up + +Each row is a compromise the balls effect makes, and the language feature that would remove it: + +| forced to | because | wants | +|---|---|---| +| 4 objects, not 25 | 64-byte arena | a bigger arena, or a pool handle (shipped for particles) | +| one flat color | no `hsv()` builtin | `hsv()` | +| one array per field | no structs | structs | + +## The library is already built; the engine is what is missing + +The library recorded what it needs from the engine while it was built, deferred so the compiled side could finish first. That list is the real roadmap, and it is more specific than "add a language feature": + +- **5. Two entry shapes:** `frame()` for composing kernels (the scalable path) and `pixel(x,y,z)` + for per-pixel ergonomics (honest ceiling around 32×32). +- **6. Stateful handles** (a `Pool`, a `BeatPhase`): script-declared, arena-allocated at compile + time, passed as an opaque first argument. No script-side memory management. + +Item 5 is worth noting against the arena work below: a particle pool as a HANDLE means the script does not spend its own 64 bytes on particle state at all. That is a better answer than widening the arena, and it is already designed. + +## The type system: the settled design (2026-08-23) + +The types as shipped: [the six types](../moonmodules/light/moonlive.md#the-six-types) and [fractional arithmetic with `fixed`](../moonmodules/light/moonlive.md#fractional-arithmetic-with-fixed); what the design did not anticipate: [the five-types plan](https://github.com/MoonModules/MoonLight/issues/173). + +**The two edges of `fixed`, stated so they are conventions rather than traps:** +- **Time never goes into `fixed` raw**: 32,768 seconds is 9.1 hours. `t` stays `int` milliseconds + and rhythm reaches a script through `beat()`/`beatsin()`, which is already the idiom. +- **Deep fractal zoom bottoms out at ~16 bits of magnification**, where the step becomes a pixel. + The same class of limit Q13 has at 13 bits, and better than float32 past that point (its 23-bit mantissa is shared with the integer part). Extreme zoom is a 64-bit-builtin problem, not a scalar-type problem. + +**`float` stays out, with a clean conscience.** The whole compiled library, the particle kernel +and escape() are integer/fixed already; math16.h supplies sin/atan/dist/sqrt; the hot path makes float promotion a fatal warning. The FPU story splits the boards (LX6/LX7/P4 have one, the RISC-V line does not), so JIT float either runs wildly differently per board or softfloats, against the one-language-four-ISAs-identical rule. Fixed-point is bit-identical everywhere, which is also what makes an effect reproducible. If the predecessor's corpus (written with floats) ever forces the issue, the answer is compile-time float-literal-to-fixed translation, not runtime float. + +**Per-type notes:** +- `bool[]` ships byte-backed first (elements are 0/1 either way), and the 8-per-byte packing lands + later as a pure memory optimization when an automaton effect pays for its read-modify-write codegen. Same rule as every constant here: against a script that needs it, not on speculation. +- Where arrays LIVE: small arrays stay in the arena (internal RAM); grid-sized state goes through + ScratchBuffer handles (item 9b's model), which prefer PSRAM and fall back to internal heap on the classic ESP32, whose fixtures are small by construction. `byte[]` is what keeps a classic heat map at 1x rather than 4x. + +## The order, and why + + +Ordered by **what removing it buys**, not by implementation cost. + +### 3. A bigger arena and more members: *check the handle route first; storage rules now in the type-system design above* + +64 bytes of member data is why an effect holds four objects rather than twenty-five. + +**But check the handle route first.** The power-functions spec's item 5, a particle pool as an +arena-allocated HANDLE, means a simulation effect stops storing its own particle state entirely, which removes the pressure without touching these constants. Widen the arena for the scripts that genuinely hold their own state; do not widen it as a substitute for handles. + +Not purely a constants bump, and the blockers are known: + +- `kCtrlBytes` / `kMaxCtrls` are both `uint8_t`, so the arena caps at 255 bytes before any type + change. 150 bytes of particle state fits under that; much more does not. +- `static_assert(kCtrlBytes <= 64, "seeded_ is a 64-bit mask, one bit per script arena byte")` + (`MoonLive.h:270`) is the real gate. Past 64 bytes the seeded-member mask needs re-indexing, and there is a worked example, because it was widened 16 → 64 once already. The assert exists because the earlier `uint32_t` version silently aliased members mod 32. +- Watch `sizeof(MoonLive)`: why growing it grows every scripted module is on [MoonLiveParticles](../../src/light/moonlive/MoonLiveParticles.h). + +### 4c. Insights from porting a per-pixel effect: *the cost is in the CALLS* + +A second port (`moonlive/effects/waves/octopus.mle`, a polar spiral) measured differently from the first, and the difference is the useful part. + +| effect | shape | S3, 64x64 | +|---|---|---| +| balls | four small discs, ~500 lit pixels | **1278 us** | +| octopus | every pixel, every frame | **21762 us** | + +A third port (`metal.mle`, an SDF shader) put a number on the most expensive builtin, measured on shiffy's 80x48: + +| variant | tick | vs plasma | +|---|---|---| +| plasma (9 calls/px, no sqrt) | **16031 us** | baseline | +| metal, 2 `polarR`, no `uv` | **32843 us** | 2.0x | +| metal, 2 blobs + `uv` | **46221 us** | 2.9x | +| metal, 3 blobs + `uv` | **59600 us** | 3.7x | + +**`polarR` costs ~13400 us per call site per frame at 3840 pixels: about 3.5 us per pixel, one +builtin.** It wraps `dist16`, a real square root, and `draw.h` already measures a sqrt-based SDF at ~108 cycles/px against ~14 for the squared form. A squared-distance builtin (`polarRSq`, or letting a script compare against `r * r` as `ripples.mle` does) is the cheap fix, and it is the same trick the compiled effects already use. + +Also measured and **disproved**: hoisting the four loop-invariant `beatsin` calls out of the inner loop into members moved 59600 to 58459 us. Call overhead per se is NOT the cost here: the square roots are. Worth recording because it contradicts the natural first guess. + +That is ~5.3 us per pixel, and it is not the arithmetic; it is **~32,000 host calls per frame**. +Each `polarA`/`polarR`/`sin`/`scale`/`beat` is a real call through the builtin ABI, and a whole-canvas effect makes eight or so per pixel. + +Two things follow, and they sharpen the priorities above: + +1. **Per-pixel builtins want to be inline ops, not calls.** `setPaletteColor` is a Call and that is + fine at ~500 pixels; at 4096 the call overhead dominates. The `Inline` kind already exists (`setRGB` uses it); the polar pair and the palette write are the candidates. +2. **A `frame()` entry shape would sidestep it entirely.** The power-functions spec's item 4, + compose kernels over a whole frame rather than script per pixel, is exactly the answer for this class of effect, and octopus is the concrete case that argues for it. + +Worth stating plainly: 35 fps for a full-canvas 64x64 effect is usable, and the port needed no new language features beyond two builtins. The ceiling here is call overhead, not expressiveness. + +### 4b. Predefined structs: what remains after `Coord3D` and `RGB` + +**No `HSV` struct.** A hue wheel is how an effect picks color while ignoring the user's palette, so `HSV` stays in `setPalEntryHSV`, which authors a palette rather than bypassing one. + +What remains: `setColorFromPalette(pos, index, brightness)` taking a `Coord3D`, and arrays of structs (§ 10). + +### 6. Function arguments and return values: *moderate, removes a real footgun* + +The forward call is the cheapest to build and the most confusing to meet, since the message is the one an actual typo produces: a second pass over the class body resolves it, which the code comment names as the reason it is refused rather than half-supported. + +### 10. User structs, and arrays of them: *readability, once the arena is bigger* + +`ball[i].x` instead of parallel arrays. Genuinely nicer and closer to how a precompiled effect reads, but parallel arrays work the moment the arena is big enough. Last because #1 removes most of the pain, not because it does not matter. + +Arrays of the five scalar types already ship (`byte heat[16]` in `ember.mle`), so what is missing here is arrays OF STRUCTS, and it splits in two. An array of a PREDEFINED struct (§ 4b) is the smaller half and the one an effect reaches for first: a script holding `Coord3D pos[8]` is exactly the parallel-array flattening this item exists to remove, and the element width the array path already carries (`idxPack`) is the machinery it needs. An array of a USER struct needs the general declaration machinery above it. Worth building in that order if this is picked up. + +### 11. What the StadBeest legs and eyes hit (2026-10-07) + +Two effects written in one session on real hardware: `stadbeest-walk.mle` (a walk cycle on 10 tubes, dancing to music) and `stadbeest-frog-eye.mle` (a frog's eye on a 241-light ring disc). Besides the member and label limits above, each of these cost a workaround: + +| limit | what it cost | wants | +|---|---|---| +| "codegen failed: assembler overflow (branch range, slot, or immediate)" | names neither the limit nor the line; finding it took a desktop bisect with `disasm.py` | the message names the table that ran out and the function it was filling | +| "too many arguments to hold" | fires when the eye's `tick()` held too many live locals, so the wording sends the author to the calls | a message about live variables when that is the cause | +| a failed recompile leaves the effect dark | on a live installation every edit that does not compile switches the light off until a fixed one arrives; it happened three times in the session | keep running the last program that compiled, and show the error beside it | +| `setPaletteColor` clamps the palette index | a palette is circular, but `hue + offset` past 255 sticks at the last color unless the script writes `mod(..., 256)` | wrap the index, as the palette itself does | +| an effect visits every grid pixel | the eye computes 1600 pixels to light 241, and the legs' grid is mostly empty between 10 tubes | a way to iterate only the layout's lights, for sparse layouts | +| a `bool` control takes a min and a max | `addControl("pulse", pulse, 0, 1)` | `addControl(name, flag)` for a bool | + +### 12. A one-to-one port of hpwit's scripts, for a like-for-like comparison (2026-10-19) + +hpwit's compiler (`hpwit/new-parser`, `asmparser2`) runs whole C-like programs. His `multi_effect_controller.sc` (800 lines) and the five `ProceduralEffects` were ported to MoonLive on 2026-10-18 (`moonlive/projects/hpwit/`), but as the same LOOK rather than the same program, so code size and frame time do not compare like for like. A one-to-one port needs the language to accept his programs as written. Each construct was compiled on the desktop on 2026-10-19: + +| his scripts use | MoonLive | what it takes | work | +|---|---|---|---| +| ~10 KB of state: struct arrays of 160 to 220 objects, a 40×80 trail, 12 KB heat and angle tables | 64 bytes of members, 16 members | an arena sized from the declarations, on the heap or PSRAM, past the `uint8_t` and 64-bit seeded-mask limits; item 3 has the blockers | L, 3 to 5 days | +| ~30 functions: struct methods and free functions | 8 per class (`kMaxEntryPoints`) | a function table sized per script; free functions outside the class | M, 1 to 2 days | +| `struct` with fields, methods and a constructor, arrays of them | refused | item 10, user structs, then their methods and arrays | XL, 1 to 2 weeks | +| `float`, `uint8_t`, `uint32_t`, casts like `(int)(x)` | refused | `float` (every target but the RP2040 has an FPU) or float mapped onto `fixed`; the sized integer names as aliases; casts | S for the names and casts, 1 day; M to map `float` onto `fixed`, 2 days; XL for a real `float` on five backends, 1 to 2 weeks | +| `grid[y][x]` (his `[y, x]`) | refused | 2D arrays over the existing element-width path | M, 1 to 2 days | +| `while`, `break`, `continue`, `i++`, `+=`, `?:`, `switch` | all refused | the statements and operators, each a parser and IR addition | S each for `while`, `++` and `+=` (rewritten into what exists), 1 day together; M each for `break`/`continue`, `?:` and `switch`, 3 to 5 days together | +| `#define` constants | refused | a constant declaration (`const int`) rather than a preprocessor | S, half a day | +| `main()` with `while (true)`, `show()`, `delay()` | `tick()` per frame | a shim: the loop body becomes `tick()`, `show()` and `delay()` become no-ops, `millis()` reads `t` | M, 1 to 2 days | +| `hsv()`, `rand(mod)`, `atan2`, `hypot`, `sin` on radians, `printfln`, `pinInterrupt` | palette, `random16`, `polarA`/`polarR`, `sin` on 0..65535, `print`, none | host bindings with his signatures, under his names, in a compatibility table | M, 2 days, once `float` is decided | +| code past 16 KB | `kCodeCap` 16 KB | the cap per platform (§ The code cap, per platform) | S to M, 1 day | + +Work is in days of agent implementation with the product owner's review, for the parser, the IR and all five backends, each with its tests; S is under a day, M one to two, L a week, XL two. In all about five weeks with `float` mapped onto `fixed`, about seven with a real `float`, the structs and the arena being most of it. + +Most rows are general language growth the MoonLive scripts want anyway; the `main()` shim and his host bindings exist only for the comparison, and stay behind a compatibility table rather than entering the standard vocabulary. **Decide first** whether the comparison is worth the compatibility layer, or whether comparing on the ported looks, now in `moonlive/projects/hpwit/`, is enough. + +## How to know a step landed + +Two measures, one local and one external. + +**Local:** re-port a simulation effect after each step and delete the row it removes from the +compromise table above. A port with no rows left (dozens of shaded, colored objects with fractional velocities on a full-size grid, at a frame rate the bench can measure) means the language carries a real simulation. + +**External, and the harder bar:** take the +[predecessor's livescripts corpus](https://github.com/ewowi/MoonLight/tree/main/livescripts) and count how many compile and run unchanged. That number is the honest progress metric, because those scripts were written without regard for our subset. Track it per step; a feature that moves it is worth more than one that does not, whatever the order above guesses. diff --git a/docs/design/small-boards.md b/docs/design/small-boards.md new file mode 100644 index 000000000..782ab7880 --- /dev/null +++ b/docs/design/small-boards.md @@ -0,0 +1,174 @@ +# Small boards: RP2350, RP2040 and Nordic + +The microcontroller boards MoonLight's next ports target, and where to buy them in the EU. **No MoonLight firmware runs on these boards.** They are the targets of the next ports, and a board moves from here to a page of its own when its port ships. The Teensy 4.1 has [its own page](../reference/hardware/teensy-4-1.md). + +Prices are from 2026-10-11 and include Dutch VAT unless marked ex. AliExpress is worth it only for Waveshare, Seeed and WeAct boards through their official stores, checking the seller per listing; Raspberry Pi and Nordic have no official store there. + +## RP2350 + +| | Board (maker) | Why | Where, price | +|--|--|--|--| +| Raspberry Pi Pico 2 W | [Pico 2 W](https://www.antratek.nl/raspberry-pi-pico-2-w) (Raspberry Pi) | The reference board, with WiFi | Antratek €12.04, [Kiwi](https://www.kiwi-electronics.com/en/raspberry-pi-pico-2-w-with-header-20342) €8.77 | +| W5500-EVB-Pico2 | [W5500-EVB-Pico2](https://thepihut.com/products/wiznet-w5500-evb-pico2) (WIZnet) | Wired Ethernet | The Pi Hut £11.20 | +| Pimoroni Pico Plus 2 W | [Pico Plus 2 W](https://shop.pimoroni.com/products/pimoroni-pico-plus-2-w) (Pimoroni) | 8 MB PSRAM, 16 MB flash, WiFi | about €25, pre-order | +| Waveshare RP2350-Zero | [RP2350-Zero](https://www.tinytronics.nl/en/development-boards/microcontroller-boards/others/waveshare-rp2350-zero-mini-development-board) (Waveshare) | The tiny one | Tinytronics €6, [AliExpress](https://www.aliexpress.com/w/wholesale-waveshare-rp2350-zero.html) about €6 | + +## RP2040 + +| | Board (maker) | Why | Where, price | +|--|--|--|--| +| Raspberry Pi Pico W | [Pico W](https://www.antratek.nl/raspberry-pi-pico-w) (Raspberry Pi) | The only RP2040 with WiFi | Antratek €9.08, Kiwi €6.52 | +| Waveshare RP2040-Zero | [RP2040-Zero](https://www.tinytronics.nl/en/development-boards/microcontroller-boards/others/waveshare-rp2040-zero) (Waveshare) | The mini one people own | Tinytronics €5, [AliExpress](https://www.aliexpress.com/w/wholesale-waveshare-rp2040-zero.html) | +| Seeed XIAO RP2040 | [XIAO RP2040](https://www.kiwi-electronics.com/en/seeed-studio-xiao-rp2040-10969) (Seeed) | The XIAO form factor | Kiwi €4.46 | + +## Nordic + +No Nordic development kit is needed: the XIAO and the Debug Probe below cover the nRF52840, and the nRF5340 comes after it, on a module board. + +| | Board (maker) | Why | Where, price | +|--|--|--|--| +| Seeed XIAO nRF52840 Plus | [XIAO nRF52840 Plus](https://www.antratek.nl/seeed-studio-xiao-nrf52840-plus) (Seeed) | Tiny, UF2 bootloader, debugged through the Debug Probe | Antratek €12.09 | +| Raytac MDBT53-DB-40 | [MDBT53-DB-40](https://www.digikey.nl/en/products/detail/raytac/MDBT53-DB-40/16630695) (Raytac) | The nRF5340, once the nRF52840 port runs. Zephyr supports it. | DigiKey, about €15 ex. Raytac ships it in about 14 days, with its own shipping fee. | + +## Nordic and Zephyr as a target + +### Verdict + +**As a big-LED controller: poor fit. As a portable-core + BLE-UI showcase: a genuinely interesting spike, gated on one load-bearing question (BLE-UI feasibility), which the research below finds to be *clean, not hacky*.** Strongest yes: Zephyr is a first-class C++20 + BSD-sockets + LittleFS RTOS whose primitives map onto almost every function in `platform.h` cleanly *and* MoonLight's core is deliberately domain-neutral, so "MoonLight's runtime runs on a tiny BLE chip" is a real, achievable, marketable claim, the port is mechanical, not architectural. Strongest no: the chips have no WiFi, no PSRAM, and no wide parallel LED DMA peripheral, so nRF loses badly at the large-installation job MoonLight's light domain is tuned for. + +The reframe from the product owner (see [§ Reframing](#reframing-core-as-library-and-the-ble-ui-question) below) changes the weighing: the question is not "can nRF drive 16 K LEDs" (it can't) but "is MoonLight's **core** a compelling showcase on a small BLE device, and can its **UI** reach that device over BLE with no WiFi/HTTP?" On the first, the core is ~90% portable as-is. On the second, the crux, a BLE GATT channel comfortably carries our JSON control/state model (BLE 5 sustains ~100–700 kbps in practice, orders of magnitude above what the UI needs), and config-over-Web-Bluetooth is well-trodden prior art. The one honest caveat is client reach: **Web Bluetooth is Chrome/Edge/Android/desktop only, never iOS Safari**, so a no-install browser UI works everywhere except iPhone, where a companion app or a third-party BLE browser is required. That caveat shapes the spike; it does not sink it. Recommendation moves from "defer" to **"a scoped showcase spike is justified regardless of the LED limits, if the iOS-client gap is acceptable for a demo."** + +### The nRF hardware + +| Chip | Core(s) | Clock | RAM | Flash | Radio | Notes | +|------|---------|-------|-----|-------|-------|-------| +| nRF52832 | Cortex-M4F | 64 MHz | 64 KB | 512 KB | BLE 5 | Too small; excluded below | +| nRF52840 | Cortex-M4F | 64 MHz | 256 KB | 1 MB | BLE 5 + 802.15.4 | USB device; QSPI (flash XIP, not a RAM heap) | +| nRF5340 | 2× Cortex-M33 | app 128 MHz + net 64 MHz | 512 KB (app) | 1 MB | BLE 5.4 + 802.15.4 | The second core is the **network core**, reserved for the radio stack, not a general app core | +| nRF54L15 | Cortex-M33 + RISC-V VPR | 128 MHz | 256 KB | 1.5 MB RRAM | BLE 6 + 802.15.4 | The VPR is a peripheral coprocessor, not an app core | +| nRF54H20 | multi-M33 + RISC-V VPRs | app up to 320 MHz | 1 MB | 2 MB MRAM | BLE 5.4 + 802.15.4 | The family flagship; app + radio cores, like the 5340 pattern scaled up | + +**No PSRAM, anywhere in the family.** There is no external-RAM interface comparable to ESP32's PSRAM (the nRF52840 QSPI does execute-in-place flash, not a mappable data heap). The RAM ceiling is the table above minus the BLE controller/stack (~30–60 KB), Zephyr kernel, and network buffers. Rough usable-for-buffers estimate: ~150 KB on a 256 KB part, ~350 KB on the 5340, ~700–800 KB on the 54H20. Against MoonLight's memory-scaling table (16 K lights proven on the ESP32's ~320 KB internal; Teensy's 1 MB rated "comfortable headroom"), the 52840/54L15 sit **below the no-PSRAM ESP32**, the 5340 slightly above it, and only the 54H20 reaches Teensy class. + +#### WS2812 output: the DMA story + +Nordic has no RMT, no LCD_CAM/i80, no Parlio, no FlexIO, no one-shot wide-parallel DMA peripheral at all. Three documented single-lane routes exist, each abusing a serial peripheral + EasyDMA: + +- **PWM + EasyDMA**: each WS2812 bit becomes one 16-bit PWM compare value (≈ 0.32/0.64 µs duty at 800 kHz), DMA-fed from a RAM sequence. Cost: **2 bytes per bit = 48 bytes per RGB LED per lane**. This is what Adafruit's nRF52 NeoPixel support and FastLED's `clockless_arm_nrf52` use. The nRF52840 has 4 PWM instances (52832 has 3); one WS2812 stream per instance → **up to ~4 PWM lanes**, each with its own 48 B/LED encode buffer. +- **I2S + EasyDMA**: the classic Nordic-blog trick: run I2S SDOUT at 3.2 MHz and encode each WS2812 bit as a 4-bit symbol (`0xE`/`0x8`). Cost: **4 bytes per color byte = 12 bytes per RGB LED**; one EasyDMA transfer carries up to 16 KB ≈ **1365 LEDs**. One I2S peripheral, one SDOUT pin → **1 lane**. This is the closest analogue to the `i80Ws2812*` whole-frame-DMA shape. +- **SPI(M) MOSI**: Zephyr's default `worldsemi,ws2812-spi` driver; ~3–4 SPI bits per WS2812 bit. One lane per SPIM instance; the nRF52840's SPIM3 runs 32 MHz with large EasyDMA, the lesser SPIMs have small DMA lengths. + +Realistic total: **~4–6 heterogeneous lanes** (4× PWM + 1× I2S + 1× SPIM3), each behind a *different* peripheral driver, versus 8–16 *uniform* lanes on one peripheral on ESP32-S3/P4, or 40+ on Teensy via FlexIO. Combined with the RAM ceiling (a 4-lane PWM setup burns 48 B/LED/lane for encode buffers: 4 × 500 LEDs ≈ 96 KB of the ~150 KB usable), the honest capability estimate for a 256 KB part is **~1–2 K LEDs total**, ~4 K on the 5340, maybe 8–10 K on the 54H20. One more hazard FastLED's issue tracker documents: the BLE controller runs at interrupt priorities application code can't mask, so any *non*-DMA (bit-banged) output glitches under radio load, DMA routes are mandatory, which the three above all are. + +### Zephyr fit with the `platform.h` seam + +Zephyr fundamentals check out. **C++20 is a supported language standard** (`CONFIG_STD_CPP20`), and the full GNU libstdc++ is there when the toolchain carries it and the application selects it (`CONFIG_GLIBCXX_LIBCPP`). The usual embedded restrictions apply: no `std::thread` or `std::mutex`, where Zephyr has `k_thread` and `k_mutex`, which does not matter here since the seam already hides threading behind `spawnPinnedTask`; no static-object destructors; exceptions and RTTI opt-in. BSD sockets (a subset), LittleFS, and a devicetree-driven driver model are all first-class. + +| `platform.h` seam group | Zephyr provides | Assessment | +|---|---|---| +| Time (`millis`/`micros`), yield/delay, `reboot` | `k_uptime_get`, `k_cycle_get_32`, `k_yield`, `k_msleep`, `k_busy_wait`, `sys_reboot` | **Easy**: direct 1:1 | +| Heap (`alloc`/`free`, introspection) | `k_malloc` / `sys_heap` + runtime stats | **Easy**; `hasPsram = false`, one heap, the `maxAllocBlock`-style diagnostics need `sys_heap` stats plumbing | +| `allocExec`/`writeExec` (MoonLive JIT) | Cortex-M executes from RAM; no W^X obstacle | **Easy**: the Thumb-2 backend the Teensy uses (`src/platform/teensy/moonlive_asm_thumb2.cpp`) emits Thumb-2, the instruction set the nRF52840's Cortex-M4 shares; unverified there | +| RTOS introspection (`taskSnapshot`), `spawnPinnedTask`/`notifyTask` | `k_thread_foreach`, `k_thread_create` + `k_event`/`k_sem` | **Easy**: but every viable nRF is **single application core** (the 5340/54H20 second core belongs to the radio stack), so the multicore Step 2 render/encode split has no core to pin to. The seam already handles this: `spawnPinnedTask` returns false → the caller runs inline, the documented allocate-and-degrade fallback. Same stance as Teensy ("desktop is OS-threaded, Teensy single-core", measured on the P4, see performance.md) | +| GPIO capability/live state | Zephyr `gpio` API + hand-written per-pin caps table | **With effort**: same per-chip table work every target needs | +| **LED DMA seams** (`rmtWs2812*`, `i80Ws2812*`, `parlioWs2812*`) | **None of these peripherals exist** | **New seam required**: an `nrfPwm`/`i2s` WS2812 function pair in the same init/buffer/transmit/wait shape. The capability-flag pattern absorbs this cleanly (`rmtTxChannels = 0`, `lcdLanes = 0`, `parlioLanes = 0`, a new lane constant), the existing drivers go inert exactly as they do on desktop, and a new driver guards on the new flag. Loopback self-test possible via GPIOTE+timer capture, but bespoke | +| `UdpSocket`/`TcpConnection`/`TcpServer` | BSD sockets subset, fd-based | **Easy port, IF an IP link exists at all** (the crux, next section) | +| WiFi (`wifiSta*`, `wifiAp*`) | Only via the **nRF7002 companion IC** (QSPI/SPI-attached, Zephyr WiFi mgmt API) | **With effort + extra chip**: the same shape as the P4's `hasWifiCoprocessor` C6 story, so the seam precedent exists | +| Ethernet (`ethInit`) | Zephyr ships a **W5500 SPI MAC driver** | **With effort**: mirrors the S3's `hasEthW5500` path exactly | +| mDNS, hostname, `httpRequest` | Zephyr mDNS responder, sockets | With effort / easy respectively, given IP | +| OTA (`http_fetch_to_ota`, `otaBegin`, `otaWrite`, `otaEnd`) | MCUboot + mcumgr/SMP (BLE or serial DFU), or hand-rolled HTTP-to-slot given IP | **With effort**: a different but standard mechanism; the seam's status-buffer contract is implementable | +| Improv provisioning (UART) | USB CDC-ACM on 52840; **Improv-BLE** is the natural transport on this family | **With effort**: note Improv's BLE variant fits nRF better than the serial one, but that's a new transport in our Improv module, more than a platform stub | +| Audio in (`audioMicInit`), FFT | nRF **PDM** peripheral (built for MEMS mics) or I2S RX; CMSIS-DSP `arm_rfft` | **Easy-to-moderate**: arguably nicer than ESP32 (PDM is purpose-built); `audioFft` maps to the textbook CMSIS kernel | +| `i2cScan`, filesystem (`fs*`) | Zephyr `i2c`, **LittleFS** | **Easy**: LittleFS is the same fs ESP32 uses | +| IR (`irRead`) | No RMT-RX equivalent; GPIOTE + timer capture | **Bespoke effort**: low priority, can stub false like desktop | + +Summary: the seam itself is no obstacle, Zephyr is the most MoonLight-shaped RTOS outside ESP-IDF, and the `platform_config.h` flag pattern (everything inert-by-`if constexpr` when a capability constant is 0/false) absorbs the missing peripherals exactly as it does for desktop. The three *structural* gaps are: **single app core** (multicore split degrades to inline, acceptable, Teensy has the same), **no PSRAM** (hard memory ceiling, the 256 KB parts sit below the no-PSRAM ESP32), and **no native IP networking** (below). + +### The architectural question: is nRF an IP-networked LED controller? + +Honestly: **no, not natively.** MoonLight's product surface is IP end to end, the HTTP config server + WebSocket UI, ArtNet/DDP/WLED-sync over UDP, mDNS discovery, OTA over HTTP, Improv provisioning *of WiFi credentials*. An nRF SoC has BLE + 802.15.4 and nothing else: + +- **BLE** carries no IP for our purposes (IPSP is dead in practice). A BLE-only nRF node cannot run NetworkModule, HttpServerModule, ArtnetDriver, or WLED sync at all, the entire networked surface would be replaced by a new BLE GATT control plane that doesn't exist in MoonLight. That is not a port; it is a second product. +- **Thread/802.15.4** does give real UDP/IPv6 (OpenThread), so `UdpSocket` technically works, but at a 250 kbps radio, pixel streaming (one 170-LED DMX universe at 44 Hz ≈ 190 kbps) saturates the mesh. Thread is a control-plane transport, not a pixel-data one. +- **WiFi via nRF7002** (companion IC over QSPI, Wi-Fi 6, the nRF7002-DK pairs it with an nRF5340) or **Ethernet via W5500 SPI** restores the whole MoonLight stack unchanged, sockets, HTTP, ArtNet, everything. Both have Zephyr drivers and both match existing seam precedents (the P4's WiFi co-processor, the S3's SPI Ethernet). But then the BOM is a two-chip radio board with fewer LED lanes, less RAM, and a slower CPU than a single ESP32-S3, the nRF's low-power/BLE strengths are dead weight in a mains-powered LED installation. + +So the honest framing: **as an IP node it fits our architecture but loses to ESP32 on every axis we care about; as a BLE node it doesn't fit our architecture.** The genuinely nRF-shaped use case, a battery-powered, BLE- or Matter-controlled wearable/small-strip product, phone-app-controlled, sub-500 LEDs, is real, but it is not the product MoonLight is (large installations, web UI, network protocols, no-reboot live reconfig of a mains-powered controller). + +### Precedent + +- **FastLED** supports nRF52 (Adafruit nRF52 boards): the `clockless_arm_nrf52` path drives WS281x via the **PWM peripheral + EasyDMA**, effectively one strip per PWM instance, with documented friction (BLE interrupt priority vs timing, per-board pin/compile issues, GitHub issues #595, #747). It proves single-to-few-lane WS2812 works; nobody uses it at installation scale. +- **WLED is ESP-only** (ESP8266/ESP32 families); no nRF port exists or is planned, a data point that the networked-LED-controller product category has not found nRF worth the port either. +- **Zephyr itself** ships three single-lane `led_strip` WS2812 drivers (SPI default, I2S, GPIO-bitbang) with nRF DK sample overlays, good bring-up references for a platform-layer driver, all small-N demos. +- **Nordic's own I2S-WS2812 blog** (2016, still the canonical reference) demonstrates the 4-bit-symbol I2S encode; community repos (electronut, cheehieu, edwios) replicate it at 12–240 LEDs. + +### Reframing: core-as-library and the BLE-UI question + +The sections above answer "is nRF a good LED-installation controller" (no). But MoonLight is **more than its light domain**, its `src/core/` is a domain-neutral runtime that the architecture explicitly holds apart from lights (see [architecture.md § Core and light domain](../explanation/architecture/index.md#core-and-light-domain): *"Core: MoonModule base, controls, scheduling, persistence, platform abstraction... Domain-neutral. Knows nothing about lights."*). Offered as a **library**, that core is a compelling thing in its own right, and the interesting fit question becomes: does MoonLight's *runtime*, not its LED pipeline, run cleanly on a tiny BLE device, and can its UI reach it? This reframes nRF from "loses the big-installation race" to "a showcase of how far MoonLight's core stretches." + +#### 1. How much of the core is portable as-is? + +Nearly all of it. What the core owns, and how its network modules go inert on a capability flag: [MoonCore](../explanation/architecture/mooncore.md). Zephyr gives the runtime a heap, a filesystem, and a clock, which is all it asks, and the UI transport that would normally ride HTTP/WS has to come from somewhere else (§ 3). + +The honest figure: **the domain-neutral core ports with essentially no architectural work**, it's the same code the desktop build already runs headless-of-LEDs. The port cost is the platform seam (§ Zephyr fit above) plus one new thing the desktop never needed: a **UI transport that isn't HTTP** (§ 3). That single item is the whole ballgame. + +#### 2. A few LEDs is a feature + +The ~1–2 K-LED ceiling on a 256 KB nRF part reads as a shortfall against 16 K-on-ESP32, but for the device class nRF *is* (battery-powered, BLE-remote, wearable, retail/prop lighting), a handful to a few hundred WS2812 is the whole point, and MoonLight's pipeline runs identically at that scale (the architecture's *"effects must run at every grid size"* rule guarantees a 16-LED strip or an 8×8 matrix is a first-class configuration, not a degraded one). The single-lane I2S-EasyDMA route (≈ 1365 LEDs, § WS2812 output) is *more* than enough for this niche, and the PWM route gives a few independent short strips. So the small-device story isn't "MoonLight crippled", it's "the same full pipeline, small, battery-friendly, phone-controlled," which is a coherent product the ESP32 line doesn't naturally serve (ESP32 + WiFi is mains-power-shaped). Re-weighed: the LED limit is a *positioning*, not a *defect*, for this specific niche. + +#### 3. THE crux: can MoonLight's UI run over BLE alone? + +MoonLight's UI ↔ device link is, at its heart, a **JSON control/state channel**: the browser sends control *writes*, the device *notifies* state back (over a WebSocket). Nothing about that model is HTTP-specific, it maps directly onto a **BLE GATT custom service**: one **write** (or write-without-response) characteristic for control-set, one **notify** characteristic for state-push. This is the exact shape of well-established prior art, and the research says the path is **clean, not hacky**: + +- **Throughput is a non-issue.** BLE 5 with a 247-byte ATT MTU and DLE sustains a theoretical ~97 kB/s (≈ 780 kbps) of notifications, and real-world BLE-throughput guides put practical rates at ~100–700 kbps depending on connection interval and MTU. MoonLight's state sync is a few hundred bytes to low-KB of JSON per push at ~1 Hz for full state, with small deltas on control changes, *three-plus orders of magnitude* under the ceiling. Chunking a larger JSON blob across successive notifications (each ≤ MTU−3 bytes) is the standard technique and is trivial here. **BLE bandwidth is not the limiting factor for our UI.** +- **The channel maps to our existing seams naturally.** State-notify ↔ the WS state push (produced already, framed onto a characteristic); control-write ↔ the `/api/control` mutation path (the same apply-core + per-control validators the HTTP API and the Improv `APPLY_OP` "REST over serial" path already reuse). In other words MoonLight *already has* a transport-agnostic control/state core with two transports (HTTP/WS and Improv-serial); **BLE GATT is a third transport onto the same core**, which is precisely the extensibility the "Improv = REST over serial" design demonstrated. +- **Prior art is abundant and specifically config-UI-over-BLE.** Zephyr + Web Bluetooth config UIs, Moddable's JSON-defined GATT services, Nordic's custom-BLE-service tutorials, and the entire **ESP32-WiFi-provisioning-over-BLE** ecosystem (Espressif's own BLE provisioning, plus community Web-BLE config portals like UriShX's `esp32_web-ble_wifi_config`) all do exactly "browser/app ↔ BLE GATT ↔ device settings." MoonLight's Improv-BLE-style provisioning would slot in beside it. +- **The one real caveat, client reach.** **Web Bluetooth (browser → GATT with no app install) is Chromium-only: Chrome/Edge on desktop + Android, never Firefox, and crucially never Safari on iOS/iPadOS** (Apple's WebKit rule means *every* iOS browser lacks it, Chrome-on-iOS included). Global support is ~76% (April 2026). So the frictionless "open a web page, connect, configure" experience works on Android + desktop but **not on iPhone**, where the fallbacks are a native companion app, a third-party BLE browser (Bluefy/WebBLE), or Nordic's nRF Connect app. This is the single honest wart on the BLE-UI story: the *device* side is clean, but the *no-install browser client* has an iOS-shaped hole. + +**Verdict on the crux:** the BLE-UI path is **clean at the protocol/device level and a well-trodden pattern**, it is a weekend-to-two-weeks spike, not a research project, *for the device firmware + an Android/desktop Web Bluetooth client*. It becomes a larger undertaking only if the showcase must include a first-class iOS client (then: a small companion app, a separate effort). Framed as a demo/advertisement, the Android/desktop Web Bluetooth client is enough to make the claim land. + +#### The advertising value + +"MoonLight's full runtime, the same MoonModule lifecycle, controls, persistence, and generic UI you run on a 16 K-LED ESP32 installation, also runs on a coin-cell BLE chip, configured live from your browser over Bluetooth" is a strong, concrete credibility claim. It demonstrates the domain-neutral-core thesis better than any doc: the *same* core, top to bottom, from a battery BLE wearable to a mains ESP32 wall of light. That is a real reason to do the spike **independent of** whether nRF ever becomes a shipping LED target. + +### Recommendation + +**Do a scoped showcase spike; do not commit to nRF as a shipping LED target.** The two questions have opposite answers and must not be conflated: + +- **nRF as a big-LED installation controller: decline / defer.** Every such configuration underperforms an ESP32 (no PSRAM, ~4–6 heterogeneous lanes, companion chip needed for IP). The multicore split, PSRAM-scale buffers, and 8–16-lane output on the roadmap all regress here. Teensy remains the right *next* hardware LED target (1 MB RAM, built-in Ethernet, 40+ DMA lanes). +- **MoonLight's portable core + BLE-UI as a showcase: pursue as a time-boxed spike.** The core ports with essentially no architectural work, the small-LED story is a coherent niche rather than a crippling, and, the decisive finding, the **BLE-UI channel is clean and well-precedented**, bandwidth-comfortable, and reuses the transport-agnostic control/state core we already have. The showcase/advertising value is real and lands independent of LED scale. **Gate: acceptable only if the iOS-client gap (Web Bluetooth ≠ Safari) is fine for a demo**, which for an advertisement/proof-of-concept it is (Android + desktop Chrome/Edge reach the device; an iOS companion app is a later, separate call). + +**Concrete first step if pursued** (sized as a spike, ~1–2 weeks): +1. A `src/platform/nrf/` skeleton under Zephyr on an **nRF52840-DK**: `platform_config.h` with all LED-lane constants 0 except one new PWM/I2S lane flag, `hasNetwork = false`, `hasWiFi/hasEthernet/hasOta = false`; implement the time / heap / fs (LittleFS) / GPIO seam groups. This alone proves the domain-neutral core boots and runs its Scheduler + persistence on Zephyr. +2. One **I2S-EasyDMA WS2812 driver** (the whole-frame-DMA shape, closest to `i80Ws2812*`) on a single lane driving a small strip/matrix, the visible output that makes the demo tangible. +3. The **BLE-UI transport**: a GATT custom service with a control-write and a state-notify characteristic, framing the *existing* JSON control/state onto BLE (chunked to MTU), plus a minimal **Web Bluetooth client page** (Android/desktop Chrome) that renders the generic module UI from the notified state and writes control changes back. This is the load-bearing deliverable, it's what turns "the core runs on nRF" into "and you can *use* it from your browser over Bluetooth." + +That spike answers the three real unknowns, how much RAM survives Zephyr + the BLE stack (does the core fit a 256 KB part?), BLE-radio-vs-DMA timing under load, and end-to-end BLE control/state round-trip latency in the actual UI, for about two weeks, and produces a genuinely marketable artifact whether or not nRF ever ships as an LED target. + +**What would additionally have to be true to promote it from showcase to a shipping target:** a concrete product pull toward BLE/Matter-controlled small-strip devices (wearables, retail, battery props); a decision on the iOS client (companion app vs. Android/desktop-only). + +### Sources + +- Nordic product pages: [nRF52840](https://www.nordicsemi.com/Products/nRF52840), [nRF5340](https://www.nordicsemi.com/Products/nRF5340), [nRF54L15](https://www.nordicsemi.com/Products/nRF54L15), [nRF54H20](https://www.nordicsemi.com/Products/nRF54H20), [nRF7002 Wi-Fi 6 companion IC](https://www.nordicsemi.com/Products/nRF7002) +- Family comparisons: [nRF52840 vs nRF54L15 (BLEFYI)](https://blefyi.com/compare/nrf52840-vs-nrf54l15/), [nRF5340 vs nRF54L15 (BLEFYI)](https://blefyi.com/compare/nrf5340-vs-nrf54l15/), [nRF54H20 announcement (CNX)](https://www.cnx-software.com/2023/04/13/nrf54h20-cortex-m33-risc-v-wireless-soc-bluetooth-5-4-le-audio-bluetooth-mesh-thread-matter/) +- WS2812 on nRF: [Nordic DevZone blog, Driving WS2812B via I2S](https://devzone.nordicsemi.com/nordic/nordic-blog/b/blog/posts/driving-ws2812b-leds-using-i2s-on-the-nordic-nrf52) (the 16 KB EasyDMA ≈ 1365-LED figure), [Electronut Labs write-up](https://electronut.in/driving-ws2812b-leds-using-i2s-on-the-nordic-nrf52832-ble-soc/), [nRF52 PWM peripheral guide](https://embeddedexplorer.com/guide-to-use-nrf52-pwm-peripheral/) +- Zephyr: [C++ language support](https://docs.zephyrproject.org/latest/develop/languages/cpp/index.html) (C++98–C++20, restrictions), [BSD sockets](https://docs.zephyrproject.org/latest/connectivity/networking/api/sockets.html), [WS2812 led_strip sample + SPI/I2S/GPIO drivers](https://docs.zephyrproject.org/latest/samples/drivers/led/led_strip/README.html), [worldsemi,ws2812-i2s binding](https://docs.zephyrproject.org/latest/build/dts/api/bindings/led_strip/worldsemi,ws2812-i2s.html), [nRF7002 DK board doc](https://docs.zephyrproject.org/latest/boards/nordic/nrf7002dk/doc/index.html), [nRF54H20 DK board doc](https://docs.zephyrproject.org/latest/boards/nordic/nrf54h20dk/doc/index.html) +- FastLED nRF52: [issue #595, NRF52 status](https://github.com/FastLED/FastLED/issues/595), [issue #747, nRF52840 support](https://github.com/FastLED/FastLED/issues/747) +- WLED supported platforms (ESP8266/ESP32 only): [WLED docs](https://kno.wled.ge/basics/compatible-controllers/) +- BLE-UI throughput: [Punch Through, Maximizing BLE Throughput](https://punchthrough.com/ble-throughput-part-4/), [Memfault Interrupt, A Practical Guide to BLE Throughput](https://interrupt.memfault.com/blog/ble-throughput-primer), [Nordic GATT Throughput Service (the ~97 kB/s @ 251-byte PDU figure)](https://nrfconnectdocs.nordicsemi.com/ncs/2.1.4/nrf/libraries/bluetooth_services/services/throughput.html) +- Web Bluetooth support: [caniuse, Web Bluetooth](https://caniuse.com/web-bluetooth), [MDN, Web Bluetooth API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API), [WebBluetoothCG implementation status](https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md), [Chromium issue, Web Bluetooth on iOS (unimplemented)](https://issues.chromium.org/issues/41280428) +- Config-over-BLE / Web Bluetooth prior art: [Zephyr + Web Bluetooth getting started](https://dev.to/denladeside/getting-started-with-zephyr-and-web-bluetooth-355d), [Moddable JSON-defined GATT services](https://www.moddable.com/documentation/network/ble/ble), [Nordic custom BLE service tutorial](https://github.com/NordicPlayground/nRF5x-custom-ble-service-tutorial), [ESP32 WiFi provisioning over BLE (Espressif/community)](https://randomnerdtutorials.com/esp32-wi-fi-provisioning-ble-arduino/), [UriShX esp32_web-ble_wifi_config (Web-BLE config portal)](https://github.com/UriShX/esp32_web-ble_wifi_config) + +## Tools + +| | Tool (maker) | Why | Where, price | +|--|--|--|--| +| Raspberry Pi Debug Probe | [Debug Probe](https://www.kiwi-electronics.com/en/raspberry-pi-debug-probe-11345) (Raspberry Pi) | SWD for the RP2040, the RP2350 and both Nordic chips, through pyOCD or OpenOCD | Kiwi €13.18 | +| SN74AHCT125N | [SN74AHCT125N](https://www.tinytronics.nl/en/components/ics-and-microcontroller-chips/ics/sn74ahct125n-4-channel-level-converter-3.3v-to-5v-14-pin-dip) (TI), two or three | 3.3 V to 5 V for WS2812 data, since every board here drives its pins at 3.3 V | Tinytronics €1 each | + +## Where to order + +- Antratek: the Pico 2 W, the Pico W and the XIAO nRF52840 Plus, together with any Teensy items. +- Tinytronics: the level shifters and both Zero boards. +- Kiwi: the Debug Probe. +- The Pi Hut: the W5500-EVB-Pico2. +- DigiKey: the Raytac board, when the nRF5340 comes up. diff --git a/docs/design/ui-extensibility.md b/docs/design/ui-extensibility.md new file mode 100644 index 000000000..296841f82 --- /dev/null +++ b/docs/design/ui-extensibility.md @@ -0,0 +1,107 @@ +# UI extensibility (app.js), bottom-up + +## The governing statement (the invariant to achieve) + +> **`app.js` is completely module-independent.** It supports only the **generic** UI: module cards, the generic control widgets (number, text, pin, bool, select, list, …), creating / deleting / replacing / reordering cards, the tick / memory / status emoji header, the self-describing-controls machinery. **No module specifics live in it, none.** A core module's UI specifics live in **core** (next to that module), a light-domain module's UI specifics live in **light**. app.js knows *control types*, never *module names*. + +This is a **rule, not a direction**, it's enforceable (grep app.js for a module name; there should be zero) and it's the acceptance test for the refactor. This study surveys how the field achieves it, measures MoonLight's current distance from it, and extracts the mechanism. The top-down companion (design from the goal, not consulting this) follows. + +The trigger: app.js has grown to **~3,200 lines** with module-specific content baked in (a hardcoded `renderFileManager` and `if (mod.type === …)` branches), and while building TasksModule we **hit the wall**, the generic list-detail renderer couldn't display a new module's data shape, forcing a workaround. That's the smell; the statement above is the fix. + +## The goal (what "generic" means here) + +MoonLight's core UI strength is that it renders **any** module's controls generically from the `/api/state` control list, one code path draws an effect, a driver, or a system service (the same self-describing-controls model the architecture is built on). A module that needs *more* than the 9 generic control types (a file tree + editor, a task table, a memory-by-type chart, a pin board diagram) should **plug that in** without app.js knowing the module exists by name. Generic core, module-owned specifics. + +## Where MoonLight is: a split personality + +The codebase already does this **two opposite ways**, which is the tell: + +- **✅ Extracted (the good pattern):** the light-domain 3D preview lives in its own file, `src/ui/preview3d.js`, `import`ed into app.js as `preview`. So there *is* precedent for a domain/module owning its UI in a separate module. +- **❌ Inlined (the anti-pattern):** FileManager's UI is **357 lines of `renderFileManager` inside app.js** (folder tree, inline editor, drag-drop, upload), reached by a **hardcoded type-check**, `if (mod.type === "FileManagerModule") { renderFileManager(...) }` (app.js:595). The generic renderer has a specific module's name compiled into it. + +And the **generic core that should stay** is clean and worth preserving: a `switch (ctrl.type)` over the 9 `ControlType`s (app.js:961), pin / bool / select / list / …, the self-describing-controls renderer. That's *control-type* dispatch (generic, keep it); the violation is *module-name* dispatch (specific, remove it). The two must not be confused: `ctrl.type === "bool"` stays; `mod.type === "FileManagerModule"` goes. + +### The exact distance to the invariant (what must leave app.js) + +Every module-name reference in app.js, each of which must move to its module's domain for the statement to hold: + +| app.js | What it is | Moves to | +|---|---|---| +| `if (mod.type === "FileManagerModule")` + `renderFileManager` (357 lines) + ~8 call sites | the file tree / editor / drag-drop UI | **core**: FileManager's own UI file | +| `if (mod.type === "FirmwareUpdateModule")` (the install-picker mount) | the firmware install picker | **core**: Firmware's own UI file | +| `state.modules.find(m => m.type === "SystemModule")` (app.js:2421) | a System-specific lookup | **core**: System's UI hook, or a generic mechanism | +| `find(m => m.type === "FirmwareUpdateModule")` (app.js:2570) | Firmware lookup | **core**: with the above | + +The `ctrl.type === "bool"/"text"/…` checks (app.js:2029-2030) are **generic control types, not module names**, they stay. + +**Home placement: the rule extends to preview3d too.** All UI lives in `src/ui/` (app.js, preview3d.js, install-picker.js). The statement says *light specifics in light, core specifics in core*, so even the already-extracted **preview3d.js is in the wrong home**: it's a light-domain widget sitting in the shared UI dir. Achieving the invariant means UI assets live **with their domain's source**, `src/light/…/preview3d.js`, `src/core/FileManagerModule.ui.js`. So the refactor is two-part: (1) *extract* module UI out of app.js into per-module files, and (2) *relocate* those files (and preview3d) into their domain tree. + +**Build-step consequence (a real cost to name).** `embed_ui.cmake` gzips + hex-embeds a **hardcoded filename list from one `UI_DIR` (`src/ui`)**, `gzip_file_hex("app.js")`, `("preview3d.js")`, … one explicit line per file, single directory. It is *not* a glob and *not* multi-directory. So relocating widgets into `src/light`/`src/core` requires teaching the embed step to **gather UI files across the domain tree** (a glob over `src/**/*.ui.js` or a manifest each module contributes to), and the served-file router (`/preview3d.js` and its siblings) to map the new paths. That's the concrete build-side work the invariant implies, small, but it exists; the top-down should scope it, not hand-wave it. + +**The live evidence this matters:** building TasksModule, its nested per-module detail is an *array of objects*, which `fillListDetail` (app.js:1537) can't render (`if (typeof v === "object" && !isScalarArray) continue`, it silently skips objects). The fix shipped was to **flatten each module to a scalar string**, a workaround in the *module* because the *generic renderer* couldn't be extended cleanly. A Tasks-owned UI hook would have rendered a real sub-table. The System Modules (Memory's by-type bars, Pins' board map) will each hit this same wall. + +## The field + +### Home Assistant: Lovelace custom cards (the direct analog) + +HA is exactly MoonLight's situation: a generic dashboard rendering entities, with an extension path for custom UI ([developers.home-assistant.io/docs/frontend/custom-ui/custom-card](https://developers.home-assistant.io/docs/frontend/custom-ui/custom-card/)): + +- **A card is a Custom Element**: `customElements.define('my-card', MyCard)`. The dashboard doesn't know the card's internals; it instantiates the tag. +- **A lifecycle contract** the core calls: `setConfig(config)` on setup/config-change (throw → the core renders an error card), and it **sets the `hass` property** on every state change (the card re-renders from it). One-way data in, event out. +- **`config-changed` event**: the card dispatches config changes back up; the core doesn't reach into the card. +- **A registry for discovery**: `window.customCards` (an array of `{type, name}`) so the picker can list custom cards without hardcoding them. + +**Take, this is the target pattern.** Generic core + custom element per module + a lifecycle contract (data-in via a property, changes-out via an event) + a registry for discovery. HA proves it scales to thousands of third-party cards with the core knowing none of them by name. + +### Web Components / Custom Elements (the platform primitive) + +The browser-native mechanism HA builds on ([MDN Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)): + +- **`customElements.define(name, class)`** registers a tag; the core writes `` and the browser instantiates the class. Encapsulated, no framework. +- **Lifecycle callbacks** (`connectedCallback`, `attributeChangedCallback`, `disconnectedCallback`), the element manages its own mount/update/teardown, so the core doesn't. +- **Scoped registries** (emerging) let independently-built widgets avoid tag-name collisions, relevant only if third-party UI is ever a goal; overkill for first-party modules. +- **Manual vs auto registration**: a module can *export* its element class and let the app register it, or self-register on import. Either fits. + +**Take:** Custom Elements are the **zero-framework, standards-based** substrate, matches MoonLight's no-build-step, no-framework UI (plain ES modules). No dependency added; it's a browser API. + +### The registry/dispatch idea (generalised) + +Across HA, VS Code (contribution points), Grafana (panel plugins), the shape is identical: **a core that dispatches by a key to a registered handler, never a switch over known names.** The core holds a `Map`; a module registers `registry.set('FileManagerModule', FileManagerWidget)`; the core does `registry.get(mod.type)?.(mod, host)` with a generic fallback. Adding a module's UI = registering an entry, touching zero core code. + +## What MoonLight already has going for it + +- **Self-describing controls**: the generic `switch (ctrl.type)` renderer is the 90% path and already works; most modules need *no* custom UI. The extension point is only for the few that exceed the 9 control types. +- **ES modules, no build step**: `import { preview } from "/preview3d.js"` already works; Custom Elements are a browser primitive needing no tooling. The substrate is present. +- **A clean control-value contract**: modules already emit their state as JSON controls; a custom widget receives the same `mod` object the generic renderer gets. The data-in side is solved. +- **One real extraction already done** (preview3d), a proof the split works; FileManager is the one to follow it, and the pattern generalises to Tasks/Memory/Pins. + +## Ideas extracted (for the top-down to design against) + +| Idea | Source | MoonLight shape | +|---|---|---| +| **Registry dispatch, not a name switch** | HA `customCards`, VS Code, Grafana | app.js holds `Map`; `registry.get(mod.type)` with the generic fallback. No `if (mod.type === "X")` in core. | +| **Custom Element per module widget** | HA cards, Web Components | Each special module ships its tag, such as ``, as a Custom Element in its own file; app.js instantiates the tag, knows nothing inside. | +| **Lifecycle contract: data-in property, changes-out event** | HA `setConfig`/`hass`/`config-changed` | The widget gets the `mod`/state via a property/method the core sets each refresh; it dispatches control changes via the existing `/api/control` path (or an event the core forwards). | +| **Module owns its file** | preview3d.js (already done), HA | One file per custom widget beside its module, `src/core/…/FileManagerModule.ui.js` and `src/light/…/preview3d.js`, as the home placement above says; `renderFileManager`'s 357 lines move out of app.js. | +| **Domain grouping** | preview3d = light domain | Light-domain widgets under one folder/entry (per your note "light domain contains its own hook-ins"); core widgets (System Modules) under another. | +| **Generic renderer stays the default** | HA renders standard entities generically | The `switch (ctrl.type)` core is untouched and remains the path for every module without a registered widget, subtraction, not addition, for the 90%. | +| **Extend the generic list-detail too** | the TasksModule wall we hit | Separately: teach `fillListDetail` to render nested object-arrays (a sub-table) so a module doesn't need a *full* custom widget for richer list detail, a middle tier between "generic" and "full custom element." | + +## Scope signal for the top-down + +The convergent answer is **a small registry + a per-module widget contract, both dead-standard (Custom Elements + a `Map` dispatch)**, MoonLight needs no framework, no build step, and already has the substrate (ES modules) and one worked example (preview3d). The work is: (1) define the widget contract (how a module widget receives state + emits changes), (2) a registry app.js consults instead of the hardcoded branches, (3) migrate FileManager out of app.js as the first citizen (proving the contract on the hardest existing case), (4) a middle tier, richer generic list-detail, so not every custom need forces a full widget. Then Tasks/Memory/Pins each add a file + a registry entry, and app.js stops growing per-module. + +There are **three tiers** the top-down should name, so a module reaches for the lightest that fits (minimalism): (a) **generic controls**, no custom UI, the default; (b) **generic-with-richer-list-detail**, a module whose need is nested/tabular detail; (c) **full custom widget**, a Custom Element for genuinely bespoke UI (file tree, board diagram). FileManager is (c); TasksModule is (a)-with-a-workaround that (b) would fix; Memory/Pins are likely (b) or (c). + +This is directly tied to the system modules: Tasks and Pins ([core system](../moonmodules/core/system.md)) and [Memory](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074597) are the modules that will want tier (b)/(c) UI, so the extension architecture should land before (or alongside) building Memory and Pins, otherwise each repeats FileManager's inlined-in-app.js mistake. + +## The acceptance test (from the governing statement) + +The refactor is done when **`grep` of `app.js` finds zero module names**, no `FileManagerModule`, `FirmwareUpdateModule`, `SystemModule`, no `renderFileManager`. Only control types (`bool`, `pin`, `list`, …) and generic card/emoji/CRUD machinery remain. Concretely, the invariant holds when: + +1. app.js dispatches custom UI via a **registry** (`Map`), never a name switch, and the registry's entries are *registered by the modules*, so app.js's own source contains no module name. +2. FileManager's 357 lines + the Firmware picker live in **core** UI files; preview3d + any light widget live in **light**, each embedded from its domain tree. +3. A new module needing custom UI adds *one file + one registration* and touches app.js **not at all**. +4. `check_specs.py` (or a new lint) could even *enforce* the invariant: fail if app.js contains a known module type name, the rule made a gate. + +That last point is the strongest form: the statement is more than an aspiration in a doc, it's a **checkable rule** the build can hold app.js to. diff --git a/docs/design/virtual-layer-downscale.md b/docs/design/virtual-layer-downscale.md new file mode 100644 index 000000000..c2f1ea191 --- /dev/null +++ b/docs/design/virtual-layer-downscale.md @@ -0,0 +1,81 @@ +# Virtual-layer render and pooling down-map + +> A forward-looking design study (backlog, present-tense-exempt). It captures a feature the product +> owner requested as a "hue mixer modifier" and the architecture verification that reframed it into a +> **Layer** feature. Unplanned for implementation; a future increment picks it up. Supersedes +> the earlier `DownscaleModifier-spec.md` draft (deleted), which framed it as a modifier. + +## Goal + +Run an effect authored for **many** pixels, a virtual 32×32 grid, and **summarize** it onto a +**small** number of physical lights: Philips Hue bulbs, or a handful of PAR/DMX fixtures. Each +physical light shows the **average color of its region of the virtual frame, ignoring black pixels**, so a fire / plasma / GEQ effect reads as a coherent ambient wash across N bulbs instead of N arbitrary single-pixel samples. + +Concretely: a `Fire` effect on a 32×32 grid, driven onto 10 Hue bulbs, makes each bulb glow the mean of its ~102-pixel slice of the fire, the flames' *summary*, not one stray pixel. + +The mirror case (**physical ≥ virtual**, e.g. a 128×128 wall from a 32×32 effect) is **already solved** by the [1:N mapping](../explanation/architecture/moonlight.md#mapping-and-blending). The genuinely new capability is the **downscale** direction (physical < virtual): render big, summarize small. + +## Why this is a Layer feature + +The request began as "make a modifier." Two verification steps ruled that out: + +1. **Not `modifyLive`.** The per-frame modifier hook is a *backward 1:1 coordinate gather*: the Layer + walks destination pixels and asks the modifier for **one** source coordinate, then copies **one** pixel (`Layer::applyLivePass`, Layer.h:~218). Pooling *combines* many source pixels into one output value (sum + count, skip black, divide), a computation a coordinate remap structurally can't express (it returns a coordinate, not a color). + +2. **Not a modifier at all, the real blocker is render size.** A Layer's logical (render) box + **starts at the physical light count** and modifiers only ever *shrink* it (`Layer::rebuildLUT`, + Layer.h:343: `Coord3D box{physicalWidth_, physicalHeight_, physicalDepth_}` → each static modifier reshapes it → `width_ = logical.x`). So a Layer renders at *most* at its physical size. A 32×32 effect onto 10 physical lights **cannot happen**: the effect would render at 10, leaving nothing to pool *from*. A driver-side pool hits the same wall: the driver reads the layer's logical buffer (zero-copy) or the LUT-mapped physical `outputBuffer_` (Drivers.h:~264), both are already physical-sized. + +So the enabling feature is a **Layer virtual/render size that can exceed the physical light set**, exactly MoonLight's `VirtualLayer` (a per-layer virtual buffer, render at a virtual size, map to physical), which the Layer doc already cites as prior art (Layer.h:33). Once a Layer can render at a virtual grid independent of N physical lights, "summarize down" is **the virtual→physical down-map rule**. It lives in the Layer / mapping, not a modifier. + +## Proposed shape (for a future implementation plan) + +Two parts; the first is the enabler, the second is the actual summary: + +### 1. Layer virtual size (the enabler) + +A Layer control (or layout property) setting a **render box independent of, and allowed to exceed,** the physical light count. Effects render into that virtual box; the mapping reduces it to the physical lights. Care points a future plan must resolve: + +- **`rebuildLUT` assumes logical ≤ physical.** It backward-maps each *physical* light to *one* logical + cell (a fan-out / scatter). A virtual grid *larger* than physical needs the inverse: many logical cells → one physical light. The [four mapping types](../explanation/architecture/moonlight.md#mapping-and-blending) hold **no N:1-averaged type**: that's the new mapping the feature introduces. +- **Memory:** the virtual buffer is `virtualW × virtualH × virtualD × channels`, which can dwarf the + physical light count (32×32×3 = 3 KB is fine; larger virtual grids need the same PSRAM-first allocation + degradation-status discipline the Layer already uses). + +### 2. The pooling down-map (the summary) + +`physical[i] = mean(non-black pixels of virtual region i)`. Two open sub-designs for the future plan: + +- **Where it runs:** a per-frame buffer pass (an N-slot accumulator, `rSum/gSum/bSum/count` per + physical light, sized on the cold path, zeroed + filled per frame) attached to the **Layer**; OR a new `MappingLUT` N:1-average mapping type that `blendMap` applies. The accumulator approach is simpler and self-contained; the LUT-type approach is more uniform with the existing mapping model. +- **Region assignment (`regionOf`), derived from the physical shape, not a user control.** The split + follows the *dimensionality of the physical light set* (read from `Layouts` / the physical dims the Layer already knows): + - **1D physical** (N bulbs in a line): N contiguous bands along the virtual grid's dominant axis. + - **2D physical** (a panel/cluster, Pw×Ph): a Pw×Ph tiling; each physical light = the mean of its + tile. + - **3D physical** (a cube): 3D blocks. + + One integer formula per axis (`region_a = coord_a × physDim_a / virtualDim_a`), no user control, no 1D-vs-2D mode. Add/remove lights or change the physical layout and it re-slices automatically (the + *No reboot to apply* / robustness guarantees). The shape is *data*, so the general algorithm reading + physical dims is the textbook construct, cleaner than shipping "1D bands first." + +- **Skip black:** an unlit virtual pixel doesn't dilute the average (`count[slot]` only increments for + non-black pixels; an all-black region → the physical light is off). Integer math only; no heap in the per-frame pass. + +### Driver-agnostic + +The output is N physical light values, whatever driver reads them (Hue over HTTP, a NetworkSend driver over ArtNet/E1.31 to N DMX PAR channels, an RMT strand of N pixels) shows the summary. The goal's "or a number of PAR lights for that matter" falls out for free. + +## Tests (when built) + +The pooling math and the `regionOf` split are **pure and host-testable**, wherever they end up living: + +- **Unit:** feed a synthetic virtual buffer (gradient, half-black frame) + N; assert each physical + value equals the mean of its non-black region; all-black region → off; correctness at N=1, N=10, N=(virtual pixel count) = identity, N > pixel count (empty slots). +- **Unit:** `regionOf`, contiguous, complete (every virtual pixel in exactly one region), no + gaps/overlap, at awkward N (7, 13) and in 1D/2D/3D. +- **Scenario:** a Fire / Noise effect on a virtual grid → summarized onto a fake N-light driver → + non-zero, stable output; robustness (change virtual size + N live, 0×0 grid) never crashes. + +## Why parked + +This is a Layer-level feature (a new render-size concept + a new N:1-average mapping), materially bigger than the "quick modifier" it was first requested as. It warrants its own spec + `/plan` + increment rather than being wedged into the modifier framework (where it fought three invariants: the 1:1 gather, free composition, and logical-space operation). Captured here so the design and the architecture findings aren't lost; a future increment picks it up. diff --git a/docs/explanation/architecture/index.md b/docs/explanation/architecture/index.md index a6c09f6e2..cde4be0d7 100644 --- a/docs/explanation/architecture/index.md +++ b/docs/explanation/architecture/index.md @@ -61,7 +61,7 @@ flowchart LR | [MoonCloud](mooncloud.md) | The opt-in server side, and the only server a device talks to | | [MoonDeck](moondeck.md) | One script per task, two front ends | | [MoonTube](../../../moondeck/moontube/moontube.md) | One run file per clip: performed as a UI test, filmed as a documentation video | -| [MoonWalk](../../moonwalk.md) | Every process, from a first reader to a release, described once and drawn on one map | +| [MoonWalk](moonwalk.md) | Every process, from a first reader to a release, described once and drawn on one map | ## Core and light domain diff --git a/docs/explanation/architecture/mooncore.md b/docs/explanation/architecture/mooncore.md index 8f83c0477..4a1ffb3ba 100644 --- a/docs/explanation/architecture/mooncore.md +++ b/docs/explanation/architecture/mooncore.md @@ -66,7 +66,7 @@ Three domain-neutral services let several controllers act as one installation. T - **Discovery**: devices find each other by presence packets on a multicast group and the broadcast address WLED listens on, and `NetworkModule` advertises each over mDNS. Live. - **Shared controls**: boards follow each other's control surface over [OSC](../../moonmodules/core/services.md#osc), the industry-standard protocol for it. A surface change on one board goes to the others as feedback, by unicast to `hosts` or to a multicast group. Each writes it onto its own surface and may send it on once, back to its origin too. Only a changed value is sent, so the origin, already holding it, stops it there. Live. -- **Clock sync**: a shared monotonic clock, so boards with the same controls also render the same moment. The design is filed in [backlog-core](../../work/future/backlog-core.md). +- **Clock sync**: a shared monotonic clock, so boards with the same controls also render the same moment. The design is filed in [backlog/core](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077324). What the synced clock is *for* is a domain question; the light domain's use of it (synced animation across a wall) is in [Multi-device sync](moonlight.md#multi-device-sync). diff --git a/docs/explanation/architecture/moonlight.md b/docs/explanation/architecture/moonlight.md index 6adbc173a..457a35c02 100644 --- a/docs/explanation/architecture/moonlight.md +++ b/docs/explanation/architecture/moonlight.md @@ -113,7 +113,7 @@ Each layer references the shared Layouts. The layer builds its mapping by walkin Effects produce light colors. They write into the Layer's buffer, which represents a logical grid. The Layer determines the buffer's dimensions (width, height, depth) from the Layouts and its modifiers. Effects receive these logical dimensions and elapsed time (millis) as their rendering context. They compute light positions from the buffer index (such as `x = i % width`, `y = i / width`). -Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. This is also what makes the cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [Multi-device sync](#multi-device-sync)). +Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. So the render loop runs uncapped rather than at a target frame rate: a cap would only lower quality below what the hardware reaches, the LED wire time already paces output, and the loop's own short sleep keeps the UI and network responsive. This is also what makes the cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [Multi-device sync](#multi-device-sync)). Effects know nothing about hardware, protocols, physical LED layout, or mapping. They only see the logical grid the layer provides. @@ -255,13 +255,13 @@ Each driver child reads from the Drivers container's output buffer. Everything b Two rules separate those channels from color, and both matter: - **Brightness never scales them.** Brightness is a light-output setting; scaling pan by it would swing a moving head toward 0/0 as the rig dims. -- **They interpolate but never accumulate** (the rule; the combining half is not implemented, see below). A blend op that INTERPOLATES (opacity, a crossfade) is meaningful on any channel. On pan it is a genuine feature: the head sweeps smoothly from the old aim to the new one as a layer fades in. A blend op that COMBINES two values is meaningful only on emissive channels: additive accumulates, and the separable modes such as multiply and screen compute a new value. Summing two lights models two sources lighting one surface. Summing two aims models nothing: it points at neither, and saturates at hard-over as soon as both layers are positioned. So an accumulating op should fall back to assignment on a motion channel, the topmost writer winning. **`blendMap` treats a light as opaque bytes and combines motion channels along with color.** It only bites with two enabled layers on a fixture that carries motion, and the fix is [backlogged](../../work/future/backlog-light.md). +- **They interpolate but never accumulate** (the rule; the combining half is not implemented, see below). A blend op that INTERPOLATES (opacity, a crossfade) is meaningful on any channel. On pan it is a genuine feature: the head sweeps smoothly from the old aim to the new one as a layer fades in. A blend op that COMBINES two values is meaningful only on emissive channels: additive accumulates, and the separable modes such as multiply and screen compute a new value. Summing two lights models two sources lighting one surface. Summing two aims models nothing: it points at neither, and saturates at hard-over as soon as both layers are positioned. So an accumulating op should fall back to assignment on a motion channel, the topmost writer winning. **`blendMap` treats a light as opaque bytes and combines motion channels along with color.** It only bites with two enabled layers on a fixture that carries motion, and the fix is [planned](../../work/Plan-20261011%20-%20Layer%20compositing%2C%20masks%20and%20transitions%20(2%20of%203).md). **DMX fixtures are addressed as a daisy chain of IDENTICAL fixtures**, the same model addressable LEDs already impose. A strip is N identical pixels at a fixed stride, and a DMX run is N identical fixtures at a fixed stride. One fixture profile describes one fixture, its channel count is the stride, and fixture *n* starts at `start + n x channelCount`. Twenty-five channels per fixture puts them at DMX 1, 26, 51, and so on, and the driver's `count` says how many are on the chain. This is what makes a moving head reachable by the same pipeline as a pixel. The light domain produces one logical light per fixture, and the driver expands each into that fixture's channel block through the profile. It is also the cheapest thing to configure, since only the start address and the fixture type are needed, never a per-fixture address table. -The trade is deliberate: **a chain must be homogeneous**. Mixing fixture types on one universe, or leaving gaps between fixtures, has no expression in this model, and neither does a fixture whose address does not sit on the stride. Those need a per-fixture address map, which is the fixture-model work ([backlog](../../work/future/backlog-light.md)). Until then, a mixed rig gives each fixture type its own driver instance with its own fixture profile, start address and count. +The trade is deliberate: **a chain must be homogeneous**. Mixing fixture types on one universe, or leaving gaps between fixtures, has no expression in this model, and neither does a fixture whose address does not sit on the stride. Those need a per-fixture address map, which is the fixture-model work ([backlog](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082264)). Until then, a mixed rig gives each fixture type its own driver instance with its own fixture profile, start address and count. ## Multicast and IGMP snooping @@ -293,7 +293,7 @@ How lighting uses the core [multi-device runtime](mooncore.md#multi-device-runti - **The same effect on every board.** [Shared controls](mooncore.md#multi-device-runtime) carry the surface, and whatever the Control card assigns it to, so several boards run one effect with one set of controls. This is the controls half of supersync; the clock is the other. - **Synced visuals from the shared clock.** Effects animate off elapsed time ([Effects](#effects)), so a synced clock is what makes a wall of controllers animate in lockstep regardless of each one's frame rate. This is the light-domain payoff of the core clock sync. -- **Light distribution**: one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards. The ArtNet *driver* sends to fixtures; device-to-device distribution as a sync topology is filed in [backlog-core](../../work/future/backlog-core.md). No bespoke protocol. +- **Light distribution**: one device sending rendered light data to another uses the existing ArtNet / E1.31 / DDP standards. The ArtNet *driver* sends to fixtures; device-to-device distribution as a sync topology is filed in [backlog/core](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077324). No bespoke protocol. ## What bounds a parallel driver diff --git a/docs/explanation/architecture/moonlive.md b/docs/explanation/architecture/moonlive.md index 23ac6282e..cbc25527b 100644 --- a/docs/explanation/architecture/moonlive.md +++ b/docs/explanation/architecture/moonlive.md @@ -77,7 +77,7 @@ The host-binding surface follows the [MoonLight effects tutorial](https://ewowi. ## A scripted module is still a module -**A scripted module differs from a compiled one in one thing only: where its behavior comes from.** Everything else is the same mechanism: the same base class, the same `prepare()`/`release()` lifecycle, the same controls, the same status and memory reporting, the same container contract. A `MoonLiveLayout` is a `LayoutBase` that answers `lightCount()` and `placeLights()` like any other; it answers them by running compiled machine code instead of arithmetic over its members. When a scripted binding needs a mechanism its compiled sibling does not, that is a finding: either the mechanism belongs in the base for everyone, or the divergence needs its reason stated where it is introduced. A binding that drifts into its own lifecycle stops being a module and becomes a second system to maintain. +**A scripted module differs from a compiled one in one thing only: where its behavior comes from.** Everything else is the same mechanism: the same base class, the same `prepare()`/`release()` lifecycle, the same controls, the same status and memory reporting, the same container contract. A `MoonLiveLayout` is a `LayoutBase` that answers `lightCount()` and `placeLights()` like any other; it answers them by running compiled machine code instead of arithmetic over its members. When a scripted binding needs a mechanism its compiled sibling does not, that is a finding: either the mechanism belongs in the base for everyone, or the divergence needs its reason stated where it is introduced. A binding that drifts into its own lifecycle stops being a module and becomes a second system to maintain. So a script runs inside its module's own callback, `tick()` for an effect and `placeLights()` for a layout, never in a task of its own: it runs on the thread that calls that callback, needs no cross-thread sync, and spends no scheduler time or stack per script. **The vocabulary follows the compiled modules, never leads them.** Every call a script makes is a call into a function compiled modules use too: `addRGB` is `draw::addPixel`, `mulRGB` is `draw::scalePixel`, and a builtin handler only converts the script's arguments. So anything a script can do, a compiled effect or modifier can do through the same function, and the two cannot drift. A capability starts in the compiled vocabulary, in the light domain's power functions, and reaches scripts when a script needs it. diff --git a/docs/explanation/architecture/moonwalk.md b/docs/explanation/architecture/moonwalk.md new file mode 100644 index 000000000..3158002fa --- /dev/null +++ b/docs/explanation/architecture/moonwalk.md @@ -0,0 +1,45 @@ +# MoonWalk + +MoonWalk is the model every MoonLight process is written in, and the map that shows them. The processes live in the stage files under [moonwalk/](../../../moonwalk/), validated by [the schema](../../../moonwalk/moonwalk.schema.json). How to use the map and write a process is [the MoonWalk reference](../../reference/moonwalk.md); why the model has its shape follows. + +## What it does + +- **One model** holds every process, the end user's and the contributor's, so the whole journey is one map. +- **The map is navigable**: on the website and in the app, zoomed out to the whole model, zoomed in by search and filters, and opened at the place the reader is, a docs page or the card open in the app. +- **A process already done is not offered again**: what the reader has done is known, and the map and the app's suggestions leave it out. +- **The pages named for an activity are generated from it**: building, testing, integrating, troubleshooting, updating the firmware, logging an issue and the rest of the how-tos. So a process carries its prose as well as its steps. +- **Every runner reads the same steps**: the docs, the app, the tests and the agents. + +## Three levels, one textbook each + +| Level | What it is | Textbook | Zoom | +|---|---|---|---| +| The journey | stages, left to right: install, first light, everyday use, advanced use, maintain, integrate, plan and set up, develop, run on hardware, test, document and commit, review and merge, release | the user story map (Patton) | zoomed out | +| A process | a goal, its actor, the processes it depends on, its steps, its branches | an Arazzo workflow | middle | +| A step | a precondition, an action, an expectation | Given, When, Then (Gherkin) | zoomed in | + +**Arazzo-shaped, not Arazzo.** Arazzo requires every step to call an API operation or another workflow, and most of the journey's steps are a click in the app, a command or a person plugging in a board. So the model keeps Arazzo's structure and names (`dependsOn`, `inputs`, a workflow per process, several per document) with its own step shape. A process whose steps are all API calls can be exported to Arazzo, which is where Redocly's Respect and Spectral come in. + +## One YAML file per stage + +The number in a stage file's name orders the backbone. The files are YAML because a process carries prose: multi-line text, Markdown and comments read well in YAML and badly in JSON strings, and MkDocs already depends on PyYAML. The schema validates YAML and JSON alike. YAML 1.1 reads an unquoted `on`, `off`, `yes` or `no` as a boolean, so the generator rejects a boolean where a name belongs. + +## A step names an intent + +A `when` names one action by intent rather than gesture, so each runner picks its own gesture: the app points at the card's +, and a test applies a state document. The runner list is derived from the action, never written down. A runner meeting an action it cannot perform describes it, as the scenario runner prints SKIP. + +**A process is a how-to, never a tutorial.** Diátaxis keeps the two apart: a tutorial is a lesson with an outcome its teacher chose, a how-to serves the reader's own goal. A step says what to decide or do toward that goal; an example value goes in its detail, never in its check, and a check tests only what holds for any reader. The lessons live in MoonTube's clips and in the tutorials, and a step may embed a clip. + +Running the same script from several processes is a call, its explanation living once, in the script's MoonDeck section or the process that owns it. **A choice is an input**, a JSON Schema as Arazzo's workflow inputs are, and a step runs only for the option its `given` names. + +**Text and shape have caps, as the help's cards do.** Text past a cap goes to its Diátaxis kind rather than being squeezed: a table to a reference page, a reason to an explanation, a symptom to Troubleshooting, a second route to its own process. + +## Done is observed + +**A step is done when its `then` holds now.** The app reads its own state, so "WiFi is set up" is done the moment a network is known, however it got there. This is the onboarding checklist's textbook rule, completion observed rather than recorded, and it needs no storage. A process is done when every expectation the state can answer holds, and it has at least one: its last step alone is not enough, since a last step is often one the state cannot see. + +What the state cannot show is vouched for by whoever knows it. The installer marks the install done, since it knows the platform for certain where a running device cannot tell a desktop from a Linux board or a container. MoonDeck marks a process done when every script its steps run last passed, and a run counts only while it is newer than the sources it covers, as a firmware build does. Those marks stay in the browser. The website cannot read a device, since an https page cannot call a device's http address, so there only the reader's marks count. + +## The map + +`moonwalk.js` draws `moonwalk.json` with Cytoscape.js, from cdnjs and about 430 KB, so it never enters the firmware. The layout is the story map's grid, stages as columns and processes stacked in `dependsOn` order, computed in a few lines rather than by a layout library. The parts of a page that are reference rather than steps, such as a table of ports or a test strategy, stay on a reference page and are linked. diff --git a/docs/explanation/why-we-write-our-own.md b/docs/explanation/why-we-write-our-own.md index 788c4bf3d..6c9ba9be7 100644 --- a/docs/explanation/why-we-write-our-own.md +++ b/docs/explanation/why-we-write-our-own.md @@ -45,7 +45,7 @@ So the position is neither enthusiasm nor resignation. A tool this consequential It is not a verdict on the libraries we moved away from. They work, they have thousands of users, and they were built by people solving real problems on hardware we have never touched. -It is also not arms-length criticism. We built, maintained and contributed to the projects this one descends from, and the code we spent years inside was written by other people *and by us*. Those lessons are recorded in [what we built](../work/past/index.md). +It is also not arms-length criticism. We built, maintained and contributed to the projects this one descends from, and the code we spent years inside was written by other people *and by us*. Those lessons are recorded in [what we built](https://github.com/MoonModules/MoonLight/issues?q=label%3Aplan%20is%3Aclosed). And it is not a general recommendation. No-dependency is right for *this* project because of what this project is for: total control of the target, and a test system with no blind spots. For most software it would be a bad trade. @@ -100,4 +100,4 @@ This is the current iteration of years of LED and light-system development, and | **StarLight** | Standalone LED firmware | [ewowi/StarLight](https://github.com/ewowi/StarLight) | | **MoonLight** | Ground-up build: 60+ effects, memory-optimized mapping, 11 driver types | [ewowi/MoonLight](https://github.com/ewowi/MoonLight) | -We built and maintained these, so MoonLight rests on our own hands-on experience. Their lessons are distilled in [`docs/work/past/`](../work/past/index.md). We carry the ideas forward and write our own code, crediting by name whoever inspired a feature. +We built and maintained these, so MoonLight rests on our own hands-on experience. Their lessons are distilled in [lessons.md](../contributing/lessons.md). We carry the ideas forward and write our own code, crediting by name whoever inspired a feature. diff --git a/docs/friend-repos/hpwit-I2SClocklessLedDriver.md b/docs/friend-repos/hpwit-I2SClocklessLedDriver.md index b1c9e8647..ec2c77b25 100644 --- a/docs/friend-repos/hpwit-I2SClocklessLedDriver.md +++ b/docs/friend-repos/hpwit-I2SClocklessLedDriver.md @@ -2,7 +2,7 @@ What landed on [hpwit/I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver)'s `main` branch, month by month. External-context reference, a factual log of a friend repo's activity, not MoonLight's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](index.md). -The library: Yves Bazin's (hpwit) clockless-LED driver that clocks WS2812-class strips out over the ESP32's I2S/LCD peripheral with DMA, the parallel-output technique MoonLight's LED-driver analysis studies. Summarised via the GitHub commits API (all commits on `main`), filtering out merge commits, lint/format churn, and 🐰-review fixups. No versioned release is cut from `main` in this window (latest tag is 1.4), so months are kept whole. +The library: Yves Bazin's (hpwit) clockless-LED driver that clocks WS2812-class strips out over the ESP32's I2S/LCD peripheral with DMA, the technique behind MoonLight's `ParallelLedDriver`. Summarised via the GitHub commits API (all commits on `main`), filtering out merge commits, lint/format churn, and 🐰-review fixups. No versioned release is cut from `main` in this window (latest tag is 1.4), so months are kept whole. > **Authorship note.** Most of the activity in this window is MoonLight's own, `ewowi` authored ~53 of the in-window commits, with the rest from the maintainer (Yves Bazin / hpwit) and a couple of others. The IDF 5.5 / arduino-less ESP-IDF / RGBCCT / >65K-LED work below is largely MoonLight upstreaming its driver needs into hpwit's library, then tracking the result here. diff --git a/docs/friend-repos/hpwit-I2SClocklessVirtualLedDriver.md b/docs/friend-repos/hpwit-I2SClocklessVirtualLedDriver.md index f88ad2e2c..ecbd39c8f 100644 --- a/docs/friend-repos/hpwit-I2SClocklessVirtualLedDriver.md +++ b/docs/friend-repos/hpwit-I2SClocklessVirtualLedDriver.md @@ -2,7 +2,7 @@ What landed on [hpwit/I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2SClocklessVirtualLedDriver), month by month. External-context reference, a factual log of a friend repo's activity, not MoonLight's own history or roadmap. Newest month on top. The reusable prompt that generates these lives in [README.md](index.md). -The library: Yves Bazin's (hpwit) "virtual pins" variant of the I2S clockless driver, drives far more strips than the chip has usable pins by fanning the I2S output through external shift registers. This multiplex technique is the load-bearing idea MoonLight's LED-driver analysis singles out (factoring the shift-register multiplex out of the I2S/LCD peripheral code). Summarised via the GitHub commits API, read across all branches (`main`, `integration`, `int2`, `variable`, `hpwit-patch-1`, `dev`, `optomize`), not just `main`. +The library: Yves Bazin's (hpwit) "virtual pins" variant of the I2S clockless driver, drives far more strips than the chip has usable pins by fanning the I2S output through external shift registers. This multiplex technique is the one behind `pinExpander` on `MoonI80` (factoring the shift-register multiplex out of the I2S/LCD peripheral code). Summarised via the GitHub commits API, read across all branches (`main`, `integration`, `int2`, `variable`, `hpwit-patch-1`, `dev`, `optomize`), not just `main`. ## September 2026 diff --git a/docs/friend-repos/index.md b/docs/friend-repos/index.md index 5da2385ea..3f8e6ed62 100644 --- a/docs/friend-repos/index.md +++ b/docs/friend-repos/index.md @@ -29,14 +29,14 @@ Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem ## What these projects do that MoonLight doesn't (yet) -Observational: where the landscape is ahead of MoonLight. These are *not* commitments; real adoption decisions live in the [`../backlog/`](../work/future/index.md), cross-referenced where one already exists. +Observational: where the landscape is ahead of MoonLight. These are *not* commitments; real adoption decisions live on [the Project](https://github.com/users/MoonModules/projects/5), cross-referenced where one already exists. - **A guided setup/installer wizard on-device** (NightDriverStrip's Setup Wizard, WLED's installer), MoonLight has the web installer + Improv, but no on-device first-run wizard. - **A large built-in effect library**: MoonLight ships a focused set (concrete-first); the WLED family ships dozens. Breadth is a deliberate non-goal until the core is proven. ## Candidates for MoonLight -Concrete ideas the digests surfaced, waiting for a decision. Each leaves this list when it is decided: into the [backlog](../work/future/index.md), or dropped. Study to think, write fresh, never copy. +Concrete ideas the digests surfaced, waiting for a decision. Each leaves this list when it is decided: into the [backlog](https://github.com/users/MoonModules/projects/5), or dropped. Study to think, write fresh, never copy. - **Run compiled MoonLive code under QEMU in CI** ([hpwit-new-parser](hpwit-new-parser.md), September 2026): a tier that executes the compiled Xtensa bytes found three compiler bugs in its first week, the class host tests cannot see. - **Run every effect with the clock started near a wraparound** ([wled-WLED](wled-WLED.md), September 2026): WLED's Stream effect broke after an hour at top speed from a 32-bit time compared against a 16-bit one. diff --git a/docs/moonmodules/light/power-functions.md b/docs/moonmodules/light/power-functions.md index bad2e47c4..4af05ac24 100644 --- a/docs/moonmodules/light/power-functions.md +++ b/docs/moonmodules/light/power-functions.md @@ -52,7 +52,7 @@ These act on the grid as a surface rather than on a shape. Between them they cov | `draw::blendPixel` | Lerps a pixel toward a color by an amount, rather than replacing it | GameOfLife, Tetrix | — | | `draw::addPixel` | Adds light to a pixel, saturating instead of wrapping to black | Blurz | — | | `draw::scroll` | Shifts the whole grid along an axis, optionally wrapping, the shift register | FreqMatrix | — | -| `draw::splat` | Draws a point at a fractional position, splitting its light across neighbouring pixels so motion is smooth on a coarse grid | *(no caller yet, the particle kernel is its consumer)* | — | +| `draw::splat` | Draws a point at a fractional position, splitting its light across neighboring pixels so motion is smooth on a coarse grid | Fireworks | — | @@ -318,4 +318,4 @@ Everything else writes; this reads. Once a frame can be sampled at an arbitrary -**On the "no caller yet" entries.** Each was added for a named consumer in the [power-function plan](../../work/future/power-functions-analysis-top-down.md): `splat`, `combineMax` and the remaining SDF and easing forms are what the particle kernel and the shader tier build on. They are listed rather than hidden so the gap between what exists and what is used stays visible. +**On the "no caller yet" entries.** Each exists for a named consumer: `combineMax` and the remaining SDF and easing forms are what the particle kernel and the shader tier build on. They are listed rather than hidden so the gap between what exists and what is used stays visible. diff --git a/docs/moonwalk.md b/docs/moonwalk.md index 0bacae6e1..805b4bf9c 100644 --- a/docs/moonwalk.md +++ b/docs/moonwalk.md @@ -10,4 +10,4 @@ On a device, the help on every card links the steps it takes part in and opens t -The map is drawn from the stage files under [moonwalk/](../moonwalk/), one per stage; [the MoonWalk model](work/future/moonwalk.md) describes them. How to use the map, run it and write a process: [the MoonWalk reference](reference/moonwalk.md). +The map is drawn from the stage files under [moonwalk/](../moonwalk/), one per stage; [the MoonWalk model](explanation/architecture/moonwalk.md) describes them. How to use the map, run it and write a process: [the MoonWalk reference](reference/moonwalk.md). diff --git a/docs/reference/attributions.md b/docs/reference/attributions.md index bbd39dd2b..6ef1e05df 100644 --- a/docs/reference/attributions.md +++ b/docs/reference/attributions.md @@ -5,7 +5,7 @@ The people, projects and published algorithms whose work shaped MoonLight. Almos | Name | Kind | Known for | |------|------|-----------| | [4wheeljive](https://github.com/4wheeljive) | 🧑 | Jeff Holman: flow fields, from a Petrick concept. | -| [Adafruit](https://www.adafruit.com) | 🏢 | The LCD_CAM discovery behind the S3 driver. | +| [Adafruit](https://www.adafruit.com) | 🏢 | The [LCD_CAM discovery](https://blog.adafruit.com/2022/06/14/esp32uesday-hacking-the-esp32-s3-lcd-peripheral/) behind the S3 driver. | | [After Dark](https://en.wikipedia.org/wiki/After_Dark_%28software%29) | 🎨 | Berkeley Systems' screensavers, the Flying Toasters and the aquarium among them. | | [Aircoookie](https://github.com/Aircoookie) | 🧑 | Christian Schwinne, creator of WLED. | | [Andrew Tuline / atuline](https://github.com/atuline) | 🧑 | The WLED-SR audio-reactive effects. | diff --git a/docs/reference/build-system.md b/docs/reference/build-system.md index 2282024df..fe3135370 100644 --- a/docs/reference/build-system.md +++ b/docs/reference/build-system.md @@ -75,7 +75,7 @@ Some variants carry [MoonBase](../explanation/architecture/moonbase.md), the sec **Why v6.1.** v6.1 (GA 2026-08-25) carries driver fixes for the newer chips (P4 parlio, RMT v2 everywhere) and is the first line with the `esp32s31` target. The pin is a signed tag rather than the moving `release/v6.1` branch, so a pull cannot shift it under a build. A GA tag also starts the 30-month support window, which a pre-release never has. Moving the pin is a deliberate re-test pass: the move to GA was validated on all four chip families with the full device scenario set (2026-09-29). -**v6.0 is the floor.** v6.0 stable is the fallback if the v6.1 line misbehaves, so the firmware and the tooling stay buildable on it. They use no IDF API, component, Kconfig symbol or tool that v6.0 lacks. An exception needs the product owner's explicit decision, recorded where it is introduced (the module spec, a comment at the dependency, the commit body), with what the fallback loses. Two stand, each for one target and tracked in the [backlog](../work/future/index.md). **P4 WiFi over the C6 co-processor** needs `esp_wifi_remote` and esp-hosted, managed components outside v6.0. The **ESP32-S31** exists only from v6.1, so it has no v6.0 to fall back to. +**v6.0 is the floor.** v6.0 stable is the fallback if the v6.1 line misbehaves, so the firmware and the tooling stay buildable on it. They use no IDF API, component, Kconfig symbol or tool that v6.0 lacks. An exception needs the product owner's explicit decision, recorded where it is introduced (the module spec, a comment at the dependency, the commit body), with what the fallback loses. Two stand, each for one target and tracked in the [backlog](https://github.com/users/MoonModules/projects/5). **P4 WiFi over the C6 co-processor** needs `esp_wifi_remote` and esp-hosted, managed components outside v6.0. The **ESP32-S31** exists only from v6.1, so it has no v6.0 to fall back to. **Where the change was.** v5 to v6.0 was the large step. The legacy peripheral drivers, RMT among them, were removed, so the LED drivers use the RMT v2, parlio and `esp_lcd` APIs. picolibc replaced newlib, warnings became errors by default, and `CONFIG_ESP_WIFI_ENABLED` went, hence the `EXCLUDE_COMPONENTS` path above. v6.1 is an ordinary minor on that baseline. @@ -83,7 +83,7 @@ Some variants carry [MoonBase](../explanation/architecture/moonbase.md), the sec ### The v6 ecosystem -Anything the IDF makes the recognized way to build, install, provision or ship is the path to take rather than a bespoke one. It is adopted step by step, each its own commit and hardware re-test, and only once it clears the v6.0 floor. None may leak an IDF assumption into shared `src/` or the desktop and Teensy builds. The plan per item is in [backlog-core](../work/future/backlog-core.md). +Anything the IDF makes the recognized way to build, install, provision or ship is the path to take rather than a bespoke one. It is adopted step by step, each its own commit and hardware re-test, and only once it clears the v6.0 floor. None may leak an IDF assumption into shared `src/` or the desktop and Teensy builds. The plan per item is in [backlog/platform](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085832). | Change | Where MoonLight stands | |---|---| diff --git a/docs/reference/hardware/esp32-s31-coreboard.md b/docs/reference/hardware/esp32-s31-coreboard.md index 1d2aabcbd..a98e87356 100644 --- a/docs/reference/hardware/esp32-s31-coreboard.md +++ b/docs/reference/hardware/esp32-s31-coreboard.md @@ -83,7 +83,7 @@ Pin map, **bench-verified** (link + DHCP confirmed on the CoreBoard). The RGMII CH9102X with the alternate BOM). Auto-download via DTR/RTS → EN/BOOT. - **Buttons**: BOOT (GPIO61), RESET (EN). - **J5, power/enable jumper** (a 2-pin header next to the 5V→3.3V DC/DC converter U4 and the 3.3V - power-on LED D11). **Leave it installed.** With J5 removed the board is *half-powered*: the CP2102N runs off USB VBUS so its port still enumerates, but the ESP32-S31's 3.3V/EN rail is incomplete and the chip drives nothing, you get a serial port that opens but zero bytes from the MCU, at any baud, in any reset/download mode (see [lessons.md](../../work/past/lessons.md), which cost an hour of chasing a cable that wasn't the problem). + power-on LED D11). **Leave it installed.** With J5 removed the board is *half-powered*: the CP2102N runs off USB VBUS so its port still enumerates, but the ESP32-S31's 3.3V/EN rail is incomplete and the chip drives nothing, you get a serial port that opens but zero bytes from the MCU, at any baud, in any reset/download mode (see [lessons.md](../../contributing/lessons.md), which cost an hour of chasing a cable that wasn't the problem). - **40-pin GPIO header** (J2). Optional 32.768 kHz crystal footprint (Y1, NC by default). ## Free GPIO for user peripherals (LED strips, loopback) @@ -107,7 +107,7 @@ The board's own peripherals claim a large, contiguous low-GPIO block; the **J2 h The two pins in **one column are physically stacked**, so a 2-pin jumper cap bridges them with no flying wire, that adjacency is what makes a column a good loopback pair. -**Free on J2 for user I/O** (read off the table above, minus the board peripherals and boot straps): the numbered GPIOs on cols 5–16 (**4, 36, 37, 38, 39, 40, 42, 43, 44, 45, 46, 47, 48, 49**) are plain I/O clear of Ethernet (2, 5–19), audio (50–57), the SD lines (broken out as `D0`–`D3` / `CLK` / `CMD` by function, cols 13–16), the onboard LED (60) and the straps (0, 1, 3, 61). The `C` label at col 17 is a chip-enable, not a GPIO. **The GPIO numbers here are read from the board silkscreen and not yet bench-confirmed**: the S31 reference pin tables have been found off-by-one before (see the [S31 Ethernet lesson](../../work/past/lessons.md)), so probe a pin before committing a design to it. +**Free on J2 for user I/O** (read off the table above, minus the board peripherals and boot straps): the numbered GPIOs on cols 5–16 (**4, 36, 37, 38, 39, 40, 42, 43, 44, 45, 46, 47, 48, 49**) are plain I/O clear of Ethernet (2, 5–19), audio (50–57), the SD lines (broken out as `D0`–`D3` / `CLK` / `CMD` by function, cols 13–16), the onboard LED (60) and the straps (0, 1, 3, 61). The `C` label at col 17 is a chip-enable, not a GPIO. **The GPIO numbers here are read from the board silkscreen, unconfirmed on the bench**: the S31 reference pin tables have been found off-by-one before (see the [S31 Ethernet lesson](../../contributing/lessons.md)), so probe a pin before committing a design to it. **Recommended assignment** (what the S31 catalog entry uses): diff --git a/docs/reference/hardware/gpio-usage.md b/docs/reference/hardware/gpio-usage.md index a38aa915c..aa1ae5aaf 100644 --- a/docs/reference/hardware/gpio-usage.md +++ b/docs/reference/hardware/gpio-usage.md @@ -22,7 +22,7 @@ For **LED output** specifically — the pins a WS2812-class strand data line can | **ESP32-P4 (P4-NANO)** | 20, 21, 22, 23, 24, 25, 26, 27, 32, 33, 39-48 | 20 | Board-specific: the NANO commits eth-RMII (28-31/49-52), C6-SDIO (14-19/54), I2C (7-8), UART0 (37-38). A carrier board changes the exposed set — the MHC-WLED shield routes through transceivers (no bare GPIO). | | **ESP32-S31 (coreboard)** | take from the [coreboard reference](esp32-s31-coreboard.md) free set | — | Most of the header is committed to on-board RGMII eth + ES8311 codec + SD + USB-host; don't guess. | -**Mapping these to actual device-model defaults** (which of a board's usable pins to pre-fill in `deviceModels.json`) is a separate, per-board step: the catalog defaults one strip for a bare dev board (the user wires the rest), or the exact wired set for a purpose-built board (QuinLED Dig-Octa: `0,1,2,3,4,5,12,13`). That per-model work is tracked in [backlog-core § LED output pins](../../work/future/backlog-core.md). +**Mapping these to actual device-model defaults** (which of a board's usable pins to pre-fill in `deviceModels.json`) is a separate, per-board step: the catalog defaults one strip for a bare dev board (the user wires the rest), or the exact wired set for a purpose-built board (QuinLED Dig-Octa: `0,1,2,3,4,5,12,13`). That per-model work is tracked in [backlog/core § LED output pins](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077175). ## ESP32 (classic) @@ -67,7 +67,7 @@ For **LED output** specifically — the pins a WS2812-class strand data line can | Avoid | GPIOs | Why | |-------|-------|-----| | Reserved | **flash/PSRAM pins per module** | The HP SPI flash + PSRAM bus (module-specific; the Waveshare P4-NANO wires them internally). Off-limits. | -| Role-conflict | **34-38** | Strapping pins (boot mode). Don't drive at reset; the first LED-driver default wrongly landed here (see [lessons.md](../../work/past/lessons.md)). | +| Role-conflict | **34-38** | Strapping pins (boot mode). Don't drive at reset; the first LED-driver default wrongly landed here (see [lessons.md](../../contributing/lessons.md)). | | Role-conflict | **37, 38** | UART0 console on the P4-NANO (`CONFIG_ESP_CONSOLE_UART_DEFAULT`) — the runtime `ESP_LOGI` lines come out here, not over USB. | | Board-wired (P4-NANO) | **Ethernet RMII** 28-31 / 49-52, **C6 SDIO** 14-19 / 54, **I2C** 7-8 | Consumed by the NANO's on-board Ethernet PHY, the C6 WiFi co-processor, and the I2C bus. | | Board-wired (P4-ETH) | **Ethernet RMII** 28-31 / 34-35 / 49-52, **ES8311 audio** 7-13, **USB D-/D+** 24-25, **microSD** 39-45 | The [Waveshare ESP32-P4-ETH](esp32-p4-eth.md) has no C6/WiFi. It carries its own onboard ES8311 codec, a native USB-C port, and a microSD slot. That page has the full per-signal map. | diff --git a/docs/reference/hardware/hub75-panels.md b/docs/reference/hardware/hub75-panels.md index a7786da4c..ceb96e591 100644 --- a/docs/reference/hardware/hub75-panels.md +++ b/docs/reference/hardware/hub75-panels.md @@ -35,6 +35,8 @@ Steps: [Set up a driver or service for the hardware](../../moonwalk.md#driver-se Every bit plane is a full scan, so `bitDepth` costs refresh and memory linearly. One 64x64 panel at 1/32 scan is 16,384 bytes a frame at 4-bit and 24,576 at 6-bit. Brightness on a HUB75 panel is time, not amplitude, so lowering it shortens each plane's lit time and keeps every color level. The price is top brightness: full white lights about a third of the time at 6-bit. +On the bench (2026-10-06, the MoonHub75's panel on LCD_CAM) 4-bit refreshes at 2,157 Hz, against the 2,441 Hz its `(height / 2) × bitDepth` line times predict before latch overhead. 6-bit costs about 2 ms more render tick, about 57 against 64 fps. + If a panel flickers, `refresh` is the measured rate to report. **A panel takes its own 5 V supply on its power connector.** A lit row draws all its columns at once, so the peak current does not fall with brightness: a 64x64 panel at full red and blue pulls about 2.6 A. A supply that sags under it, such as a controller board's USB, drops the blue and green LEDs first, which need about 3 V where red needs 2, so a color with much red in it shows as red or orange. diff --git a/docs/reference/hardware/led-drivers.md b/docs/reference/hardware/led-drivers.md index a8d192a85..78a6d87eb 100644 --- a/docs/reference/hardware/led-drivers.md +++ b/docs/reference/hardware/led-drivers.md @@ -24,10 +24,14 @@ Start with `i80`. Choose `MoonI80` only when you hit one of those two limits. It | **`Parlio`** | P4 | 1 to 16 | | **`GPIO-DMA`** | Teensy 4.1 | 1 to 16, on pins 0, 1, 14 to 27 and 38 to 41, in any order | +RMT LED cannot drive an expander: each channel emits its own self-timed stream with no clock line, and a '595 needs a shift clock. + ## Two limits A strand holds at most **2,048 lights**, on every peripheral. On the classic ESP32 the `i80` DMA cannot reach PSRAM, so the whole frame must also fit internal RAM. Past either limit the driver idles with a status rather than crashing. +A frame takes the longest strand's wire time, about 30 µs a light, whatever the lane count: 256 lights a strand is about 7.5 ms and 1,024 about 30 ms. More strands of fewer lights is how a wall gets its frame rate, and `frameTime` on the card reports that floor. + ## Lane, pin, strand A lane is one bus data line, a strand is one chain of LEDs. Wired directly, one pin is one lane is one strand. Through an expander, each data pin feeds one '595 and fans out to 8 strands, so 8 data pins reach the driver's ceiling of 64 strands. @@ -36,6 +40,8 @@ A lane is one bus data line, a strand is one chain of LEDs. Wired directly, one On an S3, P4 or S31 with `i80` or `MoonI80` as the `peripheral`, `pinExpander` on and `latchPin` set, each data pin drives one register and its 8 outputs drive 8 strands. +A rig costs its data pins plus two GPIOs: the shift clock on `clockPin` and the latch on `latchPin`, shared by every register. The clock is the peripheral's own pixel clock and costs no DMA bytes; the latch rides as a bit in every bus word. + The expander buys pins, not memory: the register is serial-in, so the DMA frame grows eightfold, about 1.1 KB per light. `i80` holds the whole frame, which past about 96 lights per strand falls to PSRAM too slow for the expander's clock. `MoonI80` streams the frame and drives 128 reliably. ## Sizing the MoonI80 ring diff --git a/docs/reference/hardware/light-fixtures.md b/docs/reference/hardware/light-fixtures.md index e43efba3c..c216941ef 100644 --- a/docs/reference/hardware/light-fixtures.md +++ b/docs/reference/hardware/light-fixtures.md @@ -41,7 +41,7 @@ That is a DIFFERENT model: this one is 11/13 channels with a plain linear dimmer | 10 | 0-255 | blue, 0 = closed | `B` | | 11 | 0-255 | white, 0 = closed | `W` | -CH6 is a straight linear dimmer. Today the driver holds it fully open and keeps brightness in the color values; routing brightness onto it is the better model and is [backlogged](../../work/future/backlog-light.md). The channels left unmapped hold at 0, which is what a light driver wants: **strobe off** (CH7) and full-speed movement (CH5). The fine channels are unused until 16-bit positioning is wired up. +CH6 is a straight linear dimmer. The driver holds it fully open and keeps brightness in the color values; routing brightness onto it is the better model and is [backlogged](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080535). The channels left unmapped hold at 0, which is what a light driver wants: **strobe off** (CH7) and full-speed movement (CH5). The fine channels repeat the coarse byte, so a full-scale aim reaches the fixture's 65535; true 16-bit positioning waits on a 16-bit aim. Two things matter when driving it by hand. Every channel at 255 makes the fixture **strobe**, because CH7 is a strobe channel rather than part of the dimmer. And the 13-channel mode's CH12 at 250-255 selects **sound mode**, where the fixture ignores DMX movement and runs its own program. diff --git a/docs/reference/hardware/mhc-wled-esp32-p4-shield.md b/docs/reference/hardware/mhc-wled-esp32-p4-shield.md index ea081d344..227acd09f 100644 --- a/docs/reference/hardware/mhc-wled-esp32-p4-shield.md +++ b/docs/reference/hardware/mhc-wled-esp32-p4-shield.md @@ -93,4 +93,4 @@ The loopback self-test drives a WS2812 frame out one pin and reads it back on a ## Cross-reference -Chip-level GPIO constraints (straps, flash/PSRAM) for the P4 are in [gpio-usage.md § ESP32-P4](gpio-usage.md#esp32-p4); this page is the *board* wiring. The catalog entry is [`mooninstaller/deviceModels.json`](../../mooninstaller/deviceModels.json) (`MHC-WLED ESP32-P4 shield`). RS-485 / DMX-512 as a first-class MoonLight output is tracked in the [RS-485 / DMX-512 wired-output backlog item](../../work/future/backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver). +Chip-level GPIO constraints (straps, flash/PSRAM) for the P4 are in [gpio-usage.md § ESP32-P4](gpio-usage.md#esp32-p4); the sections here are the *board* wiring. The catalog entry is [`mooninstaller/deviceModels.json`](../../mooninstaller/deviceModels.json) (`MHC-WLED ESP32-P4 shield`). RS-485 / DMX-512 as a first-class MoonLight output is tracked in the [RS-485 / DMX-512 wired-output backlog item](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080691). diff --git a/docs/reference/issues-in-other-projects.md b/docs/reference/issues-in-other-projects.md index 6f589935e..bdfad395a 100644 --- a/docs/reference/issues-in-other-projects.md +++ b/docs/reference/issues-in-other-projects.md @@ -30,4 +30,4 @@ The Status column holds what each tracker shows on 2026-10-26. | Issue | Filed by | Submitted | What | Our side | Status | Once it ships | |---|---|---|---|---|---|---| -| To file | us | | Six changes: bounded waits in the W5500 driver, an overridable TCP send buffer, the receive window reopened on read, mDNS NSEC answers, each driver's buffers allocated once it finds its hardware, and the Teensy 4.1 driver's per-frame checksum flags following lwIP's checksum options | The six `QNEthernet-*.patch` files in [teensy/patches](https://github.com/MoonModules/MoonLight/tree/main/teensy/patches) | To file, as [the Teensy plan](../work/present/Plan-20261010%20-%20Teensy%204.1%20%283%20of%206%29.md) lists | Drop each patch once the QNEthernet version this tree takes carries its change | +| To file | us | | Six changes: bounded waits in the W5500 driver, an overridable TCP send buffer, the receive window reopened on read, mDNS NSEC answers, each driver's buffers allocated once it finds its hardware, and the Teensy 4.1 driver's per-frame checksum flags following lwIP's checksum options | The six `QNEthernet-*.patch` files in [teensy/patches](https://github.com/MoonModules/MoonLight/tree/main/teensy/patches) | To file, as [the Teensy plan](../work/Plan-20261010%20-%20Teensy%204.1%20(3%20of%206).md) lists | Drop each patch once the QNEthernet version this tree takes carries its change | diff --git a/docs/reference/metrics/prose.json b/docs/reference/metrics/prose.json index a6fe9f864..bd246144e 100644 --- a/docs/reference/metrics/prose.json +++ b/docs/reference/metrics/prose.json @@ -1,12 +1,12 @@ { - "(total)": 2013, - "MoonLight.EmDash": 1107, + "(total)": 2017, + "MoonLight.EmDash": 1106, "MoonLight.LinkText": 7, "MoonLight.NegatedHeading": 6, - "MoonLight.SectionLength": 32, - "MoonLight.SelfReference": 17, + "MoonLight.SectionLength": 42, + "MoonLight.SelfReference": 16, "MoonLight.Spelling": 39, - "MoonLight.Temporal": 174, + "MoonLight.Temporal": 170, "MoonLight.TonePadding": 1, "MoonLight.VagueClose": 28, "MoonLight.Weasel": 602 diff --git a/docs/reference/moonwalk.md b/docs/reference/moonwalk.md index 75e1bf8ef..e016ff785 100644 --- a/docs/reference/moonwalk.md +++ b/docs/reference/moonwalk.md @@ -1,6 +1,6 @@ # MoonWalk -The reference for MoonWalk, the map of every MoonLight process. The map is one renderer, [moonwalk.js](https://github.com/MoonModules/MoonLight/blob/main/docs/assets/moonwalk.js), drawing one file, `moonwalk.json`, which [a generator](https://github.com/MoonModules/MoonLight/blob/main/moondeck/docs/gen_moonwalk.py) builds from the stage files under [moonwalk/](https://github.com/MoonModules/MoonLight/tree/main/moonwalk). Why it is shaped so: [the MoonWalk model](../work/future/moonwalk.md). The map itself: [MoonWalk](../moonwalk.md). +The reference for MoonWalk, the map of every MoonLight process. The map is one renderer, [moonwalk.js](https://github.com/MoonModules/MoonLight/blob/main/docs/assets/moonwalk.js), drawing one file, `moonwalk.json`, which [a generator](https://github.com/MoonModules/MoonLight/blob/main/moondeck/docs/gen_moonwalk.py) builds from the stage files under [moonwalk/](https://github.com/MoonModules/MoonLight/tree/main/moonwalk). Why it is shaped so: [the MoonWalk model](../explanation/architecture/moonwalk.md). The map itself: [MoonWalk](../moonwalk.md). | Part | For | It covers | |--|--|--| diff --git a/docs/reference/performance.md b/docs/reference/performance.md index 105101a8f..1686ed103 100644 --- a/docs/reference/performance.md +++ b/docs/reference/performance.md @@ -245,7 +245,7 @@ A `MoonLiveEffect` compiles its script to native code for the board's ISA once, | `setRGB(random16(256), 0, 255, 0)` (one host call) | 29 | ~140 B | | `fill(0, 0, 255)` (loop over all lights) | 47 | ~68 B | -The rows above are a dated S3 bench record; the numbers below them are what a desktop run measures today. The tick cost is native-code speed: a `setRGB` is a bounds-guard + three byte stores (~26 µs including the per-tick module overhead), `fill` adds the per-light loop. The **exec block scales with the program**, not a fixed cap: a one-liner is tens of bytes of machine code, since `place()` allocates the emitted length, word-rounded. The module reports it as its dynamic memory (`setDynamicBytes(engine_.heapBytes())`, the exec block plus the control arena), so it shows on the UI card. What the rest costs is in the table below; [livescripts-analysis-top-down.md § 3.7](../work/future/livescripts-analysis-top-down.md) covers how it scales as the language grows. +The rows above are a dated S3 bench record; the numbers below them are what a desktop run measures. The tick cost is native-code speed: a `setRGB` is a bounds-guard + three byte stores (~26 µs including the per-tick module overhead), `fill` adds the per-light loop. The **exec block scales with the program**, not a fixed cap: a one-liner is tens of bytes of machine code, since `place()` allocates the emitted length, word-rounded. The module reports it as its dynamic memory (`setDynamicBytes(engine_.heapBytes())`, the exec block plus the control arena), so it shows on the UI card. What the rest costs is in the table below. **What MoonLive costs** (the `esp32s3-n16r8` image, 2026-10-02, from the link map and the symbol table): @@ -277,6 +277,15 @@ A function's local variables live in registers and on the render task's stack wh The exec block is the emitted machine code, so its size varies by ISA. `lines.mlv` is the cheapest of the three because `line()` moves the per-cell loop out of emitted code into the shared `draw::line`. That is the argument for adding power functions as builtins rather than writing them in script. +**Every shipped script as a class** (2026-08, [the class series](https://github.com/MoonModules/MoonLight/issues/167)): `ember.mlv` uses every construct at once, and the two Xtensa targets emit byte-identical code. + +| | classic (Xtensa) | S3 (Xtensa) | S31 (RISC-V) | +|---|---:|---:|---:| +| `ember.mlv` tick | 81 µs | 117 µs | 33 µs | +| `ember.mlv` exec block | 1396 B | 1396 B | 1620 B | +| `grid.mlv` exec block | 499 B | 499 B | 880 B | +| `plasma.mlv` exec block | 1378 B | 1378 B | 2644 B | + **A script calling its own functions** pays for a recursion guard: nine instructions in a function's prologue, emitted only when the program contains a `CallScript`. `crosshair.mlv` (three functions, two calls per frame) ticks at 219 µs on the classic against 204 µs without the guard, roughly 5-10%. Unbounded recursion degrades instead of resetting: the classic ran a non-terminating script for 110 s at 109 fps. **Two cost models** (2026-08-22, shiffy's 80x48 = 3,840 lights). A shader is a function of position and time, so it pays per light. A particle script pays per object, with the per-particle work inside one C++ loop per call. @@ -397,12 +406,12 @@ Each parallel LED driver ran on real hardware at a 128×128 = 16384-light grid w | Peripheral | Board | Pins used (8 lanes) | Result | Ceiling / bound | |---|---|---|---|---| -| **Parlio** | ESP32-P4 (Waveshare P4-NANO) | `20,21,22,23,24,25,26,27` | `Drivers` tick ~30100 µs, fps 30 at 16384 lights (8 lanes, SWAR transpose). | Parlio's single-shot transfer caps at 65535 bytes in total, and a light costs `channels × 24 × slotBytes`. The ceiling is **897 lights/lane at 8 lanes RGB** and 673 RGBW. It halves to 442/332 at 16 lanes, since a 16-bit bus doubles `slotBytes`. Over that, the driver reports `too many lights per pin` and keeps running. The [chunked-DMA work](../work/future/backlog-light.md) lifts the ceiling (tier 1 → ~16-21K). | +| **Parlio** | ESP32-P4 (Waveshare P4-NANO) | `20,21,22,23,24,25,26,27` | `Drivers` tick ~30100 µs, fps 30 at 16384 lights (8 lanes, SWAR transpose). | Parlio's single-shot transfer caps at 65535 bytes in total, and a light costs `channels × 24 × slotBytes`. The ceiling is **897 lights/lane at 8 lanes RGB** and 673 RGBW. It halves to 442/332 at 16 lanes, since a 16-bit bus doubles `slotBytes`. Over that, the driver reports `too many lights per pin` and keeps running. The [chunked-DMA work](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080935) lifts the ceiling (tier 1 → ~16-21K). | | **LCD_CAM i80** (MultiPinLedDriver) | ESP32-S3 N16R8 Dev | data `18,5,6,7,8,9,10,11` · WR(clock) `12` · DC `13` | Same encoder, healthy on real i80. Encode scales ~6 µs/light (8×512 = 4096 → 23 ms; 8×1024 = 8192 → 50 ms). | **Single-DMA init ceiling 8192–12288 lights**: 8×1024 initializes, and 8×1536 fails with `LCD-IDF: bus init failed, check pins / memory`. | -| **RMT** | classic ESP32 (LOLIN D32 / WROOM) | `2,4,13,14,16,17,18,19` (pin 2 = a real 24-LED strand) | 8-pin RMT drives **8×256 = 2048 lights** at a ~12.6 ms tick. It scales to ~8192 before the tick plateaus. All lanes are healthy, and the pin-2 strand is verified lit. | **Silent alloc failure:** the RMT symbol buffer is sized for the driver's `count` window. `count=0` on a 16384 grid needs ~1.5 MB, which fails on the ~90 KB heap, and `tick()` returns with **no status** (LEDs dark). Bound the driver with the start/count window; a status for this is [backlogged](../work/future/backlog-light.md). | +| **RMT** | classic ESP32 (LOLIN D32 / WROOM) | `2,4,13,14,16,17,18,19` (pin 2 = a real 24-LED strand) | 8-pin RMT drives **8×256 = 2048 lights** at a ~12.6 ms tick. It scales to ~8192 before the tick plateaus. All lanes are healthy, and the pin-2 strand is verified lit. | **Silent alloc failure:** the RMT symbol buffer is sized for the driver's `count` window. `count=0` on a 16384 grid needs ~1.5 MB, which fails on the ~90 KB heap, and `tick()` returns with **no status** (LEDs dark). Bound the driver with the start/count window; a status for this is [backlogged](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079814). | | **I2S i80** | classic ESP32 (ESP32-WROVER) | data `2,4,13,14,18,19,21,22` · WR(clock) `32` · DC `33` (pin 2 = a real strand, verified lit) | The **same** `MultiPinLedDriver`, over the **I2S peripheral in i80 mode**. IDF routes the i80 API to I2S on the classic and to LCD_CAM on the S3 and P4. 8-lane doubling sweep, 128×128 grid, 2026-07-13. 64/pin (512) → 4877 µs, 128/pin (1024) → 8575 µs, 256/pin (2048) → 15638 µs. It scales linearly at **~7.6 µs/light**, against ~6 µs on the S3's LCD_CAM. `frameTime` reports the WS2812 wire floor (512 → 243 fps, 2048 → 67 fps). **16 lanes work on classic too** (16×256 = 4096 verified), but the WROVER exposes only ~13 non-strap pins, so 8 lanes is the practical set. | **Internal-RAM ceiling: 2048 lights at 8 lanes (4096 at 16).** The classic I2S backend **cannot DMA from PSRAM**: `esp_lcd_i80_alloc_draw_buffer` rejects `MALLOC_CAP_SPIRAM` ("external memory is not supported"). Its frame buffer is internal DMA RAM only (`maxBlock` ≈ 76 KB). At 8 lanes, 512/pin (4096) and above fail with `I2S-IDF: bus init failed, check pins / memory`, a **clean degrade** with uptime climbing through every rung. **The render is decoupled from this ceiling:** the same sweep rendered the full 16384-light grid at every rung (`Layer` ≈ 511 ms/frame, from PSRAM) while the output was capped. At 16K lights the render (511 ms) dwarfs the output (24 ms), so the render is the wall on this chip and multicore cannot help. | | **LCD_CAM 16-lane** | ESP32-S3 (SE 16 V1 + LightCrafter 16, n8r8) | SE16 data `47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1` · WR/DC `5`/`6`; LC16 data `47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48` · WR/DC ghost `33`/`34` | **Reaches the full 16384 lights, where Parlio caps at 4096.** SE16 16-lane doubling sweep, 128×128 grid, `doubleBuffer` ON, 2026-07-13. 512 → 1843 µs, 1024 → 3422 µs, 2048 → 6612 µs, 4096 → 15153 µs, 8192 → 26788 µs, **16384 → 49916 µs (~20 fps)**. The driver tick is the encode alone; the WS2812 wire overlaps in background DMA, and `frameTime` reports it (16384 → 28786 µs). With `doubleBuffer` OFF: 4096 → 22518 µs and 16384 → 77732 µs, so async is ~30–56% faster. | **PSRAM-backed, so the contiguous-block limit that caps Parlio stays out of the way.** LCD_CAM allocates its DMA buffer through `esp_lcd_i80_alloc_draw_buffer` **from PSRAM**, clear of the ~368 KB largest internal block. **At 16K the encode is the wall.** The 28,786 µs wire alone allows ~35 fps, so the tick is the 49,916 µs encode at ~20 fps. Inline on **core 0**, that encode **starves the LC16's W5500 SPI-Ethernet**, which the [multicore pipeline](#multicore-the-whole-output-stage-on-core-1-multicore-step-2) resolves. | -| **Parlio 16-lane** | ESP32-P4 (testbench, n16r8) | 16 data pins `21,20,22,23,24,25,26,27,32,33,39,40,41,42,43,44` | 16-lane doubling sweep (2026-07-12), reproduced within 0.3% on a second P4. `ledsPerPin` runs 32 to 256 on a 128×128 grid. The tick scales **linearly**: 512 → 1653 µs, 1024 → 2925 µs, 2048 → 5514 µs, **4096 → 10760 µs**. With `doubleBuffer` ON (2026-07-13), the ~7.5 ms wire wait moves into background DMA. The driver tick at 256/pin is **3,790 µs against 10,820 µs (92 driver-fps) OFF**. The board runs **76 fps against 48**, with a system tick of 13.0 against 20.6 ms. `frameTime` reports the measured wire floor: **7474 µs (133 fps max)**. The tick is effect render (~7.3 ms) plus driver (~3.8 ms) in series, which the [multicore pipeline](#multicore-the-whole-output-stage-on-core-1-multicore-step-2) overlaps toward that ceiling. OFF saves one frame of latency, below the perceptual A/V-sync threshold, so ON suits every use, audio-reactive included. | **Single-DMA ceiling ≈ 4096 lights (256/pin).** 512/pin (8192) fails with `Parlio init failed, check pins / memory`. The P4 has 33 MB free heap, but its largest contiguous internal block is ~368 KB, and the 16-bit single-shot DMA buffer needs one contiguous block. It is a **contiguous-block limit rather than total memory**, and it bites before the 65535-byte cap. The full 16384 (1024/pin) needs the [Parlio chunked-transfer](../work/future/backlog-light.md) work, which splits the frame across DMA bursts. | +| **Parlio 16-lane** | ESP32-P4 (testbench, n16r8) | 16 data pins `21,20,22,23,24,25,26,27,32,33,39,40,41,42,43,44` | 16-lane doubling sweep (2026-07-12), reproduced within 0.3% on a second P4. `ledsPerPin` runs 32 to 256 on a 128×128 grid. The tick scales **linearly**: 512 → 1653 µs, 1024 → 2925 µs, 2048 → 5514 µs, **4096 → 10760 µs**. With `doubleBuffer` ON (2026-07-13), the ~7.5 ms wire wait moves into background DMA. The driver tick at 256/pin is **3,790 µs against 10,820 µs (92 driver-fps) OFF**. The board runs **76 fps against 48**, with a system tick of 13.0 against 20.6 ms. `frameTime` reports the measured wire floor: **7474 µs (133 fps max)**. The tick is effect render (~7.3 ms) plus driver (~3.8 ms) in series, which the [multicore pipeline](#multicore-the-whole-output-stage-on-core-1-multicore-step-2) overlaps toward that ceiling. OFF saves one frame of latency, below the perceptual A/V-sync threshold, so ON suits every use, audio-reactive included. | **Single-DMA ceiling ≈ 4096 lights (256/pin).** 512/pin (8192) fails with `Parlio init failed, check pins / memory`. The P4 has 33 MB free heap, but its largest contiguous internal block is ~368 KB, and the 16-bit single-shot DMA buffer needs one contiguous block. It is a **contiguous-block limit rather than total memory**, and it bites before the 65535-byte cap. The full 16384 (1024/pin) needs the [Parlio chunked-transfer](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080935) work, which splits the frame across DMA bursts. | **LOLIN D32 (classic ESP32-WROOM) usable LED GPIOs:** `4,13,14,18,19,21,22,23,25,26,27,32,33` plus `16,17` (free on WROOM; they are the PSRAM bus on WROVER). Avoid straps `0,2,12,15`, the onboard LED on `5`, and battery-sense on `35`; input-only `34–39` can't drive an LED. (Chip-level set: [gpio-usage.md](hardware/gpio-usage.md).) @@ -424,7 +433,7 @@ The panel driver is the cheapest active module despite pushing 132 packets per f **Packets set the ceiling.** Each frame is sent synchronously from `tick()`, so cost grows with rows: a 256-row wall doubles the packet count. The card's 1 Gbit requirement is about wire time rather than bandwidth. At 100 Mbit the same bytes take ten times as long and overrun the inter-frame window the sync depends on ([drivers.md](../moonmodules/light/drivers.md#panelcard)). -**The DMA buffer size keeps it stable.** The firmware sets `CONFIG_ETH_DMA_BUFFER_SIZE` to 1536 B, one descriptor per 1512 B frame, plus `CONFIG_ETH_TRANSMIT_MUTEX`. At the 512 B default a frame spans three descriptors, so a 10-descriptor ring holds ~3.3 frames against 132 fired back-to-back. At that depth the S31 refuses ~19 000 frames and wedges twice in 20 minutes. Ring count is not the lever: 30 descriptors run no cleaner than 10. The cost is ~20 KB of internal DMA RAM, since the size applies to both rings ([lessons.md](../work/past/lessons.md)). +**The DMA buffer size keeps it stable.** The firmware sets `CONFIG_ETH_DMA_BUFFER_SIZE` to 1536 B, one descriptor per 1512 B frame, plus `CONFIG_ETH_TRANSMIT_MUTEX`. At the 512 B default a frame spans three descriptors, so a 10-descriptor ring holds ~3.3 frames against 132 fired back-to-back. At that depth the S31 refuses ~19 000 frames and wedges twice in 20 minutes. Ring count is not the lever: 30 descriptors run no cleaner than 10. The cost is ~20 KB of internal DMA RAM, since the size applies to both rings ([lessons.md](../contributing/lessons.md)). **Static RAM: 0 B.** The driver's 1 512 B packet buffer is a class member, allocated only when the driver is added. `check_footprint --module PanelCardDriver --firmware esp32s31` reports ~3 500 B of flash. @@ -441,7 +450,21 @@ The P4's WiFi comes from an on-board ESP32-C6 over SDIO. Compiling that path in Compiling the co-processor in costs roughly **4× per request and 2× on throughput**. Render holds at 359 fps on the WiFi build, so the frame loop is unaffected. The cost is per request rather than per byte, which points at a periodic blocker a request waits out rather than a slow pipe. -Measured on IDF v6.1-rc1; the remaining penalty is tracked in [backlog-core](../work/future/backlog-core.md). +Measured on IDF v6.1-rc1; the remaining penalty is tracked in [backlog/platform](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085924). + +## State over the WebSocket (`teensy41`) + +A `/ws` client receives the roots it views in full and an outline of the rest ([Web UI, side navigation](../moonmodules/core/ui.md#side-navigation)). Measured 2026-10-13 on the Teensy 4.1, one client for 30 seconds per row, subscribing as the page does (the root plus System, FirmwareUpdate, MoonCloud, PreviewDriver and MidiService). The first row is the firmware before it, which sent the whole tree to every client. + +| Client views | Document on open | Per second | +|---|---:|---| +| Whole tree, any root | 46,348 bytes | 1,040 bytes: 722 of patches, 318 of the WLED frame | +| Services (the SdCard card) | 9,872 bytes | 553 bytes | +| Effects | 9,028 bytes | 430 bytes | +| Control | 12,061 bytes | 351 bytes | +| Drivers, the largest root | 35,349 bytes | 392 bytes | + +Drivers' palette and fixture-profile lists are 25 KB of the tree. The largest block a document needs is one 2 KB fragment, or 15 KB for the fixture-profiles list, the largest single control. A `PATCH /api/state` document of up to 30 KB applies on an S3 (2026-10, [presets as state documents](https://github.com/MoonModules/MoonLight/issues/196)). ## Multicore: the whole output stage on core 1 (`multicore`, Step 2) @@ -530,6 +553,8 @@ Tick µs; FPS in parens for the 16K row: All curves scale **about linearly** in pixel count, which rules out a realloc or fragmentation pathology. The heavy effect is the 16K bottleneck on every board. On heavy compute the ranking is P4 ≫ S3 > classic, the P4's 400 MHz dual core being ~3× the S3. At light-16K the **classic (4,360 µs) beats the S3 (7,949 µs)**. The S3's PSRAM buffer has higher access latency than the classic's internal RAM, and the classic's uint16 LUT is half the size. On the heavy effect compute dominates and the S3 leads. +Pulse, the boot default, costs 250 to 280 µs at 16² on an S3, which boots its defaults at 373 fps (v5.0.0, 2026-09-22). + ### MultiplyModifier: compute down, memory up (Noise effect) With the default 2×2 kaleidoscope the modifier makes the logical grid ¼ size, so the effect computes on fewer pixels. The modifier **reduces** the tick at large grids, and its cost is the 1:N mapping-LUT **memory**. @@ -550,3 +575,5 @@ The modifier roughly **halves** the heavy tick at every grid: a quarter of the l Per-target image size, capacity and headroom are generated every commit into [repo-health](metrics/repo-health.md#firmware-size), across all 14 firmware variants. What that table cannot show is where the bytes go. On the default `esp32`, roughly a third of the image is the WiFi stack (`esp_wifi`, `wpa_supplicant`, `esp_phy`). Then come lwIP at ~180 KB, mbedTLS plus the Mozilla root bundle at ~170 KB, and FreeRTOS and the IDF core at ~150 KB. MoonLight's own code is ~120 KB, about a tenth of the binary. That is why `esp32-eth` is the smaller build: excluding WiFi is the single largest saving available. Proportions shift with the IDF version and sdkconfig; measure with `idf.py -B build/esp32-esp32 size-components`. + +Two things the UI offers cost the image almost nothing. The MoonLive script catalog holds names only, about 12 bytes each against about 800 for a script, so a library of 270 scripts is 3 KB of flash against the 220 KB of script text it names, fetched from GitHub on demand (2026-09, [the script library](https://github.com/MoonModules/MoonLight/issues/184)). The help is `help.json`, 247 KB and 57 KB gzipped (2026-10-29), fetched from the site in one request; the fetch, the cache and the rendering add 2 to 3 KB to `app.js`. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index efb7546a6..4f8277cbc 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -42,7 +42,7 @@ Confirm the firmware is innocent before reaching for the soldering iron: [Diagno - **Share a solid, thick common ground** between the strip's supply and the board. - If the TX power check implicated RF coupling, set a per-board `Network.txPowerSetting` cap, the `deviceModels.json` mechanism the ESP32-S3 N16R8 Dev uses. -How the bench found this is in [lessons.md](../work/past/lessons.md). +How the bench found this is in [lessons.md](../contributing/lessons.md). ## The lights stutter once a second diff --git a/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md b/docs/work/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md similarity index 97% rename from docs/work/present/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md rename to docs/work/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md index b603c56a8..8e26aa2a1 100644 --- a/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md +++ b/docs/work/Plan-20260901 - Input mapping and scripted sensors (7 of 12).md @@ -1,6 +1,6 @@ # Plan: input mapping and scripted sensors -Turns [input-mapping-analysis.md](../future/input-mapping-analysis.md) into steps. Two threads +Turns [the input-mapping analysis](../design/input-mapping.md) into steps. Two threads run together throughout, deliberately: - **Compiled modules** for the inputs a board ships with, declared in the device catalog. @@ -22,9 +22,9 @@ else records it. Two definitions are in the repo today and they do not agree. -- [`docs/moonmodules/light/drivers.md:3`](../../moonmodules/light/drivers.md): "A driver sends +- [`docs/moonmodules/light/drivers.md:3`](../moonmodules/light/drivers.md): "A driver sends lights somewhere." Output-only, light-specific. -- [`docs/explanation/architecture/index.md:143`](../../explanation/architecture/index.md): "producers vs consumers: producers generate +- [`docs/explanation/architecture/index.md:143`](../explanation/architecture/index.md): "producers vs consumers: producers generate data, consumers process and output it. Effects are producers, drivers are consumers." A role in a dataflow, said of the light domain. @@ -156,7 +156,7 @@ distance an effect samples every frame is not a control a person edits, and rout The pattern for a stream exists and is already domain-neutral: `AudioService` publishes an `AudioFrame` that effects pull through a static `latestFrame()`, which -[the architecture](../../explanation/architecture/moonmodule.md#data-exchange-between-modules) states as the shared-struct +[the architecture](../explanation/architecture/moonmodule.md#data-exchange-between-modules) states as the shared-struct pull. A sensor service does the same, and may *also* carry mapping rows for thresholds a person cares about ("closer than 50 cm" drives a switch). One module, two outputs, because that is what a sensor is: a stream, with events derivable from it. @@ -164,7 +164,7 @@ sensor is: a stream, with events derivable from it. So every sensor step below has two acceptance criteria, and both are on the bench: **an effect reads the value per frame through the published frame**, and **a threshold drives a control through the table**. A sensor that only does one is half-built. The full reasoning is in -[input-mapping-analysis.md](../future/input-mapping-analysis.md). +[the input-mapping analysis](../design/input-mapping.md). ## Step 0: the decisions ✅ @@ -225,7 +225,7 @@ firmware's opinion. Neither survives a board with three buttons or a remote with **`IrService` is renamed `InfraredService`.** "IR" is an abbreviation the code does not need: the project's own standard is the textbook name a new contributor reads without expanding it -([CLAUDE.md, industry standards](../../../CLAUDE.md)), and every sibling is already spelled out +([CLAUDE.md, industry standards](../../CLAUDE.md)), and every sibling is already spelled out (`AudioService`, `ButtonService`, `FilesystemModule`). The rename touches the class, the file, the factory registration, the catalog entries that add it per board, and its spec anchor. Cheap now, and it stops the abbreviation spreading to the doc, the UI card and the device catalog. @@ -256,18 +256,17 @@ and a test that the rename left no `IrService` behind. Bench: the Dig-2-Go's GPI and the infrared remote on another, both driving `Control.switch1`, so two inputs reaching one target is proven rather than assumed. -## Step 1b: an encoder that shows what it selects 🚧 (the Open Stage Control session has no strip widget; its label and pad-matrix gaps stay in backlog-core) +## Step 1b: an encoder that shows what it selects 🚧 (the Open Stage Control session has no strip widget; its label and pad-matrix gaps stay on [their card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130773)) Small, and it follows step 1 rather than joining it, so the rebuild is not also carrying a UI change. Infrared's palette actions need a destination. There is no `LightsControl` module and there will not -be one ([backlog-mixed.md](../future/backlog-mixed.md)): `palette` stays on `Drivers` because +be one: `palette` stays on `Drivers` because that is where it is consumed, and the surface reaches into it, exactly as `fader1` already targets `Drivers.brightness` and `switch1` targets `Drivers.on`. - **`encoder1` targets `Drivers.palette`**, hardcoded for now. That matches the existing pair and the - backlog's own note that per-control assignment is a later UI plus persistence job - ([power-functions-analysis-top-down.md](../future/power-functions-analysis-top-down.md)). + fact that per-control assignment is a later UI plus persistence job. - **Wrap or clamp follows the bound control's type**: a palette ring wraps, a brightness clamps. A `uint8_t` encoder covers 256 options, comfortably past the palette count. Worth stating as the @@ -292,10 +291,10 @@ a strip of small text displays across the top, showing whatever was last touched **Hardware and Open Stage Control.** Some surfaces have these displays (the X-Touch's scribble strips) and some do not, so the strip is published as a control like any other and a surface that cannot show text simply ignores it. The shipped -[Open Stage Control session](../../reference/examples/open-stage-control.json) gains a text widget +[Open Stage Control session](../reference/examples/open-stage-control.json) gains a text widget bound to it, which is also worth doing because that session already has known gaps (its widget labels do not render and its pad matrix draws nothing, both recorded in -[backlog-core.md](../future/backlog-core.md)): adding the strip is the moment to fix the session +[their card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130773)): adding the strip is the moment to fix the session by building one widget of each kind in its own editor and copying the shape it produces. **Test:** the infrared remote's palette next/prev rows driving `Control.encoder1`, with the palette @@ -676,7 +675,7 @@ to zero, so a pedal pushed fully down settled at 253 rather than 255 and full br unreachable. It now takes the remaining distance whole once the step rounds to nothing. **Still open**: the bench half. A pot on an ADC pin, both paths on the same hardware. The SE16 and -LightCrafter sense pins are the natural rig and are now free (see `backlog-light.md`), but the scale +LightCrafter sense pins are the natural rig and are now free ([their card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086286)), but the scale factor per board needs the schematic. ## Step 4: I2C sensors 🚧 (only the shared bus and its scan, `I2cBusModule`; no register read, sensor module, published frame or `i2cRead` builtin) @@ -773,7 +772,7 @@ peripheral (PCNT for the encoder, RMT or a timed capture for the echo) rather th **Test:** an encoder on the bench driving `Control.encoder1` through the module, and an HC-SR04 reporting distance through a script. -## Step 6: PIR, and the trigger vocabulary ✅ (a PIR is a `set` row, which already holds a level, so no `level` kind: two kinds would do one job; the help and the momentary-row test name it. Multi-click waits for a panel that needs it, in backlog-core) +## Step 6: PIR, and the trigger vocabulary ✅ (a PIR is a `set` row, which already holds a level, so no `level` kind: two kinds would do one job; the help and the momentary-row test name it. Multi-click waits for a panel that needs it, on [its card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131477)) The cheap finish. A PIR is a digital pin, so it needs no seam at all: it is a `ButtonService` row with the `set` event, which holds the pin's level rather than reacting to a press. MoonLight diff --git a/docs/work/present/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md b/docs/work/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md similarity index 98% rename from docs/work/present/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md rename to docs/work/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md index 6c6d008ab..204f53105 100644 --- a/docs/work/present/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md +++ b/docs/work/Plan-20261001 - Gamepads and MIDI desks (2 of 7).md @@ -10,8 +10,8 @@ Everything this needs on the receiving side is built. The plan adds transports a - **ControlModule** is the surface: 8 switches, 8 encoders, 8 faders and a pad grid, each assignable to any `Module.control`, driven through `Scheduler::setControl` and following its target back. - **The input services** map a physical input onto the surface, or onto any control directly, with the shared `InputMapping` rows (Toggle, Set, Delta): `ButtonService` for GPIO buttons, `AnalogService` for potentiometers, `InfraredService` for remotes (with a `learn` field: the next code received binds to the row), `OscModule` for OSC. -- **`ControlSurface`** is the two-way interface a desk attaches through (`sendValue`, `sendRing`, `sendColor`, `sendLabel`), and `setTouched` tells the surface a hand is on a control. It was written for MIDI hardware, and OSC is so far its only transport ([backlog-core § MIDI as a control surface](../future/backlog-core.md)). -- **[Control surfaces](../../reference/hardware/control-surfaces.md)** records the bench desks: the X-Touch and the QCon both speak Mackie Control (MCU) over MIDI, neither speaks OSC, the X-Touch adds RTP-MIDI on its Ethernet port, and the QCon is USB only. +- **`ControlSurface`** is the two-way interface a desk attaches through (`sendValue`, `sendRing`, `sendColor`, `sendLabel`), and `setTouched` tells the surface a hand is on a control. It was written for MIDI hardware, and OSC is so far its only transport ([the Open Stage Control session's labels](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130773)). +- **[Control surfaces](../reference/hardware/control-surfaces.md)** records the bench desks: the X-Touch and the QCon both speak Mackie Control (MCU) over MIDI, neither speaks OSC, the X-Touch adds RTP-MIDI on its Ethernet port, and the QCon is USB only. - **`setControl` calls `onControlChanged`** on every write, from any of those writers, and `ControlDescriptor::live` marks a control as state that is never written to flash. ## Done on this branch @@ -138,7 +138,7 @@ Checked against the tools a lighting or VJ operator already knows. The shape mat |---|---| | one service per device kind, with a transport select | QLC+ input plugins (MIDI, HID, OSC, Art-Net) feeding one input universe; Resolume and Chataigne the same split of transport from meaning | | device profiles | QLC+ input profiles per desk, the MCU protocol shared by every Mackie desk, SDL's GameControllerDB for gamepads | -| mapping rows onto a fixed surface | the MIDI-mapping tables of Resolume, Ableton and QLC+, and MoonLight's own two-step model in [input-mapping-analysis](../future/input-mapping-analysis.md) | +| mapping rows onto a fixed surface | the MIDI-mapping tables of Resolume, Ableton and QLC+, and MoonLight's own two-step model in [input-mapping-analysis](../design/input-mapping.md) | | learn | MIDI learn, universal in DAWs, VJ and lighting software | | feedback to motors, rings and LEDs | MCU feedback, QLC+ input feedback, Ableton's control-surface scripts | | the standard gamepad layout | the W3C Gamepad API and SDL's GameController API | @@ -171,7 +171,7 @@ Checked against the tools a lighting or VJ operator already knows. The shape mat ### 2b. MidiService, the Akai APC40 mkII profile ✅ -The APC40 mkII is on the bench, so the Akai profile the Decided section waited for is next. Every number comes from [the reference](../../reference/hardware/control-surfaces.md#akai-apc40-mkii). +The APC40 mkII is on the bench, so the Akai profile the Decided section waited for is next. Every number comes from [the reference](../reference/hardware/control-surfaces.md#akai-apc40-mkii). - ✅ A `profile` select on `MidiService`: Mackie Control (as now) or Akai APC40 mkII. The browser bridge already passes bytes both ways unchanged, so only the service's decoding and its desk state differ per profile. - ✅ **Alternate Ableton Live mode**, set by SysEx whenever the desk connects: every press only reports and every light is the host's, so the board owns each value and the desk shows it. The service offers the SysEx in a hidden control the bridge sends on connect; Web MIDI asks the user once for SysEx permission. @@ -183,7 +183,7 @@ The APC40 mkII is on the bench, so the Akai profile the Decided section waited f - ✅ Outbound: each knob's ring shows its encoder (set to the volume style once), each activator lights when its switch is on, and each pad shows its preset slot: off when empty, dim when stored, bright when active. - ✅ The Control module lets a surface press a preset pad and tells surfaces each pad's state, through the existing `SurfaceControl::Pad`. - ✅ Tests: decoding each message kind, the desk state per kind, the row flip, the mode SysEx and the pad states. -- ✅ Docs: the MIDI section on the services page and [Connecting a control surface](../../reference/control-surface.md). +- ✅ Docs: the MIDI section on the services page and [Connecting a control surface](../reference/control-surface.md). - ✅ Verify on the desktop app with the APC40 on the product owner's Mac in Chrome on `localhost`, then on the StadBeest, where the eyes follow over OSC. - Left for later: the master fader and crossfader, the device knobs, the scene pads, transport and tap tempo. diff --git a/docs/work/present/Plan-20261008 - ESP32-C3 and the Athom bulb (5 of 6).md b/docs/work/Plan-20261008 - ESP32-C3 and the Athom bulb (5 of 6).md similarity index 100% rename from docs/work/present/Plan-20261008 - ESP32-C3 and the Athom bulb (5 of 6).md rename to docs/work/Plan-20261008 - ESP32-C3 and the Athom bulb (5 of 6).md diff --git a/docs/work/present/Plan-20261010 - Teensy 4.1 (3 of 6).md b/docs/work/Plan-20261010 - Teensy 4.1 (3 of 6).md similarity index 94% rename from docs/work/present/Plan-20261010 - Teensy 4.1 (3 of 6).md rename to docs/work/Plan-20261010 - Teensy 4.1 (3 of 6).md index 56fbbbc57..c27b427c5 100644 --- a/docs/work/present/Plan-20261010 - Teensy 4.1 (3 of 6).md +++ b/docs/work/Plan-20261010 - Teensy 4.1 (3 of 6).md @@ -8,9 +8,9 @@ Two bench boards run the same firmware. A **Teensy 4.1-NE** has no Ethernet PHY, ## The hardware -The board, the W5500 wiring, the LED pins, power and an example breadboard setup live in the [Teensy 4.1 hardware reference](../../reference/hardware/teensy-4-1.md). +The board, the W5500 wiring, the LED pins, power and an example breadboard setup live in the [Teensy 4.1 hardware reference](../reference/hardware/teensy-4-1.md). -![The example breadboard setup](../../assets/hardware/teensy41-breadboard.svg) +![The example breadboard setup](../assets/hardware/teensy41-breadboard.svg) ## Decisions for the product owner @@ -114,7 +114,7 @@ The three risks, all settled on the board: the fifth argument (`ctrls`) arrives 🚧 **Phase 6: updates over the network, and the web installer** (missing: step 5's two browser checks). A board installed once updates without a cable, and the web installer installs a Teensy as it installs an ESP32. Steps, each verified before the next: 1. ✅ **A flat image beside the HEX.** The build also writes `MoonLight.bin` (`objcopy -O binary --gap-fill 0xff`, since erased flash reads 0xFF where objcopy fills 0x00), and the release ships `firmware--v.bin` beside the `.hex`. The HEX stays for `teensy_loader_cli` and PJRC's loader, which read only Intel HEX; both new writers take raw bytes at an offset. Verified on the desktop: every byte of the `.bin` matches the HEX and the 40 gap bytes read 0xFF; CI runs on the next push. -2. ✅ **One firmware list for every platform.** `firmwares.json` rows gain a `platform` field (`esp32`, `teensy`, `desktop`, and the [small boards](../future/small-boards.md)' families as their ports arrive), generated from one registry, `moondeck/build/firmwares.py`, that `build_esp32.py`, `build_teensy.py`, `repo_health.py` and the workflows read rather than restating it; that removes four of today's five copies of the Teensy list. The release matrix and the manifest test select their platform from it, and CMake takes the Ethernet driver as an option instead of restating the names. Verified: `check_firmwares` (16 variants), `test_host --python` and `--js`, and both Teensy images built with the right driver; CI runs on the next push. +2. ✅ **One firmware list for every platform.** `firmwares.json` rows gain a `platform` field (`esp32`, `teensy`, `desktop`, and the [small boards](../design/small-boards.md)' families as their ports arrive), generated from one registry, `moondeck/build/firmwares.py`, that `build_esp32.py`, `build_teensy.py`, `repo_health.py` and the workflows read rather than restating it; that removes four of today's five copies of the Teensy list. The release matrix and the manifest test select their platform from it, and CMake takes the Ethernet driver as an option instead of restating the names. Verified: `check_firmwares` (16 variants), `test_host --python` and `--js`, and both Teensy images built with the right driver; CI runs on the next push. 3. ✅ **Updates by upload on the Teensy** (`platform_teensy_ota.cpp`). `otaBegin`, `otaWrite` and `otaEnd` stage the image in the free program flash above the running one, from the next 64 KB boundary up to the LittleFS area, so it fits while the two images together fit 6.75 MB. It refuses a HEX file, an image without the boot header and image vector table, and an image whose variant mark (`MoonLight image for ;`) names another firmware or is missing. `reboot()` then copies the staged image over the program from ITCM, every NVIC source and SysTick off and WDOG1 fed, in 64 KB blocks, skipping the blocks the two images share, and resets. The core's flash routines are the primitives; FlasherX was not needed, and its 16 KB reserve would have let the copy erase LittleFS. `http_fetch_to_ota` refuses with "no TLS on this board", since GitHub serves release assets over HTTPS only. Verified on the bench (W5500 build): a HEX, the other variant and a text file each refused with no reboot; four updates by `curl` with the settings intact, one changing 16 of 19 blocks including the boot header, the new build stamp showing after each; 1.2 MB staged in about 33 s, back up 8 to 12 s later; an upload from the Firmware card in the UI, verified by the PO, whose overlay says "Writing to flash…" with a pulsing bar through each erase pause, since the board reads nothing from the network while it erases; and by the PO, the cable pulled within seconds of "Rebooting…" on an image differing in 16 of 19 blocks: the board stayed dark, and the web installer's program-button install brought it back with its settings and effect intact. 4. ✅ **The installer and the device UI see the Teensy.** `generate_manifest.py` reads the firmware registry and writes a Teensy manifest as one part, the `.bin` at offset 0, under the family `Teensy 4.1`, which `build_teensy.py` adds to the registry's one family vocabulary; the release generates a manifest for every shipping firmware, and the installer preview stages the built Teensy images. `deviceModels.json` gains "Teensy 4.1" and "Teensy 4.1-NE + W5500", and `check_devices` checks every `chip` against that vocabulary and every listed firmware against the registry and its family. The installer reads the port's USB ids before any ESP32 handshake and reports a Teensy as found (`16C0:0483`). `/api/firmware/url` answers 501 on a device without TLS, and the Firmware card then downloads the release's `.bin` from its Pages copy, which allows a cross-origin read, and uploads it. Verified: the manifest test covers every platform, `check_devices` fires on a wrong family, the JS and Python suites pass, the bench board answers 501, and a headless browser drove the card's URL install on the W5500 board with the image served by the installer preview in place of Pages: downloaded, uploaded, rebooted, settings intact. Verified by the PO on 2026-10-13 from the first release carrying the Teensy images, `6.1.0-dev.87`: a network update from the Firmware card, the image fetched from the release's Pages copy. 5. 🚧 **The installer flashes a Teensy** (missing: a browser install with both boxes ticked, and an install on the regular 4.1, which now runs the firmware flashed with `teensy_loader_cli`). `teensy-writer.js` speaks HalfKay as `teensy_loader_cli` does: a running board enters the bootloader through a 134-baud Web Serial open, then 1088-byte reports, each a 64-byte header carrying the block's offset and 1024 bytes of image, block 0 first with a 45 s allowance since it erases the chip, blank blocks after it skipped, and a boot report to finish. A board held in its bootloader by the program button is offered after a canceled port pick ("Teensy not listed?"), the one moment it is needed, so the port row keeps one way in. Either way the browser's chooser opens straight from the Install click while that click still counts, and the page's own step, saying what to do, opens it when the click has expired or the user cancels; every install picks the bootloader once, since Chrome does not remember the grant (`chrome://settings/content/hidDevices` lists nothing after a pick), although the bootloader reports a USB serial number (`001E0D21`, the board's serial in hex; ioreg) and only lacks a product name, so why is open. The orchestrator picks the writer by the manifest's `chipFamily` before touching the device, so each next port adds a writer and nothing else: the RP2040, the RP2350 and the XIAO nRF52840 boot into a UF2 bootloader that shows a USB drive, so one UF2 writer serves all of them (PICOBOOT over WebUSB for the Picos as the direct route). After the boot the installer finds the re-enumerated serial port, reads `MM_IP` and `MM_DEVICE` for the success link, and with **Apply device defaults** sends the catalog state as APPLY_OP frames over it: the Teensy's USB serial listener (`platform_teensy_improv.cpp`, polled from the module's tick through `improvPoll`) takes that one command through the core helper `improvApplyOp`, which the ESP32's listener now shares. **Erase chip first** zeroes the settings' LittleFS superblock pair (offset 0x6C0000) after the image, so the firmware formats it at boot; the bootloader erases only the program area otherwise. Verified on the bench: the zeroed superblocks through `teensy_loader_cli` reset a marker setting to the defaults with the board booting normally, and an APPLY_OP document over USB serial got its acknowledgement and changed a setting; the unit tests pin the shared handler. Still to verify by the PO in the browser: an install with both boxes ticked. Verified so far: a node test pins the report layout, the skipped blocks, the boot report and the refused images, and that writing the reports onto erased flash reproduces the image; and on the bench by the PO, from the local installer preview, installs of the W5500 board's firmware from the program button, reached through the "Teensy not listed?" hint, and from a running board, with no button press, the browser's chooser opening straight from the Install click, the settings intact, each ending on the board's IP and `.local` links; the program-button path asks one click to grant the board's serial port, which the browser then remembers. Chrome lists the bootloader as "Unknown device (16C0:0478)". Verified by the PO on 2026-10-13: an install of `6.1.0-dev.87` from the published installer at moonmodules.org, the board coming up with its defaults. Still to verify: an installer install on the regular 4.1, now on the bench. @@ -131,16 +131,16 @@ The three risks, all settled on the board: the fifth argument (`ctrls`) arrives **After the phases: WiFi through a coprocessor.** An ESP32-C6 on SPI or SDIO gives the Teensy WiFi, as the C6 does for the P4 through Espressif's esp-hosted. Its host side is written for ESP-IDF and Linux, so this means porting that driver and attaching it to QNEthernet's lwIP as a second interface, weeks of work rather than a step; the P4 measured the hosted link as its bottleneck, and wired Ethernet stays the Teensy's strength, so WiFi is an option for installations without a cable rather than the default. **After the phases: other Thumb-2 boards.** Every Cortex-M3 or newer runs the Thumb-2 encoder unchanged, so MoonLive costs such a target only its executable memory and `codeAddressBits`; the platform layer around it is the real work. Two follow-ups, with the boards on order: -- **The RP2350 (Raspberry Pi Pico 2), two Cortex-M33 cores.** Cheap and common, with PIO blocks for LED output, the same route the [Pi 5 analysis](../future/leddriver-analysis-bottom-up.md#raspberry-pi) points to; TrustZone may need the code memory marked non-secure. The RP2040 (Pico 1, RP2040-Zero) shares the platform layer through the Pico SDK and is worth supporting for the boards people own; its Cortex-M0+ runs only 16-bit Thumb (ARMv6-M), so MoonLive there needs a restricted encoder with literal pools, plain multiplies and short branches, and scripts refuse cleanly until it exists. Either chip reaches the network through WiFi (Pico 2 W, Pico W) or a W5500 on SPI, as the Teensy does. -- **Nordic's nRF52840 (Cortex-M4F) and nRF5340 (Cortex-M33).** [The nRF analysis](../future/nrf-zephyr-target-analysis.md) finds them a poor fit for large LED counts (one-lane serial DMA, no PSRAM, 256 to 512 KB of RAM) and a promising small BLE showcase of the core on Zephyr, where MoonLive comes along almost free. +- **The RP2350 (Raspberry Pi Pico 2), two Cortex-M33 cores.** Cheap and common, with PIO blocks for LED output, the same route the [LED-driver analysis](../design/leddriver.md) points to; TrustZone may need the code memory marked non-secure. The RP2040 (Pico 1, RP2040-Zero) shares the platform layer through the Pico SDK and is worth supporting for the boards people own; its Cortex-M0+ runs only 16-bit Thumb (ARMv6-M), so MoonLive there needs a restricted encoder with literal pools, plain multiplies and short branches, and scripts refuse cleanly until it exists. Either chip reaches the network through WiFi (Pico 2 W, Pico W) or a W5500 on SPI, as the Teensy does. +- **Nordic's nRF52840 (Cortex-M4F) and nRF5340 (Cortex-M33).** [The nRF analysis](../design/small-boards.md) finds them a poor fit for large LED counts (one-lane serial DMA, no PSRAM, 256 to 512 KB of RAM) and a promising small BLE showcase of the core on Zephyr, where MoonLive comes along almost free. -**The boards to order** (researched 2026-10-11, prices including Dutch VAT unless marked ex). The Teensy 4.1 and its Ethernet kit are on the bench since 2026-10-26. The Teensy items are below; the RP2350, RP2040 and Nordic boards and the tools for them are on [the small boards reference](../future/small-boards.md). PJRC sells no Teensy on AliExpress, and the listings there cost more or are counterfeit ([PJRC's warning](https://www.pjrc.com/teensy/counterfeit.html)). +**The boards to order** (researched 2026-10-11, prices including Dutch VAT unless marked ex). The Teensy 4.1 and its Ethernet kit are on the bench since 2026-10-26. The Teensy items are below; the RP2350, RP2040 and Nordic boards and the tools for them are on [the small boards reference](../design/small-boards.md). PJRC sells no Teensy on AliExpress, and the listings there cost more or are counterfeit ([PJRC's warning](https://www.pjrc.com/teensy/counterfeit.html)). | | Board (maker) | Why | Where, price | |--|--|--|--| -| Teensy 4.1 | [Teensy 4.1 with pins](https://www.antratek.nl/teensy-4-1-pins), with the Ethernet chip (PJRC) | The `teensy41` firmware | Antratek €42.96, Tinytronics €38.25 | -| A Teensy 4.1 cabled to the Ethernet Kit | [Ethernet Kit](https://www.antratek.nl/ethernet-kit-for-teensy-4-1) (PJRC) | The jack for the on-board PHY; needs soldering | Antratek €12.04, Tinytronics €9.50 | -| APS6404L PSRAM | [PSRAM APS6404L 8 MB](https://www.tinytronics.nl/en/components/ics-and-microcontroller-chips/ics/aps6404l-3sqr-psram-8mb-sop8) (AP Memory) | Optional, once a layout outgrows the RAM | Tinytronics €3 | +| Teensy 4.1 | [Teensy 4.1 with pins](https://www.antratek.nl/teensy-4-1-pins), with the Ethernet chip (PJRC) | The `teensy41` firmware | Antratek €42.96, Tinytronics €38.25 | +| A Teensy 4.1 cabled to the Ethernet Kit | [Ethernet Kit](https://www.antratek.nl/ethernet-kit-for-teensy-4-1) (PJRC) | The jack for the on-board PHY; needs soldering | Antratek €12.04, Tinytronics €9.50 | +| APS6404L PSRAM | [PSRAM APS6404L 8 MB](https://www.tinytronics.nl/en/components/ics-and-microcontroller-chips/ics/aps6404l-3sqr-psram-8mb-sop8) (AP Memory) | Optional, once a layout outgrows the RAM | Tinytronics €3 | **After the phases: offer the patches upstream.** The files in `teensy/patches/` fix upstream code, so each goes back to its maintainer once the port is done, which lets the patch files go. - `teensy_loader_cli-match-own-devices.patch`: ✅ opened 2026-10-13 as [PaulStoffregen/teensy_loader_cli#96](https://github.com/PaulStoffregen/teensy_loader_cli/pull/96), from the fork `ewowi/teensy_loader_cli`, fixing its open issue #60 ("Error opening HID Manager" on macOS) at the cause. The macOS HID manager matches every HID device, keyboards included, which needs the Input Monitoring permission; matching only the bootloader and the rebootor needs none. Reproduced on macOS 27.0.1 before opening it: master waits forever with the board in its bootloader, the patched build flashes it. The patch file goes once Paul merges it. @@ -165,7 +165,7 @@ The three risks, all settled on the board: the fifth argument (`ctrls`) arrives ## What comes out -The backlog line "Teensy 4.1: toolchain-file build, `.hex` for Teensy Loader" in `backlog-core.md` moves into this plan. `building.md`'s Teensy section ("Ethernet is built in on Teensy 4.1 and optional on 4.0", "OctoWS2811 gives excellent DMA-based LED output") is rewritten from what ships. +The backlog line "Teensy 4.1: toolchain-file build, `.hex` for Teensy Loader" in the backlog moves into this plan. `building.md`'s Teensy section ("Ethernet is built in on Teensy 4.1 and optional on 4.0", "OctoWS2811 gives excellent DMA-based LED output") is rewritten from what ships. ## Sources diff --git a/docs/work/present/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md b/docs/work/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md similarity index 97% rename from docs/work/present/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md rename to docs/work/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md index df2ad0a5c..931582c3a 100644 --- a/docs/work/present/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md +++ b/docs/work/Plan-20261011 - Layer compositing, masks and transitions (2 of 3).md @@ -10,7 +10,7 @@ Three requests turn out to be one mechanism. A user asks for a mask that grows a ## The pipeline today -Per frame and per layer, effects draw into the layer's buffer in child order, the modifiers fold coordinates (once at mapping time, or per frame through `modifyLive`), and then the Drivers container composites every enabled layer into the physical frame through [BlendMap](../../../src/light/layers/BlendMap.h): the bottom layer overwrites, every layer above blends with its `blendMode` (alpha or additive) and `opacity`. Then brightness, correction and output. +Per frame and per layer, effects draw into the layer's buffer in child order, the modifiers fold coordinates (once at mapping time, or per frame through `modifyLive`), and then the Drivers container composites every enabled layer into the physical frame through [BlendMap](../../src/light/layers/BlendMap.h): the bottom layer overwrites, every layer above blends with its `blendMode` (alpha or additive) and `opacity`. Then brightness, correction and output. Alpha is already `lerp(backdrop, source, opacity)`, the specification's normal mode, so the existing stage has the right form: new modes add a function, not a stage. Modifiers stay geometry: they decide where a color comes from, never how bright it is. diff --git a/docs/work/present/Plan-20261013 - Audio reactive live performance (0 of 7).md b/docs/work/Plan-20261013 - Audio reactive live performance (0 of 7).md similarity index 61% rename from docs/work/present/Plan-20261013 - Audio reactive live performance (0 of 7).md rename to docs/work/Plan-20261013 - Audio reactive live performance (0 of 7).md index 141f5386d..25b4f8a88 100644 --- a/docs/work/present/Plan-20261013 - Audio reactive live performance (0 of 7).md +++ b/docs/work/Plan-20261013 - Audio reactive live performance (0 of 7).md @@ -4,7 +4,7 @@ Draft for the product owner's approval. A band plays, and the lights answer each ## The design basis -The core backlog already analyzed this, and the plan follows its conclusions rather than reopening them ([backlog-core: notes as an effect input, the rest of the band, everything as MIDI](../future/backlog-core.md)): +Analyzed when the plan was picked, and settled: - **Notes are performance data, not control-surface input.** A desk sets controls; a keyboard's notes are read by an effect, which decides what a note means, the way it reads audio. - **Two frames, kept separate.** A `MidiFrame` carries what arrived as notes; an `AudioFrame` per analyzed channel carries what arrived as sound. A per-band onset never pretends to be a note-on with a fake pitch and velocity. @@ -34,7 +34,7 @@ An **Akai MPK261**: 61 semi-weighted keys, 16 RGB-backlit pads, 8 knobs, 8 fader - per channel, which of the 128 notes are held and how hard (velocity); - the note starts and ends since the last frame as ordered events, each with its channel, pitch and velocity, so two hits of the same pitch between frames stay two and a note shorter than a frame still fires; - pitch bend, modulation and sustain per channel; -- the MIDI clock's beat phase when a clock runs. +- the MIDI clock's beat phase when a clock runs, the field [the audio plan, step 5](Plan-20261028%20-%20Audio%20that%20answers%20the%20music%20(0%20of%205).md) fills from the music when none does. An effect opts in, as audio effects do, and on a board with no MIDI it sees an empty frame and draws its idle state. @@ -72,7 +72,7 @@ Each step ends in something verified on the bench. Two routes, the first needing no code: - **Drum triggers:** clip-on triggers into a trigger module send MIDI, so steps 1 to 3 already cover an acoustic kit. -- **Per-band onsets** in `AudioService`: the single `onset` fires on any hit, so a kick and a snare look alike. An onset per band group (low for the kick, mid and high for the snare and cymbals) separates them from the bands already computed. Best with a direct out or an aux send from the mixer giving the kit alone. +- **Per-band onsets**, built by [the audio plan, step 4](Plan-20261028%20-%20Audio%20that%20answers%20the%20music%20(0%20of%205).md): an onset per range (low for the kick, mid and high for the snare and cymbals). Best with a direct out or an aux send from the mixer giving the kit alone. - Verify with a kit, or a recorded kit track, through one microphone or line input. ### 6. Analog instruments, an investigation ⏳ @@ -86,7 +86,7 @@ Decided per instrument, cheapest first: | Voice | a pitch-to-MIDI box | pitch plus loudness envelope on one clean channel | | Any instrument, loudness only | a direct out or aux send | the existing level and bands on its own channel | -The on-device column needs a second audio channel: the multi-channel front-ends the [audio roadmap](../future/audio-dsp-roadmap.md) anticipates, such as a 4-channel TDM codec (the ES7210 on the Waveshare RGB-Matrix board) or line inputs from the mixer. The budget allows one or two analyzed channels per board. +The on-device column needs a second audio channel: the multi-channel front-ends [the audio plan's backlog](Plan-20261028%20-%20Audio%20that%20answers%20the%20music%20(0%20of%205).md#backlog) lists, such as a 4-channel TDM codec (the ES7210 on the Waveshare RGB-Matrix board) or line inputs from the mixer. The budget allows one or two analyzed channels per board. ### 7. Follow the musicians ⏳ @@ -101,21 +101,28 @@ The sensors, from what is listed to what a stage needs: | Picture | Sensor | Gives | Fit for a stage | Price | |---|---|---|---|---| -| HC-SR501 PIR | [PIR, HC-SR501](https://www.tinytronics.nl/en/sensors/motion/ir-pyroelectric-infrared-pir-motion-sensing-detector-module) | presence, about 3 to 7 m | wakes a show, follows no one | Tinytronics €3.50 | -| VL53L0X | [VL53L0X distance](https://www.tinytronics.nl/en/sensors/distance/vl53l0x-time-of-flight-tof-distance-sensor) | one distance, up to about 1.2 m | one person on one axis: a walkway, a lectern | Tinytronics €5.75 | -| VL53L8CX | [VL53L8CX zone grid](https://eckstein-shop.de/Pololu-VL53L8CX-Time-of-Flight-8x8-Zone-Distance-Sensor-Carrier-with-Voltage-Regulators-400cm-Max-EN) (Pololu) | 8 × 8 depth zones, about 4 m | coarse position of one or two people on a small stage | Eckstein €23.95 | -| MPU6050 | [MPU6050, worn](https://www.tinytronics.nl/en/sensors/acceleration-rotation/mpu-6050-accelerometer-and-gyroscope-3-axis-module-3.3v-5v) | orientation | a gesture, not a position | Tinytronics €8.75 | -| HLK-LD2450 | [mmWave radar, HLK-LD2450](https://www.tinytronics.nl/en/sensors/motion/hi-link-hlk-ld2450-24ghz-radar-sensor-module-with-bluetooth) | up to 3 targets with x and y, about 6 m, UART | the cheapest real tracker, working in darkness and haze | Tinytronics €7.00 | -| DWM3000EVB | [UWB, Qorvo DWM3000EVB](https://www.digikey.nl/nl/products/detail/qorvo/DWM3000EVB/24367408) | about 10 cm, with anchors and a tag per performer | how professional radio trackers work | DigiKey €31.34 per node | +| HC-SR501 PIR | [PIR, HC-SR501](https://www.tinytronics.nl/en/sensors/motion/ir-pyroelectric-infrared-pir-motion-sensing-detector-module) | presence, about 3 to 7 m | wakes a show, follows no one | Tinytronics €3.50 | +| VL53L0X | [VL53L0X distance](https://www.tinytronics.nl/en/sensors/distance/vl53l0x-time-of-flight-tof-distance-sensor) | one distance, up to about 1.2 m | one person on one axis: a walkway, a lectern | Tinytronics €5.75 | +| VL53L8CX | [VL53L8CX zone grid](https://eckstein-shop.de/Pololu-VL53L8CX-Time-of-Flight-8x8-Zone-Distance-Sensor-Carrier-with-Voltage-Regulators-400cm-Max-EN) (Pololu) | 8 × 8 depth zones, about 4 m | coarse position of one or two people on a small stage | Eckstein €23.95 | +| MPU6050 | [MPU6050, worn](https://www.tinytronics.nl/en/sensors/acceleration-rotation/mpu-6050-accelerometer-and-gyroscope-3-axis-module-3.3v-5v) | orientation | a gesture, not a position | Tinytronics €8.75 | +| HLK-LD2450 | [mmWave radar, HLK-LD2450](https://www.tinytronics.nl/en/sensors/motion/hi-link-hlk-ld2450-24ghz-radar-sensor-module-with-bluetooth) | up to 3 targets with x and y, about 6 m, UART | the cheapest real tracker, working in darkness and haze | Tinytronics €7.00 | +| DWM3000EVB | [UWB, Qorvo DWM3000EVB](https://www.digikey.nl/nl/products/detail/qorvo/DWM3000EVB/24367408) | about 10 cm, with anchors and a tag per performer | how professional radio trackers work | DigiKey €31.34 per node | | | [PosiStageNet in](https://posistage.net/) | positions from a professional system | MoonLight joining a rig that already tracks | free, open protocol, MIT-licensed SDK | -| OV5647 camera | [A camera on the P4](https://www.tinytronics.nl/en/raspberry-pi/raspberry-pi-compatible-camera-5mp-v1.3) (OV5647, MIPI-CSI) | person detection | later, with the [streams](Plan-20261013%20-%20Streams%2C%20every%20frame%20from%20outside%20(11%20of%2013).md) work | Tinytronics €8.00 | +| OV5647 camera | [A camera on the P4](https://www.tinytronics.nl/en/raspberry-pi/raspberry-pi-compatible-camera-5mp-v1.3) (OV5647, MIPI-CSI) | person detection | later, with the [streams](Plan-20261013%20-%20Streams%2C%20every%20frame%20from%20outside%20(11%20of%2013).md) work | Tinytronics €8.00 | **On the bench (2026-10-19): two HLK-LD2450 kit B** from Hi-Link's own store, each with a USB-to-serial adapter, a Bluetooth antenna and a cable. The adapter lets the radar be tried on a computer with Hi-Link's tool before the UART seam exists: targets, range and zone exclusion on day one. Two units answer the stage questions: whether two 24 GHz radars side by side interfere, and how their targets merge where their views overlap. -The LD2450's Bluetooth only updates its firmware, so every reading goes over UART, whose protocol is simple and has plenty of open firmware to read as prior art. Two siblings fit other jobs: the **LD2451** is larger and reaches further, far enough to track cars, and the **LD2410** is low-powered, so it does not see through walls and needs no distance gates set to stop it. The **LD2410C** also sends its UART data over Bluetooth Low Energy when it is only powered from 5 V, with an option to switch that off or require pairing, which would let a board read a radar with no wire. The radar's UART seam is the same one wired DMX-512 needs, [RS-485 / DMX-512 wired output](../future/backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver), so one seam serves both. +Whether kit B's Bluetooth carries live target frames, or only firmware updates and configuration, is unverified: Hi-Link's tool on the bench answers it. The plan reads over UART either way, since a wire needs no pairing and puts no second 2.4 GHz radio beside the WiFi, and its protocol is simple with plenty of open firmware to read as prior art. Two siblings fit other jobs: the **LD2451** is larger and reaches further, far enough to track cars, and the **LD2410** is low-powered, so it does not see through walls and needs no distance gates set to stop it. The **LD2410C** also sends its UART data over Bluetooth Low Energy when it is only powered from 5 V, with an option to switch that off or require pairing, which would let a board read a radar with no wire. The radar's UART seam is the same one wired DMX-512 needs, [RS-485 / DMX-512 wired output](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080691), so one seam serves both. The order: the radar first, which needs a UART seam the platform does not have yet; PosiStageNet in second, which is UDP and needs no new seam; the ToF zone grid as the indoor alternative once the input-mapping plan's I2C step lands and a VL53L8CX read is measured that does not stall the render task: an 8 × 8 frame over I2C is large, so the candidates are SPI, a lower ranging rate, or reading on a task off the render core. Verify with a person walking the bench stage and the heads following. +## Backlog + +On the topic and not a step. What remains here when the plan ships returns to the Project as cards, item by item, then. + +- **MIDI learn: any note or CC onto any control (robwomp, 2026-10-12).** The MIDI service decodes two known layouts, Mackie Control and an Akai APC40 mkII profile, so a controller sending plain notes or CCs outside them drives nothing: a user with a generic controller on Linux got no note or CC data in. The infrared and gamepad services already map an arbitrary input onto any control with `learn` rows, and MIDI gets the same rows: each one's `learn` arm captures the next note or CC with its channel, a target names the control, and the value scales into the target's range (a CC's 0 to 127, a note's velocity, or a note as a toggle). That is MIDI learn as consoles and DAWs offer it, through the same primitive, so one mechanism maps every input. The two profiles stay for the desks whose layout they describe, since they carry the way back (motor faders, knob rings, lit pads); a learned row has none unless the controller echoes its CCs. The bench instrument's knobs and faders reach the control surface through it. +- **The OpenLamp convention (2026-09-22).** [openlamp-spec-midi](https://github.com/openlamp/openlamp-spec-midi) is an MIT-licensed convention for driving WLED over MIDI: notes 59-68 for hues plus black and white, notes 48-56 for off, on, toggle and blackout, CC 1 for brightness, CC 3-4 for hue and saturation, CC 5-8 for effect, speed, intensity and palette, Program Change for presets, MIDI channel for targeting, and MIDI clock for beat sync. The [organization](https://github.com/openlamp) also has an Ableton Link and MIDI-clock tempo library, worth reading when step 1 wires the clock's beat phase, and a CC0 asset set of 72 palette illustrations and 216 effect previews in eight languages. Its engine is Python and cloud-free by design, so nothing there ports to a device; the value is in agreeing what a note and a CC *mean*, which buys a user their existing mappings. Judged against principle 2 before adopting: a candidate standard, but a draft "likely to change, and early on to change quickly" with low single-digit stars, so the sequence is MIDI learn above with the OpenLamp numbers as the default mapping where they fit, rather than a port of their model. It maps notes to *fixed meanings*, the control-surface half; notes as performance data (step 1) is the opposite, and the transport delivers raw messages so each consumer interprets. + ## Open questions - **Does a note decay in the frame or in the effect?** The proposal: the frame carries held notes and edges only, and each effect owns its own decay, since a splash and a held key want different curves. **Recommended (2026-10-25):** the proposal: the effect owns the decay. @@ -125,4 +132,4 @@ The order: the radar first, which needs a UART seam the platform does not have y ## What it removes -Nothing in code: it adds a MIDI frame, a position frame, two effects and a follow formation on the existing transports and fixtures, plus the UART seam the radar needs. In the docs, the three backlog sections it is built from (notes as an effect input, the rest of the band, everything as MIDI) leave the backlog once this plan is approved, since a plan is where an item goes when it is picked. +Nothing in code: it adds a MIDI frame, a position frame, two effects and a follow formation on the existing transports and fixtures, plus the UART seam the radar needs. In the docs, the five backlog sections on its topic are gone from the core backlog, since a plan is where an item goes when it is picked: three (notes as an effect input, the rest of the band, everything as MIDI) settled the design basis above, and two (MIDI learn, the OpenLamp convention) sit in the backlog above. diff --git a/docs/work/present/Plan-20261013 - Streams, every frame from outside (11 of 13).md b/docs/work/Plan-20261013 - Streams, every frame from outside (11 of 13).md similarity index 91% rename from docs/work/present/Plan-20261013 - Streams, every frame from outside (11 of 13).md rename to docs/work/Plan-20261013 - Streams, every frame from outside (11 of 13).md index 2d9e47acb..3b5cb8754 100644 --- a/docs/work/present/Plan-20261013 - Streams, every frame from outside (11 of 13).md +++ b/docs/work/Plan-20261013 - Streams, every frame from outside (11 of 13).md @@ -28,7 +28,7 @@ A source is then a transport plus a decoder, delivering frames with a small desc ## How it lands on what MoonLight has: two families -The three axes describe any stream, and they do not make every stream one module: two families, each with its own semantics, meeting in the layer, as [MoonLight, frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside) describes. The roadmap across both: +The three axes describe any stream, and they do not make every stream one module: two families, each with its own semantics, meeting in the layer, as [MoonLight, frames from outside](../explanation/architecture/moonlight.md#frames-from-outside) describes. The roadmap across both: | | Channel data, the pixel protocols | Pictures | |---|---|---| @@ -40,7 +40,7 @@ The three axes describe any stream, and they do not make every stream one module Recording is the bridge between transports and files: a live stream in, an FSEQ file out, written from the drivers' output. Art-Net "from the SD card" is that recording, as Falcon Player records it; FSEQ does not travel as a stream, since Falcon Player copies the file to its remotes. -Mapped by the receiver or by the sender, `mapped_by`, and the paths a sender-mapped frame takes: [MoonLight, frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside). What each path costs: [Performance, Network Receive](../../reference/performance.md#network-receive). +Mapped by the receiver or by the sender, `mapped_by`, and the paths a sender-mapped frame takes: [MoonLight, frames from outside](../explanation/architecture/moonlight.md#frames-from-outside). What each path costs: [Performance, Network Receive](../reference/performance.md#network-receive). ## Real time and not real time @@ -61,7 +61,7 @@ Not real time also names a third case that never reaches the device as a stream: ## The roadmap -Each family's consumer is an effect doing one gather per frame, and modifiers stay the one home for geometry: [MoonLight, frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside). +Each family's consumer is an effect doing one gather per frame, and modifiers stay the one home for geometry: [MoonLight, frames from outside](../explanation/architecture/moonlight.md#frames-from-outside). ```mermaid flowchart TB @@ -172,7 +172,7 @@ A live browser source costs bandwidth, lights × 3 bytes × fps: about 60 KB/s f TouchMyLight's cube is the first user, so 3D works from the start. -- **A light list is mapped by the sender, and carries no positions:** placed by the layout on 1D, 2D and 3D layouts alike ([MoonLight, frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside)), with the layout numbering its lights as the xLights model does ([the FSEQ player's card](../../moonmodules/light/effects.md#fseqplayer)). +- **A light list is mapped by the sender, and carries no positions:** placed by the layout on 1D, 2D and 3D layouts alike ([MoonLight, frames from outside](../explanation/architecture/moonlight.md#frames-from-outside)), with the layout numbering its lights as the xLights model does ([the FSEQ player's card](../moonmodules/light/effects.md#fseqplayer)). - **A picture reaches a 3D layout by projection:** each light samples the picture at its position seen along one axis, the planar projection media servers use. - **A volume is the native 3D stream:** a frame per depth slice, copied straight into a 3D layer. @@ -232,13 +232,13 @@ What MoonLight builds first, so that every contribution after it is one source i | # | Piece | Where | What it does | |---|---|---|---| -| G1 | **Light-list placement** ✅ built | `Layer::placeByLayout` | a sender-mapped frame placed by the layout's positions: [frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside) | -| G2 | **Network Receive's `mapped_by`** ✅ built | `NetworkReceiveEffect` | `receiver` or `sender`, the status naming the path: [its card](../../moonmodules/light/effects.md#networkreceive), the cost in [Performance](../../reference/performance.md#network-receive) | -| G3 | **The picture source** ✅ built | `PictureService` | from #138's `VideoService`, a test pattern its first source: [its card](../../moonmodules/core/services.md#picture) | -| G4 | **The picture effect** ✅ built | `PictureEffect` | from #138's sampler, point or area in linear light, fit or stretch: [its card](../../moonmodules/light/effects.md#picture) | -| G5 | **Sender mapping skips the board's mapping** ✅ built | `Layer::wiringSource`, `Drivers` | a sender-mapped frame alone on its layer goes to the output in one copy on any layout: [frames from outside](../../explanation/architecture/moonlight.md#frames-from-outside) | -| G6 | **The channel-data sink** ✅ built | `ChannelDataSink` | extracted from Network Receive, so a file source is a thin effect on it: [its header](../../../src/light/effects/ChannelDataSink.h) | -| G7 | **A contributor page** ✅ built | docs | [Adding a picture or channel-data source](../../reference/adding-a-stream-source.md) | +| G1 | **Light-list placement** ✅ built | `Layer::placeByLayout` | a sender-mapped frame placed by the layout's positions: [frames from outside](../explanation/architecture/moonlight.md#frames-from-outside) | +| G2 | **Network Receive's `mapped_by`** ✅ built | `NetworkReceiveEffect` | `receiver` or `sender`, the status naming the path: [its card](../moonmodules/light/effects.md#networkreceive), the cost in [Performance](../reference/performance.md#network-receive) | +| G3 | **The picture source** ✅ built | `PictureService` | from #138's `VideoService`, a test pattern its first source: [its card](../moonmodules/core/services.md#picture) | +| G4 | **The picture effect** ✅ built | `PictureEffect` | from #138's sampler, point or area in linear light, fit or stretch: [its card](../moonmodules/light/effects.md#picture) | +| G5 | **Sender mapping skips the board's mapping** ✅ built | `Layer::wiringSource`, `Drivers` | a sender-mapped frame alone on its layer goes to the output in one copy on any layout: [frames from outside](../explanation/architecture/moonlight.md#frames-from-outside) | +| G6 | **The channel-data sink** ✅ built | `ChannelDataSink` | extracted from Network Receive, so a file source is a thin effect on it: [its header](../../src/light/effects/ChannelDataSink.h) | +| G7 | **A contributor page** ✅ built | docs | [Adding a picture or channel-data source](../reference/adding-a-stream-source.md) | The picture sources still to come, the browser among them, are steps of [Video in, LED walls out](Plan-20261024%20-%20Video%20in%2C%20LED%20walls%20out%20(0%20of%2010).md). @@ -250,24 +250,24 @@ Each later piece is a small pull request, one topic, based on current main, with | Pull request | Who | On | |---|---|---| -| ✅ **Backlight wiring** (#140): start corner, direction, a count per edge and a gap, [the layout script](../../../moonlive/layouts/backlight.mll) | Gohnnyman | nothing | +| ✅ **Backlight wiring** (#140): start corner, direction, a count per edge and a gap, [the layout script](../../moonlive/layouts/backlight.mll) | Gohnnyman | nothing | | ⏳ Color trim, split from #139 | Gohnnyman | nothing; a separate idea | -| ✅ **FSEQ playback** from the filesystem with a playlist, on the channel-data sink: [its card](../../moonmodules/light/effects.md#fseqplayer) | TouchMyLight | G1, G2, G6 | -| ✅ **Border sampling**: edge depth, smoothing, [the Picture effect's card](../../moonmodules/light/effects.md#picture) | Gohnnyman | G4 | -| ✅ **USB capture** with its MJPEG decoder on the P4: [the Picture service's card](../../moonmodules/core/services.md#picture), the seam in [platform.h](../../../src/platform/platform.h) | Gohnnyman | G3 | +| ✅ **FSEQ playback** from the filesystem with a playlist, on the channel-data sink: [its card](../moonmodules/light/effects.md#fseqplayer) | TouchMyLight | G1, G2, G6 | +| ✅ **Border sampling**: edge depth, smoothing, [the Picture effect's card](../moonmodules/light/effects.md#picture) | Gohnnyman | G4 | +| ✅ **USB capture** with its MJPEG decoder on the P4: [the Picture service's card](../moonmodules/core/services.md#picture), the seam in [platform.h](../../src/platform/platform.h) | Gohnnyman | G3 | | ⏳ Recording the drivers' output to FSEQ on SD, clock sync | open | FSEQ playback | -Pull requests #138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../../CLAUDE.md) says. +Pull requests #138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../CLAUDE.md) says. ## Skeletons for the contributions What each pull request builds, settled before code so it arrives small and on design. The names follow the open naming question below. Each passes `check_specs` with a card, and its tests pass on the desktop before a board sees it. -**FSEQ player (TouchMyLight), on G6** ✅: built as [the FSEQ player](../../moonmodules/light/effects.md#fseqplayer). +**FSEQ player (TouchMyLight), on G6** ✅: built as [the FSEQ player](../moonmodules/light/effects.md#fseqplayer). -**The picture source (G3) and the picture effect (G4), from #138** ✅: built as [the Picture service](../../moonmodules/core/services.md#picture), [`PictureFrame`](../../../src/core/util/PictureFrame.h) and [the Picture effect](../../moonmodules/light/effects.md#picture). +**The picture source (G3) and the picture effect (G4), from #138** ✅: built as [the Picture service](../moonmodules/core/services.md#picture), [`PictureFrame`](../../src/core/util/PictureFrame.h) and [the Picture effect](../moonmodules/light/effects.md#picture). -**Backlight wiring (#140)** ✅: built into [the Backlight layout script](../../../moonlive/layouts/backlight.mll), Hyperion's count per edge and gap included. +**Backlight wiring (#140)** ✅: built into [the Backlight layout script](../../moonlive/layouts/backlight.mll), Hyperion's count per edge and gap included. **Color trim (#139's second commit).** Output calibration, so it lives in the fixture profile and the correction, judged against a 3×3 correction matrix before code. @@ -283,12 +283,12 @@ Tests: a trim scaling one channel and leaving the others; a trim of 255 being th Built: G1 to G7, the FSEQ player, border sampling, USB capture and the backlight wiring. Around them, measuring Network Receive on real boards built: -- **Network Receive's receive task** on core 1 and its `packets_per_s` and `frames_per_s`: [its card](../../moonmodules/light/effects.md#networkreceive). -- **TryLock's release by exchange**, which PSRAM's emulated read-modify-write needs: [its header](../../../src/core/util/TryLock.h). -- **The receive defaults**, a UDP mailbox of 32 and 30 Ethernet receive buffers on the P4: the reasons beside them in [the base sdkconfig](../../../esp32/sdkconfig.defaults) and [the P4's](../../../esp32/sdkconfig.defaults.esp32p4rev1-eth). -- **Network Send's paced packets**, `packet_gap`: [its card](../../moonmodules/light/drivers.md#networksend). -- **Scenarios over several boards** (`--board name=host`): [Testing, several boards](../../reference/testing.md#several-boards). -- **The measurements**: [Performance, Network Receive](../../reference/performance.md#network-receive). +- **Network Receive's receive task** on core 1 and its `packets_per_s` and `frames_per_s`: [its card](../moonmodules/light/effects.md#networkreceive). +- **TryLock's release by exchange**, which PSRAM's emulated read-modify-write needs: [its header](../../src/core/util/TryLock.h). +- **The receive defaults**, a UDP mailbox of 32 and 30 Ethernet receive buffers on the P4: the reasons beside them in [the base sdkconfig](../../esp32/sdkconfig.defaults) and [the P4's](../../esp32/sdkconfig.defaults.esp32p4rev1-eth). +- **Network Send's paced packets**, `packet_gap`: [its card](../moonmodules/light/drivers.md#networksend). +- **Scenarios over several boards** (`--board name=host`): [Testing, several boards](../reference/testing.md#several-boards). +- **The measurements**: [Performance, Network Receive](../reference/performance.md#network-receive). Open here: the color trim, recording and clock sync; the picture sources are in [Video in, LED walls out](Plan-20261024%20-%20Video%20in%2C%20LED%20walls%20out%20(0%20of%2010).md). The rest is in the backlog below. @@ -301,7 +301,7 @@ Open here: the color trim, recording and clock sync; the picture sources are in - **E1.31 multicast never arrives.** Network Receive binds 5568 but never joins the multicast groups, so both receivers in `scenario_NetworkSend_one_sender_many_receivers` read 0 packets while Network Send multicast; `UdpSocket::membership` exists in the platform layer and goes unused. Join the groups of the universes the layer covers, from `universe_start` on. - **The Art-Net broadcast counts do not add up.** In the same scenario at 64x64 (about 1,000 packets/s sent, 40 frames), the S3 counted 1,212 packets/s and 96 frames/s while the P4 counted 488 and 39. Another bench board broadcasting, or duplicates over WiFi; a packet capture on port 6454 settles it. - **A sending P4 reboots with 30 Ethernet transmit buffers.** With 30 x 1600 bytes for transmit a P4 rebooted about 15 s into sending; IDF takes every Ethernet DMA buffer from internal RAM, about 96 KB with 30 + 30, so internal-heap exhaustion is the suspect, unverified. troyhacks raised and reverted the same values within 26 minutes ([bdf6c17e](https://github.com/troyhacks/esp32-arduino-libs/commit/bdf6c17e), [c8a43b7e](https://github.com/troyhacks/esp32-arduino-libs/commit/c8a43b7e)). Transmit stays at 10. -- **The P4's HTTP server answers "incomplete request body" under a stream.** Two `set_control` writes and one `apply_state` failed this way while the P4 received DDP over Ethernet; the same short body window backlog-core already names. +- **The P4's HTTP server answers "incomplete request body" under a stream.** Two `set_control` writes and one `apply_state` failed this way while the P4 received DDP over Ethernet; the same short body window [backlog/core](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075256) already names. - **`CONFIG_ETH_IRAM_OPTIMIZATION=y` on the P4.** The one Ethernet change troyhacks kept; measure it with `scenario_NetworkReceive_two_boards`, P4 to P4. - **A UDP mailbox of 64 on the P4.** Espressif's own P4 Arduino default; 32 against 64 measured the same on the S3 over WiFi, so measure it P4 to P4 over Ethernet, where bursts arrive at wire speed. - **The receive task's priority on Ethernet.** Ours runs at 3 on core 1, below the driver task (`mmEncode`, 5); troyhacks' Art-Net receive runs at 20 on core 0, blocking in `recvfrom()`, above lwIP's own thread (`tcpip`, 18), which a busy drain could starve. On the S3 over WiFi 6 against 3 changed nothing, the loss being at the access point. Measure 6 (above the driver task) and 19 (above lwIP) P4 to P4. diff --git a/docs/work/present/Plan-20261015 - Processes, described once (17 of 23).md b/docs/work/Plan-20261015 - Processes, described once (18 of 23).md similarity index 53% rename from docs/work/present/Plan-20261015 - Processes, described once (17 of 23).md rename to docs/work/Plan-20261015 - Processes, described once (18 of 23).md index b41644ae6..404760cef 100644 --- a/docs/work/present/Plan-20261015 - Processes, described once (17 of 23).md +++ b/docs/work/Plan-20261015 - Processes, described once (18 of 23).md @@ -2,7 +2,7 @@ Plan, approved 2026-10-15. MoonLight has many processes, from a first reader to a release, and each was written in its own place and shape. This plan finds one model for all of them, so a process is described once and followed by everyone it concerns: a user in the app, a reader of the docs, a test, an AI agent and the product owner. It is called **MoonWalk**: the model, its stage files under `moonwalk/`, and the map that shows them. -Where things are described: [the MoonWalk reference](../../reference/moonwalk.md) is what exists and how to use and write it; [the model](../future/moonwalk.md) and [its schema](../../../moonwalk/moonwalk.schema.json) are why it is shaped so, chosen against the textbooks (Patton's story map, Arazzo, Given-When-Then, Diátaxis); [the inventory](../future/processes-inventory.md) lists the 71 processes found. This plan holds what is still to build, and a line per step done. +Where things are described: [the MoonWalk reference](../reference/moonwalk.md) is what exists and how to use and write it; [the model](../explanation/architecture/moonwalk.md) and [its schema](../../moonwalk/moonwalk.schema.json) are why it is shaped so, chosen against the textbooks (Patton's story map, Arazzo, Given-When-Then, Diátaxis). This plan holds what is still to build, and a line per step done. ## Why @@ -22,41 +22,54 @@ Neither a Confluence nor a Jira: the docs site is ours and GitHub's issues and P **Across origins and MoonCloud.** Browser storage stays on its origin, so what crosses origins is carried. Built: the address (`?walked=install`, the installer's handover). Candidates: `postMessage` between a page and a window it opens; marks kept on the device's own config, which costs a persisted control the 2026-10-16 decision kept out; and MoonCloud, with consent. A MoonWalk member of MoonCloud, its own checkbox and off until switched on like Stats and Talk, keeps the marks under the installation id so they follow the device to every browser, and in aggregate shows where people stop, a funnel per process. It sends process and step ids and nothing typed, under the privacy policy's rules (step 22). One consent already covers what the app reads from the docs site, the help and MoonWalk (decided 2026-10-16). -**GitHub: issues, Projects, CI, the backlog and notes.** MoonWalk in the repo is the definition; GitHub holds the work and the runs, each feature one job. **Issues** are the work items, each labelled `walk:` so the map shows open issues per process, and the issue forms are generated from MoonWalk (the steps of Log an issue become the bug form). **The backlog becomes the Project's draft items**: four Markdown files of 4,051 lines beside 4 open issues are two backlogs for one job (step 21). A draft item becomes an issue when it is accepted, so the issue list holds only accepted work and reports. **A Project** orders them: Status (Inbox, Backlog, Next, In progress, Waiting, In review, Done), Waiting holding work blocked on someone outside the project: a reply, a part, a tester, a Stage and a Process field, an Area. **Notes reach the Inbox** as a Project draft item, from the phone, `gh project item-create` or the agent, triaged into an issue or deleted (step 20). **Actions run what is already executable**: a step names a workflow, the local gates post check runs (step 19). Access: the agents' `gh` needs the `project` scope. +**GitHub: issues, Projects, CI, the backlog and notes.** MoonWalk in the repo is the definition; GitHub holds the work and the runs, each feature one job. **Issues** are the work items, and the issue forms are generated from MoonWalk (the steps of Log an issue become the bug form); a card's Process field names the process it serves, so the map shows each process's open work. **The backlog is the Project's draft items**, each carrying its fields (Kind, Platform, Trigger, Stage, Process), and `docs/work/backlog_mirror/` is their generated copy, one document per Area, so the repository versions every edit and a reader browses it as documents (step 21). A draft item becomes an issue when it is accepted, so the issue list holds only accepted work and reports. **A Project** orders them, one bag of items sorted by fields, since a draft item carries no labels and no sub-items and a view groups and filters on any field: + +| Field | Type | Values | +|---|---|---| +| Status | single select | Inbox, Backlog, Next, In progress, Waiting (blocked on someone outside the project: a reply, a part, a tester), In review, Done | +| Area | single select | Effects, Modifiers, Layouts, Drivers, Services, Core, Platform, UI, MoonLive, Process and docs: the `src/` tree and the module roles | +| Module | text | the module's class name, such as `AudioService`, empty for an item across modules, so a filter gives one module's backlog | +| Kind | single select | Bug, Feature, Restructure, Investigation | +| Trigger | text | what makes the item worth building, so a met trigger is found by scanning one column | +| Platform | single select | classic, S3, P4, C3, Teensy, desktop, Pi, all | +| Stage | single select | the MoonWalk stage an item serves | +| Process | text | the MoonWalk process id, text since the processes outnumber a select's options; the map lists a process's open cards from it, and a card naming no process fails the docs build | + +The views: a board by Status for the work in flight; a table of the Backlog grouped by Area, the overview the mirror's documents give; a saved view per Area, opened when a topic is picked; and the bugs alone, sorted by Area. An item a plan picks leaves the Project for the plan's own backlog, and what that backlog still holds when the plan ships returns as draft items, so the Project holds what no plan covers. **Notes reach the Inbox** as a Project draft item, from the phone, `gh project item-create` or the agent, triaged into an issue or deleted (step 20). **Actions run what is already executable**: a step names a workflow, the local gates post check runs (step 19). Access: the agents' `gh` needs the `project` scope. ## The steps -📖 marks what [the MoonWalk reference](../../reference/moonwalk.md) describes. +📖 marks what [the MoonWalk reference](../reference/moonwalk.md) describes. Steps 1 to 16 are done, each in a line with where its knowledge lives; git holds how. -1. ✅ **Inventory:** every process that exists as content, 71 entries, with the duplicates counted: [the inventory](../future/processes-inventory.md). -2. ✅ **The model**, approved 2026-10-16: the journey, the process format and the step shape: [the model](../future/moonwalk.md) and [its schema](../../../moonwalk/moonwalk.schema.json). -3. ✅📖 **A pilot: the whole journey on a map**: `gen_moonwalk.py` writes `moonwalk.json`, and `moonwalk.js` draws it with Cytoscape.js: [How the map is built](../../reference/moonwalk.md#7-how-the-map-is-built). -4. ✅📖 **A pilot on the device**: the app hosts the map, and the device's state marks a step done: [On a device](../../reference/moonwalk.md#on-a-device). -5. ✅📖 **The process view, live**: one renderer shows a process whole in every host: [Opening a process](../../reference/moonwalk.md#3-opening-a-process). -6. ✅📖 **MoonWalk in the installer and MoonDeck**: the installer hands a done install to the device, MoonDeck marks script steps from its runs, MoonBase links the map: [In the installer](../../reference/moonwalk.md#in-the-installer), [MoonWalk in MoonDeck](../../reference/moonwalk.md#6-moonwalk-in-moondeck), [Opening MoonWalk](../../reference/moonwalk.md#1-opening-moonwalk). -7. ✅ **The content reviewed**: one install process with a platform choice, Docker among its options; stages, dependencies and goals tuned: [Install MoonLight](../../moonwalk.md#install). -8. ✅📖 **The help and MoonWalk, linked**: one key, checked in the build; the help lists a module's processes; the follow bar: [Help and MoonWalk, linked](../../reference/moonwalk.md#11-help-and-moonwalk-linked). -9. ✅📖 **The developer process**: the commit, merge and release gates and a contributor's pull request are processes, which MoonDeck marks fresh, times and runs: [What MoonDeck serves](../../reference/moonwalk.md#10-what-moondeck-serves-gates), [MoonDeck](../../../moondeck/MoonDeck.md), [Contribute a pull request](../../moonwalk.md#contribute-pr). -10. ✅ **The mapping**, 2026-10-17: every artifact mapped onto the model, with the copies to remove; step 11 carried it out, and [the stage files](../../../moonwalk/) are its result. -11. ✅ **The migration**, 2026-10-19: each old copy deleted as its process took over, `docs/how-to/` folded into the processes and `reference/`, within the caps and the download budget: [The folder is the type](../../contributing/documentation-standards.md#what-we-document), [Prose and caps](../../reference/moonwalk.md#prose-and-caps). -12. ✅ **The REST API in OpenAPI**: rendered on the REST API page and held to the server's routes by a test: [REST API](../../reference/api.md). -13. ✅ **Tests link to the test list**: a module's Tests link opens its entry on the test page: [Turn help on in the app](../../moonwalk.md#turn-help-on). -14. ✅ **The rules on AI, compared**: the contributor vouches and says what an agent wrote, a root cause is a hypothesis until traced, an agent posts only what the product owner read: [CLAUDE.md's Roles](../../../CLAUDE.md#roles), [Contribute a pull request](../../moonwalk.md#contribute-pr). - -**The order from step 15 on**, decided 2026-10-19: the steps run in their numbers. The checks (15) come first, since every later step leans on them. A step's `then` (16) is what the scenarios (17) converge on. The guides (18) are independent, and the standards and the test engine are [a plan of their own](../past/plans/Plan-20261020%20-%20Test%20engine%20(shipped).md). GitHub (19) needs the checks and the processes settled, and its Project (20) comes before the backlog moves into it (21). MoonCloud (22) is last. - -15. ✅ **The checks.** A step naming a script, scenario, page, anchor, operation, module or control that does not exist fails the build; a process no runner verifies is listed, not counted; a check that could not run keeps its counts: [What the generator checks](../../reference/moonwalk.md#9-what-the-generator-checks), [check_docgen](../../../moondeck/MoonDeck.md#check_docgen). -16. ✅📖 **Steps that know they are done.** A step's `then`, derived for an `add` and a `set` and written by hand on a `call` that changes the device, marks the step done and shows under it: [Writing a process](../../reference/moonwalk.md#8-writing-a-process), [On a device](../../reference/moonwalk.md#on-a-device). -17. ✅📖 **Examples of a process: the tests**, 2026-10-25: a scenario names the steps it proves in `steps_proved` and a unit test in `// @step`, a step that sets a control is covered by any scenario setting or expecting it, a test naming no step fails the build, and the test page's Gaps lists the device-changing steps no test covers. A step has no loop, so the two network scripts stay scripts: [Opening a process](../../reference/moonwalk.md#3-opening-a-process), [Writing a process](../../reference/moonwalk.md#8-writing-a-process), [the test page](../../reference/tests.md). - - **The test index and the test engine** are [their own plan](../past/plans/Plan-20261020%20-%20Test%20engine%20(shipped).md). +1. ✅ **Inventory:** every process that exists as content, 71 entries, with the duplicates counted; each became a stage file under `moonwalk/`. +2. ✅ **The model**, approved 2026-10-16: the journey, the process format and the step shape: [the model](../explanation/architecture/moonwalk.md) and [its schema](../../moonwalk/moonwalk.schema.json). +3. ✅📖 **A pilot: the whole journey on a map**: `gen_moonwalk.py` writes `moonwalk.json`, and `moonwalk.js` draws it with Cytoscape.js: [How the map is built](../reference/moonwalk.md#7-how-the-map-is-built). +4. ✅📖 **A pilot on the device**: the app hosts the map, and the device's state marks a step done: [On a device](../reference/moonwalk.md#on-a-device). +5. ✅📖 **The process view, live**: one renderer shows a process whole in every host: [Opening a process](../reference/moonwalk.md#3-opening-a-process). +6. ✅📖 **MoonWalk in the installer and MoonDeck**: the installer hands a done install to the device, MoonDeck marks script steps from its runs, MoonBase links the map: [In the installer](../reference/moonwalk.md#in-the-installer), [MoonWalk in MoonDeck](../reference/moonwalk.md#6-moonwalk-in-moondeck), [Opening MoonWalk](../reference/moonwalk.md#1-opening-moonwalk). +7. ✅ **The content reviewed**: one install process with a platform choice, Docker among its options; stages, dependencies and goals tuned: [Install MoonLight](../moonwalk.md#install). +8. ✅📖 **The help and MoonWalk, linked**: one key, checked in the build; the help lists a module's processes; the follow bar: [Help and MoonWalk, linked](../reference/moonwalk.md#11-help-and-moonwalk-linked). +9. ✅📖 **The developer process**: the commit, merge and release gates and a contributor's pull request are processes, which MoonDeck marks fresh, times and runs: [What MoonDeck serves](../reference/moonwalk.md#10-what-moondeck-serves-gates), [MoonDeck](../../moondeck/MoonDeck.md), [Contribute a pull request](../moonwalk.md#contribute-pr). +10. ✅ **The mapping**, 2026-10-17: every artifact mapped onto the model, with the copies to remove; step 11 carried it out, and [the stage files](../../moonwalk) are its result. +11. ✅ **The migration**, 2026-10-19: each old copy deleted as its process took over, `docs/how-to/` folded into the processes and `reference/`, within the caps and the download budget: [The folder is the type](../contributing/documentation-standards.md#what-we-document), [Prose and caps](../reference/moonwalk.md#prose-and-caps). +12. ✅ **The REST API in OpenAPI**: rendered on the REST API page and held to the server's routes by a test: [REST API](../reference/api.md). +13. ✅ **Tests link to the test list**: a module's Tests link opens its entry on the test page: [Turn help on in the app](../moonwalk.md#turn-help-on). +14. ✅ **The rules on AI, compared**: the contributor vouches and says what an agent wrote, a root cause is a hypothesis until traced, an agent posts only what the product owner read: [CLAUDE.md's Roles](../../CLAUDE.md#roles), [Contribute a pull request](../moonwalk.md#contribute-pr). + +**The order from step 15 on**, decided 2026-10-19: the steps run in their numbers. The checks (15) come first, since every later step leans on them. A step's `then` (16) is what the scenarios (17) converge on. The guides (18) are independent, and the standards and the test engine are [a plan of their own](https://github.com/MoonModules/MoonLight/issues/205). GitHub (19) needs the checks and the processes settled. The backlog moved into the Project (21) before the Project's trial (20) ran, so the trial is the cards in daily use. MoonCloud (22) is last. + +15. ✅ **The checks.** A step naming a script, scenario, page, anchor, operation, module or control that does not exist fails the build; a process no runner verifies is listed, not counted; a check that could not run keeps its counts: [What the generator checks](../reference/moonwalk.md#9-what-the-generator-checks), [check_docgen](../../moondeck/MoonDeck.md#check_docgen). +16. ✅📖 **Steps that know they are done.** A step's `then`, derived for an `add` and a `set` and written by hand on a `call` that changes the device, marks the step done and shows under it: [Writing a process](../reference/moonwalk.md#8-writing-a-process), [On a device](../reference/moonwalk.md#on-a-device). +17. ✅📖 **Examples of a process: the tests**, 2026-10-25: a scenario names the steps it proves in `steps_proved` and a unit test in `// @step`, a step that sets a control is covered by any scenario setting or expecting it, a test naming no step fails the build, and the test page's Gaps lists the device-changing steps no test covers. A step has no loop, so the two network scripts stay scripts: [Opening a process](../reference/moonwalk.md#3-opening-a-process), [Writing a process](../reference/moonwalk.md#8-writing-a-process), [the test page](../reference/tests.md). + - **The test index and the test engine** are [their own plan](https://github.com/MoonModules/MoonLight/issues/205). - ⏳ **Backlog: the seven steps no test covers.** - `control-surface/add-midi`, `source-usb`, `source-net`, `share-source`, `share`: the MIDI tests cover the USB-MIDI and RTP-MIDI protocols, not MidiService's `source` and `share`. A desk on the bench proves them; a fake transport behind MidiService would let the desktop do it. - `multi-device/effect`: boards running one layout's script together, bench work. - `watch-output/add-rtsp`: RtspDriver listens on port 554, which a CI runner without root cannot open, and the recording encoder stand-in yields no H.264 frames to packetize. A port the test sets and a stand-in that hands out a frame make it a desktop test. 18. ⏳ **Guides that add, and `@technical` trimmed**, from the help plan. A control's `@guide` line says only what its line cannot hold, and a module with depth gets a guide about itself, how it works and how to get a look, rather than one walking its controls. An `@technical` appendix keeps only what neither the code, the test names, the base class nor the help says, one or two sentences each; a fact every module of a role shares is said once in the role's base class. Judgment work over hundreds of headers, read here for the processes it holds: a guide's "how to get a look" is a process's steps, so the inventory of step 1 reads the guides and appendices, and what is procedural moves into a process file rather than being trimmed twice. What is reference rather than reasoning, control tables, protocols and wire formats, moves to `reference/`, so it is the one folder for detail; why the code is built as it is stays at the code, where it changes with it, and the appendix line count falls rather than relocates. -19. 🚧 **Executions on GitHub.** (Built 2026-10-25: `CONTRIBUTING.md`; the pull request template, generated from the questions `contribute-pr`'s steps ask in their new `asks` field; the pull request workflows as thin callers of the scripts `ci-on-pr` names, held so by `test_workflows.py`. Missing: `release.yml`'s build steps, after v7.0.0 ships; ThreadSanitizer locally in a Linux container; recorded runs and their state.) A process file is the definition and one run of it is an instance, the split workflow engines make. GitHub keeps the instances; the definition stays in the repo as the one copy. **Decided 2026-10-25:** `CONTRIBUTING.md` is written by hand as one sentence linking [Contribute a pull request](../../moonwalk.md#contribute-pr), since a link copies nothing and needs no generator. `.github/pull_request_template.md` is generated from `contribute-pr`: it asks what the process says a pull request states (one topic within the size cap, based on main, what an agent wrote, what was tested) and links the steps, without a checklist of them. Steps come in two kinds: +19. 🚧 **Executions on GitHub.** (Built 2026-10-25: `CONTRIBUTING.md`; the pull request template, generated from the questions `contribute-pr`'s steps ask in their new `asks` field; the pull request workflows as thin callers of the scripts `ci-on-pr` names, held so by `test_workflows.py`. Built 2026-10-29: `release.yml`'s build and staging steps as calls of [release_stage](../../moondeck/MoonDeck.md#release_stage), named by the [CI on a release tag](../moonwalk.md#ci-on-release) process, its publishing steps exempt by name; ThreadSanitizer and every Linux job on a Mac through [act](https://github.com/nektos/act), which runs the workflows themselves in Linux containers; and [run_process](../../moondeck/MoonDeck.md#run_process), which runs a process's script steps locally, `ci-on-pr` among them. Missing: a release-candidate tag proving the new `release.yml`, and recorded runs and their state.) A process file is the definition and one run of it is an instance, the split workflow engines make. GitHub keeps the instances; the definition stays in the repo as the one copy. **Decided 2026-10-25:** `CONTRIBUTING.md` is written by hand as one sentence linking [Contribute a pull request](../moonwalk.md#contribute-pr), since a link copies nothing and needs no generator. `.github/pull_request_template.md` is generated from `contribute-pr`: it asks what the process says a pull request states (one topic within the size cap, based on main, what an agent wrote, what was tested) and links the steps, without a checklist of them. Steps come in two kinds: - **Volatile steps** act on a device or in the app, such as a user choosing an effect. Nothing records them, since nothing outside the device changes. - **Recorded steps** change the repo or what ships: a plan step, a commit gate, a merge, a release, a verification on hardware, a gallery submission. Each run leaves a record. @@ -64,18 +77,23 @@ Steps 1 to 16 are done, each in a line with where its knowledge lives; git holds A run's measurements, the step metrics above, ride on its record: a check run's output on GitHub, and the value series repo-health draws. - Automated recorded steps record as check runs on the commit or pull request, the local gates included through `gh api`, so a pull request shows which gate passed on which commit. Manual recorded steps record in an issue created from an issue form generated from the process file, a step per sub-issue, and a GitHub Project shows every run and the step it stands at. **Decided 2026-10-25:** a step's kind follows from its process, not its action, since `manual` and `run` go both ways ("Test it on real hardware" in Release is recorded, "Adjust its controls" in first-light-show is not). Every step of a gate process MoonDeck lists, plus `pick-spec-plan` and `contribute-pr`, is recorded; every other step is volatile. A per-step override waits for a step that needs one. Still to decide: whether a plan becomes such an issue instead of a file in `docs/work/present/`, which changes CLAUDE.md's process. **Recommended (2026-10-25):** a plan stays a file, reviewed in a diff and kept in git; revisit once step 20's trial of the Project has run. + Automated recorded steps record as check runs on the commit or pull request, the local gates included through `gh api`, so a pull request shows which gate passed on which commit. Manual recorded steps record in an issue created from an issue form generated from the process file, a step per sub-issue, and a GitHub Project shows every run and the step it stands at. **Decided 2026-10-25:** a step's kind follows from its process, not its action, since `manual` and `run` go both ways ("Test it on real hardware" in Release is recorded, "Adjust its controls" in first-light-show is not). Every step of a gate process MoonDeck lists, plus `pick-spec-plan` and `contribute-pr`, is recorded; every other step is volatile. A per-step override waits for a step that needs one. **Decided 2026-10-29:** a shipped or stopped plan is a closed issue labeled `plan`, its body what no commit holds, and the 43 past plans are such issues (#165 to #207); the plans being built follow ([Backlog](#backlog)). - **Every CI job also runs locally**, so a GitHub outage, or a CI service that changes or stops, costs a runner rather than the gates. A workflow is a thin caller of the MoonDeck script its process step names, and GitHub is one runner of the process files among others. Today each job reproduces by its own script, as `ci-on-pr` lists them, but not as one command, and four gaps remain: ThreadSanitizer needs Linux (`check_sanitizers` skips it on macOS), the MSVC build needs Windows, CodeQL needs its command-line tool, and the release's publishing (the release upload, Pages, the container registry) is GitHub's. The work: one MoonDeck entry runs `ci-on-pr` and `release`'s build steps locally, through `walk_status` as the commit checks already run; ThreadSanitizer runs in a Linux container; every workflow line that is not a script call moves into one; and a test reads `.github/workflows/` and fails on a job that calls no script a process step names. -20. ⏳ **The Project and the inbox on GitHub.** The Project with its fields and views, the `walk:` labels, the issue forms generated from MoonWalk, and the map's count of open issues per process. Apple Notes stays the scrapbook it is, the quickest place for a thought. The Inbox is tried as the step after it: a note worth acting on moves there and is triaged. Whether the Inbox can be the scrapbook too is learned by using it, and Apple Notes goes only if it can. Independent of the other steps, so it can run whenever the product owner picks it. Before step 21, about ten backlog items are moved as draft items and used for a while: queried and edited with `gh`, one converted to an issue, the board mirrored to JSON. What does not hold up in daily use changes step 21 before the rest moves. + **Every CI job also runs locally**, so a GitHub outage, or a CI service that changes or stops, costs a runner rather than the gates. A workflow is a thin caller of the MoonDeck script its process step names, and GitHub is one runner of the process files among others. Today each job reproduces by its own script, as `ci-on-pr` lists them, but not as one command, and four gaps remain: ThreadSanitizer needs Linux (`check_sanitizers` skips it on macOS), the MSVC build needs Windows, CodeQL needs its command-line tool, and the release's publishing (the release upload, Pages, the container registry) is GitHub's. `run_process.py` runs `ci-on-pr` locally through `walk_status`, as the commit checks run; `act` runs the Linux jobs in containers, ThreadSanitizer among them; every workflow line that is not a script call is in one, publishing aside; and `test_workflows.py` fails on a job that calls no script a process step names. +20. 🚧 **The Project and the inbox on GitHub.** (Built 2026-10-29: [the Project](https://github.com/users/MoonModules/projects/5) with its fields and the board, Backlog-by-Area and Bugs views; the board mirrored by [project_mirror](../../moondeck/MoonDeck.md#project_mirror); the bug-report form generated from Log an issue (`gen_moonwalk.py --issue-form`); and each process's open cards on the map, from the cards' Process field. Missing: the cards in daily use, and the Inbox trial.) Apple Notes stays the scrapbook it is, the quickest place for a thought. The Inbox is tried as the step after it: a note worth acting on moves there and is triaged. Whether the Inbox can be the scrapbook too is learned by using it, and Apple Notes goes only if it can. Independent of the other steps, so it can run whenever the product owner picks it. The trial is the moved backlog in daily use: queried and edited with `gh`, a card converted to an issue with the Project's own Convert to issue on acceptance, the mirror read by agents; what does not hold up changes the Project's fields or views. - - ⏳ **Issues in other projects stay current.** A MoonDeck check asks `gh` for the state of each row of [Issues in other projects](../../reference/issues-in-other-projects.md) and of the release that carries its fix, and flags a row whose issue closed or whose fix shipped, so a workaround does not outlive its reason. Its rows can become Project items with the rest. + - ⏳ **Issues in other projects stay current.** A MoonDeck check asks `gh` for the state of each row of [Issues in other projects](../reference/issues-in-other-projects.md) and of the release that carries its fix, and flags a row whose issue closed or whose fix shipped, so a workaround does not outlive its reason. Its rows can become Project items with the rest. -21. ⏳ **The backlog moves to the Project.** Decided 2026-10-21. Each backlog item becomes a **draft item** in the Project's Backlog column, not an issue: around 230 items as issues would bury the open issues, and a draft item stays out of the issue list while `gh` still queries and edits it. A draft item becomes an **issue** when the product owner moves it to Next, which is the acceptance and the point where an agent may pick it up. An **analysis** stays a file in `docs/work/future/`, which the item links, so the reasoning stays in git and is reviewed in a diff; an item whose text is longer than a card holds moves to such a file. A script **mirrors the board** to a JSON file, so an agent searches it offline as it searches the docs. A script reads the four backlog files, proposes one draft item per actionable item with its area and a link to any analysis it cites, and writes the list for the product owner to review before anything is created. Then it creates the items, and the four files are deleted in the same change. An item already shipped or no longer wanted is dropped in the review rather than migrated. After step 20 and its trial, since the items land in its Project. +21. ✅ **The backlog moves to the Project**, 2026-10-29. The 214 items of the Area files are draft items on the public [Project](https://github.com/users/MoonModules/projects/5), uploaded by a one-time script that git keeps, and the Area files are gone. `project_mirror.py` renders the board back to `docs/work/backlog_mirror/`, one document per Area grouped by module, so git versions every card edit, and the commit checks fail when a document differs from the board ([project_mirror](../../moondeck/MoonDeck.md#project_mirror)). A draft item becomes an **issue** when the product owner moves it to Next, the acceptance and the point where an agent may pick it up. An **analysis** is a design study in `docs/design/`, as an RFC is, which the cards and plans it informs cite. Past plans are closed issues labeled `plan`, and the lessons live in `docs/contributing/lessons.md`, so `docs/work/` holds only the plans being built and the mirror. Along the way: each plan holds its own backlog, with the round trip in [Pick, spec and plan a topic](../moonwalk.md#pick-spec-plan); the future docs were swept of what the rest of the docs already say; and ten analyses whose topic shipped dissolved into the explanation, the code and the lessons. Step 20's week of living with the cards comes after the move rather than before it. 22. ⏳ **MoonWalk in MoonCloud.** The member above: its card and consent text, a route and a table in the MoonCloud worker, the privacy policy's entry, and the funnel shown back on the card as Stats shows its totals. It follows step 4, since only the app has an installation id. -23. ⏳ **Close the plan.** Once 15 to 22 are done: the lessons the steps taught go to `docs/work/past/lessons.md`, `moonwalk.md` is read against the built system, and the plan moves to `docs/work/past` as shipped. Deleting it is the product owner's call. +23. ⏳ **Close the plan.** Once 15 to 22 are done: the lessons the steps taught go to `docs/contributing/lessons.md`, [the MoonWalk model](../explanation/architecture/moonwalk.md) is read against the built system, and the plan closes as an issue, the product owner's call. + +## Backlog + +- **Present plans to GitHub.** A plan with work running becomes an open issue labeled `plan`, its steps a task list whose progress the Project shows; a plan not started waits as a Project card holding it, and becomes an issue when work starts; a fully built plan closes as an issue, the product owner's call. A mirror like the backlog's versions the open plans, and once both mirrors are all `docs/work/` holds, the folder goes. +- **Docs link issues, not draft cards.** About 40 links in the docs, to 23 cards, use the Project's `?pane=issue&itemId=` deep link, a UI address GitHub does not document as stable. The textbook fix: convert those 23 cards to issues with the card's own Convert to issue and link the issue, which the backlog check then verifies; about 20 more open issues. The alternative keeps work documents on cards and has the published pages link the Project filtered to an Area instead. ## Decided @@ -88,10 +106,12 @@ Steps 1 to 16 are done, each in a line with where its knowledge lives; git holds - **A step's kind follows from its process** (step 19): a gate process records its runs, any other is volatile. - **A step has no loop** (step 17), as Gherkin has none: a format with variables and repetition is a programming language. Work that repeats over boards or measures a spread stays a script a `run` step names. - **`onSuccess` and `onFailure` left the schema**, 2026-10-20, as no process used them. +- **A card's Process field, not a `walk:` label per process**, links work to its process (step 20, 2026-10-29): the field is on every card already and the mirror carries it, where 73 labels would crowd the repository's label list. +- **The backlog is the Project's cards, mirrored into the repository** (step 21, 2026-10-29), over Markdown files edited by hand: GitHub's workflow does the tracking, and the mirror keeps every edit in git and the backlog readable as documents. A design study stays a document in `docs/design/`, reviewed in a diff. +- **A plan that ships or stops is a closed issue labeled `plan`** (step 19, 2026-10-29), over a file in `docs/work/past/`: its state, close reason and task progress are GitHub's, and its body keeps only what no commit holds. ## To decide - The process of step 17, the first through every runner, and whether it is the one step 5 renders. - **A support process**: a question on Discord or GitHub, an agent drafting the answer, the product owner approving and sending it. It turns the rule that an agent posts only what the product owner has read into a process. - **An orchestrator**: one agent session per issue or pull request, started from the Project and reporting its state (step 19). It changes CLAUDE.md's roles, since the product owner initiates every event: whether an agent may start work on an issue the product owner moved to Next, and stop at a draft pull request for review. -- **A plan rendered as progress**: a plan's numbered steps shown as the map shows a process, a page to share with done and next, rather than a plan becoming an issue (step 19). diff --git a/docs/work/present/Plan-20261023 - JSON in MoonLive services (6 of 6).md b/docs/work/Plan-20261023 - JSON in MoonLive services (6 of 6).md similarity index 97% rename from docs/work/present/Plan-20261023 - JSON in MoonLive services (6 of 6).md rename to docs/work/Plan-20261023 - JSON in MoonLive services (6 of 6).md index e1026e0eb..a3a9559af 100644 --- a/docs/work/present/Plan-20261023 - JSON in MoonLive services (6 of 6).md +++ b/docs/work/Plan-20261023 - JSON in MoonLive services (6 of 6).md @@ -142,13 +142,13 @@ A crossfade costs about the heavier look plus the lighter one, and the slowest, What the build changed from the plan: - **The rule for a busy target.** A second push to a target with one on its way waits and replaces any that waited before it, so no update is lost; dropping it lost text updates. -- **Text and numbers from services.** Many services quote their numbers (`"price":"252.11"`), so `jsonInt` and `jsonFixed` read a quoted number, and a script joins text with `jsonAppendText` and `jsonAppend`. `\uXXXX` escapes decode to UTF-8. Every call a script has: [the MoonLive language](../../moonmodules/light/moonlive.md#json-documents-in-a-service). +- **Text and numbers from services.** Many services quote their numbers (`"price":"252.11"`), so `jsonInt` and `jsonFixed` read a quoted number, and a script joins text with `jsonAppendText` and `jsonAppend`. `\uXXXX` escapes decode to UTF-8. Every call a script has: [the MoonLive language](../moonmodules/light/moonlive.md#json-documents-in-a-service). - **Certificates.** Every Google Trust Services site (Coinbase, TheSportsDB, Spaceflight News) failed on the ESP32 with "No matching trusted root certificate found": it serves GTS Root R4 cross-signed by GlobalSign. `CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_CROSS_SIGNED_VERIFY` fixes it, for every board. - **`.local` names.** `esp_http_client` resolves through DNS alone, so the ESP32's `httpFetch` looks a `.local` host up over mDNS first. - **TheSportsDB's test key** is `123`; `3` is retired. - **The StadBeest's looks** fade through `encoder6` to `encoder8`, since a switch writes true or false and an opacity needs 0 to 255; `switch3` to `switch5` are freed. - **`httpsPost`** is gone: MoonCloud posts through `JsonFetch` on the worker, over `httpFetch`, the one client every request shares, so the heap check covers it too. -- **HTTPS needs PSRAM.** On a classic ESP32 without PSRAM (`MM-Olimex`, 2026-10-24) the request waits for 145 KB of internal RAM, the 100 KB floor plus a 45 KB TLS session, where the board has 55 KB free at idle and 19 KB with `jsondemo.mls`'s four text layers; the bands kept their placeholders. Such a board gets its data pushed from one with PSRAM ([the MoonLive language](../../moonmodules/light/moonlive.md#json-documents-in-a-service)); smaller TLS sessions are in the backlog. A plain-HTTP feed runs there: `weather.mls`, Open-Meteo over `http://`, showed "Amsterdam 12.2 C" on `MM-Olimex` with 60 KB still free. Of the demo's other feeds only Open-Meteo answers without TLS; the rest redirect to HTTPS, which a fetch refuses. +- **HTTPS needs PSRAM.** On a classic ESP32 without PSRAM (`MM-Olimex`, 2026-10-24) the request waits for 145 KB of internal RAM, the 100 KB floor plus a 45 KB TLS session, where the board has 55 KB free at idle and 19 KB with `jsondemo.mls`'s four text layers; the bands kept their placeholders. Such a board gets its data pushed from one with PSRAM ([the MoonLive language](../moonmodules/light/moonlive.md#json-documents-in-a-service)); smaller TLS sessions are in the backlog. A plain-HTTP feed runs there: `weather.mls`, Open-Meteo over `http://`, showed "Amsterdam 12.2 C" on `MM-Olimex` with 60 KB still free. Of the demo's other feeds only Open-Meteo answers without TLS; the rest redirect to HTTPS, which a fetch refuses. - **The share price** is ASML's in Amsterdam, from Yahoo Finance's chart endpoint, keyless and unofficial, where the demo first read bitcoin's. Seen on hardware (`MM-Olimex`, 2026-10-24): a saved service naming `chase.mls`, which the board lacked, listed it as missing; an open page downloaded it into `/.moonlive`, and after a reboot with no page open the service compiled it from there. diff --git a/docs/work/present/Plan-20261024 - One resumable send in the HTTP server (9 of 9).md b/docs/work/Plan-20261024 - One resumable send in the HTTP server (9 of 9).md similarity index 100% rename from docs/work/present/Plan-20261024 - One resumable send in the HTTP server (9 of 9).md rename to docs/work/Plan-20261024 - One resumable send in the HTTP server (9 of 9).md diff --git a/docs/work/present/Plan-20261024 - Video in, LED walls out (0 of 10).md b/docs/work/Plan-20261024 - Video in, LED walls out (0 of 10).md similarity index 100% rename from docs/work/present/Plan-20261024 - Video in, LED walls out (0 of 10).md rename to docs/work/Plan-20261024 - Video in, LED walls out (0 of 10).md diff --git a/docs/work/present/Plan-20261025 - Home Assistant wall controller.md b/docs/work/Plan-20261025 - Home Assistant wall controller.md similarity index 96% rename from docs/work/present/Plan-20261025 - Home Assistant wall controller.md rename to docs/work/Plan-20261025 - Home Assistant wall controller.md index f709c627d..2a6bdd4e7 100644 --- a/docs/work/present/Plan-20261025 - Home Assistant wall controller.md +++ b/docs/work/Plan-20261025 - Home Assistant wall controller.md @@ -8,8 +8,8 @@ Plan of 2026-10-25, a Feature, for approval. A MoonLight panel on the wall shows - **ControlModule** is the device's desk: 8 switches, 8 encoders, 8 faders and 64 preset pads. Each slot drives whatever `Module.control` it is assigned and reads that control back, so the desk follows what it drives. Surfaces (`ControlSurface`: OSC, MIDI) mirror the desk through `sendValue` and `sendDisplay`. - **Inputs** share one binding table, `InputMapping`: a row is a source (a pin, an IR code, a gamepad input) plus an `InputAction` aimed at a desk slot (toggle, set while held, nudge). ButtonService, InfraredService, GamepadService and AnalogService each add only the source half. - **I2cBusModule** opens the board's one bus; a device module claims its address through `i2cDevices()`, the way AudioService claims the ES8311. -- **TextEffect** draws multi-line text in a 4×6 or 6×8 font, 128 characters at most. There is no display driver for an LCD panel, and the P4 HDMI and MIPI-DSI output is step 10 of [Video in, LED walls out](Plan-20261024%20-%20Video%20in%2C%20LED%20walls%20out%20%280%20of%2010%29.md). -- **The backlog** names the Tab5 as "a local wall preview or touch console" needing a `platform::` MIPI-DSI seam ([backlog-light](../future/backlog-light.md)), and says a touch input is "a Service under `Services` … exactly as the button and infrared rows do". +- **TextEffect** draws multi-line text in a 4×6 or 6×8 font, 128 characters at most. There is no display driver for an LCD panel, and the P4 HDMI and MIPI-DSI output is step 10 of [Video in, LED walls out](Plan-20261024%20-%20Video%20in,%20LED%20walls%20out%20(0%20of%2010).md). +- **The Tab5** is a local wall preview or touch console, which needs a `platform::` MIPI-DSI seam; a touch input is a Service under `Services`, as the button and infrared rows are. - **Proven on the bench (2026-10-25):** a live `PATCH /api/state?live=1` from a script on the Mac put the HA states on the Waveshare as one TextEffect, using the 6×8 font, 10 characters by 8 lines. ## The rule this plan follows diff --git a/docs/work/present/Plan-20261025 - MoonCore under other apps (2 of 12).md b/docs/work/Plan-20261025 - MoonCore under other apps (2 of 12).md similarity index 98% rename from docs/work/present/Plan-20261025 - MoonCore under other apps (2 of 12).md rename to docs/work/Plan-20261025 - MoonCore under other apps (2 of 12).md index bc292bb5d..429c58dc3 100644 --- a/docs/work/present/Plan-20261025 - MoonCore under other apps (2 of 12).md +++ b/docs/work/Plan-20261025 - MoonCore under other apps (2 of 12).md @@ -4,7 +4,7 @@ Plan of 2026-10-25, a Restructure, for approval. Another repository takes MoonLi ## What exists -- **Core compiles without light** ([Code ratchets](../past/plans/Plan-20261003%20-%20Code%20ratchets%2C%20one%20report%20for%20simplicity%20(13%20of%2013).md), step 12): `mm_core` compiles through an include root without `light/`, every core header on its own, and `relative include path` keeps `..` from reaching past it. +- **Core compiles without light** ([Code ratchets](https://github.com/MoonModules/MoonLight/issues/197), step 12): `mm_core` compiles through an include root without `light/`, every core header on its own, and `relative include path` keeps `..` from reaching past it. - **Core links without light.** On the desktop, `libmm_core.a` and `libmm_platform.a` need no `mm::` symbol outside themselves. The app supplies `main.cpp` and the type registry (`module_types.cpp`), both outside core. - **The root CMakeLists assumes it is the top level.** It always builds the MoonLight app and the tests, and names paths through `CMAKE_SOURCE_DIR` (23 times, 7 more in `test/`) and the include root through `CMAKE_BINARY_DIR`. Under `add_subdirectory` or `FetchContent` those point into the consumer's tree. - **`mm_core` builds three MoonLight inputs**: the web UI (`ui_embed`, from `src/ui`), the version (`build_info_gen`, from `library.json`) and the script catalog (`moonlive_catalog`, from `moonlive/`). @@ -16,7 +16,7 @@ Plan of 2026-10-25, a Restructure, for approval. Another repository takes MoonLi - MoonLive's light script kinds in `MoonLiveScriptFile.h` (`.mle`, `.mll`, `.mlm`, `.mlp`, beside core's own `.mls`), and the generated `script_catalog.h`, which lists MoonLight's scripts. The light kinds' modules and builtins already live in `src/light/moonlive/`. - **The UI names light types**: 69 literals in `src/ui`, counted by `check_code`'s `module type in the UI`. The 3D view is core's; the preview frames it shows come from `PreviewDriver`, a light driver. - No install or export rules, so `find_package` has nothing to find. -- **FastLED-MM** (2026-04 to 2026-05) is a PlatformIO and Arduino project on projectMM, MoonLight's predecessor, with ArduinoJson and ESPAsyncWebServer. A sketch is an effect module filling a `CRGB leds[]` and a driver module calling `FastLED.show()`; it shows a live preview and takes Art-Net. Its README says it is on hold until MoonLight ships as a library. [how-moonlight-works.md](../../explanation/how-moonlight-works.md) names it as the second domain. +- **FastLED-MM** (2026-04 to 2026-05) is a PlatformIO and Arduino project on projectMM, MoonLight's predecessor, with ArduinoJson and ESPAsyncWebServer. A sketch is an effect module filling a `CRGB leds[]` and a driver module calling `FastLED.show()`; it shows a live preview and takes Art-Net. Its README says it is on hold until MoonLight ships as a library. [how-moonlight-works.md](../explanation/how-moonlight-works.md) names it as the second domain. ## End states diff --git a/docs/work/Plan-20261028 - Audio that answers the music (0 of 5).md b/docs/work/Plan-20261028 - Audio that answers the music (0 of 5).md new file mode 100644 index 000000000..938f5ccfb --- /dev/null +++ b/docs/work/Plan-20261028 - Audio that answers the music (0 of 5).md @@ -0,0 +1,151 @@ +# Plan: audio that answers the music + +Draft for the product owner's approval. The goal is the best visible answer to the music: silence is dark, a quiet passage stays quiet, a loud one is loud, the bars sit balanced across the spectrum, a kick drum is found whatever song is playing, and the beat lands on the beat. The least to set follows from it, since a level that learns the song beats any knob set for one song. The analysis stays one pipeline on every board; measurement-grade accuracy (an analyzer's calibrated bars) is not the goal and goes to the backlog. + +The yardstick is music information retrieval, the field whose libraries (Essentia, librosa, aubio) describe music to a machine: loudness, spectral balance, onsets per band, and tempo with beat phase, in that order. Steps 2 to 5 build one each; step 1 makes the bands those descriptors are read from. Two things the field settles that this plan follows: a band per instrument is not a thing (instruments overlap in frequency, and separating them is a neural net on a GPU), while the drum kit does separate by range; and genre presets lose to adaptation (a floor, a gate and a gain that learn the dynamics give a quiet classical passage and a loud rock chorus the same treatment with no switch). + +## The design basis + +- **One pipeline, no engine select** ([CLAUDE.md, principle 3](../../CLAUDE.md#principles)): one analysis path, the same on every chip. +- **The textbook.** A band is the power in its slice of the spectrum, normalized so that white noise reads flat (Welch's periodogram). A noise gate opens fast and closes slow with hysteresis, as every studio gate does. Automatic gain is one offset that holds the loudest band at the top, with a fast attack and a slow release, as a compressor's make-up gain does. +- **Study, credit, write fresh.** MoonLight's audio path is its own implementation, from the INMP441 datasheet and standard DSP; the people whose thinking it studies are credited by name below and in the header's `@attribution`. +- **Measured on synthesized signals** on the desktop, then judged by eye on the bench: the product owner's eyes are the measurement for a light show. + +## What exists + +- **`AudioService`** ([AudioService.h](../../src/core/services/AudioService.h)): a 512-sample block, a DC blocker, a Hann window, esp-dsp's FFT on the ESP32 and a DFT on the desktop, a frame every 23 ms. +- **The bands** ([AudioBands.h](../../src/core/services/AudioBands.h)): sixteen log-spaced bands, each the loudest bin of its slice. Pink noise, the spectrum real music resembles, falls toward the treble on that estimator, which the learner in `BandConditioner` then corrects per band. +- **The level path**: `floor` is a silence threshold and a window bottom at once; `levels` switches between a manual `gain` and a learned window; `BandConditioner` and `LevelConditioner` learn a floor and a peak each and apply a compressor's ratio. Three mechanisms share the one job of "show the music at the right size", from three places. +- **`OnsetDetector`**: one `onset` for a hit anywhere in the spectrum, so a kick and a snare look alike. +- **The readers**: thirteen effects read `bands[16]`, the WLED sync packet carries sixteen, and MoonLive reads `audioBand(i)` and `audioPeakHz`. + +## Prior art + +Audio-reactive lighting has a lineage (WLED-SR, WLED-MM, MoonLight), and four people's thinking is weighed here, their ideas and our analysis. + +**Frank (softhack007)**, main author of the WLED-MM audioreactive usermod, who the product owner worked alongside for years: the adaptive noise gate that step 2 builds. His concept, analyzed with his permission: a standard noise gate on the time-domain level, open fast and close slow with hysteresis, keyed to a *learned* floor so it self-calibrates to whatever source is connected, with thresholds as factors of that floor rather than absolute counts. His constraints bind step 2: the gate decides "is there sound" and leaves scaling to the gain; it reads the RMS the level path already computes, so it adds no delay; the whole chain stays under 30 ms, and a 512-sample block at 22,050 Hz already spends 23 of them, so every smoother is a one-pole. + +**Troy (troyhacks)**, MoonModules: [his AudioReactive rewrite](https://github.com/troyhacks/WLED), commit `ed544259` of 2026-10-25, on an ESP32-P4, checked against a hardware analyzer. Most of it chases measurement accuracy (a 4096-point transform, biquad pre-filters, calibration from a measured impulse response, 31 third-octave bands, self-tests at boot), which this plan leaves to the backlog. Two decisions carry over: + +| Decision | What it does | Why it matters here | +|---|---|---| +| **A unit contract** | Every band is a power density normalized by the sample rate and the window's power, so white noise reads flat by definition and pink noise falls 3 dB an octave | A test can assert "balanced bars" instead of a learner correcting for the estimator | +| **Four quantities, kept apart** | The level is the RMS of the fresh audio in dB; the floor snaps down at once and creeps up with a 10 s time constant, only while the level sits within 3 dB of it; the gate opens at `squelch` dB above the floor and shuts 4 dB lower; the AGC is one dB offset holding the loudest band at 0 dB, 20 ms attack, 1,500 ms release, frozen while the gate is shut | Nothing reads its own output, so the deadlocks of one number doing four jobs are impossible; our three mechanisms approximate this from three places | + +A later version, described on Discord on 2026-10-28 and unpushed, replaces the FFT with filter banks, which time a hit within a tick rather than within a 23 ms block; it changes no step here and is [the trial in the backlog below](#backlog), run once the steps give it a bench. + +**LedFx**, the host-side effect engine whose whole purpose is audio reactivity: its mel-spaced bands and per-band asymmetric smoothing, reached independently of the WLED lineage. Step 1 keeps log spacing (mel and log coincide above 1 kHz and the sixteen readers depend on the layout); the asymmetric smoothing is the attack and release step 2 gives the gain. + +**Damian (DedeHai)**, WLED: the fixed-point FFT for chips without an FPU. Every MoonLight audio target has one, so the float transform stays; the int16 path is the lever if a C3 ever analyzes audio. + +## Steps + +Each step ends in a desktop test and a bench look. + +### 1. Balanced bars by construction ⏳ + +- A band is the power summed over its slice, normalized by the sample rate and the window's power, in `AudioBands.h`; the loudest-bin estimator goes. +- Desktop tests: synthesized white noise reads every band within 1 dB of flat; pink noise falls 3 dB an octave; a sine lights one band; the dominant peak reads as before. Flux and onset are recomputed from the new bands, and a synthesized kick still fires `onset` while steady pink noise does not. +- Bench: pink noise from a phone through the microphone, and the bars sit level. + +### 2. One level path, no knobs ⏳ + +- Four quantities, as the prior art has them: the level in dB, a floor that snaps down and creeps up only near the floor, a gate with hysteresis that opens fast and closes slow (Frank's adaptive gate, under his constraints above; the silence threshold `floor` already gives is its set half, the learned floor its adaptive half), and one dB offset holding the loudest band at the top, frozen while the gate is shut. +- A setting stays when the bench shows a better result with it than without, and goes when the automatic path matches it. `floor` stays, the one setting a room may need, with a default that suits a MEMS microphone; `levels`, `gain`, the conditioner's ratio and maximum gain, and the two learned tables are each tried against the automatic path on the bench. A choice that belongs to an effect (which signal it follows: a band, the bass, the onset, the beat) sits on the effect's card, part of the effect rather than a tuning. +- Tuned in a quiet room first, then in a noisy one with a varying low-frequency ambient, the case that made the earlier self-calibrating prototype hard to settle. +- The card shows the four quantities, so a gated room reads as "gated at −52 dB" rather than as a dead audio path, the bug report a high default floor earns today. +- Bench: silence is dark, a quiet passage stays quiet, a loud one is loud, a song that starts soft and ends loud fills the rig at both ends, and the StadBeest's eyes follow on the bench network. + +### 3. The kick is found in any song ⏳ + +- Three sums beside the sixteen bands, each a `uint8_t` 0 to 255 on the bands' scale: the power of the FFT bins whose centers fall in 20 to 150 Hz, 150 Hz to 2 kHz, and 2 kHz to Nyquist, normalized as step 1 normalizes a band, so a sum reads on the same dB scale as a band, so an effect keyed on the bass finds the kick whichever band it lands in; MoonLive gains `audioBass`, `audioMid` and `audioTreble`. +- Bench: `BeatRipplesEffect` keyed on the bass sum through a kick-drum track. + +### 4. A kick and a snare look different ⏳ + +- An onset per range (low, mid, high) from the sums of step 3, each with the flux-against-its-mean decision `OnsetDetector` already makes; `onset` stays as the any-hit signal. Moved here from [the live-performance plan, step 5](Plan-20261013%20-%20Audio%20reactive%20live%20performance%20(0%20of%207).md), which keeps the drum-trigger route. +- Desktop test: a synthesized kick fires the low onset and not the high one; a synthesized hi-hat the reverse. +- Bench: a drum track, the kick splashing from the floor and the hi-hat sparkling at the top, on one layer each. + +### 5. The beat lands on the beat ⏳ + +- Tempo and beat phase from the onset curve, the standard algorithm (Ellis, *Beat tracking by dynamic programming*, 2007: autocorrelation of the flux history over the last few seconds picks the tempo in 60 to 200 BPM, and the phase that lines up with the recent onsets picks the beat). The frame carries `bpm`, `beatPhase` in 0 to 1 and `beat` for the block the phase wraps. MoonLive's `audioBeat`, a level-above-its-average guess until now, returns the tracked beat, and `audioOnset` and `audioBpm` are added. A reactive effect trails a hit by a block; a beat-synchronous one fires on it, and knows when the next one comes. +- The beat phase is what the live-performance plan's `MidiFrame` takes from a MIDI clock when one runs; the audio estimate fills the same field when none does, so an effect reads one phase from either source. +- Desktop tests: a synthesized click track at 120 BPM reads 120 within 1 BPM and `beat` fires within a block of each click; a track that steps to 140 BPM follows within four beats; silence reads no tempo. +- Bench: `BeatRipplesEffect` on `beat` instead of `onset` through a steady song, the ripples on the pulse rather than on every hit. + +## What an effect reads + +[`AudioFrame`](../../src/core/util/AudioFrame.h) is the whole contract: one flat struct per tick, every field a small integer, read through `AudioService::latestFrame()` by an effect and through builtins by a MoonLive script. Today it carries the level (raw and smoothed), the sixteen bands (raw and with meter ballistics), the dominant frequency with its magnitude, the flux and `onset`. So an effect sees volume, a spectrum and "a hit happened"; twelve of the seventeen audio effects draw the bands, the rest read the level or `onset`. The plan widens the struct and nothing else: + +| Step | New fields | MoonLive | +|---|---|---| +| 1, 2 | none; `bands` and `level` mean what they did, read better | | +| 3 | `bass`, `mid`, `treble` | `audioBass`, `audioMid`, `audioTreble` | +| 4 | `onsetLow`, `onsetMid`, `onsetHigh` | `audioOnset(range)` | +| 5 | `bpm`, `beatPhase`, `beat` | `audioBpm`, `audioBeat` | + +A field is 0 when its source is absent, so an effect on a board without a microphone draws its idle state, as today. The new fields are local: the WLED audio-sync packet carries none of them, so a board in receive mode leaves them at 0 while it keeps its own `bandsSmoothed` across the fresh frame, and a desktop test pins both. + +## What it costs, per chip + +**Analysis is not where the frame goes.** AudioService's fixed I2S block read adds 13 to 22 µs to an average tick ([performance.md](../reference/performance.md), 2026-06-17); the 512-point FFT every 23 ms at 22,050 Hz waits for a measurement of its own. Each step keeps it that way, and its desktop test and bench run record the tick it adds. + +| Step | What it adds per block | Estimate, to be measured | +|---|---|---| +| 1 | a power sum over the 256 bins, replacing the loudest-bin search | the same loop as today | +| 2 | a few multiplies for the floor, gate and offset | negligible | +| 3 | three sums over bins step 1 already reads | negligible | +| 4 | three flux decisions | negligible | +| 5 | an autocorrelation of about 6 s of flux, some 260 values, over the 30-odd lags of 60 to 200 BPM, plus a phase fit | about 8,000 multiply-adds, under 0.1 ms on a classic ESP32 at 240 MHz, and 1 KB of history | + +**Beat tracking is cheap here because it runs on the onset curve, not on the audio.** The expensive half of a beat tracker is the onset analysis, which the FFT already pays for. A tracker gets costly when it evaluates every lag at the audio rate, or runs a dynamic program over a long history every sample; this one evaluates once per block over a few hundred values. + +**The same signals on every chip, the headroom spent on resolution.** Every chip that analyzes audio carries all five steps, so an effect behaves alike on each, as decided for the filter-bank trial. A chip with spare cycles spends them on how finely the signals are measured, never on extra ones: + +- **A 1024-point FFT for the bass.** At 22,050 Hz and 512 points a bin is 43 Hz wide, so 20 to 150 Hz is three bins; 1024 points halve the bin width, at twice the block latency. The decimated FFT in [the backlog](#backlog) is the other route to the same bass. +- **50% overlap.** A new block every 256 samples halves the onset step from 23 to 12 ms, which tightens the beat phase. +- **44,100 Hz** for the treble sum and the hi-hat onset. + +Each is chosen per chip by the platform, a default rather than a user setting, so it adds no knob. The classic ESP32 is the floor: each step is measured there first, and a step over its budget there, about 1% of a 60 fps tick, gets cheaper on that chip rather than dropped from it. + +## Effects on the new signals + +Each is a small change once its field exists, and together they are the bench for the plan, since a signal nobody draws is not verified. In the order the steps land: + +| Effect | Reads | What it shows | Step | +|---|---|---|---| +| **An audio modifier** | `levelSmoothed` | Scales any effect's brightness, speed or size with the music, so every effect in the catalog answers it without an audio variant each. The one that makes the level path visible on a whole rig | 2 | +| **`BeatRipplesEffect` on the bass** | `bass`, then `onsetLow` | Stones dropped on the kick rather than on any hit, the first effect to find the kick in any song | 3, 4 | +| **Three zones** | `bass`, `mid`, `treble` | Three colors, the bass on the floor, the mids in between, the treble at the top, breathing with their range: the spectrum reduced to what an audience reads at a glance | 3 | +| **Drum kit** | `onsetLow`, `onsetMid`, `onsetHigh` | A kick splashes from the floor, a snare flashes the middle, a hi-hat sparkles the top, each a short-lived sprite the `SpriteFountainEffect` already knows how to throw | 4 | +| **`PulseEffect` and `PaintBrushEffect` on the beat** | `beat` | One shell, one stroke, per beat instead of per hit, steady through a song | 5 | +| **Beat sweep** | `beatPhase` | A bar that crosses the rig once per beat, position from phase, the classic sweep in time that a reactive effect cannot do because it moves between hits | 5 | +| **Palette per bar** | `beat` | The palette advances every fourth beat, so color changes land on the bar line | 5 | + +The modifier is the recommended first, since it turns the existing catalog audio-reactive at once; the rest follow their steps. + +## Backlog + +On the topic and not a step. What remains here when the plan ships returns to the Project as cards, item by item, then. + +- **Analyzer-grade accuracy**, what a rig cannot show, each taken when a user or an effect asks: a transform sized by the chip (2048 points on the S3, 4096 on the P4) for two bass notes to read as two bars; a biquad pre-filter chain (rumble high-pass, anti-alias low-pass) from esp-dsp's `dsps_biquad_f32`; calibration against a measured chain and a self-test on the card; the 31 ISO 266 third-octave bands for an analyzer effect. Troy's rewrite has all four, checked against a hardware analyzer. +- **A second, decimated FFT for the low bands (2026-10-05).** At the default 22.05 kHz a 512-sample block gives 43 Hz bins every 23 ms, so the bands below about 130 Hz own one bin each and two bass tones a semitone apart land in the same band. A longer FFT alone buys resolution at the cost of time for every band (2048 samples: 10.8 Hz, but 93 ms). The textbook multirate answer keeps both: low-pass and decimate by 4 to 5.5 kHz, run a 512-point FFT on that for the bands under about 2.7 kHz (about 11 Hz bins), and keep the 23 ms FFT for the rest. Cost is one more 512-point FFT and a small decimation filter per block. Pinned on the desktop by two synthesized bass tones (50 and 60 Hz) landing in different bands, then measured on an S3 against the Audio module's tick time. +- **A filter bank for the bands (2026-10-28), a trial.** Troy's unpushed version computes each band as a resonator answering every sample, so a band's time resolution matches its frequency and a hit is timed within a render tick rather than within the 23 ms FFT block: sharper onsets, a better beat phase, a bass that follows a note. Sixteen second-order biquads at 22 kHz are a few MFLOPS, inside an S3's FPU and trivial on a P4, Teensy or desktop; the classic and the C3 are the question. Every device behaves the same, so this is a trial of one against the other, not a choice per chip: both behind the one `AudioFrame` with the same unit contract and the same tests, a select on the card for the trial only, run after the steps give it a bench (the synthesized tests on the desktop, onset timing on a click track, the tick cost measured on the slowest chip, and the bench by eye). Then one of the two stays, on every chip, and the other and the select go; `peakHz` keeps a transform either way. +- **More audio effects on the bands**, 2D and palette-driven frequency-reactive ones, beside [the effects on the new signals](#effects-on-the-new-signals). +- **Pin auto-scan**: detect the microphone's `sdPin` with `wsPin` and `sckPin` fixed, a noise prompt and a confirm; explicit pin controls ship today. +- **Sources beyond the I²S microphone**, each a variant behind the same platform read, the analysis untouched. The I²S peripheral already drives MCLK (`mclkPin`) for a line-in ADC. + - **PDM microphones**: the IDF `i2s_pdm` driver, a sub-mode of the same peripheral. + - **Analog line-in**: DedeHai has it on the S3 and Troy in ParrotRadio, where he recorded and played back through it. An analog path is validated by listening to it, since one that only feeds a level meter "may not be as accurate as it looks" (Troy). + - **I²C-configured codecs** (the ES8311 class): Espressif's `esp_codec_dev` component carries the register tables for many codecs, so none is hand-rolled. + - **Multi-channel front-ends**: a TDM codec (the ES7210 on the Waveshare RGB-Matrix board) or a DSP board giving one analyzed channel per instrument, the input [the live-performance plan](Plan-20261013%20-%20Audio%20reactive%20live%20performance%20(0%20of%207).md) needs for acoustic drums. +- **Desktop audio capturing what the machine plays (2026-09-29).** `platform_desktop_audio.cpp` enumerates capture devices only (`ma_context_get_devices` with null playback lists) and opens `ma_device_type_capture`, so on a Windows bench the picker offers `default` and the microphone and the speakers are absent, with no Stereo Mix to fall back on. miniaudio has `ma_device_type_loopback`, WASAPI-only, taking a playback device: enumerate playback devices too, mark them in the picker so a microphone and "what is playing" tell apart, and open loopback when one is chosen. macOS has no equivalent and needs a virtual device such as BlackHole, so the picker offers what the platform can do rather than a row that fails. A disconnected microphone jack reports a plausible level (RMS 20 to 30 at mains hum), which the status of step 2 tells from a working input. + +## Open questions + +- **Which settings stay on the card:** step 2 settles it per setting on the bench. **Recommended (2026-10-28):** the result decides, not the count; the expectation is `floor` alone, and a response-speed setting (attack and release) is the likeliest second if a bench case wins with it. +- **Whether step 2 keeps a manual mode** for a show that wants deterministic levels. **Recommended (2026-10-28):** no; freezing the floor and the offset is the deterministic mode, as a `freeze` toggle, if a show asks. + +## What it removes + +In code: the loudest-bin estimator and the bin-ownership loop in `AudioBands.h` with their technical notes, and, as step 2 confirms, `levels`, `gain`, the conditioner's ratio and maximum gain and the two learned tables. In the docs: the audio DSP roadmap is gone, its prior art and adaptive gate in this plan and its source list in the backlog above; the audio-reactive follow-ups section left the light backlog and the desktop-capture and noise-floor items left the core backlog, into steps 2 and 3 or the backlog above; per-band onsets left the live-performance plan for step 4. diff --git a/docs/work/backlog_mirror/cards.json b/docs/work/backlog_mirror/cards.json new file mode 100644 index 000000000..45a35e54a --- /dev/null +++ b/docs/work/backlog_mirror/cards.json @@ -0,0 +1,2142 @@ +[ + { + "title": "`applyControlValue` should say whether the value changed (2026-10-03)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077117", + "number": null + }, + { + "title": "`ListSource` methods compile twice: ~8.6 KB of thunks (2026-10-01)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076985", + "number": null + }, + { + "title": "A bus pin that belongs to another peripheral is accepted", + "area": "Core", + "module": "PinsModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076440", + "number": null + }, + { + "title": "A config written for next boot is overwritten by the next save (2026-10-04)", + "area": "Core", + "module": "FilesystemModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074945", + "number": null + }, + { + "title": "A device without a deviceModel is not told its catalog half is missing (bench, 2026-08-26)", + "area": "Core", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076840", + "number": null + }, + { + "title": "A driven GPIO the Pins map never sees: bus padding, and a hidden clockPin", + "area": "Core", + "module": "PinsModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076362", + "number": null + }, + { + "title": "A non-ASCII Windows profile path defeats the desktop settings directory (2026-08-23)", + "area": "Core", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077638", + "number": null + }, + { + "title": "A Progress total rides the value patch (2026-10-19)", + "area": "Core", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076676", + "number": null + }, + { + "title": "A request body shares the two upload slots (2026-10-25, from the pre-merge review)", + "area": "Core", + "module": "HttpServerModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075256", + "number": null + }, + { + "title": "A script's setControl rebuilds a control subtree on every write (2026-09-06)", + "area": "Core", + "module": "Scheduler", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076587", + "number": null + }, + { + "title": "A second I2C bus, once a board wires one (2026-10-06)", + "area": "Core", + "module": "I2cBusModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075332", + "number": null + }, + { + "title": "A second instance says it is serving, serves nothing, and shares the first one's config (2026-09-29)", + "area": "Core", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077564", + "number": null + }, + { + "title": "A slot whose target is gone sends 0 to its followers (2026-10-08)", + "area": "Core", + "module": "ControlModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074535", + "number": null + }, + { + "title": "Advanced network settings (2026-10-03)", + "area": "Core", + "module": "NetworkModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076054", + "number": null + }, + { + "title": "An early state push costs 3 to 5 ms on the S3 (2026-10-01)", + "area": "Core", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077064", + "number": null + }, + { + "title": "An explicit `Custom` Ethernet preset is reseeded to a named one", + "area": "Core", + "module": "EthernetModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074778", + "number": null + }, + { + "title": "Clock sync: a shared monotonic clock across devices", + "area": "Core", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077324", + "number": null + }, + { + "title": "Config backup and restore: identity, a recipe, and tiers 2 and 3", + "area": "Core", + "module": "FilesystemModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075118", + "number": null + }, + { + "title": "Config provenance: firmware/MCU → deviceModel (catalog follow-ups)", + "area": "Core", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077405", + "number": null + }, + { + "title": "DevicesModule: interop plugins and the command half", + "area": "Core", + "module": "DevicesModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074692", + "number": null + }, + { + "title": "Drivers' palette and fixture-profile lists, sent once rather than in every view (2026-10-13)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077706", + "number": null + }, + { + "title": "Effects and Drivers find their source one way, not two (2026-10-08)", + "area": "Core", + "module": "Scheduler", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076507", + "number": null + }, + { + "title": "Generic control and state topics over MQTT: the automation escape hatch", + "area": "Core", + "module": "MqttModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075589", + "number": null + }, + { + "title": "HA update entity via MQTT discovery: release check", + "area": "Core", + "module": "MqttModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075650", + "number": null + }, + { + "title": "Hot path: move blocking work off the render callbacks (architecture)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077246", + "number": null + }, + { + "title": "Improv-as-REST follow-ups", + "area": "Core", + "module": "ImprovProvisioningModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075428", + "number": null + }, + { + "title": "Live RMII Ethernet reconfigure", + "area": "Core", + "module": "EthernetModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074861", + "number": null + }, + { + "title": "Memory: the largest buffers, by name", + "area": "Core", + "module": "SystemModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074597", + "number": null + }, + { + "title": "ModuleFactory: registration can fail silently (uint8_t ceiling, unchecked bool)", + "area": "Core", + "module": "ModuleFactory", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075508", + "number": null + }, + { + "title": "NetworkModule treats a panel-output NIC as the device's uplink (2026-09-28)", + "area": "Core", + "module": "NetworkModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076135", + "number": null + }, + { + "title": "One board-preset helper for Ethernet and the SD card (2026-10-12)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076913", + "number": null + }, + { + "title": "Persistence overlay: partial-save and schema-change audit", + "area": "Core", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077474", + "number": null + }, + { + "title": "PinsModule: strict reject-on-add mode", + "area": "Core", + "module": "PinsModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076292", + "number": null + }, + { + "title": "Playlist in core: one sequencer for FSEQ, effects and DemoReel (2026-10-19)", + "area": "Core", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076772", + "number": null + }, + { + "title": "Refresh a multicast membership on a fixed pace (2026-10-09)", + "area": "Core", + "module": "NetworkModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075801", + "number": null + }, + { + "title": "Remove the 6.0 network adoption (2026-10-04)", + "area": "Core", + "module": "NetworkModule", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075864", + "number": null + }, + { + "title": "Remove the flat config conversion (2026-10-08)", + "area": "Core", + "module": "FilesystemModule", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075031", + "number": null + }, + { + "title": "Runtime board presets (multi-commit, partially landed)", + "area": "Core", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077175", + "number": null + }, + { + "title": "Some devices stop answering mDNS over WiFi (2026-10-08)", + "area": "Core", + "module": "NetworkModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075946", + "number": null + }, + { + "title": "The access point never blocks a WiFi retry (2026-10-09)", + "area": "Core", + "module": "NetworkModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075741", + "number": null + }, + { + "title": "The HTTP server is audited for the two PHP request-handling bug classes (2026-10-21)", + "area": "Core", + "module": "HttpServerModule", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075179", + "number": null + }, + { + "title": "The persisted interface label carries the negotiated link speed (2026-09-28)", + "area": "Core", + "module": "NetworkModule", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076214", + "number": null + }, + { + "title": "What disable means: release the memory, or keep the module hot (2026-10-20)", + "area": "Core", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076730", + "number": null + }, + { + "title": "16 bits at the output: what remains", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081005", + "number": null + }, + { + "title": "A '595 expander on Parlio", + "area": "Drivers", + "module": "ParlioPeripheral", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079383", + "number": null + }, + { + "title": "A '595 expander on the Teensy's GPIO-DMA bus", + "area": "Drivers", + "module": "GpioDmaPeripheral", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078012", + "number": null + }, + { + "title": "A control change reaches a driver at the frame boundary (2026-10-21)", + "area": "Drivers", + "module": "DriverBase", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077803", + "number": null + }, + { + "title": "A power budget the device knows about (2026-09-02)", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080199", + "number": null + }, + { + "title": "An identity correction is one copy (2026-10-14)", + "area": "Drivers", + "module": "DriverBase", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077859", + "number": null + }, + { + "title": "ArtPoll discovery: know which tubes are alive", + "area": "Drivers", + "module": "NetworkSendDriver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078870", + "number": null + }, + { + "title": "Brightness belongs on a fixture's DIMMER channel, not only in the color values (WANTED)", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080535", + "number": null + }, + { + "title": "Chunked transfer (Step 4): the 16K lever", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080935", + "number": null + }, + { + "title": "Classic-ESP32 shift-register ring on raw I2S: the high-light-count classic driver", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080365", + "number": null + }, + { + "title": "Clocked and one-lane strips over SPI", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079973", + "number": null + }, + { + "title": "Configure a ColorLight card without Windows (2026-10-16, asked)", + "area": "Drivers", + "module": "PanelCardDriver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079294", + "number": null + }, + { + "title": "Depth that degrades instead of failing", + "area": "Drivers", + "module": "Hub75Driver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078249", + "number": null + }, + { + "title": "Four-scan panels whose physical layout is not their logical one", + "area": "Drivers", + "module": "Hub75Driver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078323", + "number": null + }, + { + "title": "Frames to a board over USB serial, the desktop as renderer", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080036", + "number": null + }, + { + "title": "Graceful blank-on-stall: a stalled bus darkens the strip (2026-07-15)", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080268", + "number": null + }, + { + "title": "HLS upscaling is cache-hostile on large walls (2026-08-28)", + "area": "Drivers", + "module": "HlsDriver", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078077", + "number": null + }, + { + "title": "Migrate HueDriver's name buffers to ScratchBuffer (finish the ScratchBuffer sweep)", + "area": "Drivers", + "module": "HueDriver", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078467", + "number": null + }, + { + "title": "MoonI80 prime-only ring: no stall backstop (sibling-path gap)", + "area": "Drivers", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080110", + "number": null + }, + { + "title": "MoonI80 ring: the first frame after a rebuild trips a transient give-up status (2026-07-16)", + "area": "Drivers", + "module": "MoonI80Peripheral", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078773", + "number": null + }, + { + "title": "MoonI80 streaming ring: open instruments and cleanups", + "area": "Drivers", + "module": "MoonI80Peripheral", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078616", + "number": null + }, + { + "title": "MoonLight hangs when a USB network adapter is re-plugged (2026-09-28)", + "area": "Drivers", + "module": "PanelCardDriver", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078957", + "number": null + }, + { + "title": "Multi-card walls: does a daisy chain work today?", + "area": "Drivers", + "module": "PanelCardDriver", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079144", + "number": null + }, + { + "title": "Multicore step 2b: a ping-pong second output buffer", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080455", + "number": null + }, + { + "title": "One encode, two readers: HLS and RTSP sharing the encoder (2026-09-22)", + "area": "Drivers", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080774", + "number": null + }, + { + "title": "P4 Parlio streaming ring: lift the ceiling past about 21K lights", + "area": "Drivers", + "module": "ParlioPeripheral", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079464", + "number": null + }, + { + "title": "Pan/tilt travel is hardcoded, and positioning is 8-bit (WANTED)", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080613", + "number": null + }, + { + "title": "Preview does not resume after a long tab hibernation (observed once, 2026-08-27)", + "area": "Drivers", + "module": "PreviewDriver", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079553", + "number": null + }, + { + "title": "Repaint only what changed", + "area": "Drivers", + "module": "Hub75Driver", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078195", + "number": null + }, + { + "title": "Repeated planes: full top brightness and 8-bit depth", + "area": "Drivers", + "module": "Hub75Driver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078402", + "number": null + }, + { + "title": "RMT int_ena read-modify-write race (classic ESP32, level-5 refill)", + "area": "Drivers", + "module": "RmtLedDriver", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079890", + "number": null + }, + { + "title": "RMT over DMA on the S3/P4, with a completion callback (Funkelfetisch, July 2026)", + "area": "Drivers", + "module": "RmtLedDriver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079737", + "number": null + }, + { + "title": "RmtLedDriver's out-of-memory status has no test, and cannot have one yet (2026-09-09)", + "area": "Drivers", + "module": "RmtLedDriver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079814", + "number": null + }, + { + "title": "RS-485 and DMX-512 wired output: the physical-DMX driver", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080691", + "number": null + }, + { + "title": "Self-describing preview frame header", + "area": "Drivers", + "module": "PreviewDriver", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079652", + "number": null + }, + { + "title": "Smaller asks from the same thread", + "area": "Drivers", + "module": "PanelCardDriver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079230", + "number": null + }, + { + "title": "Spout and Syphon as video outputs, only if proven", + "area": "Drivers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080849", + "number": null + }, + { + "title": "Stop reading fixtureRef after the release that follows 2026-10-05", + "area": "Drivers", + "module": "DriverBase", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077925", + "number": null + }, + { + "title": "The interface picker re-enumerates adapters on every control rebuild (2026-09-28)", + "area": "Drivers", + "module": "PanelCardDriver", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079059", + "number": null + }, + { + "title": "The ring's memory readout counts one buffer, not the pool (CodeRabbit, 2026-09-09)", + "area": "Drivers", + "module": "MoonI80Peripheral", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078684", + "number": null + }, + { + "title": "The ring's refill jitter (2026-10-22, from the internal RAM plan)", + "area": "Drivers", + "module": "MoonI80Peripheral", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078532", + "number": null + }, + { + "title": "The shift-register driver init", + "area": "Drivers", + "module": "Hub75Driver", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078142", + "number": null + }, + { + "title": "16-bit noise: warp16 / turbulence16", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082034", + "number": null + }, + { + "title": "A real 2D/3D PacMan", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081828", + "number": null + }, + { + "title": "Add real z-axis variation to 2D effects", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081899", + "number": null + }, + { + "title": "Aurora hitches on the desktop and not on the S3 (2026-09-04)", + "area": "Effects", + "module": "AuroraEffect", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081062", + "number": null + }, + { + "title": "BlurzEffect: a compounding blur has no rate carry", + "area": "Effects", + "module": "BlurzEffect", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081157", + "number": null + }, + { + "title": "BouncingBalls onto the particle kernel: a rework, not a swap", + "area": "Effects", + "module": "BouncingBallsEffect", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081225", + "number": null + }, + { + "title": "Clear the grid on an effect's FIRST frame", + "area": "Effects", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082101", + "number": null + }, + { + "title": "DemoReel: a 3D test for the hosted D1/D2 extrude", + "area": "Effects", + "module": "DemoReelEffect", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081326", + "number": null + }, + { + "title": "E1.31 multicast receive (IGMP join)", + "area": "Effects", + "module": "NetworkReceiveEffect", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081542", + "number": null + }, + { + "title": "FixedPoint fades outside the Layer's aggregation (2026-09-07)", + "area": "Effects", + "module": "FixedPointEffect", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081402", + "number": null + }, + { + "title": "Moving-head effects from MoonLight, including two of troyhack's (2026-09-04)", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081692", + "number": null + }, + { + "title": "Speed up the fluid solver: 4 fps at 128x128, and it is not the divide (2026-09-05)", + "area": "Effects", + "module": "FluidEffect", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081468", + "number": null + }, + { + "title": "Sprite follow-ups", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081753", + "number": null + }, + { + "title": "The catalog sweep: every natural-motion effect onto the power functions", + "area": "Effects", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081620", + "number": null + }, + { + "title": "worley cellular noise, when an effect needs it", + "area": "Effects", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081966", + "number": null + }, + { + "title": "Fixture model: moving heads and beams", + "area": "Layouts", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082264", + "number": null + }, + { + "title": "Mixing light types in one Layouts", + "area": "Layouts", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082337", + "number": null + }, + { + "title": "Render larger than the fixture and pool down onto a few lights (Hue, PAR)", + "area": "Layouts", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082192", + "number": null + }, + { + "title": "MultiplyModifier mapping-LUT memory at large grids", + "area": "Modifiers", + "module": "MultiplyModifier", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082394", + "number": null + }, + { + "title": "Retro looks on a picture: scanlines, chroma bleed, tracking glitches (robwomp, 2026-10-21)", + "area": "Modifiers", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082480", + "number": null + }, + { + "title": "A for loop whose step never advances holds the render task (2026-10-29)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082667", + "number": null + }, + { + "title": "A MoonLive control past a limit vanishes without a word (2026-10-08)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082998", + "number": null + }, + { + "title": "A MoonLive script's branch tables are fixed and on the stack (2026-10-08)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082924", + "number": null + }, + { + "title": "A scripted control's name can disagree with the file that declares it (2026-09-04)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082787", + "number": null + }, + { + "title": "A scripted driver", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082735", + "number": null + }, + { + "title": "A scripted modifier needs a way to drop a light", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083694", + "number": null + }, + { + "title": "A scripted modifier that animates every frame", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083636", + "number": null + }, + { + "title": "A scripted modifier that reshapes the grid", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083746", + "number": null + }, + { + "title": "A scripted module's DIMENSION chip still comes from its type, not its script", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083143", + "number": null + }, + { + "title": "Catch device-backend operand defects on the host", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083336", + "number": null + }, + { + "title": "Changing a live MoonLiveEffect's script does not rebind it (2026-09-29)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083569", + "number": null + }, + { + "title": "Desktop backend: a control-arena write is not seen by a second `run()` of an already-compiled program (2026-08-20)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082850", + "number": null + }, + { + "title": "Downloading a scripted palette can repoint the active one (2026-09-06)", + "area": "MoonLive", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083503", + "number": null + }, + { + "title": "Drain MoonLive's print() through a queue", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083459", + "number": null + }, + { + "title": "MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 3 findings left)", + "area": "MoonLive", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083200", + "number": null + }, + { + "title": "RISC-V emits 1.7 times the code Xtensa does (2026-10-08)", + "area": "MoonLive", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083063", + "number": null + }, + { + "title": "Size the MoonLive control arena to the script", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083397", + "number": null + }, + { + "title": "The code cap, per platform (2026-10-16)", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082596", + "number": null + }, + { + "title": "The compile-failure latch is not provable on the host", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083272", + "number": null + }, + { + "title": "The next language steps, in order", + "area": "MoonLive", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082542", + "number": null + }, + { + "title": "A flashable SD image with MoonLight already on it (robwomp, 2026-09-08)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084753", + "number": null + }, + { + "title": "A StadBeest eye reset once by the task watchdog (2026-10-24)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084185", + "number": null + }, + { + "title": "A Teensy sometimes drops a message the page sends (2026-10-13)", + "area": "Platform", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084990", + "number": null + }, + { + "title": "Adopting the v6.x ecosystem changes", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085832", + "number": null + }, + { + "title": "An HTTPS request on a board without PSRAM (2026-10-24, from the JSON plan)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084099", + "number": null + }, + { + "title": "An update's connection that would not open until a reset (2026-10-22, from the internal RAM plan)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084374", + "number": null + }, + { + "title": "Back to WLED over WiFi (2026-10-09)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084501", + "number": null + }, + { + "title": "Bench-verified classic parallel setups, not yet in the catalog", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086478", + "number": null + }, + { + "title": "Boot-time buffer degradation on non-PSRAM at 128×128", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085395", + "number": null + }, + { + "title": "Classic-board memory: the wins are system-level, not per-module (2026-09-09)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086575", + "number": null + }, + { + "title": "ESP32 UDP receive is bounded by PACKET COUNT, not bytes", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085150", + "number": null + }, + { + "title": "ESP32-P4 400 MHz on rev-3+ silicon", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086159", + "number": null + }, + { + "title": "ESP32-P4 DHCP hostname not shown by the router (recheck later)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086097", + "number": null + }, + { + "title": "ESP32-P4 Parlio: a real long WS2812 strip", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086025", + "number": null + }, + { + "title": "ESP32-P4 WiFi through the C6 co-processor", + "area": "Platform", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085924", + "number": null + }, + { + "title": "Ethernet on the QuinLED Dig-Next-2 (W5500 over SPI)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085683", + "number": null + }, + { + "title": "LED output from a Raspberry Pi's own pins (robwomp, 2026-10-11)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086230", + "number": null + }, + { + "title": "LEDs on the Teensy flash white, then the board resets (2026-10-13)", + "area": "Platform", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084894", + "number": null + }, + { + "title": "LightCrafter 16: questions for the maker (2026-10-07)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086342", + "number": null + }, + { + "title": "Link-time optimization on the ESP32 builds", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085768", + "number": null + }, + { + "title": "Memory ceiling on non-PSRAM ESP32 with WiFi and Ethernet", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085323", + "number": null + }, + { + "title": "MoonBase follow-ups", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084833", + "number": null + }, + { + "title": "Move more devices to MoonLight over WiFi (2026-10-09)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084435", + "number": null + }, + { + "title": "Network Receive on a P4 past 3,000 packets a second (2026-10-26)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083932", + "number": null + }, + { + "title": "Platform API: `std::span` migration", + "area": "Platform", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085617", + "number": null + }, + { + "title": "SD card writes over SPI stop the card on the Dig-Octa (2026-10-12)", + "area": "Platform", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085076", + "number": null + }, + { + "title": "SE16 / LightCrafter power monitoring: sense pins free, module still to build (2026-09-02)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086286", + "number": null + }, + { + "title": "Sensor input on Raspberry Pi 5: microphone, IMU, line-in", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086408", + "number": null + }, + { + "title": "SIMD inner loops for noise and the bilinear lerp on the P4", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083858", + "number": null + }, + { + "title": "SIMD inner loops for noise and the bilinear lerp on the S3", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083794", + "number": null + }, + { + "title": "The `.deb` ships the systemd unit, raw Ethernet included (2026-10-22, from robwomp on Discord)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083996", + "number": null + }, + { + "title": "The arm64 `.deb` is untested on Raspberry Pi OS bookworm (2026-09-13)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084649", + "number": null + }, + { + "title": "The desktop build advertises no mDNS (2026-09-06)", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084562", + "number": null + }, + { + "title": "Trim the TLS certificate bundle to the common roots", + "area": "Platform", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085540", + "number": null + }, + { + "title": "What the WiFi buffer copy costs (2026-10-24)", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084278", + "number": null + }, + { + "title": "WiFi Art-Net performance", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085235", + "number": null + }, + { + "title": "WiFi runtime disable", + "area": "Platform", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085471", + "number": null + }, + { + "title": "`check_docgen` reads declarations from the clang AST (2026-10-15)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087063", + "number": null + }, + { + "title": "A catalog entry extends another (2026-10-18)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086933", + "number": null + }, + { + "title": "A preview GIF for every MoonLive script (2026-10-19)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130339", + "number": null + }, + { + "title": "A scenario that runs until something breaks (2026-09-29)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129420", + "number": null + }, + { + "title": "A tagged release does not reach the web installer until the next main deploy", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "release", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128996", + "number": null + }, + { + "title": "A Windows scenario run recorded tick averages 250x its own repeat, cause unknown (2026-09-29)", + "area": "Process and docs", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130012", + "number": null + }, + { + "title": "An example: MoonLight in a pod beside the music server (belrik, 2026-10-21)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087152", + "number": null + }, + { + "title": "An offline test run takes 16 minutes instead of one (2026-10-03)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129170", + "number": null + }, + { + "title": "British spellings and em-dashes predate the prose gate (319 files)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128769", + "number": null + }, + { + "title": "build_desktop.py cannot relink while the app is running (2026-09-28)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130177", + "number": null + }, + { + "title": "build_esp32.py reports success without building, under Git Bash (2026-09-28)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130100", + "number": null + }, + { + "title": "CI: pin GitHub Actions to commit SHAs (supply-chain hardening)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129085", + "number": null + }, + { + "title": "clang-tidy: triage the remaining 30 findings", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129754", + "number": null + }, + { + "title": "disasm.py cannot run on Windows (2026-08-21)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130264", + "number": null + }, + { + "title": "ESP32 clang/LLVM toolchain: extend the clang checks to src/platform/esp32/", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129673", + "number": null + }, + { + "title": "Flaky unit tests: the AudioService sync suite contends on a fixed UDP port", + "area": "Process and docs", + "module": "AudioService", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086639", + "number": null + }, + { + "title": "GCC Release-mode truncation warnings (17, non-blocking)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287088042", + "number": null + }, + { + "title": "Headers link the docs by page, not by host (2026-10-21, from the PR 150 review)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086721", + "number": null + }, + { + "title": "Installer UX polish", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087561", + "number": null + }, + { + "title": "Live full-suite run leaks state between scenarios", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129508", + "number": null + }, + { + "title": "macOS code-signing (Developer ID)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087327", + "number": null + }, + { + "title": "MoonDeck doc-asset endpoint hardening", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128928", + "number": null + }, + { + "title": "MoonDeck scripts crash on Windows whenever their output is redirected (2026-08-22)", + "area": "Process and docs", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129942", + "number": null + }, + { + "title": "Network round-trip test: drop and reorder measurement", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128844", + "number": null + }, + { + "title": "Offer MoonLight as a library", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087985", + "number": null + }, + { + "title": "Pin the preview cancel over loopback (test)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129830", + "number": null + }, + { + "title": "Promote the one-line // cap to an error, once the 604 .cpp blocks are homed (2026-09-21)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129590", + "number": null + }, + { + "title": "Publish SHA-256 sums with release assets", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087501", + "number": null + }, + { + "title": "Remove the tests' own clock resets, now the listener's job (2026-10-25)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086775", + "number": null + }, + { + "title": "Retire the OTA fallback repository, once a device has another recovery route", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087252", + "number": null + }, + { + "title": "sigrok/fx2lafw cross-check + MoonDeck LED driver test script", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130440", + "number": null + }, + { + "title": "The prose sweep, now counted and ratcheted (2026-09-28)", + "area": "Process and docs", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087002", + "number": null + }, + { + "title": "The restructured scenarios lost four things the archive asserted (2026-09-29)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129247", + "number": null + }, + { + "title": "The static set runs on every supported device before the rename (2026-09-29)", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129341", + "number": null + }, + { + "title": "Three decisions left open by the code ratchets (2026-10-25, from the Code ratchets plan)", + "area": "Process and docs", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086853", + "number": null + }, + { + "title": "Windows code-signing", + "area": "Process and docs", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087406", + "number": null + }, + { + "title": "A Service that calls another device's API (robwomp, 2026-10-11)", + "area": "Services", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130713", + "number": null + }, + { + "title": "An SD card inserted after boot mounts only when its module restarts (2026-10-12)", + "area": "Services", + "module": "SdCardService", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130634", + "number": null + }, + { + "title": "IR RX live-reconfigure recovery (unconfirmed, park until it recurs)", + "area": "Services", + "module": "InfraredService", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130529", + "number": null + }, + { + "title": "MoonCloud: share a MoonLive script over MoonTalk (2026-09-10)", + "area": "Services", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130853", + "number": null + }, + { + "title": "Read a vehicle's CAN bus and drive lights from it (2026-09-11)", + "area": "Services", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130992", + "number": null + }, + { + "title": "The Open Stage Control session's labels (2026-08-30)", + "area": "Services", + "module": "", + "kind": "Investigation", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130773", + "number": null + }, + { + "title": "The sensors an installation needs (2026-09-01)", + "area": "Services", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130923", + "number": null + }, + { + "title": "A desktop build reports firmware \"unknown\", so the update UI guesses from the browser (2026-09-06)", + "area": "UI", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131545", + "number": null + }, + { + "title": "Double-click and multi-click on a button (2026-10-25)", + "area": "UI", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131477", + "number": null + }, + { + "title": "File Manager follow-ups", + "area": "UI", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131328", + "number": null + }, + { + "title": "Moving-head preview = peer interpreter", + "area": "UI", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131696", + "number": null + }, + { + "title": "RGBW preview end-to-end", + "area": "UI", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131627", + "number": null + }, + { + "title": "Sparse fixture-profile editor", + "area": "UI", + "module": "FixtureProfilesModule", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131084", + "number": null + }, + { + "title": "Split app.js by feature, and take the module names out of it (2026-10-15)", + "area": "UI", + "module": "", + "kind": "Restructure", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131241", + "number": null + }, + { + "title": "The update overlay's Cancel button does nothing on a plain OTA (2026-09-14)", + "area": "UI", + "module": "", + "kind": "Bug", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131157", + "number": null + }, + { + "title": "UI features deferred from v1", + "area": "UI", + "module": "", + "kind": "Feature", + "status": "Backlog", + "process": "", + "card": "https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131412", + "number": null + } +] diff --git a/docs/work/backlog_mirror/core.md b/docs/work/backlog_mirror/core.md new file mode 100644 index 000000000..7483b1a89 --- /dev/null +++ b/docs/work/backlog_mirror/core.md @@ -0,0 +1,532 @@ +# Backlog: Core + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## ControlModule + +### A slot whose target is gone sends 0 to its followers (2026-10-08) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074535) + +**Found** on the StadBeest after a repower: the legs' walking layer was saved disabled, so the script that `fader2` to `fader6` drive had no controls. +The slots could not follow their targets, stayed at 0, and the OSC feedback sent those zeros to the eyes, whose `bpm` and `sensitivity` then sat at their minimum. +Live slots are not saved, so after a boot only a target or a writer gives them a value. + +**The fix to build:** a slot with no target to follow holds no value rather than 0, and the mirror sends nothing for it until the target exists. +The followers then keep what they last had, and a refresh repairs them once the leader's target is back. +Pinned by a test: a disabled target's slot produces no feedback datagram. + +## DevicesModule + +### DevicesModule: interop plugins and the command half + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074692) + +Discovery is shipped ([DevicesModule](../../moonmodules/core/system.md)). It stays a code-wired child of Network: it is fleet-scope rather than this-device, so it is neither a System nor a Services module, and it moves to a top-level container of its own only once a second fleet module exists to justify one. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): + +- **More discovery plugins**: ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only, never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../../moonmodules/light/drivers.md#hue): bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the color-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver**: pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP; do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) +- **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so MoonLight can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync). +- **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. +- **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes MoonLight the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. + +Full design + the reasoned transport split: `Plan-20260629 - UDP device discovery + mDNS advertise-only` (in the plans archive). + +## EthernetModule + +### An explicit `Custom` Ethernet preset is reseeded to a named one + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074778) + +`ethPresetIsUnset()` returns true for any editable row, and `Custom` is the only editable row, so it cannot tell "nothing has chosen a preset" from "the user chose Custom". A board saved as `Custom` whose pins happen to match a named preset is therefore reseeded to that preset on the next rebuild, and its pin rows are hidden: a hand-wired board silently adopts a board map it was never given. + +The seam is what makes this awkward. `defineControls` runs before the config is restored, so the seed cannot simply latch a flag on the first render: that is the legacy path the seed exists for, a board whose file predates `ethBoard` and carries only pins. Distinguishing the two needs to know whether the restore supplied an `ethBoard` value at all, which is not visible where the seed runs. An attempt that latched on the first render broke the legacy path and was reverted. + +**What it costs today**: nothing on a board the catalog configures, since those name their preset. It bites a user who deliberately picks `Custom` on a board whose pins match a preset, which is the hand-wired case `Custom` exists for. + +**Where to look**: `FilesystemModule::applyNode` overlays values, calls `rebuildControls`, then overlays again. A control that wants to know whether IT was restored needs a signal from that pass, which no control has today. That is the general shape worth solving rather than a special case for one select. + +### Live RMII Ethernet reconfigure + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074861) + +RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honors the no-reboot principle. + +## FilesystemModule + +### A config written for next boot is overwritten by the next save (2026-10-04) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074945) + +Network and Ethernet return false from `appliesConfigLive`, so a file written through `POST /api/file` waits on disk for the next boot. +Any save before that boot writes the module's in-memory values over it, so the upload is lost without a word. +Seen on the S3: a restored `NetworkModule.json` came back with the old `txPowerSetting` until the restore was followed straight away by a reboot. +The fix: `applyConfigFile` marks a module it does not apply live as pending on disk, and the flush skips that module's file until the next boot. +The uploaded file then wins with no UI change; the alternative, answering "applies at next boot" and offering a restart, still loses the file when the restart is declined. +Pin it with a test that writes the file, dirties the module, flushes, and finds the file unchanged. + +The root fix is a network bring-up that reconciles instead of re-creating. `NetworkModule::setup()` (netif create, driver install, the eth→WiFi→AP cascade) runs once at boot and crashes if re-run live, which is why Network opts out of the live apply; once it reconciles, a restored `NetworkModule.json` applies live and the pending mark is no longer needed for it. + +### Remove the flat config conversion (2026-10-08) + +Kind: Restructure · Trigger: in the release after next · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075031) + +A config file in the flat format builds before 2026-10-08 saved is read as the state document it describes at the first boot and saved as one, so an in-place update keeps its config. +In the release after next, remove the conversion and everything that only serves it: +- `flatToStateDocument`, `isFlatConfig` and their helpers in `StateDocument.cpp` (`writeFlatNode`, `writeFlatValues`, `takeLive`, `flatName`, `distinctName`, `flatRoot`); +- the conversion in `FilesystemModule::loadSubtree`; +- the flat fallback in MoonBase's `ConfigScrape.h` (`flatChildKey`, `flatChildValue`); +- `configToDocument` and `isFlatConfig` in `src/ui/migrate.js`, after which an older backup no longer restores; +- the test "A config file in the flat format older builds wrote loads at boot and is saved back as a document", and the MIGRATING entry's lines on the conversion. + +`NetworkModule::adoptLegacySettings` calls `isFlatConfig`, so the 6.0 network adoption above goes first or in the same change. + +### Config backup and restore: identity, a recipe, and tiers 2 and 3 + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075118) + +Tier 1, a browser-side bundle over the file API with the rename map and restore report, ships in the File Manager. + +- **Restore clones a device's IDENTITY along with its config** (found answering + [#76](https://github.com/MoonModules/MoonLight/issues/76), 2026-09-04). Backup bundles every + file including hidden `.config`, and restore writes them all back unfiltered, so restoring one + device's bundle onto another copies four things that must differ per device: + + | field | why it must differ | + |---|---| + | `deviceName` | the single network identity: mDNS hostname, SoftAP SSID and DHCP hostname all derive from it (`SystemModule.h`). Twelve clones all answer to `.local`, resolution goes non-deterministic, MoonDeck's device list collapses to one row | + | WiFi `password` | travels in the bundle (the UI button warns), so a shared or attached backup leaks it | + | a static IP | if set, every clone claims one address | + | `universeStart` and the Art-Net/DDP window | exactly what must differ per device in the light-pole case #76 describes: cloned, every pole shows the same thing | + + This turns "clone this pole to the other eleven" from the feature the issue wants into a trap. + **The fix is small and is a prerequisite for the recipe idea rather than a separate job:** restore + treats identity as per-device, either skipping those fields or prompting once with the target's + current values prefilled. Worth deciding whether the bundle should carry the secrets at all, or + keep an identity section the restoring device is expected to supply. +- **A backup is a file bundle, not a recipe** (#76 step 2): nothing binds it to a `deviceModel`, so + restoring a Dig-Quad bundle onto an S3 writes pin maps that do not fit the board. The 28 profiles + in `deviceModels.json` are the missing half. +- **Tier 2**, a single-archive device endpoint (one request instead of a walk); **tier 3**, restore hosted on + MoonBase's page, the migration answer for future partition-table moves. + +## HttpServerModule + +### The HTTP server is audited for the two PHP request-handling bug classes (2026-10-21) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075179) + +PHP's critical advisory of 2026-10-21 (CVE-2026-109618, CVE-2026-109619) names two bug classes any C++ server can have. **Out of memory mid-request**: an allocation fails while a request is half built, and cleanup or a later step treats the partial state as valid. That path is live here: a classic ESP32 fell to 916 bytes free during a page load. **State outliving its request**: a reused connection slot (`wsClients_`, `streams_`) keeps a pointer, length or buffer from its previous client. The audit is a read-only pass over `HttpServerModule` and the platform TCP layer, every allocation-failure path and every slot reuse, and each finding is pinned by a desktop test that forces the failure. + +### A request body shares the two upload slots (2026-10-25, from the pre-merge review) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075256) + +A request whose body trails its headers waits in a `FileTransfer` slot (`deferRequest`), of which there are two. With two uploads in flight, a `POST /api/control` whose body comes in a second segment answers 503 "every send slot is busy", and a slow-bodied control write holds an upload slot for the 10 s stall window. A slot of its own for `Kind::Request`, or a count kept apart, separates them; it waits for a user who meets it, since two uploads at once is rare. + +## I2cBusModule + +### A second I2C bus, once a board wires one (2026-10-06) + +Kind: Feature · Trigger: a board that wires a second bus · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075332) + +`I2cBusModule` drives one bus on controller 0, which covers every catalog board: one bus carries about 112 addresses, so a codec, a sensor and a display share it. +A board needs a second bus for two chips on the same fixed address, devices at different voltages or pull-ups, or a long cable run kept off the onboard devices. +The shape then: the module becomes addable with a controller-number control, the platform keeps one bus per controller, and a device on a bus (`AudioService`'s codec first) gains a bus selector defaulting to 0, so the catalog's boards keep working unchanged. +The trigger is a real board; a touch controller or camera on its own pins next to the codec is the likely first, the Guition M3's touch wiring being unchecked. + +## ImprovProvisioningModule + +### Improv-as-REST follow-ups + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075428) + +What remains of "Improv = REST over serial" ([ImprovProvisioningModule](../../moonmodules/core/system.md)): + +**Open follow-up: shared JS helpers across device-UI and mooninstaller.** `safeLocalGet` / `safeLocalSet` (3-line hostile-storage guards) are duplicated in `src/ui/install-picker.js` (device firmware, embedded as a C string via `embed_ui.cmake`) and `mooninstaller/devices.js` (web installer page, served from Pages). The two live in different build contexts so the shared extract isn't trivial — it'd need a new `src/ui/safe-storage.js` plus updates to: `embed_ui.cmake` (embed the new file), `ui_embedded.h` generator (new C array), HTTP server file routing (new path served), `release.yml` workflow staging, `preview_installer.py` staging. Five files for one 3-line helper is too much pre-merge. Worth doing when the next shared helper arrives — `relativeTime` and `formatBytes` are candidates. Two helpers earn the build-glue cost; one doesn't. + +**Open follow-up: P4 Improv scan on a cold WiFi link (bench check).** `improvHandleScan` in `src/platform/esp32/platform_esp32_improv.cpp` calls `esp_wifi_scan_start`, which needs the WiFi driver started. On native ESP32/S3 the driver is up by the time a user provisions; on the P4 the radio lives on the C6 and comes up only after the esp_hosted prelude in `ensureWifiInit()` (triggered by `wifiApInit` / `wifiStaInit`). A scan requested on a P4 that has not initialised WiFi returns an error cleanly rather than scanning a cold link, so nothing crashes. The check: bench-verify whether a P4 provisioned from cold needs the link brought up first, and if so route the scan through the public `wifiAp`/`wifiSta` path. + +## ModuleFactory + +### ModuleFactory: registration can fail silently (uint8_t ceiling, unchecked bool) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075508) + +**Found:** 2026-07-27, while adding the effects grid sweep; flagged by the 👾 Reviewer. + +`ModuleFactory::count_` is a `uint8_t`, and at the 255 ceiling `grow()` returns false, registration fails, and the return value is discarded at every call site, including the static-init registrars, which cannot check it anyway. The failure is silent: the type is simply absent from `/api/types` and `create()` answers `nullptr` for it, far from the cause. + +Production is not near the ceiling today (~90 types), so this is not urgent. + +**Options, cheapest first:** (a) widen `count_`/`capacity_` to `uint16_t` (a handful of bytes, removes the ceiling as a practical concern); (b) make a failed registration loud in a debug build (assert or a boot-time log) so it can never be silent again. Both are probably the right pair. + +## MqttModule + +### Generic control and state topics over MQTT: the automation escape hatch + +Kind: Feature · Stage: Integrate · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075589) + +The MQTT bridge ships a **semantic** topic surface (`/on/set`, `brightness/set`, `hsv/set` → the `Drivers` controls) sized for Homebridge/HomeKit, which need fixed, typed topics per accessory. That leaves everything else the web UI can reach — effect choice, layout, modifier params, palette-by-index — unreachable from a home hub. The ask that surfaced this: *"expose the whole REST API over MQTT."* + +**It's compatible with Homebridge/HA, because it's a second topic namespace, not a replacement.** Homebridge only ever subscribes to the semantic topics it's configured for; a generic topic family sits beside them and a broker carries both. The two surfaces already share one apply-core — `Scheduler::setControl(module, control, json)`, the same seam `/api/control` and IR funnel through — so a generic MQTT surface is that core under a different topic shape, honouring the module's own "MQTT is a transport, not new control logic" contract. + +But "the whole REST API" over-scopes: of the routes (`/api/control`, `/api/state`, `/api/system`, `/api/modules[/…]`, `/api/dir`, `/api/file`, `/api/firmware/*`, `/api/reboot`, `/api/types`, the `/json/*` shim, `/ws`), only two are genuinely pub/sub-shaped. The rest are request/response or bulk transfer and belong on REST: + +- **`/api/control/set`** ← `{"module","control","value"}` (the exact JSON `/api/control` takes) → `applySetControl`. The real win: any control on any module — the effect/layout/modifier/palette reach HomeKit can't express. The apply-core already validates (range / read-only / module-not-found via `SetControlResult`), so safety is free. +- **`/api/state/get`** → publishes the `/api/state` JSON (retained). For dashboards (fps / heap / tick / current effect across a fleet), offline detection, and alerting — the monitoring half. + +Explicitly **out** (no practical MQTT case, and wrong for the transport): +- **`/api/file`, `/api/dir`, `/api/firmware/upload`** — kilobytes-to-megabytes of binary; MQTT is a small-message bus, REST already streams these correctly. +- **`/api/modules` add/delete/move/replace** — stateful, order-sensitive pipeline surgery a human does once in the UI; an automation reshaping the pipeline is an anti-pattern (the *Robust to any input* guarantee makes it *safe*, not *advisable*). +- **`/api/reboot`** — one narrow action; a single fleet-reboot topic is a mild nice-to-have, not "the API." +- **`/api/types`, `/json/*`, `/`, `/ws`, `/api/firmware/url`** — discovery metadata, the WLED shim, the UI itself, the socket: static, already-served-elsewhere, or a different protocol. + +**Shape:** ~15 lines routing into the existing `applySetControl` + a state publish; gate behind a `generic API` bool defaulting **off**, so a home user's broker isn't a remote-config backdoor and the curated HomeKit surface stays the clean default. **Don't** auto-generate a semantic topic per control — HomeKit/HA need stable typed topics; a generic `palette/set` whose meaning shifts per firmware breaks their discovery. Keep semantic topics hand-curated; let the generic pair be the escape hatch. + +**Open question — Homebridge example maps both `setBrightness` and `setHSV`.** In `homebridge-mqttthing`'s `lightbulb`, HSV's *value* (V) already carries HomeKit's Brightness characteristic, so mapping both topic pairs may double-drive brightness (mqttthing's docs lean toward using one or the other). The [MQTT § Homebridge example](../../moonmodules/core/system.md#mqtt) currently lists both, to show the device's full topic surface. Resolve on hardware: flash a board, run Homebridge + mqttthing with that config, and check whether the Home-app brightness slider misbehaves — if it does, drop the two `brightness` topics from the example; if not, it's a non-issue. (Flagged by CodeRabbit; parked here rather than changing the doc on an untested hunch.) + +### HA update entity via MQTT discovery: release check + +Kind: Feature · Stage: Integrate · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075650) + +**Still to build: device-side release check** (the update entity itself is shipped, [MQTT](../../moonmodules/core/system.md#mqtt)). `installed_version` and `latest_version` are equal today (both `MM_VERSION`), so HA renders the entity as up-to-date and the Install button is disabled. What's missing is a periodic poll of `https://api.github.com/repos/MoonModules/MoonLight/releases/latest`: at boot (~30 s post-network-up) and every 24 h thereafter, fetch the release JSON, extract `tag_name`, and if newer than `MM_VERSION` call `MqttModule::publishUpdateState()` with the updated `latest_version` (the publish path is already there: only the caller is missing). ~2 KB per check. + +Add a `ReleaseCheckModule` (or a small extension inside NetworkModule / FirmwareUpdateModule — decide when building) that owns the poll timer + last-seen version cache and calls into `MqttModule::publishUpdateState()`. ~50 additional lines. + +## NetworkModule + +### The access point never blocks a WiFi retry (2026-10-09) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075741) + +After losing its network, a device opens its access point and retries its known networks every minute (`retryKnown` in `NetworkModule.h`), but skips the retry while a client is on the access point. +A phone that stays on it, or rejoins it by itself, then keeps the device off its network indefinitely. +WiFi has preference, so the retry runs whoever is on the access point: drop the `wifiApClientCount() > 0` check. +- **The cost:** someone on the access point while a known network exists is interrupted for a few seconds once a minute; a first setup has no known network, so it never retries. +- **A test:** a client on the access point, the known network back, and the device joins it within the retry interval. + +**Some devices do not rejoin their WiFi** (2026-10-09): now and then a device stays off its network after the router came back. This block is one suspect; the other is the path for losing the network while connected, where the WiFi driver can get stuck and need the radio restarted. +Next time one hangs, capture before touching it: +- **The Network card's status:** "No network", "AP: MM-…" or "WiFi STA: …" says which state it is stuck in. +- **The access point's `clients`**, which says whether a phone is holding it. +- **The serial log**, through `monitor_esp32.py`, whose `NetworkModule:` lines say what it tried last. + +### Refresh a multicast membership on a fixed pace (2026-10-09) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075801) + +A socket in a multicast group joins again after 3 seconds of silence, which repairs a router that forgets a member, with a gap of about 2 seconds each time. +The GL.iNet Slate AX forgets after about 18 seconds, so audio on the StadBeest drops every 20.6 seconds, measured on the bulb. +The standard fix is the router's multicast querier; this item is for a network that cannot be configured, such as a venue's. +- **The rule:** join again 10 seconds after the last join as well, so a membership is renewed before such a router forgets it. +- **The cost:** a leave and a join, two IGMP packets of about 60 bytes per socket every 10 seconds, and at most one lost packet in the moment between them. +- **Where:** `UdpSocket::keepMembership` in `platform.h`, one more condition, and a test beside the silence re-join's. + +### Remove the 6.0 network adoption (2026-10-04) + +Kind: Restructure · Trigger: once a release has carried it · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075864) + +`NetworkModule::adoptLegacySettings` and the two `adoptLegacy` it calls move 6.0's top-level network keys onto the Ethernet and WiFi cards at the first boot, so an in-place update keeps its network. +Remove all three, their test `unit_NetworkModule_legacy.cpp`, and the "update to this release before a later one" line in MIGRATING's WiFi and Ethernet entries, once a release has carried it and the field has moved past 6.0. + +### Some devices stop answering mDNS over WiFi (2026-10-08) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075946) + +**Found** on the StadBeest: after the legs restarted, their MIDI service could not resolve the bridge's `MM-MIDI-rtp.local` for five minutes, and by address it connected at once. +Browsing from a Mac on the same network listed `_http._tcp` for MM-Eye2 alone: the legs, the left eye and the bridge announced nothing, and no `_apple-midi._udp` record appeared. +The left eye announces nothing new, so the cause is wider than the RTP-MIDI announcement. +The WLED native app finds devices by browsing `_wled._tcp`, so it is the likely reason it does not always list every device. + +**The fix to build:** find why a device stops answering, starting from what differs between MM-Eye2 and the others: WiFi power save dropping multicast between beacons, transmit power (the bridge runs at 8), the responder losing its interface after a reconnect, or the announcement order in `mdnsInit`. +Watch it with a working `dns-sd -B` from a computer and the device's serial log, and confirm the bridge's `_apple-midi._udp` record appears. +Pinned on the bench: every device answering its `.local` name and listed in the WLED app after a restart and after a WiFi reconnect. + +### Advanced network settings (2026-10-03) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076054) + +Left out of the plan that makes Ethernet, WiFi and the access point Network submodules, and expected to be asked for: + +- **Roaming** between access points of one network while connected, by signal threshold (802.11k/v/r where the chip supports it). +- **Enterprise WiFi**: WPA2/WPA3-Enterprise with a username, a password or a certificate. +- **Pinning a network to one access point** (BSSID) or one band. +- **WiFi power save**: the modem-sleep mode, traded against latency for Art-Net and the UI. +- **The country code**, set by hand where adopting the router's country is not enough. +- **The access point's address**, fixed at 4.3.2.1. +- **IPv6 beyond link-local**: global addresses and a static one; the station and Ethernet carry a link-local address, which mDNS advertises. + +### NetworkModule treats a panel-output NIC as the device's uplink (2026-09-28) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076135) + +A NIC claimed for raw L2 panel output carries no LAN, but NetworkModule enumerates it anyway and reports `Connected via Ethernet` on it. When the panel link renegotiates, every flap runs the full cascade, which drops the browser's WebSocket and makes the UI look dead while the server is healthy. Seven flaps were logged in one bench session. The driver already announces itself through `ethClaimRawL2(true)`, so the claim is there to honor; the change is for NetworkModule to skip a claimed adapter. Crosses two modules, so it wants a decision rather than a patch. + +### The persisted interface label carries the negotiated link speed (2026-09-28) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076214) + +PanelCardDriver saves its adapter as `Realtek USB GbE Family Controller, 1 Gb`, speed included. A renegotiation to 100 Mbit rewrites that string, which dirties the config and rewrites `Drivers.json`. Matching already strips the suffix and compares the stable head, so the binding survives, but an identifier that changes when a cable renegotiates is the wrong thing to persist. Store the adapter's stable identity and render the speed for display only. + +## PinsModule + +### PinsModule: strict reject-on-add mode + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076292) + +[PinsModule](../../moonmodules/core/system.md) is shipped; [PinsModule.h](../../../src/core/system/PinsModule.h) records the design. The soft-flag choice obviated the reassignment broker (a live pin swap already works: set A→B's pin, then B→A's, the transient conflict clears). + +The one still-open item is an **optional strict reject-on-add mode** for the installer/catalog path: a "clean tree" that *refuses* a module add whose pins collide, rather than accepting it soft-flagged. This is add-path *validation* (a different feature than the live soft-flag reassignment), wanted only if a stricter installer UX is desired. Spec + `/plan` if picked; the soft-flag default stays for live editing. + + +Why soft-flag is the default, and what strict mode must not break: a refusal on add wedges a legitimate live swap, since two drivers exchanging pins pass through a transient double claim, while a claim that lands and flags red never wedges, which is the degrade-visibly rule. Strict mode is for the installer and catalog path only, where a tree is built from scratch and no swap is in flight. It wants an explicit shared-pin opt-out for the one legitimate case, two consumers reading one input, as ESPHome's `allow_other_uses` does. + +### A driven GPIO the Pins map never sees: bus padding, and a hidden clockPin + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076362) + +**Found:** 2026-08-21, on MM-S31, after a bench session that started as "the LED panel stopped working" and cost hours chasing a firmware regression that did not exist. + +An ESP32-S31 driving a ColorLight receiver card over raw Ethernet showed the panel dark while every diagnostic said the transmit path was healthy: link negotiated at 1000 Mbit, ~4600 packets/s, zero drops, and a byte-for-byte dump of a 128x128 frame matched `ColorLight5A75Packet.h` exactly. The card's own activity LED never blinked. Four firmware versions across two ESP-IDF releases behaved identically. + +The cause is a **GPIO collision that no part of the system could report**. `ParallelLed` carried `clockPin = 10`, and GPIO 10 is `txd2` on the S31's RGMII bus (`platform_esp32.cpp`: the EMAC's fixed IO_MUX pads are 8-19 plus MDC/MDIO on 5/6). The LED driver drove one of the four Ethernet transmit data lines, so every frame left the MAC counted-as-sent and arrived corrupt. Disabling the LED drivers fixed it instantly; moving the clock to GPIO 21 fixed it with all four drivers running. + +One defect remains, the other two being fixed ([lessons](../../contributing/lessons.md#esp32-s31-rgmii-ethernet-bring-up-four-bugs-between-code-compiles-and-link-up)): + +**`clockPin` defaults to 10 and is hidden.** `MoonLedDriver::clockPin = 10` is a hardcoded default that lands inside the S31's reserved RGMII block, and `addBusControls` hides the control unless `pinExpanderMode()` is on. So on this board the value was invisible on the card, unchangeable through the UI, and still driving a pad. A pin with a real effect must be visible, whatever mode it is in. The first sighting was on an S3 (2026-07-12): an `i80` lane list `18,5,6,7,8,9,10,11` overlapped the hidden `clockPin` 10 and `dcPin` 11 defaults, so two lanes carried the bus clock and DC toggling instead of pixel data, and nothing flagged it. + +### A bus pin that belongs to another peripheral is accepted + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076440) + +- **A bus pin that belongs to another peripheral is accepted, and takes the board off the + network** (2026-09-06). An Olimex ESP32-Gateway given a WR/DC pin on its Ethernet MDIO/MDC + (18/23) or its PHY reset line (5) lost its Ethernet link within seconds and looked crashed + (the firmware kept ticking on serial; it stayed reachable only through its WiFi fallback). + The pin registry already + detects a double claim (`PinsModule::flagConflicts`) but only colors the edge, so a control + write that lands on a GPIO another control owns should be refused with a status naming the + owner, the way a reserved flash pin already is. A collision with a network pin is worse than + most: it ends the session that could have fixed it, and the SMI pins stay re-muxed until a + reboot even after the driver releases them. + +## Scheduler + +### Effects and Drivers find their source one way, not two (2026-10-08) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076507) + +Effects finds the top-level Layouts, and Drivers the top-level Effects, by type through `Scheduler::topOfType` when nothing was injected, which is what lets a state document build a tree with no wiring. +`setLayouts` and `setEffects` remain beside it for the unit-test rigs, 171 calls in 74 files, so two mechanisms do one job. +The fix: a rig adds its containers to a Scheduler, as the scenario runner does through `createTopLevel`, and the setters go. + +### A script's setControl rebuilds a control subtree on every write (2026-09-06) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076587) + +Measured on the P4 at .139: **2 fps**, with `MoonLive-2` at 251 ms and `MoonLive-3` at 245 ms per +tick, together 498 ms of a 506 ms frame, and HTTP down to a 0.5 s round trip because it is served +from the same loop. Both services ran `sweep.mls`, whose `tick20ms` writes four faders. Two copies +at 50 Hz is 400 control writes a second, and `Scheduler::setControl` calls `rebuildControls()` +unconditionally on each one, so each fader write tears down and re-creates the whole `Control` subtree. A person moving a slider +does this a few times a second and nobody notices; a script at 50 Hz multiplies it by hundreds. + +Two things to fix, and they are independent. **The rebuild should be conditional**: a `live` +control's value change cannot alter the schema, and `rebuildControls` already computes +`schemaSignature()` before and after to decide whether to notify, so it knows. Rebuilding only when +the shape can actually change (what `setLive` and `affectsPrepare` already distinguish) removes the +cost for every value write, scripted or human. **And the script tick is too fast for its job**: a +fader sweep does not need 50 Hz, so `sweep.mls` should run on a slower tick, which also caps the +damage any future script can do through this path. + +Not a regression from the RMT work: it predates it and was found while measuring an unrelated slow +board. + +## SystemModule + +### Memory: the largest buffers, by name + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287074597) + +The System card shows heap used by type and the largest internal block, and each Tasks row shows a module's `dynamicBytes`, but nothing names the big buffers: the layer buffer, a driver's DMA frame or ring, the Art-Net handoff. A read-only list under System, sorted largest first and fed by each owner's self-report, makes a 48 KB buffer visible as itself. It samples existing accounting once a second and intercepts no allocation; a leak hunt is a separate build-flag `heap_trace` mode, as CPU% is for Tasks. + +## Across modules + +### A Progress total rides the value patch (2026-10-19) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076676) + +A Progress control's total travels only in `writeControlMetadata` (Control.cpp), so a changed total needs a schema resync, which `rebuildControls()` triggers because `aux` is in the schema hash. +One line in `writeControlValue` and one in `app.js`'s patch handler would let the total ride the value, and the hash could then skip it, so a track change in FseqPlayerEffect stops resyncing every browser. +Pin it with a test that changes the total, patches, and finds the browser-side max moved with no resync. + +### What disable means: release the memory, or keep the module hot (2026-10-20) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076730) + +A disabled module keeps its settings only ([coding-standards](../../contributing/coding-standards.md)), and deleting a module already releases everything, so the question is whether disable should release too or keep the module hot for an instant resume. The contract test (`unit_ModuleContract.cpp`) lists the modules that keep runtime memory while disabled, Layer and Drivers with their pixel buffers the largest, and fixing them would settle the answer, so they wait for it. The analysis weighs: presets and DemoReel switching many modules, where kept memory grows with what is added rather than what runs; a quick toggle, where hot means an instant resume; the measured re-enable cost of every module, its `prepare()` time, gathered with the contract test's placement; three shapes, release on disable, keep everything hot until delete, or keep the settings and what is slow to rebuild while releasing the large buffers; and how comparable systems treat a disabled or paused element. The product owner decides, and the rule in coding-standards, the contract's lists and the Layer and Drivers fix follow. + +### Playlist in core: one sequencer for FSEQ, effects and DemoReel (2026-10-19) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076772) + +A playlist is a list of items with an optional duration each, played in order or shuffled once per pass, repeated off, one or all, under play, pause, stop, previous and next: the media player's model. Two copies of that sequencing exist: `DemoReelEffect::advance()` cycles effects, and `FseqPlayerEffect` cycles files with its own cursor, shuffle pass and loop mode, written as the same shape. An effects playlist would be the third, so it starts by putting the model in core once, a `Playlist` over core's editable `ListSource` whose owner supplies how to play item *i* and when it ends: the FSEQ player's items are files that end with the track, an effects playlist's are presets applied through the state-document engine that end after their duration, as the presets plan foresees, and DemoReel becomes an effects playlist of effects. Preset looping, rotating between a first and last [preset](../../reference/presets.md) slot on a timer, is an effects playlist whose items are presets. The change that builds the effects playlist moves FSEQ's and DemoReel's sequencing into it. + +### A device without a deviceModel is not told its catalog half is missing (bench, 2026-08-26) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076840) + +A board ran for days with `deviceModel` empty and `ethType` 0: Improv and access-point provisioning restore the WiFi credentials only, and nothing tells the user the catalog half (Ethernet wiring, per-board settings) is missing. Candidates: MoonDeck re-pushes the catalog on discovering a device whose deviceModel is empty, or the UI badges the state. + +### One board-preset helper for Ethernet and the SD card (2026-10-12) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076913) + +EthernetModule and SdCardService each carry the same board-preset machinery: the per-chip filtered option list, selection by label, the applied tracker, applying a preset when the selection moves, and reading the preset back off the pins. +A `core/module/BoardPresets.h` helper would own the options, index, count, selection and applied state, with the module supplying its table and two functions: write a preset into the pins, and match the pins against one. +Do it before a third preset table appears (a display's or a microphone's wiring), so that one becomes a table rather than a third copy. + +### `ListSource` methods compile twice: ~8.6 KB of thunks (2026-10-01) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076985) + +Every module that is `MoonModule` and `ListSource` gets its list methods emitted twice on ESP32: once as the method, once as a "non-virtual thunk" for the second base, and GCC copies the whole body into the thunk instead of a jump. +Measured on the S3 image with `xtensa-esp32s3-elf-nm -S`: 8.6 KB in all, the largest `GamepadService` 1.6 KB, `ButtonService` 1.3 KB, `FixtureProfilesModule` 1.2 KB, `ControlModule` 1.1 KB, `InfraredService` 0.9 KB, `AnalogService` 0.9 KB. +Candidates, to measure rather than assume: a `ListSource` that is a member the module hands out (no second base, so no thunk), or keeping the bodies out of line so the thunk can only jump. +Done when the thunk total in the S3 symbol table is near zero, with the delta in repo-health. + +### An early state push costs 3 to 5 ms on the S3 (2026-10-01) + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077064) + +`notifyValuesChanged()` patches one module on the next 20 ms tick, at most 25 times a second; with a MIDI desk following a self-playing Pong it took the S3 from about 325 to 295 fps, 3 to 5 ms per push. +Building that patch walks one module, so the time is most likely the WebSocket write on the render thread, but the split is unmeasured: one write per frame instead of two saved nothing. +Time the build and the send separately on the S3, then decide between moving the write off the render thread and a lower cap. + +### `applyControlValue` should say whether the value changed (2026-10-03) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077117) + +The boot reapply in `FilesystemModule::reapplyNode` detects a changed value by serializing each control before and after the apply and comparing the bytes, a list control included. +`applyControlValue` reports only whether the value parsed and applied. The proposed shape is the apply also reporting whether storage moved, an `ApplyResult::Unchanged` or a flag, so `Scheduler::setControl` can skip the reaction to a write that changes nothing. +Cold path today, so a tidy-up rather than a cost. + +### Runtime board presets (multi-commit, partially landed) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077175) + +The firmware-vs-board separation, the runtime pin controls and the catalog that carries per-board defaults are shipped ([MoonInstaller](../../explanation/architecture/mooninstaller.md#the-three-words)). What remains: + +**Prior art: the predecessor's per-board pin database** ([ModuleIO.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleIO.h)). The predecessor MoonLight (our own earlier project) already models exactly this for ~25 boards across ESP32-D0 / S3 / P4: a `pins[]` array of `{GPIO, usage, index}` plus board-level `maxPower`, `ethernetType`, `ethPhyAddr`, `ethClkMode`. Don't copy the file or paste its tables here: read it when building the catalog and write our own. Its `usage` enum enumerates the hardware functionalities a board preset here *could* drive; those still without a device-side consumer: + +- **LED output pins**, a gap with two halves now the parallel drivers take 16 lanes: + - **Per-model usable-GPIO map (the data).** Identify **which up to 16 GPIOs each device model actually exposes for LED output**: not the chip's full pin count, but the pins broken out to a usable header/connector AND safe to drive (exclude strapping, flash/PSRAM, input-only, and pins already owned by eth-RMII / I²C / the onboard LED). The codebase knows the chip *ceiling* (`MM_MAX_GPIO` from `CONFIG_SOC_GPIO_PIN_COUNT`, [Control.h:13](../../../src/core/module/Control.h)) and the live *ownership/reserved* grading ([PinsModule](../../moonmodules/core/system.md)), but NOT the per-board *exposed-and-safe* set, which is board knowledge (schematic/pinout per model). The authoritative source is the **annotated pinout image per model under `docs/assets/deviceModels/`** (e.g. [`esp32-s3-n16r8-dev.png`](../../assets/deviceModels/esp32-s3-n16r8-dev.png)) (and MoonLight's `ModuleIO.h` `pins[]` as prior art: read it, write our own against `deviceModels.json`). The per-MCU reading is in [gpio-usage § Usable LED-output GPIOs](../../reference/hardware/gpio-usage.md#usable-led-output-gpios-per-mcu-quick-reference); do the same read per model. + - **Encode as per-model defaults + coverage (the catalog).** The 16 usable lane GPIOs become the model's default `pins` string, so a fresh flash of e.g. "Serg UniShield V5" comes up with the *right* lane pins pre-filled, not blank/generic. Coverage is uneven: of 25 models, **7 carry no LED-pin config** (Olimex Gateway, LOLIN D32, Generic ESP32 Dev, ESP32-S3 N16R8 Dev, LightCrafter 16, SE 16 V1, and any bare dev board), and those default to nothing and force the user to guess. Fill every model's LED-capable pin default, and **document the per-model map** (the annotated-pin images are the natural home). **The per-board usable set is wider than the exposed header:** a pin the board commits to a peripheral it *doesn't mount* is fair game (the Olimex Gateway leaves 6 clean LED pins only if you count the **unmounted micro-SD** pins 4/13/14), so the per-model map must record *which non-exposed/repurposable pins are safe on this board*, not just the header breakout. **Note the i80 gotcha:** an i80 model should pick `clockPin`/`dcPin` clear of its data lanes; an overlap is a *warning*, not a blocker (the driver still runs; that one lane just carries the clock/DC waveform, which is fine for an unused parked lane but garbles an active strand), so a catalog default that overlaps an *active* lane would ship a subtly-broken board. +- **Power budget**: `maxPower` (Watts) per board, for a future current-limit / brightness-cap control. +- **RS485 / DMX**: TX/RX/DE pins (wired DMX-512 output beyond the current Art-Net path). The driver that consumes this is the [RS-485 / DMX-512 wired output item](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080691) in the light backlog. + +Sequencing rule (unchanged): each functionality lands a device-side control first, then its preset field; the catalog grows one earned consumer at a time, never as a speculative pin dump. + +### Hot path: move blocking work off the render callbacks (architecture) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077246) + +`-Wfunction-effects` proves the render path really does block — these are not annotation gaps, +they are synchronous I/O, allocation and lifecycle work reachable from `tick()`. Each needs the +work moved to a worker or made resumable, so each is a design change rather than a lint fix. +Confirmed by external review (CodeRabbit, PR #56). + +**`tick()` — every frame, the sharp ones:** +- `PreviewDriver::tick` — the socket half is FIXED (the pull-model transport only ARMS a message; + every socket byte moves on the transport tick). What remains: `buildCoordTable()` resizes + `keptIdx_` and the staging buffer on a rebuild/adopt tick. Suppressed at the site with the reason. +- `ParallelLedDriver::tick` — `tickSync()`/`tickRing()` reach `busWaitIfBusy()`, which spins for + the DMA peripheral. Deliberate (the driver owns the bus for the frame) but blocking. Suppressed. +- `Drivers::tick` — joins/stops the render-split worker synchronously on the timed-out recovery + path. Wants asynchronous handling that still preserves buffer ownership. +- `HueDriver::tick` / `tick1s` — synchronous HTTP (`pushOneChangedLight`, `pollPairing`, + `fetchLights`, `fetchGroups`) plus allocation. Wants a queue to a worker. +- `Layer::tick` — calls `applyState()` on a modifier rebuild, which runs prepare/release and + resizes ScratchBuffers. Wants the rebuild deferred to the scheduler's non-render prepare path, + keeping today's coalescing of `consumeNeedsRebuild()`. +- `DemoReelEffect::tick` — `advance()`/`swapTo()` create, delete and `applyState()` child effects + from the render callback. Wants a pending-switch flag consumed off-tick. +- `NetworkSendDriver::tick` — sends inline; wants to enqueue. +- `RmtLedDriver::tick` — waits on hardware completion and reset; wants polling or offload. +- `AudioService::tick` — UDP `sendTo`/`recvFrom` every frame (`syncSend`, `syncReceive`, + `syncEnsureSocket`). + +**`tick20ms()` / `tick1s()` — milder, same shape:** +- `HttpServerModule::tick20ms` — inline `handleConnection` transport work, so the 100 ms budget + cannot actually bound it. Wants resumable connection handling. `tick1s` writes WLED state + frames inline; wants the existing resumable sender. +- `MqttModule::tick1s` — synchronous DNS + discovery-buffer allocation. +- `NetworkModule::tick1s` — WiFi/AP lifecycle transitions and tree rebuilds. +- `FilesystemModule::tick1s` — the debounced `flush()` does filesystem work; the poll itself is + fine, the save wants a worker. +- `FileManagerModule::tick1s` — `platform::filesystemUsed()`; wants a cached value. +- `SystemModule::tick1s` — the P4 `coprocessorWifi()` path calls + `esp_hosted_get_coprocessor_fwversion()` synchronously; wants a cached snapshot. +- `ImprovProvisioningModule::tick` — runs the queued APPLY_OP inline; wants a cold task to + execute and publish only the result. +- `Scheduler::tick` — dispatches all of the above, so its own contract is only as good as theirs. + +**Explicitly NOT in scope:** the float-math findings (`BouncingBallsEffect`, `RipplesEffect`, +`SphereMoveEffect`). Review suggested fixed-point, but the float trajectory IS the ported +MoonLight behaviour and fidelity is deliberate. If the FPU-less Xtensa cost is real, that is a +profiling question first, not a lint fix. + +### Clock sync: a shared monotonic clock across devices + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077324) + +The second half of the core [multi-device runtime](../../explanation/architecture/mooncore.md#multi-device-runtime); discovery ships, this does not. The design: one leader sends its elapsed time over OSC, the protocol that already carries the shared controls, with the same `addressing`; followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on, which is why it is core rather than light-domain. + +The light-domain payoff is a wall of controllers animating in lockstep: effects already animate off elapsed time, so feeding them the leader's synced clock instead of each device's local one is the whole change on the render side. Device-to-device light *distribution* is a separate topology question and rides the existing ArtNet / E1.31 / DDP standards rather than a bespoke protocol. + + +Three render-side consequences. Time-seeded randomness takes a frame index derived from the synced clock rather than raw milliseconds, so devices a millisecond apart hash alike. `hashInt` is the default for anything a synced effect randomizes, and the `Random8` stream stays for local-only effects. Particles, ripple, fire and the cellular automata expose a deterministic re-seed from (time, seed), so a late device is placed into the same state by replay or keyframe. + +### Config provenance: firmware/MCU → deviceModel (catalog follow-ups) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077405) + +The model is shipped ([MoonInstaller § Config provenance](../../explanation/architecture/mooninstaller.md#config-provenance-mcu-devicemodel)). The remaining forward-looking pieces, a `devices.json`/MCU-layer split and annotated-pin images, stay gated by the sequencing rule (no catalog field ahead of a consumer). + +### Persistence overlay: partial-save and schema-change audit + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077474) + +The absent-key fix (`json::hasKey` guard in `applyControlValue`, so a saved file omitting a key no longer zeroes the control's default — the P4 `ethType` no-DHCP root cause) closed the acute hole. A broader audit would harden the overlay against the rest of the schema-drift surface, now that controls carry meaningful non-zero defaults: +- **Type-change safety (not migration, see MIGRATING.md).** `ethType` changed `int16` → `uint8` (Select). A persisted file written under the old type still loads (the flat parser reads the number either way, and Clamp snaps an out-of-range value to the new bounds). The task is to *confirm* each type transition degrades gracefully through the robust loader (absent → default, stale → clamp, unknown → ignore) — especially Text/Password/IPv4 buffer-size changes — NOT to add migration code. A transition that would lose data silently is documented as a break in the documented break, per the settled policy. +- **Test coverage.** `unit_Control_apply_absent_key` pins the absent-key contract; extend with a type-change round-trip (save int16, load into uint8 Select) and a narrowed-range clamp case so a future schema change can't regress silently. +This is hardening, not a known bug — the shipped fix is correct for the cases that occur today. + +### A second instance says it is serving, serves nothing, and shares the first one's config (2026-09-29) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077564) + +Launching the desktop binary twice leaves a silent zombie. The second process prints `MoonLight is running: http://localhost:8080/` and `HTTP server -> http://:8080`, then listens on no TCP or UDP port at all, and carries on rendering at about 14% of a core. Measured on Windows with a 128x128 layout: the first instance kept the port and kept serving throughout, so the deliberate omission of `SO_REUSEADDR` does its job and the port is never stolen. `HttpServerModule` prints `HTTP server failed to open port`, but the banner in `main_desktop.cpp` prints regardless. + +The sharper half is the filesystem. The second instance mounts the SAME data root and writes to it: `saved /.config/NetworkModule.json` landed while the first instance was live and serving. Two processes owning one config directory is a corruption waiting for the right interleaving, and neither of them knows the other exists. + +A bind that fails is a startup failure and wants to read as one: exit, or say plainly that another instance holds the port, with the banner following what actually bound rather than what was intended. Whether a second instance should be refused outright is the open design question, since the data root is per user rather than per process and two of them cannot both own it. + +### A non-ASCII Windows profile path defeats the desktop settings directory (2026-08-23) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077638) + +`std::getenv("LOCALAPPDATA")` returns the ANSI form of the path, so a Windows user whose profile name carries characters outside the system codepage (CJK and Cyrillic on a Western machine; most accented Latin survives cp1252) gets `?` where those characters were. `?` is not legal in a Windows filename, so `create_directories` fails, `fsMount` returns false, and the driver reports `cannot use ..., persistence disabled` naming the mangled path. It degrades visibly rather than corrupting anything, which is the standard Principle 5 asks for, but that user has no working persistence. + +Reading the variable wide is only half of it. `toFsPath` composes a `std::filesystem::path`, which stores wide on Windows, but every open in the layer goes back through `.string()` to reach `std::fopen` (fsRead, fsReadAt, fsWriteAtomic, fsWriteStream, and the mount probe), a narrowing the code comments on deliberately at `fsRead`. So a wide `LOCALAPPDATA` alone would produce a correct path that still cannot be opened: the fix is one `_wfopen`-on-Windows helper shared by all five sites, plus a test with a non-ASCII root. + +Not done with the per-user data directory because it reverses a documented decision across the whole desktop filesystem layer, four of whose five call sites predate that change. What the change did do is make the root capable of containing a username, where it was previously the literal `build`. Worth closing the next time this file is opened for other reasons. + +### Drivers' palette and fixture-profile lists, sent once rather than in every view (2026-10-13) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077706) + +Drivers is the largest root in the state, 35 KB of the Teensy's view where Services is 10 KB, and two lists are 25 KB of it, mostly repetition. +The `palette` options carry 66 palettes with 16 hex colors each for the dropdown's swatches, which change only with the firmware or a palette script: the state carries the names, and the page fetches the colors from a route the browser caches across reloads. +FixtureProfiles' `profiles` summary is 1.9 KB, and its `detail`, 14.5 KB, repeats each expanded row's editor fields, their name, type and bounds, for every profile: the row schema is sent once, as `optionSets` already sends the role names, and each row carries its values. +Together Drivers falls to about 12 KB, and the largest single control, the block a streamed state still needs whole, from 15 KB to a few. diff --git a/docs/work/backlog_mirror/drivers.md b/docs/work/backlog_mirror/drivers.md new file mode 100644 index 000000000..3bd563a9d --- /dev/null +++ b/docs/work/backlog_mirror/drivers.md @@ -0,0 +1,590 @@ +# Backlog: Drivers + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## DriverBase + +### A control change reaches a driver at the frame boundary (2026-10-21) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077803) + +With the render split on, a driver's `tick()` runs on the encode task while its `onControlChanged`, `onCorrectionChanged`, `tick20ms` and `tick1s` run on the main loop, and only structural changes and `setEnabled` park the encode task first. +An audit of `src/light/drivers/` found the main loop writing state the encode task reads: `DriverBase`'s `correction_` (rebuilt on a control change, ramped by `tick20ms`, `motionHeld` set by `tick1s`), `effectiveBrightness_`, the `wire_` buffer a channel-count change reallocates, the `start` and `count` window; HueDriver's pairing and light tables; NetworkSendDriver's `dest_` addresses on the inline send path and its `corrected_` buffer; PwmLightDriver's `lights_` and `duty_`; RmtLedDriver's and ParallelLedDriver's whole geometry through `parseConfig` and `reinit`, ParallelLedDriver calling `parseConfig` and `setStatus` from the encode task itself; RtspDriver's and ParallelLedDriver's `setStatus` from `tick()`; PreviewDriver's `downscale_` and `keptIdxAllocFailed_`. +The textbook move is the one live reconfiguration already promises, a change applied on the next frame: the main loop records the change, and `Drivers::tick` applies it at the frame boundary, after `quiesceEncode()` and before it notifies the worker, so a driver's state has one writer at a time. The thread sanitizer lane reports none of these today, since no test changes a control while the encode task runs; the first test that does pins the move. +The same pass covers ParallelLedDriver's `peripheral` swap, which disengages the render split through the quiesce hook and, as `peripheral` is not in its `affectsPrepare`, leaves it off until another prepare runs. + +### An identity correction is one copy (2026-10-14) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077859) + +Every driver runs `Correction::apply` on every light, even when the correction changes nothing: brightness 255 (global and local), the linear curve, a fixture profile whose channels match the layer's, and no dimmer, white or motion channels. +The hottest-path rule says that case is a copy: `Correction` knows at rebuild whether it is the identity, and a driver that sends its buffer whole (Network Send, the panel cards) copies the layer's bytes instead of looking each one up. +The default CIE curve is not the identity at 255, so the skip applies only with the linear curve. It applies to every driver once the sender already sends the output's byte order; network and panel drivers then send one copy, while LED strips still encode bits but drop the per-byte lookup and reordering. + +### Stop reading fixtureRef after the release that follows 2026-10-05 + +Kind: Restructure · Trigger: the release after 2026-10-05 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287077925) + +- **Stop reading `fixtureRef` after the release that follows 2026-10-05.** A driver saves its profile's name in `fixture` since then, and `DriverBase` still reads the hidden `fixtureRef` an older config saved beside a row number. With the next release out, remove the control and `DriverBase::adoptLegacyName` with its two calls, and add a `migrate.js` entry mapping `fixtureRef` onto `fixture` so an older backup still restores. + +## GpioDmaPeripheral + +### A '595 expander on the Teensy's GPIO-DMA bus + +Kind: Feature · Platform: Teensy · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078012) + +`supportsPinExpander()` is false, so a Teensy drives 16 strands at most. The port write carries no shift clock of its own, so the clock has to come from the writes themselves, at a DMA rate not yet measured. TriantaduoWS2811 drives 32 strands from 3 pins through FlexIO clocking '595s at zero CPU cost, and FlexIO is what the Teensy plan set aside for want of free contiguous pins beside the W5500. The reasoning: [the LED-driver analysis](../../design/leddriver.md). + +## HlsDriver + +### HLS upscaling is cache-hostile on large walls (2026-08-28) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078077) + +`HlsDriver`'s `scale` control replicates each light into a scale x scale block. Measured on the +bench P4 at 128x128: **~1 ms per frame at 1:1, ~60 ms at scale 4** (a 512x512 output). The frame +is 16x larger, so ~16 ms would be the honest cost; the extra 4x is the access pattern. The loop +walks LIGHTS and writes each block as `scale` short rows, the first filled and the rest copied +from it by `memcpy`, scattered across a 786 KB buffer, so consecutive lights touch distant addresses and every write misses cache, where the +1:1 path writes straight through sequentially. + +**Not urgent, because the default path never hits it:** auto-scale only engages on walls below +the encoder's floor ([watching the output](../../reference/watching-the-output.md)), where the output is small by construction. The expensive case is a manual scale on an already-large wall, which is also +where upscaling has the least to offer. + +**The fix when it earns its place:** iterate the OUTPUT rows rather than the input lights, so +writes are sequential: for each output row, walk its source row once and emit `scale` copies of +each light's color, then `memcpy` that finished row to the remaining `scale - 1` rows of the +block. Same output, one pass through the destination in address order. + +## Hub75Driver + +### The shift-register driver init + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078142) + +A HUB75 "panel" is a family, and the differences are not discoverable from its dimensions. FM6124 and ICN2038S panels need a different init sequence; WLED-MM picks FM6124 for "outdoor" panels, and its comment says the symptom of the wrong choice is a dark panel or pastel colors. Build trigger: a panel showing that. + +### Repaint only what changed + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078195) + +`tick()` corrects and encodes the entire frame every time, whatever moved. The library tracks a dirty bit per pixel in `setPixelColor` and repaints only those in `show()`. For a full-frame effect the two are comparable; for a sparse overlay, a clock, or a mostly-static scene, the difference is the whole encode. + +Worth measuring before building: our encode is a tight loop over a packed buffer, and a dirty-bit test per pixel is not free either. The win is real only where the frame is mostly unchanged. + +### Depth that degrades instead of failing + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078249) + +`bitDepth` is a user control with a hard ceiling, so asking for more than the wall can hold fails init with a memory error. WLED-MM reduces depth automatically by pixel count (8 bits under `MAX_PIXELS_8BIT`, then 6, 4, 3), so a large wall gets a dimmer picture rather than no picture. + +That fits the allocate-and-degrade rule every other output seam here follows. It interacts with the weighting item below, since the library's `lsbMsbTransitionBit` is the same trade expressed as refresh rather than as memory, so build them together rather than adding two overlapping automatic reductions. + +### Four-scan panels whose physical layout is not their logical one + +Kind: Feature · Trigger: someone owns such a panel · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078323) + +Some panels scan in a pattern where the physical row order does not match the logical image, and driving them without remapping produces a scrambled picture that looks like a wiring fault. The library handles this with `VirtualMatrixPanel` and `setPhysicalPanelScanRate` (FOUR_SCAN_32PX_HIGH, FOUR_SCAN_64PX_HIGH), and WLED-MM exposes them as bus types 105 to 108. + +Our `Hub75Geometry::rowsPerScan()` handles a panel driving more than two rows per address step, which is the common multi-pair case, but not an arbitrary remap. Build trigger: someone owns such a panel. The remap belongs in the Layout rather than the driver, since it describes where lights physically are, which is what a Layout is for. + +### Repeated planes: full top brightness and 8-bit depth + +Kind: Feature · Trigger: a wall that reads too dim at 6-bit, or a user who needs 8-bit gradients · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078402) + +The planes are weighted by their lit window ([HUB75 panels](../../reference/hardware/hub75-panels.md)). That costs top brightness, since the lower planes are dark for most of their pass: full white is about 47% of the row time at 4-bit and 33% at 6-bit. It also caps depth at 6, where plane 0 of a 64-column row still lights 2 words. The reference library repeats the upper planes instead, and windows only the lowest (`lsbMsbTransitionBit`, chosen automatically for a refresh floor): about 95% top brightness and 8-bit depth, at roughly 290 Hz for one 64x64 panel. + +**The approach: repeat DMA descriptors, not data.** Plane `p` is stored once and the descriptor chain points at it 2^p times, so the weighting costs descriptors rather than frame memory. mrcodetastic's ESP32-HUB75-MatrixPanel-DMA does exactly this (`configureDMA`, the `for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++)` loop), and it carries a refinement worth taking with it: `lsbMsbTransitionBit` is a dial, not a constant. Planes below it show once each; planes above get full weighting. The library raises it until a configured minimum refresh is met, trading exactness at the low end for rate. That makes depth and refresh one control instead of two fighting ones. + +**What it costs here: a new LCD_CAM backend.** The blocker is not the weighting loop, it is that `esp_lcd_panel_io_tx_color` owns its descriptors and gives no way to aim several at one buffer. The library programs the silicon directly instead (`gdma_new_channel`, `gdma_connect(GDMA_TRIG_PERIPH_LCD)`, hand-linked `dma_descriptor_t.next`). So this is the same step this project already took for WS2812, from `platform_esp32_i80.cpp` (675 lines, esp_lcd) to `platform_esp32_moon_i80.cpp` (1,952 lines, own GDMA), with the same hazards that took bench bisects to find there: re-mounting the chain per frame, terminator placement, producer/consumer headroom. + +**Parlio cannot express it.** Its only repeat primitive is `loop_transmission`, which repeats the whole buffer forever with no per-plane control. Weighted depth would be an LCD_CAM capability, and the `peripheral` select has to say so rather than offering a choice that silently changes the picture. + +Build trigger: a wall that reads too dim at 6-bit, or a user who needs 8-bit gradients. It stays automatic, chosen from `bitDepth` and a refresh floor, with no new setting. + +## HueDriver + +### Migrate HueDriver's name buffers to ScratchBuffer (finish the ScratchBuffer sweep) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078467) + +`HueDriver::ensureNameBuffers()` / `freeNameBuffers()` is the alloc-in-build / free-in-release / null-guard bundle that `ScratchBuffer` absorbs — and it does **no** `dynamicBytes` accounting, so the module's memory readout under-reports its Hue-light name buffers. It was skipped in the ScratchBuffer migration (its buffers are `char[]` name tables, not the grid-sized state the sweep targeted). Now that `DriverBase::release()` chains to `MoonModule::release()` (fixed on PR #43), a `ScratchBuffer` member here would free correctly on disable and self-report its bytes. Mechanical, one-file, behaviour-preserving. Flagged by the 👾 Reviewer on PR #43. + +## MoonI80Peripheral + +### The ring's refill jitter (2026-10-22, from the internal RAM plan) + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078532) + +LCD-MM's refill interrupt fires every 89 µs at 3-row slices, yet almost every second it goes about 1.1 ms without firing. +So `ringAuto` keeps 54 rows of lead ([LED drivers](../../reference/hardware/led-drivers.md)), where hpwit's S3 driver runs on 10. + +Measured with `ringDbg`'s `gap`, `ml` and `c` fields. The gap ends mid-frame as often as at the idle tail. + +Ruled out: +- the flash cache: `co` stays 0; +- the core: registered on core 1 it was the same, and it broke the cache guard, so it stays on core 0. + +Next, the gap measured against the frame's own events, the arm and the snapshot, then against other interrupts on core 0. + +A lead of 10 rows would free most of the ring's internal RAM. + +### MoonI80 streaming ring: open instruments and cleanups + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078616) + +The ring ships and is wall-verified through 48 strands × 256 (12,288 lights). The mechanism lives in [MoonI80Peripheral.h](../../../src/light/drivers/MoonI80Peripheral.h) and [LED drivers](../../reference/hardware/led-drivers.md); the design arc in [the closed plans](https://github.com/MoonModules/MoonLight/issues?q=label%3Aplan%20is%3Aclosed). Open items: + +- **Last-8-panels white flash, the "44-46 flash" (OPEN: the lanes ≥ 32 encode path).** On the 48×256 wall, brief WHITE/bright flashes (not wrong colors) over an otherwise-correct image, confined to strands 40-47 (the last 8 panels), mostly panels 44-46, wandering within the last 8. **Brightness-gated: clean below 5, flashes at ≥5**: since brightness scales pixel values through a LUT, below 5 the frame collapses to near-all-zero bits, so the gate really means *the flash needs SET (one) data bits*. **The same physical wall runs clean on hpwit's driver, so it is OUR driver, not the hardware.** **A live pin reorder located it:** the reorder moved the strands and the flash stayed with lanes 32 to 47, so it is the lanes ≥ 32 (`maskHi32`) encode path under the wall's exact active set, not the last '595, GPIO 17 or the latch bit. Ruled out by live hardware tests (do NOT re-chase): (1) the encode source (PSRAM vs internal; the ISR-source staging fix did nothing, reverted); (2) the ISR/lapping regime (the flash is on prime-encoded rows too); (3) it being a tail-*rows* phenomenon; (4) shift-clock margin via `shiftOverclock` (already at the 20 MHz OFF setting, still flashes); (5) bus-bit latch adjacency (a `latchBitHigh` diagnostic moved the latch to bit 7, still flashes). The encoder passed a host fuzz over RANDOM masks (`ParallelSlots.h` byte-for-byte correct), which never hit the wall's trigger: next, reproduce on the host with the wall's real geometry, active set and content, starting at the 64-bit mask round-trip (`maskLo32 | maskHi32 << 32` in `ParallelLedDriver.h` against the encoder's re-split in `ParallelSlots.h`). All diagnostics from this investigation are reverted, and the tree is clean. +- **MoonI80 direct mode flickers below ~30 lights on a PSRAM frame buffer (OPEN, cause unproven).** The write-up, what is established and the theory to test: [lessons.md](../../contributing/lessons.md#mooni80-flickers-below-30-lights-on-a-psram-frame-buffer-open-cause-unproven). Next instrument: `loopbackTest` + `loopbackIntrusive`, which captures what the peripheral actually emitted and separates a corrupt frame from a stalled transfer (needs the RX jumper pin — the SE16 routes its LED outputs, so pick one that can read back). +- **Ring bus init hard-fails instead of stepping down when `ringBufs` is raised past what RAM allocates** (wall went dark until the control was lowered again; the pool alloc steps down but a later allocation, likely the descriptor link list, does not). A control change must degrade, never dark the output. +- **~1-frame white/colored flash every ~5 s** seen at some configs — plausibly fixed by the frame-close latch word (a strand whose last data bit ended HIGH missed its reset that frame); soak-observe on the wall before closing. +- **Prime barrier fps cost.** The ring's prime holds a busy-wait until the previous frame's deterministic wire end (`waitWireDrained` in `primeRingRange`). The wait is ~0 when the snapshot + render gap already span the wire, but on fast frames it serializes wire → prime and caps fps at 1/(wire + snapshot + prime). If the fps work wants that overlap back, the barrier can go finer-grained (per-buffer: buffer b is safe once the drain passes slice `b + nSlices − ringBufs`) — measure first. +- **Multi-strand loopback**: the instrument drives one `loopbackStrand`, so it is structurally blind to a multi-strand fault (it passed while the wall was visibly corrupt). +- **Shift-mode loopback host coverage**: nothing in the suite drives shift-mode loopback end-to-end through the mock bus — the detection gap that let two loopback bugs live unnoticed. (The stall bug itself is fixed: capture-first alloc + pool step-down; verified on two boards.) +- **Loopback teardown leak + heap fragmentation**: cycling the private-bus loopback drops ~80 KB internal and the heap stays fragmented (measured maxBlock 13–44 KB with 240–310 KB free). Capture-first + step-down made runs reliable despite it, but the leak itself stands — chase when the loopback is next touched. +- **Ride-the-live-ring loopback — parked**: built (`loopbackIntrusive` + the snapshot pattern hold) but an RMT-RX cannot capture on a GPIO the LCD_CAM is actively driving (reads 0 sym). Revival path: briefly output-detach just the RX pin for the capture window. +- **Diagnostic surface** (`ringDbg` incl. tw/ts/tp + sg/se, the dbg statics, the `kCyPerUs = 240` hardcode): kept deliberately through the tuning era; gate/remove when the ring fps work closes. +- **fps header reports the module TICK rate**, not the frame rate (`frameTime` is the real one); any fps claim predating 2026-07-17 reads with that in mind. +- **`ringAuto` "just works" — verify the geometry pick on smaller configs.** The pick is wall-verified at 48×256; the smaller common configs (16×256 and below) still need a bench pass. If any has a strictly better geometry `ringAuto` misses, teach it that ONE *measured* rule (no per-effect/per-density heuristics — the flicker was never geometry). `ringAuto` itself stays visible as the recourse until this proves it always picks right, then it can go expert-only too. + +### The ring's memory readout counts one buffer, not the pool (CodeRabbit, 2026-09-09) + +Kind: Bug · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078684) + +`LedPeripheral::busHeapBytes()`, which MoonI80 does not override, counts `busCapacity()`, +the size of ONE buffer, twice when `busBuffer(1)` exists, which +is right for the double-buffered path and wrong for the ring, where `createRingState` allocates +`ringBufs` slices (up to 30 at the 48x256 geometry) plus the shared zero-pad. So a ring board +under-reports its DMA memory by roughly the buffer count, on the card that exists to make exactly +that number visible. + +Not a correctness bug: the memory is spent either way, and nothing sizes an allocation from this +value. It is a reporting bug in the one readout a user consults before adding lights. + +**What it takes:** a `busHeapBytes` override on MoonI80 (the ring knows `ringBufs * bufBytes + pad` +at creation). Wants a ring-mode +accounting test alongside, since the existing ones only cover the non-ring path. Touching +`createRingState` means a bench pass on the wall, which is why this is its own change. + +### MoonI80 ring: the first frame after a rebuild trips a transient give-up status (2026-07-16) + +Kind: Bug · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078773) + +A cosmetic residual left after the rebuild-wedge fix (below): on boot, and for a beat after any shift-ring rebuild, the driver shows **"output stalled"** even though `wireUs` reports live completions — the *first* frame after a fresh ring build occasionally misses its completion window and trips the dead-frame give-up before the ring settles, so the stale error latches until the next interaction clears it. It is NOT the old wedge (that stayed dead until reboot; this self-clears on any control edit and the ring is genuinely driving underneath). Two clean fixes to weigh: (a) don't count the very first frame after a rebuild toward `deadFrames_` (give the fresh ring one grace frame), or (b) have the give-up retry re-derive status the moment `wireUs` shows a real completion. Low priority — the LEDs drive correctly; only the status string is briefly wrong. + +## NetworkSendDriver + +### ArtPoll discovery: know which tubes are alive + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078870) + +`NetworkSendDriver` unicasts to a list of receivers (`hosts` + `lightsPerHost`), which is the Art-Net-4-conformant model. What it cannot do is **tell whether a receiver is actually there**: UDP is fire-and-forget, so a dead tube is invisible to the sender. The spec's own answer is discovery — *"The transmitting device must regularly ArtPoll the network to detect any change in devices which are subscribed"* — and it is the natural next increment. + +**Why it earns its place (product-owner experience, 2026-07-13):** a dead tube in a prior setup *made the other tubes hiccup*. The mechanism is that a send to an unresolvable address stalls or errors **inside the frame loop**, delaying the packets for every destination after it — so one dark tube degrades the live ones, every frame. The send loop tolerates that (a failed send drops only that packet), but tolerating is not the same as **knowing**: with discovery we can simply **skip destinations that haven't answered**, which removes the stall at its source rather than absorbing it. + +**Scope (~130–150 lines + tests; the receive half already exists).** `ArtNetPacket.h` already builds+parses, `UdpSocket` already binds and reports the sender's IP: +- **ArtPoll send** — broadcast a 14-byte poll every ~3 s (this is the one packet Art-Net *does* broadcast, by design). +- **ArtPollReply parse** — fixed-layout 239-byte reply: node name, IP, and its subscribed universes. +- **A node table** — IP → last-seen; mark offline after ~9 s of silence (3 missed polls, the spec's own cadence). +- **Use it:** skip offline destinations in the send loop; surface the live/dead list as a read-only status. **Bonus, and arguably the real prize: auto-populate `hosts`** — the user stops typing addresses at all, which is how a professional controller behaves. + +Do it as its own increment. The multi-destination unicast it builds on has shipped. + +## PanelCardDriver + +### MoonLight hangs when a USB network adapter is re-plugged (2026-09-28) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287078957) + +Unplugging and re-plugging a USB Ethernet adapter while PanelCardDriver is active wedges the whole application. Measured: the render task stops logging entirely (0 bytes in 8 s), one thread spins at 74% of a core while the other two sit in `Wait`, the listener still accepts so connections pile up unanswered (11 of them), and HTTP never returns, tested to 45 s. The last line written is always `NetworkModule: Ethernet up (recovered from Idle)`, so it enters the spin somewhere in the Ethernet-recovery path on the render thread rather than blocking on a lock. + +Reproduced once, on a bench with a Realtek USB GbE adapter feeding a ColorLight card. The trigger is ordinary: anyone driving panels from a USB dongle does this. The suspects are the pcap calls that recovery reaches, since `ethBindRawInterface` runs `pcap_findalldevs` and `pcap_open_live` on an adapter that has just re-enumerated, and both are called from `prepare()` on the render task. Worth a second reproduction with a debugger attached before guessing further: a spinning thread with a live listener is a loop, not a deadlock, and the loop has a home. + +### The interface picker re-enumerates adapters on every control rebuild (2026-09-28) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079059) + +`rawInterfaces()` caches nothing, and `defineDriverControls()` calls it on every rebuild of a driver that owns an interface Select. On Windows `pcap_findalldevs` opens each adapter in turn and measures ~2.3 s, so every control change on PanelCardDriver, every `/api/types` that builds the type catalog, and every UI refresh paid it. Measured with a panel driver present: `POST fixture` 2.2 to 6.7 s, `/api/types` 2.3 to 4.4 s, `/api/state` 2.2 s. The same payloads on a config with no panel driver: 23 to 36 ms. POSIX is not affected, since it enumerates with `getifaddrs`. + +A cache with a 60 s window took those to 8 to 35 ms and was reverted before the release as too much risk for hardware-critical code. Two things learned from doing it: the window has to outlast a person pausing to think, because a 3 s one leaves every deliberate control change paying full price; and skipping the rebind needs `ethLinkUp()` in the condition, because an open pcap handle whose link is down still wants rebinding and guarding on "already bound" alone removes the only recovery a user has. Scope any cache to Windows, since it buys POSIX nothing and costs it hot-plug latency. + +### Multi-card walls: does a daisy chain work today? + +Kind: Investigation · Trigger: ask before building · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079144) + +The ColorLight format has **no card addressing in the PIXEL path**: the destination MAC is a fixed +constant and every card filters on it, so every card on a segment shows the same image. A user with +six cards on a switch observed exactly that. + +The DISCOVERY path does distinguish them. A discovery reply (0x08) carries a controller number at +payload offset 0x62, and the acknowledgement echoes it plus one, which is how a sender tells several +cards apart. Documented by a reader of [Harald Kubota's protocol +write-up](https://hkubota.wordpress.com/2022/01/31/winter-project-colorlight-5a-75b-protocol/) and +confirmed by its author. That is an identity, not a destination: it does not let a sender aim pixel +data at one card, so the same-image behavior above stands. It is what a per-card brightness or +color-temperature feature below would key on. + +The industry-standard answer is **daisy-chaining** — a sending card's ports each drive a chain, and each card takes its region by position in the chain. That user works around it with per-card VLANs and a managed switch instead, which he built for throughput and for per-card color-temperature grouping across mixed panel batches; he described it as his own solution, not a standard. + +**Establish first whether a daisy chain already works with MoonLight** (one contact has a 96K daisy-chained rig). If the cards self-assign by chain position, the standard multi-card case is already solved and nothing is needed. Only if it does not work is there a feature here, and it should follow the daisy-chain standard rather than the VLAN workaround. 802.1Q tagging is technically a clean fit for a raw-L2 sender (the tag is part of the Ethernet header, the switch strips it before the card, so card firmware is unaffected), but it serves one bespoke architecture. + +### Smaller asks from the same thread + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079230) + +- **Read the wall layout from the ColorLight cards.** The cards can report their configuration and at least one user's own tool already does it; it would remove the manual layout step. +- **Per-card color temperature and brightness**, via the ColorLight sync-packet bytes, grouped by sync group — used to color-match mixed panel batches live. + +### Configure a ColorLight card without Windows (2026-10-16, asked) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079294) + +Asked on Discord by robwomp: a way to set up ColorLight receiving cards from a Linux command line, so a ready-made MoonLight image (a Pi running the desktop build with PanelCardDriver) could configure the card itself and make a card and its panels plug and play. + +Today the card's configuration (panel size, panel count, scan, driver IC) is written once with ColorLight's LEDVision, and its firmware with LED Upgrade, both Windows-only ([panel cards §4 and §7](../../reference/hardware/panel-cards.md)). The protocol for those writes is ColorLight's own and undocumented; the pixel and discovery frames are known from [Harald Kubota's write-up](https://hkubota.wordpress.com/2022/01/31/winter-project-colorlight-5a-75b-protocol/), the configuration frames are not. + +The route: capture what LEDVision sends while it writes a known configuration (Wireshark on the card's Ethernet), decode the frames, and replay them. [REA](https://github.com/morluto/rea) (MIT) does the decode step: its packet mode reads the capture through TShark and reports repeated values and field candidates, run from the terminal (`npx rea-agents …`) rather than installed into the agent's configuration. Its static .NET inspection could read how LEDVision builds the frames, if LEDVision is .NET and its license allows it. Then replay them from PanelCardDriver as a "configure card" action taking the wall's panel size, count, scan and IC. Reading the configuration back (the "read the wall layout" ask above) shares the same decoding. The firmware path stays with LED Upgrade: a bad write bricks a card, which a configuration write does not. Ask before building: it needs a card on the bench and a Windows machine running LEDVision for the capture. + +## ParlioPeripheral + +### A '595 expander on Parlio + +Kind: Investigation · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079383) + +`supportsPinExpander()` is false, so a P4 on Parlio drives 16 strands at most. Parlio generates its pixel clock itself and `platform_esp32_parlio.cpp` leaves `clk_out_gpio_num` unconnected since WS2812 ignores it; connected, it is the '595's shift clock, and the latch rides as a bus bit as it does on `i80`. Whether the clock output and the bit packing carry the expander's eight words a slot is a bench question, and the eightfold frame meets the single-transaction ceiling the chunked-transfer item lifts. The reasoning: [the LED-driver analysis](../../design/leddriver.md). + +### P4 Parlio streaming ring: lift the ceiling past about 21K lights + +Kind: Feature · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079464) + +**Port the ring concept to the P4 Parlio path**, to drive far more than its current whole-frame ceiling. troyhacks' MoonLight Parlio driver reaches **~21K LEDs RGB (~16K RGBW)** at 16 lanes — but NOT by materialising the whole encoded frame: he stages into a **fixed ~512 KB PSRAM buffer** and DMAs it out in **64 KB chunks** (`max_transfer_size = 65535`), so the DMA never needs the whole frame contiguous. That is the SAME idea as our MoonI80 ring, applied to Parlio on the P4 (where — unlike the S3 shift clock — the DMA *can* sustain PSRAM reads at the WS2812 rate). His ceiling is a *chosen buffer size*, not a hardware wall, so it caps at ~21K. + +**Two tiers, in order:** +1. **Match him (moderate):** chunk our Parlio DMA the way he does (bounded PSRAM staging + 64 KB bursts) instead of one contiguous encoded frame — this alone lifts our P4 Parlio ceiling to his ~16–21K. Our Parlio path already uses PSRAM; the change is the chunked-DMA transfer shape, not a new architecture. +2. **Beat him (the ring):** run our MoonI80 **streaming ring** on P4 Parlio — small internal buffers, refilled per drain from a PSRAM source, holding internal RAM *constant* regardless of light count (the hpwit model our ring already implements). troyhacks' 512 KB buffer is still *bounded*; the ring is not, so this goes past ~21K to light-count-independent. It reuses the ring mechanics we already have (linear self-terminating chain, per-drain refill, drain-count termination); the P4 Parlio backend gets a ring variant beside its whole-frame path, the way MoonI80 has both. + +**Prerequisite / sequencing:** tier 2 wants the **ISR-refill + `MM_HOT`** work done first (same as the classic ring above), since a high-rate Parlio refill benefits from the ISR-grade determinism. Confirm the CURRENT P4 Parlio tested ceiling first (re-measure on the P4 bench) before claiming a head-to-head — the ~21K figure is troyhacks' *buffer-fit* limit, not a verified-on-wire number, and his code steps the clock down at 256/512 LEDs per lane (signal integrity at long strands), which our own measurement must account for. Reference: the parlio source is `src/MoonLight/Nodes/Drivers/parlio.cpp` in the predecessor; our Parlio backend is `platform_esp32_parlio.cpp` + `ParlioLedDriver.h`. + +## PreviewDriver + +### Preview does not resume after a long tab hibernation (observed once, 2026-08-27) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079553) + +Desktop instance at 1024x1024: after the browser tab sat backgrounded for about an hour, the +preview pane stayed static on return. PreviewDriver ticked at ~1 us (no standing request served) +while a FRESH WebSocket client received frames normally, and the Drivers card showed the +encode-worker-stalled latch. A page refresh reportedly did NOT revive it; toggling `multicore` +(a Drivers re-prepare) did. Suspects: the tab-hide hibernation path's tracked retry vs the +wake-up re-request, or per-driver lease state that only prepare() resets. Needs a reproduction +with the WS uplink logged before it can be fixed. + +### Self-describing preview frame header + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079652) + +The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the color payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below. + +## RmtLedDriver + +### RMT over DMA on the S3/P4, with a completion callback (Funkelfetisch, July 2026) + +Kind: Feature · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079737) + +Funkelfetisch's fork carries a finished branch, `codex/upstream-rmt-rgbw-performance`, that the +classic-ESP32 flicker work of 2026-09-05 makes worth adopting: on chips with RMT DMA +(`SOC_RMT_SUPPORT_DMA`: S3, P4) it sets `with_dma` with the IDF-recommended 1024-symbol block, so +the frame streams from RAM and the refill interrupt that causes flicker on a DMA-less chip does not +exist at all. It replaces the blocking `rmt_tx_wait_all_done` with `rmt_tx_register_event_callbacks` +(`on_trans_done`) plus a per-channel busy flag so the next tick skips while a frame is in flight, +and reports `"RMT DMA"` in the driver status so a user can see which path is live. Files: +`platform_esp32_rmt.cpp` (+105), `RmtLedDriver.h` (+75), `LedDriverConfig.h`, `Correction.h` +(RGBW profiles, a separate topic in the same branch), with unit tests. + +What it does NOT address: on the classic ESP32 the DMA half compiles to nothing, and nothing in it +moves the channel's interrupt off core 0 (the root cause found on the Dig-Next-2, fixed by +creating the channel from core 1). The two are complementary, one driver with the right answer +per chip: DMA where the silicon has it, the core-1 refill where it does not. Adopt his DMA and +callback path and keep the core hop. Study, do not copy: write it against the seam as it stands, +credit the branch. + +Re-read against the wire-byte driver (2026-09-09), which changed two of the assumptions above. The +driver ships the correction's wire bytes and the peripheral expands them +([RmtLedDriver.h](../../../src/light/drivers/RmtLedDriver.h)). So (a) his `mem_block_symbols = 1024` is now the +only symbol memory in play and is cheap, and (b) `rmt_transmit` already takes the bytes, so the DMA +path needs no buffer change, only `flags.with_dma` plus the callback. The `txInFlight_` guard STAYS +whatever happens: the peripheral streams straight out of the driver's frame buffer, so a rebuild +that frees it mid-frame still tears; his busy flag would replace the blocking wait, not that guard. +The win left on the table is the blocking `rmtWs2812Wait` on S3/P4, which today costs the tick the +whole wire time of the longest strand. + +### RmtLedDriver's out-of-memory status has no test, and cannot have one yet (2026-09-09) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079814) + +`frameUnusable_` reports a frame buffer the driver could not get (or, on classic, one that landed in +PSRAM where the refill cannot read it), and two guards keep that error from being overwritten by the +resting "driving N of N" status. All of it shipped unpinned, and not by oversight: the desktop +`allocInternal` is a flat `trackedAlloc` that never refuses and ignores `setTestMaxAllocBlock`, and +`ptrIsPsram` is hard-coded false. A test written against that would pass whatever the driver did. +`unit_JsonSink_overflow` shows the trap: it grows until a 64-bit host might refuse and accepts "no +refusal" as a pass, so it tests nothing on a machine with RAM. + +**What it takes:** a cap inside the desktop `allocInternal` that `setTestMaxAllocBlock` (or a new +`setTestInternalCap`) drives, so a test can make the frame allocation fail deterministically, plus a +`setTestPsramFrom(ptr)` seam so the PSRAM branch is reachable off-target. Then three cases: the +status appears, `prepare()` does not replace it with "driving N of N", and `reinit()` does not clear +it (the bug the bench found: the error retracted microseconds after it was set). A platform change +with its own review, which is why it is here and not in the change that shipped the status. + +### RMT int_ena read-modify-write race (classic ESP32, level-5 refill) + +Kind: Bug · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079890) + +- **RMT `int_ena` read-modify-write race (classic ESP32, level-5 refill).** `RMT.int_ena` is one register written by both the render task (arming a frame) and the level-5 refill handler (disarming a finished one), through `rmt_ll_enable_interrupt`'s `|=` / `&=`. A handler firing between the task's read and its write loses the task's update, leaving a channel armed or silent. Never observed: the window is a few instructions and the two writes rarely target one channel, so the symptom would be a stuck channel after hours rather than anything the bench shows. Left unfixed deliberately, because the two obvious guards are both wrong here and each was tried on hardware: `portENTER_CRITICAL_ISR` spins on a lock the level-5 handler has just preempted (it runs above `XCHAL_EXCM_LEVEL` 3 by design) and deadlocks the core, and a compare-and-swap builds but crashes, since `S32C1I` addresses only data memory and a peripheral register raises `EXCCAUSE` 3. The remaining candidate is masking to level 5 (`XTOS_SET_INTLEVEL`) around the two-instruction update, which needs no lock and no atomic bus access; it compiles but is unproven on hardware and wants a soak before it displaces firmware that is flicker-free on two boards. + +## Across modules + +### Clocked and one-lane strips over SPI + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287079973) + +APA102, SK9822 and HD107S take a clock and a data line, which is SPI at tens of MHz, so one pin pair carries ten thousand lights where WS2812 carries 2,048; the P4's octal SPI adds eight data lines for a large bank. The same peripheral drives one WS2812 lane on MOSI, each bit encoded in three or four SPI bits at 2.4 to 3.2 MHz by DMA with no CPU: the route for a chip with two RMT channels such as the C3, and the one every Raspberry Pi shares. Both are one driver behind a platform seam in the init, buffer, transmit and wait shape the other LED peripherals use, with a fixture profile per chip type; `PinList.h` already carries the higher cap a clocked type passes. + +### Frames to a board over USB serial, the desktop as renderer + +Kind: Feature · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080036) + +A desktop has no LED pins and reaches a board through the network drivers today. A board on a USB cable is the other route: the desktop renders and sends RGB frames over USB serial, and the board clocks them out through its own LED driver. An S3's native USB-Serial-JTAG sustains about 12 Mbit/s, which carries about 10,000 lights at 50 fps; a UART bridge (CP210x, CH340) about 1 Mbit/s, so past that Art-Net, E1.31 or DDP over Ethernet to several boards stays the scale route. It needs the board-side receiver, the serial sibling of `NetworkReceiveEffect`, and a desktop driver beside `NetworkSendDriver`. + +### MoonI80 prime-only ring: no stall backstop (sibling-path gap) + +Kind: Bug · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080110) + +**Found:** 👾 Reviewer, pre-commit on the whole-frame stall fix (2026-07-22). + +The MoonI80 wait-timeout backstop (`moonI80Ws2812Wait`, `platform_esp32_moon_i80.cpp`) now recovers a lost/coalesced EOF on **two** of the driver's three completion paths: the lapping ring (`nSlices > ringBufs`, oracle-gated) and the whole-frame path (`!isRing`). The **prime-only ring** (`isRing && nSlices <= ringBufs`) falls through both conditions, so a lost terminator-EOF there leaves `busy` stuck true with no recovery — and every prime/arm/transmit-ring path refuses under `busy`, the same permanent-wedge class the whole-frame fix just closed. + +Mitigated in practice: prime-only fires exactly one EOF per frame (no intra-frame coalescing), so the lost-EOF trigger is far rarer than on the whole-frame or lapping paths. But it is the same defect, and per the CLAUDE.md sibling-path rule (a cross-cutting recovery that core owns for one path should cover the sibling path, not be re-implemented per case) the backstop should extend to it rather than leave a third path uncovered. + +**Fix:** widen the backstop's condition so a stuck prime-only ring finalizes too — likely a single "any ring frame whose wire time has elapsed with `busy` still set" oracle that subsumes both ring branches, calling the shared `finalizeStalledTransfer`. Verify on the expander wall (prime-only = the small-strand ring config), since the ring recovery is not desktop-testable. + +**Related (same file, same wall dependency):** the whole-frame backstop stops the hardware before draining the FIFO and the EOF ISR drops a firing against an empty FIFO, which rejects a late EOF that arrives after recovery. A tighter guarantee — an ISR that has *already passed* the empty-FIFO guard cannot then clear a freshly-recovered `busy` or give `wireFree` for the next transfer — would need explicit serialization (disable the EOF interrupt across finalize+rearm, or an explicit recovery/rearm state the ISR checks). The window is extremely narrow (an ISR in-flight at the instant of finalize) and stopping GDMA+LCD first already disables the source; the hardened version is an ISR-concurrency change that must be proven on the wall, not landed blind. + +### A power budget the device knows about (2026-09-02) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080199) + +**Measured on MM-StadBeest** (LightCrafter 16, 1440 lights, through the new `power.mls` readout): + +| brightness | rail | current | +|---|---|---| +| 16 | 4.86 V | 0.9 A | +| 60 | 3.9 V | 4.3 A | +| 120 | (browned out) | | + +Current is roughly linear in the control value, so the usable range is compressed into the bottom of +the travel and the top of the slider is a power hazard rather than a setting. The board crashed +twice during this session at brightnesses a user would reasonably try. + +**What prevents the crash: a power budget from measured values.** A board that measures its input voltage or current dims when the voltage sags below a threshold or the current passes a limit (see the power-monitoring entry below). A board without sensing relies on safe mode and the fade-in, which bring it back reachable after any supply failure. + +NOTE the rail sag above is NOT the supply's fault: an LRS-350-5 delivers 60 A, and this browned out +at 4.3 A. Roughly 0.25 ohm of series resistance in the feed, so wiring and injection points, which +is worth measuring before tuning anything in firmware. + +**Measured again 2026-10-09**, full white on all four channels: 4.7 V at 0.5 A, 4.1 V at 9 A, 3.3 V at 18.4 A, held for minutes while USB powered the board. The feed is now about 0.08 ohm (2.5 m of 2 mm² speaker cable each way), about 26 W of heat at full load. Powered from the feed alone, the board hangs under that sag rather than resetting, since the chip's supply stays above the brownout level; raising that level to 2.98 V, the S3's level 3, still hung without a reset. Two power cycles bring a hung board back dark and reachable through [safe mode](../../reference/troubleshooting.md) (bench-verified). What remains is the device acting on what it measures: dimming when the input sags or the current passes a limit, on boards that measure. + +### Graceful blank-on-stall: a stalled bus darkens the strip (2026-07-15) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080268) + +When the bus stalls mid-frame the WS2812 strip is left holding **random / max-brightness lights** (often all-white — the all-ones failure pattern) that only a **power cycle** clears. That is a robustness gap: WS2812s latch their last received color and hold it until re-clocked or power-cycled, so a frame that dies mid-stream leaves every light past the failure point stuck bright. The give-up guard today stops *spending the render thread* on a dead bus (correct) but does nothing about the *strip's* state, so the user sees a wall of garbage LEDs and reaches for the plug. + +**The fix: on give-up (and on a rebuild that SHRINKS the reachable range), clock out ONE clean all-black frame** — every lane LOW → every light receives 0,0,0 → the strip goes dark. This turns "stall = a wall of random bright LEDs until power-cycle" into "stall = strip cleanly dark," which is the honest *degraded, not crashed* state the *[Robustness](../../explanation/architecture/moonmodule.md#robustness)* rule asks for. It also covers the PO's specific case (drop `ledsPerPin` 256→128 and the abandoned 128–256 range stays lit): a full-length black frame on the shrinking rebuild blacks the whole physical strip once, no boundary to compute. + +**The load-bearing caveat:** if the bus is wedged *because it cannot complete a transfer*, a black frame may not clock out either — so this is a best-effort **attempt**, not a guarantee: try the black frame on give-up; if it clocks, the strip darkens; if the DMA is truly dead, we are no worse off than today (and the rebuild-wedge fix above is the real cure for *that* class). Note it must be a genuine transmitted frame (all lanes driven LOW through the normal encode+transmit), not merely zeroing the DMA buffer — the strip only changes on a clocked frame. Pin it with a test: after `kDeadFramesBeforeGiveUp` dead frames, the driver emits one all-zero frame through the transmit seam (the mock asserts a zero frame was handed to the bus), and a subsequent recovery resumes normal content. + +### Classic-ESP32 shift-register ring on raw I2S: the high-light-count classic driver + +Kind: Feature · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080365) + +**The PO wants shift-register output on classic ESP32 boards at the MAXIMUM light count**, which the whole-frame `i80` backend cannot deliver: its frame buffer must be internal RAM, which caps it near 2048 lights ([performance.md](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid)). Above that, classic needs the **hpwit refill-ring model**: a handful of tiny internal DMA buffers (~1.2 KB total, light-count-independent) refilled by the I2S EOF ISR, which transposes the next pixel row out of a **PSRAM** framebuffer as it goes. The DMA never touches the source array — only the CPU/ISR does — so the source lives in PSRAM and internal RAM stays constant. This is how hpwit (and WLED-MM/MoonLight on his driver) reach **48×256 ≈ 12K lights at ~100 fps on classic silicon with WiFi up**, with the same '595 expander. + +**Why it must be a SEPARATE driver, not a flag on `I80LedDriver`:** `esp_lcd` owns the classic I2S DMA and only does whole-frame, so the ring cannot be bolted onto the esp_lcd path — it needs a second classic driver written on the **raw I2S registers** (`i2s_ll` / the LCD-mode register file directly, below esp_lcd, the way the S3/P4 MoonI80 backend sits below esp_lcd on LCD_CAM). Its ISR is small enough to fit the classic's ~70 KB IRAM (hpwit's does), and — the key move — it registers the interrupt **without** `ESP_INTR_FLAG_IRAM` (his source comment: removed "to avoid Cache Disabled but Cached Memory Region Accessed") so the refill ISR is legally permitted to read the PSRAM framebuffer. The trade it accepts vs. our whole-frame path: it **gives up the whole-frame path's WiFi-underrun immunity** (a WiFi burst that starves the refill ISR can glitch a frame), which the ring mitigates with a tunable buffer-count cushion (`nbDmaBuffer`, hpwit's default 6). For a ≤2K-light WiFi-busy install the whole-frame i80 is still the better choice; the ring is for the >2K-light case classic cannot otherwise reach. + +**Reference (study, don't copy; write fresh against our architecture):** the line-by-line source read is in [the LED-driver analysis](../../design/leddriver.md). The S3/P4 MoonI80 ring is the closest in-tree prior art for the ring mechanics (linear self-terminating chain, per-drain refill, drain-count termination), but its refill is a task and its buffers are internal-only *because the LCD_CAM GDMA can't sustain a PSRAM read at the shift clock*; the classic I2S ring is the inverse (PSRAM framebuffer legal, ISR refill mandatory), so it borrows the *shape* but not the constraints. Do the S3/P4 **ISR-refill + `MM_HOT`** work first (it proves the ISR-refill pattern in-tree on the friendlier unified-DIRAM chips); the classic raw-I2S ring is the next tier up, reusing that pattern where IRAM is genuinely tight. + +**Why MoonI80 cannot serve the classic, and what this driver inherits (2026-09-06).** `MoonI80` +is written against **LCD_CAM**: it drives the GDMA link list and the LCD registers directly +(`gdma_link_*`, `lcd_ll_*`) to bypass `esp_lcd`'s per-transaction peripheral reset. The +classic ESP32 has no LCD_CAM at all; its i80 is the **I2S** block in LCD mode, a different +peripheral with its own register file (`i2s_ll_*`) and its own DMA, so none of MoonI80's code +applies and `MoonLedDriver::lanesAvailable()` reports `platform::lcdLanes`, which is 0 there. The +picker hides the backend rather than gating it, which is why the classic has exactly one parallel +route today: `esp_lcd`'s I2S backend, whole-frame, internal-RAM-only, capped near 2048 lights. +This driver is the second route, and it stands in the same relation to `esp_lcd` on I2S as MoonI80 +does on LCD_CAM: same shape, no shared code. + +Two things it inherits from the 2026-09-06 i80 work, both worth keeping. It should claim **I2S +instance 1** and leave 0 for audio, for the reason [platform_esp32_i80.cpp](../../../src/platform/esp32/platform_esp32_i80.cpp) records, +and owning the peripheral directly +it can simply ASK for instance 1 rather than steering `esp_lcd` by parking instance 0, which is the +workaround the current backend needs. And it inherits the package-aware pin refusal: a pin the +package lacks wedges the flash cache silently, which cost a full day of bisection on the +ESP32-PICO-V3-02. + +### Multicore step 2b: a ping-pong second output buffer + +Kind: Feature · Trigger: workload-gated · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080455) + +A second output buffer, whose recoverable wait [`renderWait`](../../reference/performance.md#multicore-the-whole-output-stage-on-core-1-multicore-step-2) measures, costs one full frame buffer (~48 KB at 16K lights). A **light effect on many lights** (output-bound — a solid color or slow gradient across 16K lights) shows 6–11 ms of wait, which 2b recovers whole. So it's a real but narrow win: **gated on the product owner naming that light-effect/many-lights workload as worth the 48 KB**, not on a generic fps gain. + +### Brightness belongs on a fixture's DIMMER channel, not only in the color values (WANTED) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080535) + +`Correction::apply()` holds a profile's `Dimmer` channel wide open at 255 and puts brightness into +the R/G/B/W values through `briLut`. That is correct output and it is the only rule that serves +every fixture (an addressable strip has no dimmer channel), but it is not how a lighting console +drives a fixture that HAS one, and it costs real quality: + +- **Resolution.** At 10% brightness the colors are driven at 0-25, about 25 usable levels instead + of 255. A slow fade to black steps visibly; the dimmer channel exists so colors keep full 8-bit + range while intensity varies. +- **Dimming quality.** A moving head's dimmer is often 16-bit or curve-corrected for the LED's + response. Linear scaling of 8-bit color values is the crudest dimming there is, and it is worst + at the low end where the eye is most sensitive. +- **Matching.** A PAR with a real dimmer and a strip without one, both at 20%, do not read as the + same brightness: one dims in the LED driver, the other in integer arithmetic. + +**The rule to implement:** when a profile declares a `Dimmer` role, route the driver's brightness to +that channel and drive the colors at full saturation; keep today's behavior (scale the colors) +only where the fixture has no dimmer. The per-light color values still carry the effect's own +shading, so an effect that paints one light dark stays dark. Watch the one case where fixture and +light are not 1:1: several light cells behind ONE master dimmer cannot express per-cell brightness +through it, so those keep the color-scaling path. + +Found on the bench 2026-08-28 wiring the first moving head. Note the dimmer was not written AT ALL +before that day (the shipped `IRGB` profile could never light a fixture); writing it at 255 is the +fix that made a fixture light, not the finished design. + +### Pan/tilt travel is hardcoded, and positioning is 8-bit (WANTED) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080613) + +Two fidelity gaps in how an aim becomes a real beam, both found on the bench 2026-08-29 reviewing +the preview's beam cones. + +**Travel is assumed, not declared.** `EffectBase` and the preview both hardcode *540 degrees of +pan, 180 of tilt*, which is the common mid-size convention and happens to match the bench fixture. +Real heads vary: 630 pan on larger bodies, 360 or 530 on compact ones, 270 tilt on beam fixtures. +Nothing declares which fixture is plugged in, so the preview draws every rig with the bench head's +travel and silently lies about where a beam points. Zero point varies too (some fixtures put +mechanical zero at DMX 0 rather than centering at 128), and the axis direction is a setting on the +fixture's own display (`rPAN` / `rTIL` on the bench head), so even one fixture has no fixed mapping. + +**The DMX byte is already the right abstraction, and it is why nothing can over-rotate.** An effect +never emits an angle: `MovingHeadEffect::axis()` maps its sweep onto a 0..255 byte and clamps, so +0 and 255 *are* the mechanical stops whatever they are in degrees, and the head cannot be asked +past its travel. A sweep that reaches a stop flattens there (the beam parks) rather than wrapping, +which is what the fixture itself does. Degrees exist only for DRAWING. + +So the fix is scoped to the preview, and the open question is where travel is declared: + +- **`PreviewDriver` controls** (`panTravel`, `tiltTravel` in degrees, sent in the aim message) — + small, live-reconfigurable like every other setting, and honest that travel is a property of the + fixture someone plugged in. The leanest thing that stops the lie. +- **The fixture profile**: correct once two different heads run at once, but a profile is a + *channel-role* layout today, and carrying physical travel widens what a profile means. Belongs + with the [fixture model](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082264), not before it. + +**Positioning is 8-bit while the fixture offers 16.** The bench head has a fine channel for each +axis ([light fixtures reference](../../reference/hardware/light-fixtures.md)); both repeat the coarse byte, so pan resolves +to 540/256 = about 2.1 degrees per step. Across a room that is a visible jump on a slow sweep, and +it is the bigger fidelity win of the two. Needs a 16-bit path from the effect's sweep through +`FixtureChannels` to the profile's fine-channel roles, so it is the larger job. + +### RS-485 and DMX-512 wired output: the physical-DMX driver + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080691) + +MoonLight already speaks DMX **over the network** (Art-Net / sACN via `NetworkReceiveEffect`). The missing half is **wired DMX-512 out**: driving DMX fixtures (moving heads, par cans, wired pixel controllers) directly over an RS-485 differential pair, which is what the RS-485 hardware on carrier boards like the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) is *for*. DMX-512 is a 250 kbps async serial frame (a break + mark-after-break + 513 bytes: start code + 512 channels) shipped over RS-485 — the textbook fixture-control transport. A DMX driver would map the light buffer (or a fixture/attribute model — see the [Fixture model — moving heads, beams](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082264) item below) to DMX channels and clock the frame out a UART in RS-485 mode. + +**What it needs that we don't have yet:** +- **A `platform::` UART-RS485 seam.** The ESP32 UART has a hardware RS-485 half-duplex mode (`uart_set_mode(UART_MODE_RS485_HALF_DUPLEX)`) that auto-drives the transceiver's **DE/RE** (driver-enable / receiver-enable) line — the thing our current pin handling has no concept of (we drive pins as plain GPIO). A DMX driver is where DE/RE control first earns its place, and only for a **bidirectional** channel: firmware DE/RE toggling is what lets one channel switch Tx↔Rx without a hardware switch. A **fixed-transmit** channel needs none — its transceiver is hard-wired to drive. On the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) that split is physical, with a mechanical slide switch on the one bidirectional channel. Firmware DE/RE control is what a board would need to make a channel bidirectional *without* such a switch. +- **The DMX frame timing** — the break/MAB is generated by a baud-rate switch or a GPIO toggle around the UART frame; standard, host-testable as an encoder. +- **A fixture/channel-mapping model** — trivial for a dumb pixel-per-channel strip, real work for typed fixtures (pairs with the moving-head fixture-model item; a wired-DMX driver and a network-DMX(Art-Net) input would share that fixture model). + +**The channel-mapping half is unblocked.** The per-light encode path handles an arbitrary channel count ([lessons.md](../../contributing/lessons.md)). A DMX universe is exactly that model: a light with `channelsPerLight = ` (16-ch moving head, 7-ch par, …), and the buffer's bytes ARE the DMX channel values. So a DMX driver's "map the buffer to channels" step is now the trivial part — it ships the light buffer's bytes straight into the 512-channel frame. What remains genuinely new is the **transport** (the RS-485 UART seam + break/MAB timing) and the **typed-fixture model** (naming which channel is Pan vs Dimmer — the moving-head fixture item), not the encode. + +**Can a board drive XLR fixtures directly? Yes, with an RS-485 transceiver — that's the one required part.** DMX-512 is RS-485: a *differential* pair (D+/D−, ±2–6 V), not the 3.3 V single-ended UART the MCU emits, so an MCU TX pin can NOT wire straight to XLR. A transceiver chip (MAX485 / SN75176 / THVD-class, ~$0.50) sits between the UART and the connector and drives the differential pair; the DE/RE line (the UART-RS485 seam above) flips it Tx↔Rx. **3-pin XLR** carries it: pin 1 = ground, pin 2 = D−, pin 3 = D+. With a transceiver present, daisy-chaining ~10 moving heads (10 × 16 ch = 160, inside one 512-channel universe) over standard DMX in→out is well within the RS-485 limits (32 unit loads / 1200 m); the last fixture wants a 120 Ω terminator (a fixture/cable concern, not the MCU). So whether a catalog board can drive XLR *directly* hinges on one schematic question: does it carry an RS-485 transceiver + XLR/terminal (then yes, direct, as [the MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) does), or only the WS2812 level-shifted outputs (then a ~$0.50 breakout is needed). + +Sequencing: it's a **driver** (`src/light/drivers/`) + a platform UART-RS485 seam + a fixture model shared with the Art-Net path — the buffer→channel encode is already done. Plan when a DMX fixture is actually on the bench and a catalog board's `supported`/`planned` list points at wired DMX. The [PinsModule pin-assignment work](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287076292) covers the RS485/DMX TX/RX/DE slot; this is the driver that consumes it. + +### One encode, two readers: HLS and RTSP sharing the encoder (2026-09-22) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080774) + +The claim ([watching the output](../../reference/watching-the-output.md)) refuses the second of HLS and RTSP. The better end state is the one [the RTSP plan](https://github.com/MoonModules/MoonLight/issues/194) describes and the P4 already half-implements: **one encode, two readers**, each taking what it needs from the same encoded frame, which is cheaper than either driver running alone twice over. + +What makes it a design task rather than a patch: the two drivers must **agree** on geometry, fps and bitrate, and nothing makes them agree now. HLS derives its bitrate from the grid and picks an encoder by name; RTSP takes the frames raw and names no encoder. The open questions are whose configuration wins when both are enabled, what happens when one changes `targetFps` mid-stream, and whether a reader that stops should relax the shared settings. A refcount alone answers none of those, which is why the claim ships first. + +Build trigger: someone wanting both streams from one device at once. Until then the claim is the honest behavior. + +### Spout and Syphon as video outputs, only if proven + +Kind: Feature · Platform: desktop · Trigger: only if proven · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080849) + +One user runs OBS → Spout → his own VLAN-tagged card driver; he asked whether MoonLight could be a Spout source. Spout (Windows, DirectX/OpenGL) and Syphon (macOS, Metal/OpenGL) share a GPU texture zero-copy, so they are lower latency and bit-exact, but they are **same-machine only**, are **two** platform implementations, and leave **Linux and the Pi with nothing**. At LED-wall pixel counts (a 256x256 wall is 65K pixels) the latency difference is far below one frame of the render loop, so it does not decide the choice; coverage does. A Spout user is also reachable through [NDI](../../reference/watching-the-output.md) in one hop, since OBS, Resolume and TouchDesigner all speak both. + +Also note MoonLight renders into a CPU buffer, so a Spout/Syphon path would upload to the GPU purely to hand off, spending the zero-copy advantage it was chosen for. + +### Chunked transfer (Step 4): the 16K lever + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287080935) + +- **Chunked transfer (Step 4) — the 16K lever, and now the ONE mechanism behind three separate ceilings.** Split a frame into transactions the DMA can actually swallow, feeding them back-to-back. It was scoped as a Parlio fix; it is really a **core-path** fix, and the shift-register expander is only its third beneficiary. + + **The three ceilings it lifts, all unshifted-first:** + 1. **P4 Parlio: ~4,096 lights**, a contiguous-internal-block limit rather than the 65,535-byte cap ([performance.md § Multi-pin](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid)). So chunking is **the only path to the 16×1024 = 16,384 lights the 16-lane widening promised.** This is the headline win and has nothing to do with shift registers. + 2. **Classic ESP32: 2,048 lights.** Its I2S DMA cannot reach PSRAM at all, so the frame must fit internal RAM. The **staged** form below lifts it: the DMA only ever reads small INTERNAL chunks that the CPU fills from a PSRAM frame. + 3. **The 74HCT595 expander.** `i80` holds the whole frame and caps near 96 lights per strand; `MoonI80` streams it ([LED drivers](../../reference/hardware/led-drivers.md#wiring-a-74hct595-pin-expander)). An add-on, and explicitly **not** the reason to build this. + + **Two distinct limits, one idea — keep them straight.** For **Parlio** the constraint is *transaction size* (contiguous block + 65,535 bytes), so chunking means smaller transactions. For **i80** there is no single-shot cap at all (it chains DMA descriptors) — there the constraint is *where the DMA reads from*, so the win comes from **staging**: keep the frame in PSRAM, but have the CPU copy it a chunk at a time into small internal-RAM buffers that the DMA reads. Same mechanism, different reason, and conflating the two is what muddled the shift-register investigation. + + **Prior art, and why staging is the shape.** hpwit's drivers never DMA from PSRAM: the pixel data may live there, but an EOF ISR transposes it into small **internal bounce buffers** that the DMA clocks out (`owner_check = false`, a fixed ring, built once). Espressif do the same thing under a different name — the RGB-LCD driver's **bounce buffers**. The one place hpwit *does* DMA straight from PSRAM is the **P4 Parlio** path, and he chunks it (≤64 KB via IDF's parlio driver) — which is consistent with our own finding that the P4 handles a PSRAM whole-frame shift transfer fine while the S3 does not. The staged form gets hpwit's underrun immunity **without leaving `esp_lcd`** (still IDF-maintained, still one code path across classic/S3/P4, no raw-register driver) and **without putting the CPU back on a per-LED ISR deadline** — a bulk sequential PSRAM→internal copy per chunk is what PSRAM is good at, unlike a real-time streaming read. + + **The two mechanisms do DIFFERENT jobs, and the 100 fps target needs both.** The WS2812 wire time is a physical constant — 30 µs per light, serial down each strand — so the *only* lever on frame rate is **lights per strand**, never the chip: + + | strands × lights | total | wire time | fps ceiling | + |---|---|---|---| + | 16 × 1024 (direct, 16 GPIOs) | 16,384 | 30.7 ms | **33 fps** | + | **48 × 256 (6 pins × '595)** | **12,288** | **7.7 ms** | **130 fps** ✅ *(hpwit + PO proved this in practice — StarLight)* | + | **64 × 256 (8 pins × '595)** | **16,384** | **7.7 ms** | **130 fps** ✅ | + + So: **chunking/staging buys LIGHTS (memory); the expander buys FRAMES PER SECOND (short strands).** Neither substitutes for the other, and 12–16K at 100+ fps needs both — the expander's own ~145 KB frame is exactly the one that does not work from PSRAM today, so it *depends* on the staging fix. (A third piece is needed too: at 130 fps the CPU encode must finish in <7.7 ms, and it measures ~24 ms at 16K — that is what the multicore render↔encode split exists for.) + + **The classic ESP32 is a target, not a write-off.** It is routinely dismissed for work like this, and the dismissal is wrong: hpwit and the PO have *run* 48 strands × 256 at ~100 fps on classic silicon (StarLight), with the same '595 expander, while WiFi was up. 240 MHz, two cores, and a 30 µs/light budget is a lot of headroom. The thing that would stop us is **our own encode cost**, which is software we control, not a property of the chip — and the ~24 ms/16K figure quoted in the multicore analysis is (a) measured on a **P4**, not a classic, and (b) **pre-dates the SWAR transpose** that shipped since. Do not carry that number into a classic-ESP32 feasibility argument; measure the real one on the real chip. What the classic genuinely needs is the **staged** form of chunking (its I2S DMA cannot reach PSRAM at all), which this item provides. + + **Build and prove chunking on the unshifted path first** (Parlio 4,096 → 16,384 is the measurable win, on proven code), then let shift mode inherit it — that is a sequencing rule about *where to de-risk the mechanism*, **not** a claim that the expander is optional. It is not: it is the only route to 100 fps at this scale without spending 48+ GPIOs. Correct WS2812 inter-chunk timing is the one hard constraint: the lines must idle LOW for < 300 µs between chunks or the strand latches mid-frame. The driver already rejects an over-limit frame with a loud status. Measured detail: [performance.md § Multi-pin](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid). + +### 16 bits at the output: what remains + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081005) + +- **16 bits at the output: what remains (2026-10-05).** Fine roles and the 16-bit curve table shipped, as [FixtureProfiles](../../moonmodules/light/supporting.md#fixtureprofiles) describes; the 16-bit Layer stays stashed, since it measured +56% frame cost. + - Verify on a WS2816 strip that the dark end of a fade is smooth, with the built-in `GRB 16-bit`. + - TM1814 and TM1914: an inverted line and a settings preamble before the pixels (8 bytes of current, 6 of mode, each followed by its inverse), as NeoPixelBus sends them; a driver protocol, not a profile. + - UCS7604: its RGB or RGBW mode is set by configuration, so it needs its datasheet before a profile. + - WS2916A-RGBW (Worldsemi): 16-bit GRBW with a 5-bit current gain per channel. Worldsemi's ESP32 example sends a 32-bit gain header once per frame, then 64 bits per pixel, MSB first. + - The header holds the G, R, B and W gains, 5 bits each, with the remaining 12 bits set to 1. + - A bit is 1.3 µs (T0H 300 ns, T1H 700 ns), and the reset is longer than 280 µs, past WS2812's. + - Needs a per-frame header in the LED drivers, the gains as controls, and a `GRBW 16-bit` profile; check our reset against 280 µs. + - WS2915 (Worldsemi): five channels, RGB plus two independent whites, 5-bit gain and 16-bit data, 5 to 48 V. Its frame layout is unpublished; the WS2916A's header with a fifth gain is the likely shape, to confirm from the datasheet. + - WS2916B: RGB at 16 bits (48-bit pixels), 3.3 to 5.5 V. + - Sources: [Worldsemi product list](https://www.world-semi.com/) and [Worldsemi's examples](https://github.com/WorldsemiLED/worldsemi-led-examples); the 4 kHz PWM reported for the WS2915 is unconfirmed. + - Temporal dithering on an 8-bit wire, fed by the same 16-bit table. + - Pan and tilt beyond 8 bits, once a layer slot carries motion wider than 8 bits; today their fine byte repeats the coarse one. diff --git a/docs/work/backlog_mirror/effects.md b/docs/work/backlog_mirror/effects.md new file mode 100644 index 000000000..b6d637ac8 --- /dev/null +++ b/docs/work/backlog_mirror/effects.md @@ -0,0 +1,194 @@ +# Backlog: Effects + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## AuroraEffect + +### Aurora hitches on the desktop and not on the S3 (2026-09-04) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081062) + +A periodic hitch in the desktop preview that the same firmware at 64x64 on an S3 does not show. Ruled out by measurement: integer overflow, the drift sawtooth wrap (7 of 255 worst case), field speed (the desktop moves 15x less per frame), the contrast window's easing, and the preview transport at a large grid. The one difference left is about 385 fps against 25, so the next step is a desktop frame-rate cap: if the hitch tracks render rate, something is per-frame where it should be per-unit-time. A weighted layer crossover (cubed weights, 2.7x less frame-to-frame change for 3.4% more time per pixel) was built and reverted; it is worth another look once the hitch is understood. + +## BlurzEffect + +### BlurzEffect: a compounding blur has no rate carry + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081157) + +`Layer::fadeToBlackBy` takes a rate per reference frame and the Layer scales it once, the pattern every fade uses ([architecture § Where the machinery lives](../../explanation/architecture/moonlight.md)). `draw::blur` does not fit it: blur COMPOUNDS, so applying it twice at half strength is not one blur at full strength, and the fractional carry that makes a fade frame-rate independent produces the wrong result. BlurzEffect is the effect this bites. The open question is what the right construct is — a per-frame blur budget, a single blur at an accumulated strength, or leaving blur explicitly frame-gated and documenting it as not-a-rate. + +## BouncingBallsEffect + +### BouncingBalls onto the particle kernel: a rework, not a swap + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081225) + +The effect computes each ball's height from the closed-form projectile equation in float, exact and frame-rate independent, so a plain Euler swap is a downgrade: against the closed form it drifts 8 cm on a 1 m trajectory after one second, more than a pixel on a 16-row panel. What the kernel adds is what the closed form cannot: balls that collide with each other and move sideways instead of each in its own vertical lane. The effect keeps up to 16 balls per column in per-column arrays, so the rework is one shared pool plus a decision about how many balls a wide fixture gets, judged on the panel against the original and landing only if at least as beautiful. + +## DemoReelEffect + +### DemoReel: a 3D test for the hosted D1/D2 extrude + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081326) + +DemoReel extrudes a hosted D1/D2 effect after its tick; its test hosts on a 2D grid only. Pin it with a DemoReel test that hosts a D1 and a D2 effect on a 3D grid and checks the spread. + +## FixedPointEffect + +### FixedPoint fades outside the Layer's aggregation (2026-09-07) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081402) + +`FixedPointEffect` calls `draw::fade` directly where every other fading effect calls +`layer()->fadeToBlackBy`, so it skips what the Layer adds: the per-frame aggregation (N effects on +one layer cost one buffer pass, gentlest rate wins), the framerate scaling, the sub-unit carry, and +the buffer-generation bump. Invisible until a second effect shares the layer. + +The swap is one line and was tried: it makes the effect **3.1x brighter at high framerate than at +low**, against the 1.35x band `unit_Effects_framerate` enforces. The two calls mean different +things. `draw::fade(cv, n)` applies n every frame; `fadeToBlackBy(n)` is a RATE per reference frame +that Layer scales by elapsed time. The `fade` control (default 70, range 0..255) was tuned against +the first meaning, so moving it needs the default re-tuned and the trail looked at on a device, not +a silent swap. + +Worth doing, as its own change: it is the last effect outside the shared fade path, and while it +stays outside, "every fade goes through the Layer" is not true. + +## FluidEffect + +### Speed up the fluid solver: 4 fps at 128x128, and it is not the divide (2026-09-05) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081468) + +Measured on an S31 (RISC-V, 320 MHz, octal PSRAM at 200 MHz), `iterations` 5, depth 1: + +| grid | Fluid tick | fps | cycles per cell-update | +|---|---|---|---| +| 32x32 | 8.5 ms | 109 | 151 | +| 64x64 | 38 ms | 24 | 159 | +| 128x128 | 204 ms | 4 | 205 | + +A cell-update is four loads, three adds, a multiply and a store: under 20 cycles of arithmetic. It +costs **151 even at 32x32**, where the whole working set is small enough to cache, so the loop +itself is roughly 8x more expensive than the work it does. Memory adds a further 35% by 128x128 but +is not the wall: at 4 fps the solver moves ~25 MB/s, about 5% of what this PSRAM delivers. + +**A wrong turn worth recording.** The first diagnosis blamed the 64-bit divide in `relax()`, on the +reasoning that Xtensa has no integer divide instruction. It was implemented (a power-of-two shift +dispatched once per call, bit-exact over 8M values) and measured on the board: **no change, 326 ms +before and after**, so it was reverted. Two errors: the S31 is RISC-V rather than Xtensa, and at +151 cycles per update the divide was never the dominant term. A desktop measurement could not have +caught either, since arm64 divides in hardware; only the board settles it. + +**Allocation placement is worth 1.6x, and nobody chose it.** Same firmware, same grid, same +controls: **326 ms after a fresh boot, 204 ms after resizing the grid to 32 and back to 128**, +reproducible across reboots. The solver's six buffers are ~638 KB and land in PSRAM either way, but +where they land at boot is slower than where they land once the heap has moved. Whatever is done +about speed, this says a boot-time allocation can be paying a large penalty invisibly, and it is +worth understanding before optimizing the loop around it. + +Ordered by expected return: + +1. **Cut the 64-bit arithmetic in the inner loop.** Every cell computes an `int64` shift, an + `int64` multiply and an `int64` divide on a 32-bit core, where each is several instructions and + a register pair. This is the most likely source of the 151 cycles. A 32-bit formulation, or a + narrower intermediate with a proven bound, is the first thing to measure. Precedent: the SWAR + work found the 32-bit pair form bit-identical and 41% smaller. +2. **Hoist `idx()`.** The loop addresses five neighbors per cell through `idx(x, y)`, each a + multiply-add. Walking row pointers instead is the standard fix and removes most of the address + arithmetic. +3. **Understand the allocation-placement effect above**, since it is worth more than most loop + tuning and costs nothing to trigger deliberately once understood. +4. **Solve the pressure at half resolution.** Pressure is smooth, so a half-scale solve with a + bilinear upsample of its gradient costs a quarter of the cells. Same trade `fieldScale` already + makes for noise fields, measured 3.0x there. Changes the picture slightly, unlike 1 to 3. +5. **Fewer iterations, documented per target.** Linear in cost: 5 to 2 is 2.5x, and the picture + gets springier. The card should say what a target can afford rather than leaving a user to find + 4 fps. +6. **Question whether the full solver belongs on this class of board at all.** See the ColorTrails + entry below: a separable noise advection gets a flowing, swirling picture for two passes over + the grid and no solve. The fluid's own header already calls it a desktop and P4 effect. + +**Why `fluid.mle` runs at 13 fps while the compiled effect runs at 3.** They are not the same +algorithm: the script is a curl flow, not a solver +([generative effects](../../explanation/generative-effects.md)). Per frame at 128x128 the script visits ~16k cells +and divides nowhere; the compiled solver visits ~429k, of which 318k carry the 64-bit divide. That +is 27x the work, and the measured gap is only 4.3x because the interpreter gives most of it back in +dispatch overhead. So a COMPILED curl effect would beat both. What curl cannot do is what a solver +does: no pressure, no interaction between jets, and no vortex forming out of the flow's own +history. Whether that is worth 27x is a question for the product owner's eyes. + +**Measure on hardware, not on the desktop**: this is an in-order-core property and the desktop +divides in hardware, which is exactly how the wrong diagnosis above survived a desktop check. +Record before and after in performance.md per target. + +## NetworkReceiveEffect + +### E1.31 multicast receive (IGMP join) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081542) + +NetworkReceiveEffect accepts E1.31 via unicast only — the same scope MoonLight ships. Multicast senders address the per-universe group `239.255.{universe_hi}.{universe_lo}`, which a receiver must join via IGMP. **The platform half of this now exists**: `UdpSocket::joinMulticast()` shipped with the WLED audio-sync work (2026-08-29) and is used in anger there, on both desktop and ESP32. What is left for E1.31 is the join-per-accepted-universe bookkeeping, not the socket support. Add when a multicast-only sender actually shows up on a bench; until then the spec documents "point sACN senders at the device's IP". + +## Across modules + +### The catalog sweep: every natural-motion effect onto the power functions + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081620) + +[Migrating an effect](../../moonmodules/light/power-functions.md#migrating-an-effect) asks for it; [the inventory](../../design/effects-power-functions-inventory.md) records per effect what it uses today and which kernels it could run on, and a row is deleted when its effect is rewritten. Batches go by kernel: the noise effects, the polar and oscillation effects, then Echo, Lissajous and PaintBrush onto `advect`, `decay` and `lineAA`, each compared on the panel against how it ran and re-baselined with the reason. An effect a showcase supersedes, as Trails supersedes Lissajous, is deleted rather than kept beside it. + +### Moving-head effects from MoonLight, including two of troyhack's (2026-09-04) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081692) + +MoonLight has several moving-head effects that have no equivalent here, two of them troyhack's. +Migrate them all, on the power functions per the standing mandate rather than traced across. + +### Sprite follow-ups + +Kind: Feature · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081753) + +Deliberately deferred when sprites landed: P4 PPA acceleration behind the same `draw::sprite` +signature (the 2D-DMA blitter the WLED-MM-P4 world uses via LovyanGFX; ours would sit in the +platform layer, no vendored GFX library), Porter-Duff alpha when a real consumer arrives, and +MoonLive sprite data (needs the stage-3 builtin table + arrays). + +### A real 2D/3D PacMan + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081828) + +The migrated 1D PacMan and 1D Ant effects were removed — a chase rendered on a single strip reads as a blob of moving dots, not a game, so they weren't worth keeping. A proper **2D (or 3D) PacMan** — an actual maze, Pac-Man and ghosts navigating it, power dots, the blue-ghost flee state — would be a genuinely fun signature effect. Build it fresh as a grid game (not a strip port); the WLED/MoonLight 1D versions are reference for the state machine only, not the rendering. + +### Add real z-axis variation to 2D effects + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081899) + +Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. + +### worley cellular noise, when an effect needs it + +Kind: Feature · Trigger: an effect needs it · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287081966) + +- **`worley` cellular noise, when an effect needs it** (deferred 2026-08-07, PO). The one power function the plan names that stayed unbuilt on purpose. Worley (cellular / Voronoi) noise measures the distance to the nearest of a set of scattered feature points, which is what produces the look nothing else does — cracked mud, scales, stained glass, a caustic. The fields we have cannot fake it: `fbm` and `warp` are smooth by construction, so their creases never form cells. + + **Why it waits.** An industry-standardness audit (Fable, 2026-08-07) put it explicitly on the "do NOT add yet" list: a practitioner does not consider it table stakes, and every other field function in the library earned its place by having a caller. Building it now would make it the only entry whose justification is "the canon has one". The trigger to build it is an effect that wants cells — at which point it lands with that effect, the way `hashInt` landed with Dissolve and `sampleWrap` with Echo. + + **What it costs when it comes:** the standard cheap form is a 3×3 neighborhood scan around the sample's cell, hashing each neighbor's feature point and keeping the nearest distance — nine `hashInt` calls and nine distance tests per pixel, so meaningfully dearer than one noise sample. `dist16` and `hashInt` already exist, so the function itself is short; the cost question is what it does to a per-pixel budget on a large fixture. + +### 16-bit noise: warp16 / turbulence16 + +Kind: Feature · Trigger: an effect wants a large smooth field · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082034) + +- **16-bit noise: `warp16` / `turbulence16`** (2026-08-08), unbuilt, to match the 8-bit compositions. The trigger is an effect that wants a large smooth field; it lands with that effect and its measurements, the way the 8-bit compositions landed with theirs. + +### Clear the grid on an effect's FIRST frame + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082101) + +- **Clear the grid on an effect's FIRST frame** (2026-08-08). Fourteen effects still show the previous effect's picture on the frame right after a switch: BouncingBalls, Fireworks, FreqMatrix, FreqSaws, GEQ, GEQ3D, GameOfLife, Lissajous, NoiseMeter, PaintBrush, Random and three more. Most predate the power-function branch. + + **What the user sees.** Switching to one of these leaves the old frame underneath for a moment — an audio effect with no signal yet, or a simulation still seeding, paints nothing and inherits whatever was there. Owning the background is each effect's job ([Layer.h](../../../src/light/layers/Layer.h)). + + **Why it waits.** It is fourteen effects' worth of change across audio-reactive and simulation families, each needing its own judgement about whether to clear, fade, or seed differently — not a mechanical sweep. `unit_Effects_gridsweep.cpp` already measures it (`afterFirst`) and asserts only the settled frame, so the number is visible without blocking. diff --git a/docs/work/backlog_mirror/layouts.md b/docs/work/backlog_mirror/layouts.md new file mode 100644 index 000000000..d69301430 --- /dev/null +++ b/docs/work/backlog_mirror/layouts.md @@ -0,0 +1,25 @@ +# Backlog: Layouts + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## Across modules + +### Render larger than the fixture and pool down onto a few lights (Hue, PAR) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082192) + +A fire or plasma effect on a virtual 32x32 grid driven onto ten Hue bulbs or a handful of DMX PARs, each light showing the mean of the non-black pixels in its region, so the fixture reads as one coherent wash instead of ten stray samples. It is a Layer feature, not a modifier: a modifier's live pass gathers one source coordinate per destination, and the logical box never exceeds the physical light count, so there is nothing to pool from. Two parts: a render box allowed to exceed the physical layout, and an N:1 averaging map beside the four mapping types, with regions cut per axis from the physical dimensions and no user control. The pooling math and the region split are pure and host-testable. The design and the architecture findings: [the downscale study](../../design/virtual-layer-downscale.md). + +### Fixture model: moving heads and beams + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082264) + +Today a "light" is a point at a static coordinate with a color. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus color, beam-width, etc. — per-light **vector** state, not just color, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + color-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench. + +**Sub-item — per-emitter targets, and fixing the Yellow/UV RGB-synthesis placeholder.** `Correction::apply()` today synthesizes every non-RGB emitter (White, WarmWhite, Yellow, UV) from RGB via the one `whiteMode` gate. That conflates two different physics: **White/WarmWhite are broadband illumination** with a real achromatic basis (`min(R,G,B)` is a sound approximation — warm vs cold differ only in phosphor CCT a byte value can't express), but **Yellow/Amber (~590 nm) and UV (~400 nm) are saturated/out-of-gamut emitters with no honest RGB pre-image.** Yellow's `min(R,G)` stand-in reads greener than a true amber die AND fires on far too much (any red+green content — yellows, whites, skin tones — muddies the fixture); UV's blue-excess is an eyeball hack. Amber is a *real, common, targetable* emitter (RGBA / RGBAW PARs ship a dedicated ~590 nm die), so the right model is an **effect that targets amber DIRECTLY** (a typed attribute), not RGB-and-synthesize. The fixture model is where that lands. **Decision to make when built:** whether Yellow/UV should default to **off** (0, like `whiteMode::None`) until an effect drives them — more honest than always firing a wrong approximation — vs. keeping the "light it up to eyeball the wiring" placeholder. **Practical test to run then:** on a real RGBA/RGBAW+UV PAR, compare the synthesized amber against a directly-driven amber die (does `min(R,G)` look acceptably yellow, or muddy?), and confirm the White subtraction under `Accurate` reads correct with all emitters present. Rationale + the current formulas live in `Correction.h`'s emitter-field comment. + +### Mixing light types in one Layouts + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082337) + +Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). diff --git a/docs/work/backlog_mirror/modifiers.md b/docs/work/backlog_mirror/modifiers.md new file mode 100644 index 000000000..848b98b72 --- /dev/null +++ b/docs/work/backlog_mirror/modifiers.md @@ -0,0 +1,24 @@ +# Backlog: Modifiers + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## MultiplyModifier + +### MultiplyModifier mapping-LUT memory at large grids + +Kind: Investigation · Platform: classic · Trigger: a classic board is connected · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082394) + +The modifier reduces compute and costs mapping-LUT memory, measured per grid in [performance.md](../../reference/performance.md#multiplymodifier-compute-down-memory-up-noise-effect). Under the physical→logical fold build each physical light contributes ≤1 destination, so the destinations array is bounded by the real light count regardless of chain depth, so there is no build-time fan-out. + +**This is NOT a no-PSRAM blocker:** 16K Noise + Multiply has run on a classic ESP32 (no PSRAM, 320KB internal) before at **10–20 FPS** (WiFi vs Ethernet), sending frames out over **ArtNet to a display, not physical LED drivers**. It works there because classic's `nrOfLightsType` is `uint16_t` (half the LUT size) and the modifier shrinks the logical render grid. So the action is **re-verify the working classic setup when a classic board is connected** (find the config, its grid, mirror and ArtNet target, that reproduces the historical 10–20 FPS), not "fix an impossibility." Worth investigating only if that re-verification shows the LUT memory has regressed since: the destinations array is the obvious lever (it stores a `nrOfLightsType` per physical destination; a 2× kaleidoscope is 1:1 in *count* so the LUT need not store fan-out > the physical count: confirm it isn't over-allocating to `maxMultiplier()` when the effective fan-out is 1). Capture the classic numbers into performance.md's multi-board table first. + +## Across modules + +### Retro looks on a picture: scanlines, chroma bleed, tracking glitches (robwomp, 2026-10-21) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082480) + +Asked for on Discord by robwomp, who loves the glitchy analog and digital look of the 90s and 00s and has Rockchip boards with HDMI and CSI inputs. +A captured picture becomes worth watching on lights once it can be degraded on purpose: scanlines, a color channel bleeding sideways, a frame that tears and rolls like a VHS tracking error. +Each is a modifier on what the Picture effect draws, so it works on any source, a capture, a GIF or a browser tab. +Ask robwomp which looks matter most before building, and have them tested on robwomp's capture hardware. diff --git a/docs/work/backlog_mirror/moonlive.md b/docs/work/backlog_mirror/moonlive.md new file mode 100644 index 000000000..32893d00c --- /dev/null +++ b/docs/work/backlog_mirror/moonlive.md @@ -0,0 +1,267 @@ +# Backlog: MoonLive + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## Across modules + +### The next language steps, in order + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082542) + +The [language roadmap](../../design/moonlive.md) orders them by what removing each buys: [a bigger arena and more members](../../design/moonlive.md), [what remains of the predefined structs](../../design/moonlive.md), [function arguments and return values](../../design/moonlive.md), [user structs and arrays of them](../../design/moonlive.md), [what the StadBeest legs and eyes hit](../../design/moonlive.md) and [a one-to-one port of hpwit's scripts](../../design/moonlive.md). A step lands when it deletes a row from the roadmap's compromise table, and the predecessor's livescripts corpus compiling unchanged is the outside measure. + +### The code cap, per platform (2026-10-16) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082596) + +Measured across all 53 shipped scripts with `moondeck/moonlive/disasm.py` (asked on Discord by hpwit, author of ESPLiveScript): compiled bytes per source byte run Thumb-2 1.4x, Xtensa 1.7x, arm64 2.2x, RISC-V 2.8x. The largest script, `stadbeest-sauron.mle` (135 lines, 5.7 KB of source), compiles to 7.8 KB on Xtensa and 13.7 KB on RISC-V, so a script about 20% bigger is refused on a C3 or a P4. + +A script's code meets three ceilings and the lowest wins: `kCodeCap`, the staging the compiler reserves per token (`codeCapFor`, capped at `kCodeCap`), and the executable memory `allocExec` finds. Only the last is the hardware: IRAM on a classic ESP32 holds tens of KB, while a P4's 768 KB of internal memory is executable and a desktop's is unbounded. The fix is the code cap as a per-platform value from the platform layer: 16 KB on a classic ESP32 and a C3, 32 KB on an S3, 64 KB on a P4 and the desktop. Pinned by a test that a script past 16 KB compiles on a platform whose cap allows it and is refused with a clear message where it does not. The members, frame slots, entry points and labels are separate budgets, costing memory per script on every chip, so they stay until a script needs more. + +### A for loop whose step never advances holds the render task (2026-10-29) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082667) + +`parseFor` in `MoonLiveCompiler.cpp` requires the step to assign the loop variable but not to change it, so `for (int i = 0; i < 1; i = i) {}` compiles, and the emitted loop compares a counter that never moves against its limit with no iteration cap. On a device the render task never returns and the task watchdog resets the board, where the rule is degrade, never crash. The cheap tier is an iteration budget per entry, checked at the loop's back-edge, that fails the script with a status and leaves the layer dark, as a parse error does. Traced in the code, not run: a test of it would hang the suite until the budget exists, so the test lands with the fix. + +### A scripted driver + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082735) + +[MoonLive](../../explanation/architecture/moonlive.md) names a driver as a role still to come. A `MoonLiveDriver` is a `DriverBase` whose `tick()` runs compiled code over the output buffer, the way `MoonLiveLayout` answers `placeLights()`. The builtin it needs is a read of the corrected frame; the binding stays as thin as the effect's. + +### A scripted control's name can disagree with the file that declares it (2026-09-04) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082787) + +Reproduced with `Nebula.mle` on the desktop: the file declares `rate`, the UI renders `rate1`, a name the file held earlier in the session. Ruled out: the builtin table, `kMaxCtrls`, the arena, declaration order and the control name. Start at `rebuildControls()` after a recompile and whether a control that vanished from the script is cleared from `ControlList`; a test that compiles, renames one control, recompiles and asserts the old name is gone pins it. Beside it: `/moonlive/Nebula.mle` and `/.moonlive/nebula.mle` are two files on a device and one on a case-insensitive desktop. + +### Desktop backend: a control-arena write is not seen by a second `run()` of an already-compiled program (2026-08-20) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082850) + +The live-edit path is "move a slider, the next frame reads the new arena +byte, no recompile". On the host JIT the first render is correct, the write lands (the arena +reads back `232,3` = 1000 at the control's own `offset`), and a second `run()` of the same +compiled program still renders the OLD value. + +**Ruled out — do not re-investigate these:** +- Not `LoadCtrl16` missing: it is implemented, lowers to `a.load16`, and `load16` exists in all + three assemblers including `platform/desktop/moonlive_asm_host.cpp` (`ldrh`, immediate scaled). +- Not uint16-specific: a `uint8_t` member behaves identically. +- Not `defineControls()` or the control sink: a class with an unrelated second function fails the + same way, and one with no members at all (a literal in `tick`) does too. +- Not the arena address or offset: `controlSlot(dc[0].offset)` is the byte the write reaches. + +**A large part of the original symptom was a TEST BUG, not an engine bug.** `run()` without an +entry name starts at the BLOCK START — the first function compiled — which in a class with +`defineControls()` is `defineControls`, not `tick`. So the script rendered nothing and looked +like a broken control read. Naming `kEntryTick` makes the initial render correct in every +variant tried (with/without `defineControls`, with an unrelated second function, `tick` first or +second, uint8 and uint16). Only the re-read after a slot write is still wrong. + +**Reproduce** (in `unit_moonlive_fill.cpp`, which has `kCtrlTable`/`kSys` to hand): compile +`class T { int big = 5; defineControls() { addControl("big", big, 0, 1000); } tick() { +setRGB(0, big, 0, 0); } }`, `run(..., kEntryTick)` → 5, write the slot to 7, run again → still 5. + +**Impact: desktop only, nothing ships broken.** A wide control is hardware-verified on both ISAs +(S3 Xtensa and S31 RISC-V drive ember's `cycle` to 2000 and back). What is missing is DESKTOP +coverage of the live-edit loop — the path users touch most — so no host test can pin it and the +next regression there would surface only on a board. Add the runtime assertion together with the +fix, not before: asserting the current behavior would encode the bug. + +### A MoonLive script's branch tables are fixed and on the stack (2026-10-08) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082924) + +**Found** on the StadBeest eyes: one more `if` in a helper turned a working script into `codegen failed: assembler overflow`. +Each function takes three labels (entry, depth guard, exit) before its own `if`s, loops and divisions by a non-power-of-two. +The assembler's label table holds 96 two-byte code positions in the stack the 48 four-byte ones took (2026-10-10), which let Sauron blink. +The nearer limits are now the compiler's own `kIrLabels` at 40, which reports "too many branches", and the 96 fixups. + +**The fix to build:** the label and fixup tables of all three assemblers move from members to the heap, as the code buffer already did, then `kAsmFixups` and `kIrLabels` rise (to 255 and 96); `Label` stays a byte. +The spill pass and the lowering hold `kIrLabels`-sized arrays on the stack too, so they move with it. +Verified by compiling the shipped scripts on a classic ESP32, an S3 and a P4, whose compile stacks are the ones at risk, and pinned by a host test with a script past 40 branches on each backend. + +### A MoonLive control past a limit vanishes without a word (2026-10-08) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287082998) + +**Found** in review: an `addSelect` with a 17th option, or one whose names overflow the 96-character option store, adds no control at all, and nothing tells the script's author why. +The other limits of `addDeclaredControl` behave the same way: past `kMaxCtrls` controls, a member already bound, a slot running past the arena, or names past the engine's 128-character name store (`nameText_`, about sixteen controls of seven characters). + +**The fix to build:** each refusal names itself on the effect's status line, as a compile error does, such as `control "shape": more than 16 options`, so the author sees which declaration lost and why. +Pinned by a test per limit that reads the status. + +### RISC-V emits 1.7 times the code Xtensa does (2026-10-08) + +Kind: Investigation · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083063) + +**Found** on the StadBeest eyes: the script is 7 KB on an S3 and 12.4 KB on RISC-V, where one `smoothstep` call added to `tick` cost 432 bytes on its own. +A call inside a function with many live values saves and restores each of them, so every addition to a drawing loop costs RISC-V 400 to 500 bytes, and large scripts reach the 16 KB code buffer first on the P4 and the S31. + +**The fix to build:** measure where the RISC-V lowering spends its bytes on the shipped scripts (`disasm.py --isa riscv`), starting with the save and restore around host calls, and keep values that live across a call in callee-saved registers rather than saving them per call. +Pinned by a size test per backend on the largest shipped script. + +### A scripted module's DIMENSION chip still comes from its type, not its script + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083143) + +`writeModuleJson` emits an instance's `tags()` (HttpServerModule.cpp), so a MoonLive module shows +the emoji its loaded script declares. Its DIMENSION does not follow the same path: `/api/types` +carries `dim` per TYPE, captured at boot from a probe with no script loaded, and the module state +carries no `dim` at all. So a script declaring `int dimensions() { return 3; }` renders as 🟦 on the +card while behaving as D3 through `Layer::extrude`: the behavior is right and the chip lies. + +The fix is not a line in the serializer. `MoonModule` deliberately has no `dimensions()`: +`ModuleFactory::registerType` detects one with `if constexpr (requires ...)` on the CONCRETE type +precisely so the light-domain `Dim` enum stays out of core (ModuleFactory.h). Core holds a +`MoonModule*` when it writes state, so emitting a per-instance dim means giving `MoonModule` a +virtual that returns a byte, which puts a light-domain concept on the domain-neutral base for the +sake of a chip. + +Options, cheapest first: a `uint8_t dimByte()` on MoonModule defaulting to 0 (the enum stays in the +light domain, only the number crosses, mirroring what the probe already does); or leave it and +accept that a scripted module's dimension chip reflects its type. Worth doing when someone is +annoyed by the wrong chip, not before. + +### MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 3 findings left) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083200) + +Four 🟠 Major boundary findings from the PR #29 review are real but each is its own scoped change, not a tail on the ring branch. The Critical sibling (a `cpl<3` overflow guard in the MoonLive effect's `tick`) landed with the branch it was found on. + +**One of the four is CLOSED (2026-09-07):** the scenario now uses `PreviewDriver`, the in-process +sink, instead of `NetworkSendDriver`. Its `tick_us` half was reviewed and DISMISSED rather than +fixed: a `measure` step asserts nothing, it records, and that recording is what feeds repo-health's +per-commit performance trend. A reviewer reading the file could not see that; deleting the +baselines would have blinded the trend to fix nothing. Recorded here so it is not re-raised. + +The three that remain: + +- **Core includes platform, compiled core in `mm_core`.** `src/core/moonlive/MoonLive.cpp` `#include`s `platform/platform.h` and calls the exec-memory API directly, and the root `CMakeLists.txt` compiles `MoonLive.cpp`/`MoonLiveCompiler.cpp` into `mm_core` and links `mm_core → mm_platform` — violating the header-only-core / no-platform-includes contract both files declare. The runtime exec-memory placement layer wants a core-neutral injected interface (or to move out of `src/core`), so the compiled/platform-dependent surface sits behind `mm_platform` and `mm_core` stays INTERFACE-only. These two are one change (same boundary). +- **W^X disabled in the board default.** `esp32/sdkconfig.defaults.esp32s3-n16r8` turns off `CONFIG_ESP_SYSTEM_MEMPROT_FEATURE` and enables `CONFIG_HEAP_HAS_EXEC_HEAP` for *every* build on that board, even with no MoonLive effect installed. The JIT genuinely needs a writable-then-executable heap, but that belongs in a dedicated MoonLive/JIT opt-in overlay or an explicit build profile, not the board default — so a stock build keeps memory protection on. + +### The compile-failure latch is not provable on the host + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083272) + +- **The compile-failure latch is not provable on the host** (2026-08-18). `MoonLiveScript::sync` + refuses to re-attempt a script that failed until its (name, content) changes. The latch exists for + a device-only reason: each attempt is two LittleFS reads (~5 ms on an S3), a layout is asked from + `lightCount()`/`placeLights()` as well as `prepare()`, and the pipeline asks repeatedly while + sizing a fixture, so the retries starve the render task until the watchdog resets the device. + + On the host a re-read costs microseconds and nothing observable differs. Four test shapes were + tried and each still passed with the latch REMOVED ENTIRELY, so none was kept: what survives pins + only that a script fixed in place compiles without a rename, which is control-checked. Closing + this needs either a counting seam (a compile counter the test can read) or a platform fake whose + reads are observable. Until then the latch is protected by its comment and by hardware, not by a + test. + +### Catch device-backend operand defects on the host + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083336) + +- **Catch device-backend operand defects on the host** (2026-08-18). Two array-codegen bugs shipped + to an S3 while all 1313 host tests stayed green, and both were control-checked: reintroducing + either one leaves the suite fully passing. `IrInst::c`/`d` are VREG fields the spill pass + renumbers, so a width parked there becomes a register number; and `sourcesOf` writes its sources + back POSITIONALLY, so reporting `kArg4` first shifts every real operand one place along. arm64's + register map absorbs both, which is exactly why the suite cannot see them. + + Three test shapes were tried and deleted for failing their control run: emitted-bytes difference + tests (wrong bytes still differ from other wrong bytes) and a register-liveness walk (the index + satisfies it whatever happens to the value). What DOES work is asserting an assembler primitive + directly, as `unit_moonlive_codegen_xtensa.cpp` now does for `addImm`. The general form is + probably an IR-level invariant check rather than a bytes-level one: assert that no op reports a + fixed ABI vreg among its positional sources, and that non-register operands never occupy c/d. + That is a property of the IR the host CAN evaluate, unlike the emitted code. + +### Size the MoonLive control arena to the script + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083397) + +- **Size the MoonLive control arena to the script** (2026-08-18). `kCtrlBytes` is a fixed 64 bytes + per engine (three engines per pipeline), so a script declaring one byte pays for 64 and one + wanting a 128-light array is refused. The arena is already `platform::alloc`'d, so the constant is + habit rather than necessity, and the member byte count is known at compile time. + + What blocks it: the system variables sit ABOVE the script region at compile-time constant offsets + (`kSysWidth = kCtrlBytes + 0`), baked into emitted code as `LoadCtrl` immediates and cached as + slot POINTERS by the bindings. A script-sized region moves every one of them. Closing it means + putting the system variables BELOW the script region so their addresses stop depending on it, + which touches the sysvar table, the bindings and every emitted immediate. The hard ceiling stays + 255 either way, since an arena offset is a `uint8_t` in the record, in `controlSlot` and in the + instruction. Until then, raising the constant is one edit and the failure is a clear compile + error naming the arena, so hitting it is visible rather than silent. + +### Drain MoonLive's print() through a queue + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083459) + +- **Drain MoonLive's `print()` through a queue** (2026-08-09). `print(v)` writes to serial directly, and an EFFECT script runs on the render tick — so a print inside one blocks the frame for as long as the UART takes. The burst cap bounds it (a handful of writes per compile, then a compare and a return), but bounded is not free, and `tick()` is annotated `MM_NONBLOCKING`. + + **What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for. + +### Downloading a scripted palette can repoint the active one (2026-09-06) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083503) + +Found while testing the palette-download fixes. A `.mlp` that sorts BEFORE an already-installed one +silently changes what the current selection points at: with `fire.mlp` active at index 60, +downloading `beat-flash.mlp` re-sorts the scripted tail and index 60 becomes `beat-flash.mlp`. The +stored value never moved; what it means did. + +[Palette.h](../../../src/light/util/Palette.h) sorts scripted palettes AFTER the built-ins, whose indices are fixed, and solves half of it. What is not solved is the scripted tail among itself, and +that tail grows at runtime, which is what the download path made reachable. + +Nobody has reported it, and the blast radius is small: a rig with one scripted palette cannot hit it, +and the value re-reads correctly the moment the user picks again. It matters most where a preset or +an MQTT/HA automation stores the index and replays it later, which is the case where nobody is +watching the LEDs when it changes. + +**What it would take:** persist the scripted palette by NAME alongside the index and re-resolve the +index on load, so a stored selection survives a re-sort. `paletteScript` already holds the resolved +file name for the editor, so the value exists; what is missing is using it as the authority when the +list changes. The alternative, appending new scripts rather than sorting them, keeps indices stable +but makes the picker unreadable as the list grows, which is the trade the sort was chosen over. + +### Changing a live MoonLiveEffect's script does not rebind it (2026-09-29) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083569) + +**Being worked on the macOS bench; this entry is measurement for whoever has it.** Rewriting the `script` control on an existing `MoonLiveEffect` leaves the module largely rendering what the previous script rendered. The card reports a clean status, the byte count updates, frames keep animating, so nothing in `/api/state` says anything is wrong. Only the pixels do. + +Measured on Windows over the 33 shipped `.mle` effects, same scripts and same instrument, changing only the route. Each script ran 3 s while its preview frames were sampled, then scripts were compared on mean level and on frame-to-frame motion; a pair matching closely on both is a pair that cannot be telling the pixels apart: + +- Reusing one module and rewriting its `script` control: **59 indistinct pairs**, in groups. Nine scripts (noise, octopus, plasma, rain, ripples, sparkle, spectrum, stadbeest-frog-eye, stadbeest-walk) all sat at mean 27.2 and motion 0.16, and six more (fountain, fractal, gradient, lines, metal, mh-ambient) at mean 42.9 and motion 0.33. +- A fresh module per script, which is what the UI's picker does: **1 indistinct pair**, and the values separate as you would expect, with fractal at 108.2, noise at 127.2, mh-aim at 255.0 and stadbeest-frog-eye at 151.5. + +The picker works because it does two things rather than one: `replacePickedType` in `src/ui/app.js` calls `replaceModule` to put a NEW module in the slot and only then `setCardScript`. A user who switches scripts from the dropdown on an existing card gets the broken path; a user who picks from the module picker does not, which is why the same library looks fine one way and stale the other. + +Two scripts render nothing that changes even by the working route, and may simply be correct: `gradient.mle` produces one distinct frame at mean 104.8 with zero motion, and `mh-aim.mle` one distinct frame with every sampled channel at 255. `mh-aim` aims a moving head rather than painting a grid, so full white may be the honest answer. Worth an eye rather than an assumption. + +The check itself is worth keeping. Frame distinctness plus cross-script comparison catches exactly what compiling cannot, and it is what the existing test suite has no view of: `unit_MoonLiveScripts.cpp` proves all 55 scripts COMPILE, and every one of them passed while the pixels were telling this story. + +**From the card (2026-09-29, desktop).** Six consecutive picks from the script dropdown: two switched, four did not, and the fourth failure recovered on its own. The file is on the device and complete, the card shows the new name, and no alert fires. The dirty-editor path is not the cause: it alerts "Not switching: this script still has unsaved changes" (`src/ui/app.js`). The duplicated download logic in the dropdown was a real defect and is fixed, pinned by `test/js/ui-script-picker-download.test.mjs`, and it did not change the symptom. + +**Worth deciding alongside the fix**: whether the script control should be editable at all, or whether a scripted module's script is chosen once through the module picker. The module picker path works reliably, so a read-only control would remove the failing surface rather than repair it. + +### A scripted modifier that animates every frame + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083636) + +- **A scripted modifier that animates every frame** (2026-10-11): `MoonLiveModifier` compiles a `modifyLive()` entry, so a script moves lights per frame with `t` (scroll, wobble, sweep) as the compiled Rotate does. Geometry only: dimming is a blend mode or an effect's `mulRGB`, per [the layer compositing plan](../Plan-20261011%20-%20Layer%20compositing%2C%20masks%20and%20transitions%20(2%20of%203).md). Built once a script needs it; the cost to measure is one compiled-script call per light per frame, on an S3 at its largest grid. + +### A scripted modifier needs a way to drop a light + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083694) + +- **A scripted modifier needs a way to drop a light** (2026-08-10). A coordinate slot is a byte, so a script that computes past 255 wraps: `shift.mlv` with a large `amount` lands lights back at the left edge instead of walking them off it. The Layer already drops an out-of-bounds position, but a script has no way to SAY out-of-bounds — every value it can write is a valid coordinate. Needs a sentinel the binding recognizes (or a wider coordinate slot), at which point the "walks off the edge" behavior a scroll modifier wants becomes expressible. + +### A scripted modifier that reshapes the grid + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083746) + +- **A scripted modifier that reshapes the grid** (2026-08-10). `ModifierBase::modifyLogicalSize` lets a modifier change the logical `width`/`height`/`depth` — a Multiply kaleidoscope grows the grid, a crop shrinks it — and a compiled modifier uses it. A SCRIPTED one cannot: system variables are read-only, so `MoonLiveModifier` writes the box in and never reads it back. Needs a writable system variable — the binding reads the slots after the script returns and reports the result through `modifyLogicalSize` — which is a new `SysVarKind` (or a mutable flag on `SysVar`) plus the read-back, not a new builtin. Until then a scripted modifier can fold coordinates but not resize the grid they live in. diff --git a/docs/work/backlog_mirror/platform.md b/docs/work/backlog_mirror/platform.md new file mode 100644 index 000000000..796c0677c --- /dev/null +++ b/docs/work/backlog_mirror/platform.md @@ -0,0 +1,673 @@ +# Backlog: Platform + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## Across modules + +### SIMD inner loops for noise and the bilinear lerp on the S3 + +Kind: Feature · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083794) + +The noise inner loop and the bilinear resampler are the per-pixel cost of every field and transport effect. PIE variants behind `src/platform/`, bit-identical to the scalar forms by test, and the FPU `curl16` where `hasFpu`, measured through the KPI gate. + +### SIMD inner loops for noise and the bilinear lerp on the P4 + +Kind: Feature · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083858) + +The same PIE variants and FPU `curl16` as on the S3, measured on the P4 through the KPI gate. The P4's PPA fits `upscale16`, a scaled blit, and nothing in advection, which displaces every pixel by its own velocity. + +### Network Receive on a P4 past 3,000 packets a second (2026-10-26) + +Kind: Feature · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083932) + +Below about 3,000 packets a second a P4 receives every frame; past it packets go, more for Art-Net than for DDP, as [the table and where a packet's time goes](../../reference/performance.md#network-receive) show. +Network Receive's task spends tens of microseconds per socket call, a `select` hundreds. +Both network tasks may run on either core, yet stay on core 1 with core 0 idle. +Two levers, each its own change: the network tasks on core 0, where pinning them by `ETH_MAC_FLAG_PIN_TO_CORE` and lwIP's task affinity kept Ethernet from coming up, so that needs its own look first; and lwIP's raw `udp_recv` callback writing into staging from lwIP's task, with no socket, no `select` and no receive task. + +### The `.deb` ships the systemd unit, raw Ethernet included (2026-10-22, from robwomp on Discord) + +Kind: Feature · Platform: Pi · Stage: Install · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287083996) + +The package holds only the binary, so a Pi or NanoPi user writes `/etc/systemd/system/moonlight.service` by hand from MoonWalk's "Run at boot" step. That unit grants no `CAP_NET_RAW`, so the panel card driver's `AF_PACKET` socket fails unless MoonLight runs as root, and the `setcap` alternative is lost whenever apt replaces `/usr/bin/MoonLight`. `CAP_NET_RAW` is all MoonLight needs, since it sets no interface flags. The fix: `package_desktop.py` installs the unit with `AmbientCapabilities=CAP_NET_RAW` and a dedicated `moonlight` user, so installing is `apt install` plus `systemctl enable --now moonlight`. MoonWalk's unit step shrinks to that enable, and the `setcap` step stays for a binary run by hand. + +### An HTTPS request on a board without PSRAM (2026-10-24, from the JSON plan) + +Kind: Investigation · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084099) + +A board without PSRAM never fetches over HTTPS: `tlsRoom` asks for the 100 KB internal floor plus a 45 KB TLS session, and a classic ESP32 has 55 KB free at idle. `CONFIG_MBEDTLS_DYNAMIC_BUFFER` allocates mbedTLS's buffers as a connection needs them rather than the worst case up front, which may fit one request on an idle board. To measure on `MM-Olimex` (.207): the peak a session takes with it, what a smaller floor costs WiFi and the HTTP server, and whether a feed then fetches with lights running. Until then such a board gets its data pushed from one with PSRAM. + +### A StadBeest eye reset once by the task watchdog (2026-10-24) + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084185) + +On 2026-10-23 eye2 (`MM-Eye2`, an ESP32-S3-Zero) reset once by the task watchdog, shortly after its OSC service was added, with no USB attached to read the panic. It has not recurred: both eyes ran the crossfading autopilot without a reset afterwards, and on 2026-10-24 they report `POWERON` after the beast was switched on. Recorded so a recurrence is recognized: `bootReason` on the System card is the one reading the network gives, since the board keeps no crash record; the panic itself needs the eye on USB with `monitor_esp32.py`. + +### What the WiFi buffer copy costs (2026-10-24) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084278) + +`CONFIG_LWIP_L2_TO_L3_COPY` copies every received frame so a socket the blocked loop stops draining cannot hold the 32 WiFi receive buffers; without it, every firmware upload stalled at 150 to 860 KB. Its cost is unmeasured: the frame rate on a busy DDP stream with and without it, same scene. If it costs much, firmware uploads move onto the transfer slots so nothing blocks the loop, and the copy goes. + +### An update's connection that would not open until a reset (2026-10-22, from the internal RAM plan) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084374) + +On 3.0.0 a board could not open the update's connection (`ESP_ERR_HTTP_CONNECT`) until it was reset, with 80 KB of internal RAM free. TLS has lived in PSRAM since then (`CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC`), which may be what cured it; it has not recurred. Recorded so a recurrence is recognized: the free internal RAM and largest block at that moment are the first readings to take. + +### Move more devices to MoonLight over WiFi (2026-10-09) + +Kind: Feature · Stage: Install · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084435) + +The migration image (`moonbase/migrate/`) moves a board running WLED to MoonLight through WLED's own update page, with no cable: verified on WLED 0.15.0 on an ESP32-C3, on a bench board with a power cut at every step and on an Athom bulb. +An image is built for one chip and carries one variant's partition table, so each further reach is its own piece: +- **Every MoonBase variant:** `migrate: True` on `esp32`, `esp32-16mb`, `esp32-wrover`, `esp32-pico` and `esp32s3-zero`, each rehearsed on a bench board of that chip and shipped as a release asset. The variants without MoonBase (the larger S3s, the P4) need another design, the image writing the app itself. +- **Other WLED versions:** 0.13 and 0.14 keep the same files and layout, untested. 0.16 checks an upload's release name, so the user ticks WLED's option to skip it, untested. A WLED build with one app slot cannot move the image aside. +- **Tasmota:** its layout already has a recovery app in the factory slot and one app slot, so the image arrives running where MoonBase goes with nowhere to move aside; it would write over the recovery app instead. Its WiFi lives in a binary settings block in NVS, which needs its own reader. +- **ESPHome:** its update accepts the image, but its WiFi is compiled into the firmware, so MoonBase opens its access point. +- **A generic WiFi source:** ESP-IDF's WiFi driver can keep the last network in NVS under a namespace every framework shares; tried when no WLED files are found, it may carry the network from the others. Bench first, since whether it is there depends on how each firmware starts WiFi. + +### Back to WLED over WiFi (2026-10-09) + +Kind: Feature · Stage: Install · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084501) + +The migration image moves a WLED device to MoonLight without a cable, but the way back needs one: MoonBase installs MoonLight images only, so a bulb going back to WLED is opened for USB. +Owners and makers of these devices care most about this: a firmware change that cannot be undone without a cable is a bigger step than one that can. +It is ours to build rather than WLED's, since the layout it undoes is MoonLight's, about one to two sessions: +- **A small image that downloads WLED itself** from WLED's GitHub releases while it runs, so MoonLight never redistributes WLED's binary and the image stays far from what it writes. +- **WLED goes into WLED's second app slot**, past the image's own position, so nothing moves aside; then WLED's table, and the boot pointed at that slot. WLED starts, formats its filesystem and opens its own access point. +- **MoonBase installs it** only once the image is stamped as one of MoonLight's, a deliberate change to the one rule that refuses other images (`src/core/util/FirmwareImage.h`). +- **A "Back to WLED" button** in the Firmware card, a release asset per chip, and the bench rehearsal with a power cut at every step that the way in had. +- The WiFi stays behind: WLED opens its access point, where the user sets it. + +### The desktop build advertises no mDNS (2026-09-06) + +Kind: Feature · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084562) + +`mdnsInit` on the desktop platform is a stub returning false, so a desktop instance is invisible to +discovery: it neither announces itself nor answers `.local`. On a board mDNS is what makes a +device findable without knowing its address. + +Nice-to-have rather than blocking, because instances address each other by IP and that is what the +network drivers take (`NetworkSendDriver`'s `ips` list). What it costs today is convenience: a +container on a NAS has to be looked up rather than named, and a fleet of them cannot be enumerated +the way a fleet of boards can. + +**What it costs:** a platform implementation per OS, which is the awkward part. macOS has Bonjour +built in, Linux would want Avahi (a dependency the container deliberately does not have), and +Windows has its own. A small self-contained responder is the alternative, since the announce side is +a handful of multicast records and we do not need the resolver half. + +Worth revisiting if instance-to-instance sync grows: discovery is the difference between configuring +a fleet by address and having it assemble itself. + +### The arm64 `.deb` is untested on Raspberry Pi OS bookworm (2026-09-13) + +Kind: Investigation · Platform: Pi · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084649) + +One piece of the arm64 verification was never done, because the bench has no such board. The job runs on +`ubuntu-22.04-arm` **deliberately**, for glibc 2.35: Raspberry Pi OS bookworm ships 2.36, and a +build on `ubuntu-24.04-arm` (glibc 2.39) would install there and then die at startup with +`GLIBC_2.38 not found`, the same failure the Dockerfile records hitting on debian12. The floor is +what makes bookworm work, and **bookworm is the one target nobody has run it on**. + +Two halves are untested together: that the binary starts (the symbol check says the highest +requirement is `GLIBC_2.35`, which is the proxy), and that `Depends: libcurl4 | libcurl4t64` +resolves there. Trixie provides only `libcurl4t64`, bookworm only `libcurl4`, and only the first +half has been exercised. A wrong alternation fails visibly at `apt install` with an unsatisfiable +dependency rather than installing something broken, which is the good failure mode. + +**A user with a Pi settles it in one command.** Closing this needs a report, not a code change. + +### A flashable SD image with MoonLight already on it (robwomp, 2026-09-08) + +Kind: Feature · Platform: Pi · Stage: Install · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084753) + +Suggested on Discord while bringing up a NanoPi R28S: most Pi users want to write an `.img` to a +card and be running, not to install a toolchain. Armbian's own build tooling supports exactly this +(`armbian/os` carries per-application "extensions", OMV being a small worked example), so the image +is a spin of a maintained distribution plus our package rather than a distribution to maintain. + +Wants the arm64 `.deb` above first: with it the extension is roughly "install this package, enable +this service", which is the shape those extensions already have. Without it, the image would have to +carry a source build, which is the thing it exists to avoid. + +### MoonBase follow-ups + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084833) + +MoonBase v1 ([MoonBase](../../explanation/architecture/moonbase.md)) +ships exactly one action: install firmware (upload + URL). The name is deliberately broader than +"recovery", these are the candidate next actions, each solving something only a separate boot +image can solve, and each must earn its bytes in the slot's headroom (a few KB of code is fine; a +new IDF component is the expensive kind). + +- **Boot with config disabled**: one-shot flag the app reads at startup to skip loading + `/.config`: diagnose "is it my config or the firmware?" without erasing anything. +- **WiFi re-provisioning**: edit the stored credentials from MoonBase's page (today it only + *reads* them; the AP fallback plus the app's provisioning already covers most of this). +- **Firmware downgrade guard**: MoonBase installs whatever image it is given; a version display + (read from the incoming image's app descriptor) before flashing would make an accidental + downgrade visible. +- **Hardware diagnostics**: chip/flash/PSRAM identification and a minimal pin tester, for + triaging a board that misbehaves under the full app. +- **MoonBase Ethernet beyond classic RMII**: the P4's IP101/managed-component PHY and the S3's SPI W5500, + which matter only if MoonBase ever goes beyond the 4 MB classics. +- **MoonBase as the only update mechanism, all boards** (PO, 2026-08-26): would delete the app's + whole in-place OTA path (a real subtraction) and grow every app slot, with the stronger + power-fail story everywhere. Three deciding factors first: MoonBase Ethernet beyond classic RMII (the P4 has no + WiFi of its own and uses a different PHY; the S3 uses SPI W5500), a migration that does not lose config ([config backup and restore](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287075118)), and accepting + ~45 s of visible downtime per update where dual-OTA installs in the background. Trigger: + MoonBase proven in the field on the 4 MB boards. + +### LEDs on the Teensy flash white, then the board resets (2026-10-13) + +Kind: Bug · Platform: Teensy · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084894) + +Five times in one session, the 64-light strip on the Teensy 4.1 bench board went full white, and the board restarted with reset reason `UNKNOWN`, no fault mark and no watchdog: a supply dip, which a full-white strip on USB power explains. +Each white flash came while the device built a large state document: switching SdCard's board with the old 46 KB full state, and subscribing to Drivers or the whole tree with the new one; none came with the LEDs disconnected. +A WS2812 strip reads a data line held high as all ones, so suspect the GPIO-DMA output stalling high, or its buffer moving under the DMA when the Layer reduces its buffer under memory pressure ("buffer reduced" was showing). +Reproduce with a logic analyser on the data pin while subscribing to Drivers, before changing code. + +### A Teensy sometimes drops a message the page sends (2026-10-13) + +Kind: Bug · Platform: Teensy · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287084990) + +Choosing Custom on SdCardService's board select reached the Teensy once in three tries in one session, and every time in later ones; the same clicks always reach the Dig-Octa. +A lost write leaves the page showing a choice the device never made, and nothing tells the user. +Suspects, in order: the `/wsp` preview stream filling the send side while a frame from the page waits, a long render tick delaying the poll that reads the socket, and power dips while the LEDs show full white. +Reproduce with the preview open and closed and the LEDs dark and lit, logging each frame the device receives, before changing any code. + +### SD card writes over SPI stop the card on the Dig-Octa (2026-10-12) + +Kind: Bug · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085076) + +On the QuinLED Dig-Octa's SPI slot (SCK 14, MOSI 15, MISO 36, CS 16), a write past about 50 to 200 KB fails mid-transfer, and the card then answers no command until its power is cut. +Reads hold: a 5 MB file reads back byte-exact at 184 KB/s. +The same no-brand 32 GB card writes 5 MB cleanly on the native SD bus of the P4 and of the Olimex ESP32-Gateway. +Ruled out: the clock (20, 10 and 4 MHz fail alike), 30 ms between writes, and the wiring, which Quindor's schematic shows with 10 kOhm pull-ups, its own LDO and decoupling. Turning the LED driver off moved the limit a little without removing it. +Next: the same 5 MB test with an A-brand card, since SPI mode is the part of the spec newer cards treat as secondary. ESPixelStick, the one other firmware driving this slot, throttles its writes into 7 KB blocks with a 30 ms gap and pauses LED output during an upload (forkineye/ESPixelStick issue 802 and `src/FileMgr.cpp`). +Until it is solved, a card mounted over SPI shows a warning status. + +### ESP32 UDP receive is bounded by PACKET COUNT, not bytes + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085150) + +Reported from other projects working the same ground: the ESP32 family's incoming UDP limit behaves +as a **mailbox of packets** rather than a memory budget, with a hard numerical ceiling, and the P4 +is no better. The observed shape is perfect reception up to that count and then progressively worse +loss as universes climb, rather than a clean cliff. Raising it is tunable but bounded, since the raw +packet buffers are full-frame sized whatever the payload. + +Two consequences worth having in mind: + +- **Pixels-per-packet is the lever, not bandwidth.** Art-Net is DMX512 on the wire, so it is capped + at 512 values per packet whatever the frame size; DDP fills close to a whole MTU. For the same + pixel count DDP therefore needs far fewer packets, which is the resource that runs out first. + This matches our own measurement that ArtNet is where the WiFi limit bites. +- **A P4-specific escape exists.** Have the packet handler DMA the payload to PSRAM and release the + hardware buffer immediately, then parse from another task, so the mailbox drains at memory speed + rather than at parse speed. That is real work and speculative, but it is the shape of a fix rather + than a tuning knob. + +Unverified on our own bench: this is other people's measurement, recorded so the next receive-path +investigation starts from it rather than rediscovering it. Confirm before acting on it. + +### WiFi Art-Net performance + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085235) + +128×128 WiFi ArtNet measurements exist (see [performance.md](../../reference/performance.md) "ArtNet over WiFi" and "Build-variant WiFi comparison"). Remaining matrix: + +- WiFi STA 64×64 (4K LEDs, 24 universes) +- WiFi STA 32×32 (1K LEDs, 6 universes) + +This determines the practical LED limit for WiFi-only boards. Until the `sdkconfig.defaults` TX-buffer fix lands (identified in the build-variant table), **prefer wired Ethernet for any ArtNet workload on classic ESP32** — the default `esp32` build carries both stacks, so Ethernet is available even when the original measurements were taken on the old `esp32-eth-wifi` variant. + +### Memory ceiling on non-PSRAM ESP32 with WiFi and Ethernet + +Kind: Investigation · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085323) + +On the `esp32` firmware, which carries both stacks, default 128×128 grid, measured when the two were a separate variant, free heap at boot is ~28 KB — not enough for `esp_wifi_init` (needs ~16 KB RX buffers) after the light pipeline allocates ~210 KB. The device stays running but WiFi init fails silently. + +Fix options in increasing scope: +- **Cap the default grid** — drop to 64×64 on a classic board without PSRAM (Layer ~32 KB + LUT ~16 KB = 48 KB, comfortably under). Simplest. +- **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 [lessons.md](../../contributing/lessons.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 + +Kind: Investigation · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085395) + +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 memory ceiling above). +- **PSRAM for the buffers** on PSRAM-equipped variants: sidesteps DRAM fragmentation entirely (related: the [Memory ceiling](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085323) 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. + +### WiFi runtime disable + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085471) + +WiFi initializes only when a station or the access point starts, and `wifiRadioDown` frees it when both stop, so a board that Ethernet carries holds no WiFi memory. Turning WiFi off entirely, the access point fallback included, is undesigned: whether the gate keys off detected hardware presence, an explicit control, or a deviceModel-catalog field is not decided until a concrete case asks for it. + +### Trim the TLS certificate bundle to the common roots + +Kind: Feature · Trigger: flash runs tight on a board · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085540) + +`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN` keeps the common roots only, an estimated 40 KB less flash; `_NONE` saves about 50 KB but breaks TLS. + +### Platform API: `std::span` migration + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085617) + +Several `platform.h` APIs still use `(buf, len)` pairs where `std::span` would catch length/pointer mismatches at compile time. Concrete sites: `http_fetch_to_ota`, `improvProvisioningInit`, and friends. ~2 h including ripple updates to callers. Do alongside the next platform-API expansion (Windows socket port or POST /api/firmware streaming). + +### Ethernet on the QuinLED Dig-Next-2 (W5500 over SPI) + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085683) + +The DN2 is in the catalog with no Ethernet block, so it comes up WiFi-only. Adding it is a `deviceModels.json` entry and nothing else: the preset work landed the seam, and W5500 already applies live. + +**RMII is impossible on this board, so the W5500 is the only route.** The DN2 carries an ESP32-PICO-V3-02, whose package leaves GPIO 16, 17, 18 and 23 unbonded, and those are the RMII management and clock pins. This is the same package fact behind the flash-cache wedge recorded in [lessons](../../contributing/lessons.md). QuinLED's own LAN8720 board stacks onto the QuinLED-ESP32 only, not onto a Dig board. + +**The wiring, from [Quindor's firmware source](https://github.com/intermittech/WLED-Dig-Next-2_W5500)**, using the QEXT header for the bus and the Stemma QT header for the two control lines. Fitting Ethernet therefore costs the I2C port: + +| W5500 | connector | GPIO | +|---|---|---| +| SCLK | QEXT green | 25 | +| MOSI | QEXT blue | 32 | +| MISO | QEXT yellow | 0 | +| CS | QEXT white | 33 | +| INT | Stemma QT SDA | 15 | +| RST | Stemma QT SCL | 14 | + +MISO on GPIO 0 is deliberate and sound: GPIO 0 is a strapping pin, and a W5500 leaves MISO high-Z while CS is inactive, which is what Espressif's rule about not driving strapping pins at reset asks for. + +**It stays `Custom`, and that is the finding.** Across the three W5500 boards now known, LightCrafter 16, SE 16 V1 and this one, no two share a single pin on any of the six lines. SPI Ethernet is a module wired to whatever a board left free, so there is no W5500 pattern to make a preset from, and inventing one would be a preset that fits nothing. + +**What is unverified.** The pin table is trustworthy as what that firmware does, because it was read out of the code rather than a description. What rests on one source is that it works: a single-author repository created 2026-09-19, self-labelled experimental, tested on one unit, with LED output, buttons, relays and mic explicitly not exercised. No independent build was reported when this was written. So the open question is not the pins but whether SPI Ethernet and the LED output coexist on this board under load, which is a bench answer. + +### Link-time optimization on the ESP32 builds + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085768) + +Splitting `HttpServerModule` into three files cost 7,360 bytes on the classic ESP32 (2,129,216 to 2,136,576), the compiler no longer inlining across the files. Link-time optimisation (`-flto`) optimises the whole program at the link and wins that back, and likely more across the firmware. ESP-IDF's current docs offer it as `CONFIG_COMPILER_LTO_LINKTIME`; v6.1, which MoonLight builds with, has no such option. Try it once the ESP-IDF release MoonLight moves to offers it, per board: the image size, the IRAM placement in the map file (an interrupt handler must not inline code from flash), a run on the RMT and parallel drivers, and the build time, which LTO makes two to three times longer. + +### Adopting the v6.x ecosystem changes + +Kind: Investigation · Trigger: each v6 item's own trigger fires · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085832) + +The as-is state of each item is the table in [build-system.md § The v6 ecosystem](../../reference/build-system.md#the-v6-ecosystem); this entry carries the plan and the triggers. + +**Per item, how and when:** + +- **EIM** — adopt any time. It shipped *in* v6.0 so it clears the v6.0-floor rule, and it has a headless CI mode. Add EIM as the **preferred** path in `setup_esp_idf.py` (`eim install`), keep `install.sh` as a documented fallback for one release. Keep the exact-commit pin: EIM's multi-version management helps reproducibility, it does not replace the pin. +- **PSA Crypto** — nothing to migrate while we stay on high-level components. Watch only: if a feature needs hashing or signing directly (signed-OTA verification, a device identity), write it against the **PSA API** from the start, not legacy mbedTLS. Trigger: first direct crypto use. +- **`network_provisioning`** — adopt to close the phone-app + SoftAP gap. It is in v6.0 (clears the floor) and is the IDF-native standard. Add it as a **sibling provisioning module** beside ImprovProvisioning (both wired-by-code System modules; a device offers whichever transports its chip supports), reusing the same WiFi-credential plumbing. Not a replacement for Improv: they cover different front-ends (browser vs phone-app) and a product can want either. Weigh the BLE-stack cost per chip. Spec before code. +- **CMake Build System v2** — watch until GA, not while it is a preview: adopting a preview build system is the opposite of common-patterns-first. Trigger: v2 ships as the default. Then dry-run a build under v2, fix any `idf_component_register()` / Kconfig fallout, switch. Low risk given how little custom CMake we have. +- **Built-in MCP server** — evaluate, do not default to it. The risk is a *second control path* bypassing the script policy, and it is ESP32-only (no desktop), so it cannot be the uniform path. If adopted, wrap it behind a script (`moondeck/run/idf_mcp.py`) so the policy layer still applies. Trigger: a concrete debug workflow the scripts cannot cover. + +**Sequence.** Close the v6.0 gaps one at a time as normal feature commits: + +1. **EIM installer** — rework `setup_esp_idf.py` to prefer `eim install`, keeping `install.sh` as a one-release fallback. Smallest and lowest-risk (build-path only, no firmware change, no hardware re-test), and EIM's multi-version management is what cleanly supports the v6.0-floor / v6.1-fallback juggling, so it sequences first as an enabler. +2. **`network_provisioning`** — the headline capability: the phone-app + SoftAP onboarding flow. Its own plan. +3. The rest (PSA-native crypto, CMake v2, MCP) as their triggers fire. + +**The pin's drift, shown in MoonDeck.** The pin and its policy live in [build-system.md § ESP-IDF version](../../reference/build-system.md#esp-idf-version); its drift warning shows only while `setup_esp_idf.py` runs, so a MoonDeck banner or status dot surfacing the same drift is still to do. + +**Guardrails.** Platform-generic stays intact: these are ESP32-specific gains and none may regress Teensy or the desktop paths, which do not use ESP-IDF at all. An IDF feature is adopted *inside* the ESP32 platform layer / build tooling, never by leaking an IDF assumption into shared `src/` or the desktop build. And the v6.0 floor holds: adopt only what is in v6.0, so the v6.0 fallback keeps working. + +### ESP32-P4 WiFi through the C6 co-processor + +Kind: Bug · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287085924) + +Rounds 1 (board and Ethernet-only) and 2 (Parlio LED driver) have landed; WiFi through the C6 is what remains. + +Issue #146 (a P4 `eth-wifi` boot loop) was fixed without anyone seeing WiFi work: the bench P4-NANO's C6 never answers on SDIO (`ESP-Hosted link not yet up`, no `Card init success`), with or without the fix. So every P4 WiFi question waits on a reporter's board. The textbook cause of a silent C6 is a host/slave mismatch: our host side is ESP-Hosted 2.12.13, built into the firmware, while the C6 runs whatever slave firmware the board maker flashed. **Make our own P4-NANO's link come up, then keep it as the WiFi reference.** + +- **The slave image**: `network_adapter.bin` built from esp-hosted-mcu's `slave` example at the host's version (2.12.13), for the C6. +- **Flashing it, with the link down** (our case): directly over the C6's own UART with its boot pin held, through an ESP-Prog or a USB-serial adapter. The P4-NANO's schematic says where the C6's UART and boot pads are; ESPHome's board page names only the SDIO pins (`clk` 18, `cmd` 19, `d0`-`d3` 14-17) and the C6 reset (`GPIO54`). +- **Flashing it, with the link up**: ESP-Hosted's transport OTA, its `host_performs_slave_ota` example, which ESPHome wraps as the `esp32_hosted` update platform. This is the path for users once one board works, and a candidate for MoonLight itself: carry the slave image and offer the update on the WiFi card, as ESPHome does. +- **Say which failure `wifiCoproc` shows**: `no version reply` covers two cases, a link that never came up and a C6 too old to answer the version query (or answering 0.0.0), and only the first stops WiFi. `coprocessorWifi()` in `platform_esp32.cpp` checks the transport state first and reports `link down` or `C6 too old to report`, so a reporter's System card answers what today needs a serial log. +- **Then**: the round-3 regression (associates on rc1, not on the v6.1 pin) gets measured on a board with a known-good link, and the two defects of the cascade below get a repro. + +What #146 taught, for any P4 change: before the scheduler starts, the P4 has 148 KB of internal RAM (retention, RTC and SPM; the 384 KB L2 region still holds the startup stacks), and every global constructor allocates from it. ESP-Hosted's took 123 KB until `CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM` moved its buffers to PSRAM. A boot that asserts in `vApplicationGetIdleTaskMemory` is that budget exhausted; a `heap_caps_get_free_size(MALLOC_CAP_INTERNAL)` report from a constructor of priority 101 and an `ESP_SYSTEM_INIT_FN` at stage 999 brackets it. + +#### Round 3: association and the slow link + +- **Round 3 regression: WiFi associates intermittently on IDF v6.1 (2026-10-12; the PO sees it associate on some boots and not on others, 2026-10-16).** The variant keeps shipping so users can try it. It associated and served traffic on IDF v6.1-rc1 (below). The bench P4-NANO (v1.3, port `usbmodem5ABA0767291`, registry .139) flashed with `esp32p4rev1-eth-wifi` on the v6.1 pin, Ethernet unplugged: Improv delivered the bench SSID over USB serial, the board answered "unable to connect", and it never appeared on the network. Its log does not reach `monitor_esp32` on that USB-JTAG port, so the cause is unread. First step: read the P4's console (UART pins with an adapter) for the esp_hosted / C6 lines, then compare against the rc1 build that worked: the IDF move from rc1 to v6.1 and the esp_hosted version it pulls are the two candidates. +- **Round 3, WiFi via the C6 co-processor: the link is slow (2026-08-19).** Boots and associates on IDF v6.1-rc1; the remaining defect is throughput, bench-bisected below. How the variant is built: [lessons](../../contributing/lessons.md#esp32-p4-round-3-wifi-via-the-c6-the-abstraction-the-earlier-round-feared-wasnt-needed). + + **Open issues before this is done:** + + 0. **The esp_hosted build is slower on HTTP — BISECTED to the hosted link, not to us (2026-08-19), and much improved but NOT gone (re-measured 2026-08-21).** + + **Original (2026-08-19):** Same board, same commit, same application code, measured over **Ethernet on both builds** so the radio is not in the path: + + | build | per-request (`/api/system`) | throughput (73 KB `app.js`) | + |---|---|---| + | `esp32p4rev1-eth-wifi` | alternating 0.434 / 0.788 / 0.446 / 0.813 s | 42 KB/s | + | `esp32p4rev1-eth` | 13 ms flat, 12 consecutive fetches | 708 KB/s | + + Render is healthy in both (121-134 fps), so this is not frame-loop contention. Because the two images share every line of application code, the penalty is **esp_hosted being compiled in and its SDIO link serviced**, degrading traffic on an interface it is not even carrying. The cost is **per-request, not per-byte** (a 1 KB fetch cost nearly as much as a 36 KB fetch), which points at a periodic blocker a request must wait out rather than a slow pipe. + + Already excluded on the bench: SDIO width/clock (already 4-bit/40 MHz, the maximum), WiFi buffer counts (match the S3's), ICMP latency (6.6 ms). An earlier `SystemModule` fix (`coprocessorWifi()` retried an unanswered version query every tick: 1,012,344 us → 257 us) was real and is kept, but it was **not** this: the stutter outlived it and vanished only when the C6 path left the image. + + **Per-module tick timing, both builds on the SAME Ethernet interface (2026-08-19).** The measurement that says what this is NOT: + + | module | eth-only | eth-wifi | factor | + |---|---|---|---| + | HttpServer | 113 us | 3441 us | **30x** | + | File Manager | 279 us | 4686 us | 17x | + | Services | 242 us | 1388 us | 5.7x | + | Audio | 239 us | 1376 us | 5.7x | + | Effects | 172 us | 397 us | 2.3x | + | **Network** | 10 us | **92 us** | (negligible in absolute terms) | + | Drivers | 6601 us | 4215 us | **0.64x (FASTER)** | + | ParallelLed | 7186 us | 6569 us | 0.91x (faster) | + + **Two theories are refused by this table.** It is not a blocking WiFi call in `NetworkModule` (`Network` costs 92 us, and `wifiStaRssi`/`wifiTxPower` are state-gated off entirely in `ConnectedEth`); and it is not periodic WiFi scanning, since no module carries anything like a 0.37 s cost. Instead **modules that touch no network at all** (Audio, Noise, Effects, Layer) slow by the same kind of factor as the HTTP path: a broad, roughly proportional slowdown of ordinary code. + + **Working theory: L2 cache contention.** Both builds are byte-identical in every memory setting (`CACHE_L2_CACHE_128KB`, `SPIRAM_SPEED_200M`, `FLASHFREQ_40M`, `SPIRAM_MODE_HEX` — a full sdkconfig diff shows no cache/PSRAM/flash/CPU-freq difference), and code executes from flash through that shared 128 KB L2 in both. Adding esp_hosted's tasks, ISRs and DMA buffers evicts application code that was previously resident, so ordinary work starts missing to flash/PSRAM. This also explains the otherwise odd inversion: the DMA-bandwidth-bound `Drivers`/`ParallelLed` got *faster*, which is what happens when the CPU competes less for the same bus. **Consistent with the data, not yet proven** — separating cache contention from plain CPU stealing needs a cache-hit-rate counter or a per-tick histogram, neither of which we expose today. + + **The cache theory does NOT explain the visible LED hiccup, and there are TWO effects here (PO observation, 2026-08-19).** Uniform cache contention predicts a smooth frame-rate drop (133 → 96 → ~73 fps), which is what the averages show. It does not predict a *stall*, yet a once-per-second hiccup is plainly visible on the fixture. So the throughput loss and the hiccup are separate problems and must be chased separately. + + **Our instrumentation structurally cannot see the hiccup.** `MoonModule::tickTimeUs_` is a MEAN (`accumUs_ / frameCount`, [MoonModule.h:656](../../../src/core/module/MoonModule.h)) and `Scheduler::fps()` is derived from it, so a single 300 ms frame among 70 good ones shifts the average ~4 ms and vanishes. Every per-module number in the table above is an average and none of them can confirm or refute a stall. External evidence of the stall is therefore weak but non-zero: sampling `fps` once per second for 30 s gives a steady 73 with periodic dips to 69-70 (~55 ms lost in those seconds), and the dips are NOT on a clean 1 s period. Ruled out along the way: the degradation is not caused by our own HTTP polling (fps is identical at 76 after 25 s of zero traffic and under continuous polling), and the early 96 → 73 decay is warm-up, not load. + + **Per-TASK CPU, both builds, `--task-cpu-stats` (2026-08-19) — this is the decisive measurement.** Same profiling overhead on both, so the comparison is clean: + + | task | prio | eth-only | eth-wifi | | + |---|---|---|---|---| + | `main` (render) | 1 | **35.4%** | **93.3%** | the same work costs 2.6x the CPU | + | `IDLE0` | 0 | 63.3% | 5.1% | core 0 headroom is gone | + | `ipc1` | 24 | 7.8% | 12.7% | present in BOTH, so not the cause | + | `mmEncode` | 5 | 3.4% | 6.0% | | + | `sdio_read` / `sdio_write` / `sdio_process_rx` / `rpc_rx` / `rpc_tx` | 23 | absent | **0.0-0.1%** | the hosted tasks are IDLE | + + **What this refutes.** Not a busy WiFi task: every SDIO/RPC task sits at 0.0-0.1%. Not periodic WiFi scanning, and not a blocking call in our render path either, since `main` is not *waiting* — it is *running*, and burning 2.6x the cycles for identical work. Not `ipc1` (priority 24, preempts everything), which is present on the eth-only build too. There is no WiFi *activity* to stop, so a "compile it in but don't run it" variant would likely change nothing; the cost is already there with the link idle. + + **What this supports.** The same instruction stream executing 2.6x slower with no extra runnable work is the signature of **memory contention**, consistent with the L2-cache theory above: esp_hosted's footprint evicts application code from the shared 128 KB L2, so ordinary code stalls on flash/PSRAM fetches. Cycles are spent *inside* `main`, which is why every module slowed proportionally and why the DMA-bound drivers (bandwidth-bound, not cache-resident) got faster. + + **Three more theories died on the bench (2026-09-29, IDF v6.1 final).** Each was tested against 30 consecutive `/api/system` fetches over Ethernet, so the radio carried nothing in any of them. + The FreeRTOS tick warning esp_hosted prints at boot (`CONFIG_FREERTOS_HZ is 100, ESP-Hosted recommended 1000`) is an unconditional nag rather than a measurement, and the arithmetic runs the wrong way: IDF's `usleep` busy-waits sub-tick intervals exactly, so the first eight TX retries cost 14.4 ms at 100 Hz against 17.2 ms at 1000 Hz. + Priority inversion is refuted by its own null result: raising our task from 1 to 10 changed nothing measurable, and IDF's own `esp_http_server` runs at priority 5, which would be equally starved if six tasks at 23 were the mechanism. + Nagle with delayed ACK looked compelling, since responses are written as a header then a body with no `TCP_NODELAY` and the median penalty is close to a 40 ms delayed-ACK timer, but setting it moved neither build: eth-wifi stayed at ~55 ms and eth-only was already 8-44 ms. + `CONFIG_LWIP_TCPIP_TASK_PRIO=23` is kept, on the grounds that level-with rather than below is defensible, NOT because it is proven: one run lost its 230-270 ms tail spikes and a later run saw them return. + + **What the penalty actually costs a user, measured per interface (2026-09-29, both fixes in, 30 fetches of `/api/system` each).** + + | build and link | median | spikes | + |---|---|---| + | eth-only, Ethernet | **14 ms** | none | + | eth-wifi, Ethernet | 51 ms | 3 of 30 at ~200 ms | + | eth-wifi, WiFi (RSSI -44) | 57 ms | 4 of 30 at 280-870 ms | + + Over Ethernet the eth-wifi build is DEGRADED rather than unstable, and an earlier note here calling it unstable on every interface was wrong. + A click lands in 51 ms where a person notices around 100, so the interface feels fine and the 3.6x is invisible for single actions. + It surfaces in two places: the ~200 ms outlier roughly once in ten requests, which a page load of eight parallel requests will usually hit, and any client that issues hundreds of requests, which is why the live scenario sweep failed 4 of 6 on this build over Ethernet while a human clicking around noticed nothing. + Over WiFi the median barely moves but the tail doubles and reaches 870 ms, and under scenario load it reaches multi-second timeouts. + So the honest split is: usable on a wired link, unreliable on a wireless one, and the eth-only build is the one to run when a P4 has to be dependable. + + **The interface is noticeably quicker than it was (PO observation, 2026-09-29).** Changing an effect over WiFi went from a visible hang to something that responds, which a fetch loop cannot measure: that gesture is a POST plus a tree rebuild plus a state resync, so it pays the tail several times over where a single GET pays it once. + THREE things changed together and no single one is credited: `TCP_NODELAY` on accepted connections, `CONFIG_LWIP_TCPIP_TASK_PRIO=23`, and a board no longer carrying a failed scenario's leftover driver. + That last one also taints the earlier "20-second timeouts on WiFi" figure recorded here, which was measured on a board in that state; the 4x penalty over Ethernet is clean and reproducible, the WiFi instability was not measured as carefully. + + **Why the co-processor board and not the others.** A native-radio chip carries its WiFi stack in the image the cache was sized around; the P4 has no radio, so WiFi arrives as an additional resident subsystem (six tasks, an SDIO driver, RPC machinery, DMA buffers) on a chip whose 128 KB L2 is shared by everything. The difference is not silicon quality but whether WiFi is extra code competing for that cache. + + **Still unexplained: the visible ~1 s LED hiccup.** Cache contention predicts the steady 2.6x, not a stall. Per-task CPU is cumulative-since-boot and cannot show a spike either. So the hiccup remains unmeasured, and the worst-case instrumentation below is still the next step for it specifically. + + **Instrumentation gap to close first — worst-case tick tracking.** Add a per-module and per-scheduler *max* (and ideally a coarse histogram or a "frames over 2x mean" counter) alongside the existing mean, and expose it in `/api/system`. Cheap (one comparison per tick), and it converts "the PO can see a hiccup that no number shows" into a measurable quantity. Without it, every theory below is unfalsifiable from the host side. Reading the P4 console needs `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` or a UART adapter on GPIO 37/38 (see the dev-loop note in round 4), so serial is not the fast path. + + Then, cheapest first: (a) read the new max/histogram to see whether the cost is uniform or spiky (uniform favors cache contention, spiky favors a blocking call, and the two coexisting is now the leading reading); (b) the P4 cache performance counters for a direct miss-rate read; (c) moving the hot render path to IRAM/internal RAM and re-measuring, which is also a candidate fix rather than only a diagnostic. + 1. **Runtime SDIO re-init of the C6 fails: CONFIRMED a C6 slave-firmware problem (not a guess).** SystemModule now exposes a `wifiCoproc` read-only control (via `platform::coprocessorWifi()` → `esp_hosted_get_coprocessor_fwversion()`), and on the bench it read **`not detected`** at the time (the control now reports **`no version reply`**, and only after a bounded number of attempts): the C6 returns no valid firmware version. **What that means was overstated here.** It was read as the signature of absent / incompatible slave firmware, but the same board later associated and served traffic over that very link while this RPC still went unanswered, so an unanswered version query says the QUERY failed, not that the C6 is absent. The round-3 conclusion below rests on the `sdmmc_card_init failed` re-init evidence, not on this control. Likely a version mismatch on top of that: The host pulled esp_hosted **2.12.9**; Espressif's P4-Function-EV-Board ships its C6 pre-flashed with esp_hosted slave **v0.0.6**, and the **Waveshare NANO is a different board that may carry a different / absent C6 slave image**. The symptom fits: boot inits the host SDIO master fine, but resetting the C6 (GPIO 54) and re-enumerating it as a slave fails (`sdmmc_card_init failed`) because the C6 has no compatible slave firmware responding. **Primary next step: build + flash the version-matched esp_hosted slave firmware onto the NANO's C6.** The slave project is already vendored at `esp32/managed_components/espressif__esp_hosted/slave/` (`sdkconfig.defaults.esp32c6`, `partitions.esp32c6.csv`); `idf.py create-project-from-example "espressif/esp_hosted:slave"` → `set-target esp32c6` → flash. **Caveat / needs PO + bench hardware:** flashing the C6 on the EV board uses an **ESP-Prog wired to the `PROG_C6` header** with the P4 held in bootloader mode (esp_hosted `docs/esp32_p4_function_ev_board.md` §5.2): the NANO's C6-flash path must be confirmed (separate USB? equivalent header? ESP-Prog?), and an ESP-Prog may be needed. An OTA slave-update path exists but needs a *working* link first (chicken-and-egg here). This is a hardware-provisioning task, not application code. Secondary fallbacks if firmware-match doesn't fix it: an esp_hosted option to skip the reconfigure/slave-reset when the transport is already up at boot; a slower SDIO freq or 1-bit mode; verify GPIO 54 reset polarity/timing for the NANO. **(Note: EIM, the build-system v6 ecosystem item, does NOT help here; it's a host-machine installer, unrelated to device-side C6 firmware.)** + + **User lead (2026-07-09): avoid the WiFi teardown/re-init on hosted targets entirely; it may sidestep the slave-reset failure.** A user hit the mirror symptom on a *different* codebase (ESP32-Sveltekit / WLED-MM, not MoonLight — its `lib/framework/WiFiSettingsService.cpp` doesn't exist here): after a clean C6-flash the P4-NANO WiFi worked, but their app's boot-time `WiFi.disconnect(true)` fully tore down the WiFi stack, and the later AP bring-up then failed with `esp_hosted_transport_config: Transport already initialized` / `esp_hosted_init failed!` / `AP enable failed!`. Their working fix: on `CONFIG_ESP_WIFI_REMOTE_ENABLED` (= hosted) targets, **don't do a full stack reset** — keep STA enabled, reconnect *without* a full teardown/re-init, and never call `WiFi.disconnect(true)` in the disconnect callback. **Why this is relevant to us even though the file differs:** our own comment at `platform_esp32.cpp:796` already documents that the esp_hosted transport is set up **once at boot** and is fragile to re-init (`connect_to_slave` = a transport reconfigure that resets the slave + re-inits SDIO and fails on a live link). Our disconnect *callback* is already safe (`wifiEventHandler` on `STA_DISCONNECTED` only sets a flag — no teardown), BUT our **failover path is not**: `wifiStaStop()` (`platform_esp32.cpp:915`) calls `esp_wifi_deinit()`, and the STA-retry / AP-fallback then re-runs `ensureWifiInit()` → `esp_wifi_init()` — the exact deinit→reinit cycle that on a hosted target triggers the GPIO-54 slave reset (round-3 open issue 1's `sdmmc_card_init failed`). So the round-3 failure ("runtime SDIO re-init of the C6 fails") and this user's report may be **the same root cause**: the re-init shouldn't happen at all on a hosted target. **Concrete next step to try on the bench:** guard the teardown/re-init on `platform::hasWifiCoprocessor` (already defined = `isEsp32P4 && hasWiFi`) — on hosted targets, do NOT `esp_wifi_deinit()` in `wifiStaStop()` and do NOT re-`esp_wifi_init()` in `ensureWifiInit()` once the boot-time init is up; instead just `esp_wifi_disconnect()` + `esp_wifi_set_config()` + `esp_wifi_connect()` (STA retry) or `esp_wifi_set_mode(APSTA)` for the fallback, reusing the live transport. This is cheaper than the C6 reflash and independent of #18759, so it's worth trying first once the board boots. If it works, it also removes the slave-reset from the normal failover, not just the AP case. Blocked behind the #18759 boot crash like everything else P4-WiFi, but this is the first thing to try when the board boots again. + +#### The cascade into a dead co-processor link aborts the board (2026-09-08) + +Bench, MHC-WLED ESP32-P4 shield on `esp32p4rev1-eth-wifi`, no Ethernet cable: the NetworkModule +cascades to WiFi, and the board reboots every ~22 s with `task_wdt: main (CPU 0)` while `IDLE0` +runs. The main task is not spinning, it is BLOCKED: on the P4 every `esp_wifi_*` call is forwarded +over SDIO to the ESP32-C6 by `esp_wifi_remote`, and this shield's C6 never completes the ESP-Hosted +handshake (`E H_API: ESP-Hosted link not yet up` at boot), so `esp_wifi_init` and the calls after it +each wait out their timeout, on the render thread, past the 5 s watchdog. The wait is +esp_hosted's `transport_drv.c` slave-ready loop: 200 ms polls, a slave reset every 50 of them (the +`Reset slave using GPIO[54]` lines at 10 s and 21 s), up to MAX_RETRY_TRANSPORT_ACTIVE = 100 +polls, so worst case ~20.0 s in the caller's task, which is ours. Not the HWLOOP/FFT +erratum: audio was ruled out by the watchdog text itself (a blocked main task with idle running, +not a spinning core). Control experiment, same image on the bench P4 (.139, same shield model, +esp_hosted host 2.12.13): its SDIO card init succeeds (`Card init success, TRANSPORT_RX_ACTIVE`), +it never associates either, but the main task keeps ticking, it falls back to its own AP, and it +rejoins Ethernet when the cable returns. The new shield never prints a card-init success: its C6 +does not answer the bus (no slave firmware, or one that does not match the 2.12.x host). + +Two defects are ours, whatever the C6 carries: + +1. **The cascade does not consult the link.** `coprocessorWifi()` in `platform_esp32.cpp` already + asks the C6 for its firmware version, bounded to two attempts, exactly to detect an absent or + incompatible slave. The WiFi cascade never asks; it walks straight into `esp_wifi_init`. Gate the + cascade on that answer (or on `esp_hosted` reporting the link up) and degrade to "no network, + C6 not answering" as a Network status. +2. **A failing cascade must not abort.** Robustness says degrade visibly, never crash. Even with the + gate, a link that dies later would hit the same watchdog: the forwarded calls need to run off the + render thread, or with a timeout shorter than the watchdog, so the worst case is a status line. + +Practical today: the shield runs the eth-only image without WiFi, or its C6 gets the ESP-Hosted slave +firmware flashed (the `ships: false` note on the variant in `build_esp32.py` says why that is not +yet reproducible). The Improv script's eth-only rule and the catalog's two firmwares for the shield +are already in place. + +**Everything tried on 2026-09-08 was reverted; both P4s now run `esp32p4rev1-eth` and are stable.** +The tree is back to its pre-attempt state except for the catalog, which now lists BOTH P4 firmwares +for the MHC-WLED shield so the installer offers the WiFi variant at all (it listed only the eth one, +which is why the WiFi image could not be picked). What the day established, so the next attempt does +not repeat it: + +- **The C6 link is INTERMITTENT on this shield, not simply dead.** After the vendor C6-update tool + ran (it never completed an OTA: it looped on "Not able to connect with ESP-Hosted slave device"), + the link came up and the board reached a DHCP lease on WiFi, first attempt, no retries. A power + cycle later it was back to `sdmmc_send_cmd returned 0x107` and a reboot loop. Any future fix has + to survive a cold boot, not one lucky session. +- **A patient STA retry (MoonLight's 5 s cadence, ~2 min budget) was implemented and reverted.** It + never fired on either board: both connected on the first attempt when the link worked, and when it + did not the board died before the render loop. Keeping untested robustness was not worth the + surface. The reasoning still holds and is worth redoing WITH a repro: MoonLight retries forever + and never tears the radio down, and its own comment warns that toggling WiFi on a co-processor + board forces costly esp_hosted reinit cycles, which is exactly what our AP fallback does. +- **Espressif does not endorse retrying into readiness.** Their esp-hosted troubleshooting puts + "not able to connect with slave" and SDIO 0x107 down to wiring, pull-ups, signal integrity, or a + host/slave VERSION MISMATCH: "use the same version for master and slave". Our host is 2.12.13; the + shield's C6 is on the factory build the vendor tool calls 0.0.6 (target 2.0.17). Updating the C6 + needs Method 2 (direct USB/UART to the C6), since Method 1 needs the very link that is broken. + +**A fail-fast gate was tried on the bench (2026-09-08) and REVERTED.** The idea was right and both +signals were wrong. `platform::wifiHardwareReady()` gated the STA and AP init, and it STOPPED the +reboot loop dead: the shield ran 80 s with 0 reboots and 59 ticks, logging "WiFi co-processor link +not up" instead of aborting. But on the bench P4 (.139), whose link demonstrably works, the same +predicate read FALSE at its 26 s cascade and refused WiFi on a healthy board. Two signals were +tried, neither is usable as a readiness test: + +- **`ESP_HOSTED_EVENT_TRANSPORT_UP`**, subscribed on the default event loop (both lazily and from + `ensureNetifInit`, i.e. before esp_hosted's task posts it). Never observed arriving. esp_hosted + posts through its own `g_h.funcs->_h_event_post` indirection; where that lands was not chased. +- **`esp_hosted_get_coprocessor_fwversion` returning a non-zero version.** Also false on .139, which + matches what `coprocessorWifi()` already records: on a live link this RPC times out rather than + answering, which is why that function is bounded to two attempts. + +**Not a regression: the RELEASED v4.0.0 image fails the same way on this shield** (bench, same day). +Flashed from the web installer after an erase, it logs `App version: v4.0.0` and then: + +``` +W (13438) H_SDIO_DRV: Reset slave using GPIO[54] +W (13438) gpio: conflict found for GPIO[54] +E (14988) sdmmc_io: sdmmc_io_rw_extended: sdmmc_send_cmd returned 0x107 (ESP_ERR_TIMEOUT) +E (14988) H_SDIO_DRV: failed to read registers +``` + +and reboots without ever reaching the render loop. The same v4.0.0 image serves WiFi on the bench +P4 (.139) with no SDIO error and no GPIO 54 conflict, and no MoonLight config on any P4 entry +references GPIO 54 (esp_hosted drives it as the slave reset). The two boards differ in silicon +revision: this shield is chip rev **v1.0**, .139 is **v1.3**. So the C6 side of this shield does not +come up, which is a board/slave-firmware matter rather than anything in our WiFi path, and the +firmware's job is only to degrade rather than reboot. + +**The product owner reports this same shield ran WiFi under MoonLight on IDF 5.5**, which makes a +dead C6 unlikely and points at host-side SDIO configuration on 6.1. What was checked (2026-09-08): + +- The SDIO data pins are NOT board-preset dependent: they come from the SLOT choice + (`ESP_HOSTED_SDIO_SLOT_1`, fixed silicon pins), so swapping `ESP_HOSTED_P4_DEV_BOARD_*` presets + would not move them. The preset mostly moves SPI pins, which we do not use. +- `ESP_HOSTED_SDIO_GPIO_RESET_SLAVE` defaults to **54 on any P4** regardless of preset, so the + `gpio: conflict found for GPIO[54]` line is esp_hosted resetting the slave twice, not a wrong pin + from our config. No MoonLight P4 entry references 54. + +**Our SDIO configuration is not the difference.** Diffed against a known-good local reference +(`ewowi/FlowFields/sdkconfig.esp32-p4`, an IDF 5.5-era P4 build), every hosted setting is IDENTICAL: +pins (CLK 18, CMD 19, D0-D3 14-17), `GPIO_RESET_SLAVE` 54, `RESET_ACTIVE_HIGH=y`, `CLOCK_FREQ_KHZ` +40000, `SLOT_1`, `4_BIT_BUS`, `RESET_DELAY_MS` 1500, `RX_STREAMING_MODE`. So reset polarity, reset +pin and SDIO clock are all ruled out as differences, and the identical released v4.0.0 image works +on .139 and not on this shield. Same firmware, same config, two boards, two outcomes: what remains +is on the board side (C6 slave firmware, power/strapping, or the SDIO traces on this revision), and +the only MoonLight work left is the degrade-instead-of-reboot fix above. + +What esp_hosted's private `is_transport_tx_ready()` reports is the signal the slave-ready loop +itself polls, but it lives in a PRIVATE include dir (`host/drivers/transport`, not in the +component's `pub_include`), so reaching it means either adding that dir to our include path or +asking upstream to export a readiness getter. That is the next thing to try, and it needs the +.139-still-associates control run alongside the shield-stops-rebooting one: the fix is only right +when BOTH hold. + +#### Reading the P4's runtime log over USB + +The P4-NANO's primary console is **UART on GPIO 37/38** (`CONFIG_ESP_CONSOLE_UART_DEFAULT`), not the USB port, so `ESP_LOGI` / `mm_net` lines are *not* visible over `/dev/cu.usbmodem*` by default — only the ROM boot banner and `std::printf`-to-stdout (which routes to the **secondary** USB-Serial-JTAG console) come through. Two workarounds when you need the runtime log over USB: (a) temporarily set `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` (note the JTAG endpoint re-enumerates when the app starts, so a reader must reconnect across the drop — `idf.py monitor` handles it; a plain fixed `pyserial` handle dies); or (b) hang a USB-UART adapter on GPIO 37/38. This cost real time during the P4 no-DHCP hunt; the fastest signal there turned out to be a `printf` of the runtime struct (stdout → secondary JTAG console) plus a `git worktree` bisect (build an old commit, flash, check LAN reachability) to prove code-vs-hardware without needing the log at all. + +### ESP32-P4 Parlio: a real long WS2812 strip + +Kind: Investigation · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086025) + +A real *long* WS2812 strip, not just the bench panel, is the remaining hardware proof of the Parlio driver. The loopback self-test caps its frame to `ParallelLedDriver::kLoopbackTestLights`, which its declaration explains. + +### ESP32-P4 DHCP hostname not shown by the router (recheck later) + +Kind: Investigation · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086097) + +the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) — the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. + +### ESP32-P4 400 MHz on rev-3+ silicon + +Kind: Feature · Platform: P4 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086159) + +**Found:** bench, 2026-07-25 (Quindor Discord question about the P4's 400 MHz). + +The P4 build runs at **360 MHz** because IDF's `Kconfig.cpu` caps a `SELECTS_REV_LESS_V3` build (which we set, so a stock binary boots on the v0.x/v1.x P4 chips in the field) at 360; 400 MHz is IDF's default only for rev ≥ 3. Forcing 400 on our bench P4 (a **rev v1.3** chip) was tried and **bootloops** — `assert failed: esp_clk_init clk.c:105` — so 400 is a genuine hardware limit on pre-rev-3 silicon, not marginal stability. The ~11% compute headroom is left on the table for rev-3+ owners. + +The rev-3 image exists: `esp32/sdkconfig.defaults.esp32p4rev3` sets `CONFIG_ESP32P4_REV_MIN_300`, so the bootloader refuses a pre-rev-3 chip. What remains is `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_400` in that fragment, which waits until a rev-3 P4 on the bench proves 400 runs clean: no untested clock config, per the same rule the S31/320 and this P4/400 bootloop both taught. + +### LED output from a Raspberry Pi's own pins (robwomp, 2026-10-11) + +Kind: Feature · Platform: Pi · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086230) + +Suggested on Discord after the Teensy port: a Pi runs MoonLight today as the Linux desktop build, so its LEDs are reached only through the network drivers. +Driving a strip from the Pi's own header needs the platform backend that does not exist yet (`src/platform/rpi/`, the same prerequisite as the sensor item below), and a `LedPeripheral` behind it. +The routes differ by model: on a Pi 4 and earlier, DMA through the PWM, PCM or SPI peripheral, the way rpi_ws281x does; on a Pi 5 the header sits behind the RP1 chip, whose PIO blocks are the route. +SPI is the one path every model shares, one lane per SPI bus with each WS2812 bit encoded in SPI bits, and the place to start. +Portable GPIO libraries (WiringPi, libgpiod) are too slow for LED timing, so they serve buttons and relays, not this. +On a Pi 5, own PIO programs reach the hardware through the kernel's PIO driver, which piolib shows; Adafruit's PioMatter proves HUB75 that way, and HUB75 on a Pi is a second backend once this one exists. +The research, with sources: [the LED-driver analysis](../../design/leddriver.md). + +### SE16 / LightCrafter power monitoring: sense pins free, module still to build (2026-09-02) + +Kind: Feature · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086286) + +Both boards carry voltage and current sensors. MoonLight records the SE 16 V1's pins +(`MoonBase/Modules/ModuleIO.h`, board presets): **voltage GPIO 8, current GPIO 9**; the LightCrafter's are on [its page](../../reference/hardware/lightcrafter-16.md#power-sensing). + +**Still open**, per board: `voltagePin`/`currentPin` in the two device definitions, "Power +monitoring" moving from `planned` to `supported`, the SE16's scale factors (the divider ratio and +shunt value, which are NOT in MoonLight's preset and need the schematic), and a reading checked +against a meter. + +Worth doing because it also gives the ADC seam a real bench rig: an on-board analog signal beats a +hand-wired potentiometer. NOTE the pin move is unverified on hardware, both boards being offline +when it was made: confirm LED output still works after the change before trusting it. + +### LightCrafter 16: questions for the maker (2026-10-07) + +Kind: Investigation · Platform: S3 · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086342) + +The [LightCrafter 16 page](../../reference/hardware/lightcrafter-16.md) holds what the catalog and limpkin's pull requests say. +These are open, to ask limpkin: + +- The module part, the level shifter, the output voltage and the connectors. +- The power input: the connector, the voltage range, the fusing and the reverse-polarity protection. +- Which USB-C connector is the S3's own USB and which the serial bridge, and which chip the bridge is. +- Whether the S3's own port supplies 5 V to a plugged-in device, such as a MIDI desk. +- The RS-485 transceiver and the Hall current sensor parts. +- A schematic and a pin-layout image. + +The device model's use of GPIO 19 and 20 as the LCD driver's unused signal lines is unverified on hardware. + +### Sensor input on Raspberry Pi 5: microphone, IMU, line-in + +Kind: Feature · Platform: Pi · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086408) + +Audio-reactive lighting (and motion-reactive) is core to what WLED-MM / MoonLight are known for. The Pi 5 is the right host for it: it has the CPU and RAM for real FFT-based audio analysis that the Xtensa ESP32 struggles with, and a full Linux audio + I²C stack. The audio side exists: `AudioService` on the desktop backend captures any PulseAudio or PipeWire device on a Pi, and the effects read its frame. What is missing is a `src/platform/rpi/` backend for I²S and I²C, since the Pi runs the **desktop** backend, which has no GPIO access. So this is a domain expansion built on a real platform-backend prerequisite, not a small add. + +**Target sensors and their Pi 5 interfaces:** + +- **Microphone** — I²S MEMS mic, or a USB audio device read via ALSA. The high-value one: FFT → frequency bands + beat detection drive audio-reactive effects. +- **Line-in** — the Pi 5 has no native analog input, so this is a USB audio interface / DAC HAT feeding the same audio pipeline as the mic; only the source differs. +- **IMU / gyro** — an I²C device (MPU-6050 / 9250-class) on the Pi's I²C bus; tilt / motion → effect parameters. + +**Increments:** a real `src/platform/rpi/` hardware backend (GPIO/I²C/I²S/ALSA) first, the prerequisite; until it lands, the Pi runs the desktop backend with no sensors. The IMU and line-in then slot into the same source-module + platform-API shape `AudioService` has. + +### Bench-verified classic parallel setups, not yet in the catalog + +Kind: Feature · Platform: classic · Trigger: a classic board ships parallel output as its default · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086478) + +- **Bench-verified classic parallel setups, not yet in the catalog** (2026-09-06). Every classic + board in `deviceModels.json` ships `RmtLedDriver`, so none carries a `ParallelLedDriver` entry, + and the driver's defaults (`clockPin` unset, `dcPin` 33) work on a classic board without one. + Two setups are proven on hardware and worth recording before they are lost: the **Olimex + ESP32-Gateway** drives 64 lights on GPIO 16 with `clockPin` 32 / `dcPin` 4, and its free pins are + scarce enough that a catalog entry should pin them explicitly rather than inherit a default (its + Ethernet PHY holds 18/23 as MDIO/MDC and 5 as reset, all of which the old defaults collided + with); the **QuinLED Dig-Next-2** drives 256 lights on GPIO 2 with `clockPin` unset / `dcPin` 33. + Add them when a classic board actually ships parallel output as its default, rather than + speculatively across the other 16 classic boards, none of which has been tested this way. + +### Classic-board memory: the wins are system-level, not per-module (2026-09-09) + +Kind: Investigation · Platform: classic · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086575) + +The Dig-Octa on a clean boot with RmtLed has **88,284 bytes free internal, 86,016 largest block**. +Under load and after driver swaps that largest block fell to 24,576, so fragmentation matters as +much as the total. + +What the modules hold is NOT where the RAM went. The whole tree reports **9,342 bytes** of +`dynamicBytes` (Layer 3,586, Preview 2,560, RmtLed 1,536, Drivers 1,536). So per-module trimming has +almost nothing left to win, and the remaining ~230 KB of internal RAM in use is WiFi, lwIP, FreeRTOS +task stacks and the binary's static data. + +Where to look, in order of likely return: + +- **lwIP pool sizes** (`CONFIG_LWIP_*`): TCP PCBs, pbuf counts, and the send/receive windows are all + sdkconfig knobs, sized for a general-purpose device rather than a controller with a handful of + connections. +- **WiFi buffer counts** (`CONFIG_ESP32_WIFI_*_BUFFER_NUM`): the static RX/TX buffer pools are the + single largest tunable block on a classic board. +- **Fragmentation, not just totals.** A 24 KB largest block with 50 KB free is a placement problem; + allocating the long-lived buffers early and together is what fixes that. + +Each is an sdkconfig change measurable on the bench in minutes (`freeInternal` and `maxBlock` on a +clean boot), and each risks a different failure: too few WiFi buffers drops packets under load, too +small a stack overflows under a rare path. So measure one at a time on a board that is actually +serving the UI, not idle. The payoff is real: at 88 KB free a classic board is one large allocation +away from trouble, which is what drove both driver decisions on 2026-09-09. diff --git a/docs/work/backlog_mirror/process-and-docs.md b/docs/work/backlog_mirror/process-and-docs.md new file mode 100644 index 000000000..f717fd1be --- /dev/null +++ b/docs/work/backlog_mirror/process-and-docs.md @@ -0,0 +1,406 @@ +# Backlog: Process and docs + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## AudioService + +### Flaky unit tests: the AudioService sync suite contends on a fixed UDP port + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086639) + +**Found:** 2026-07-27, caught by `premerge.py`; pinned to the exact cases by looping the suite and keeping the failing logs. Fails roughly **1 run in 10**. + +Two cases in `test/unit/core/unit_AudioService_sync.cpp` fail intermittently, both around the socket rather than the audio logic: + +- `:125` *"…drives frame_, then holds it and reports listening"* — `CHECK(strcmp(status(a), "listening") == 0)`. The receive path **binds** the fixed port `kTestSyncPort` (21988); when a previous run's socket is still in `TIME_WAIT` the bind fails and the service never reaches "listening". +- `:95` *"Local+send: lazy-opens once and reports sending"* — `CHECK(strcmp(status(a), "sending") == 0)` plus `syncOpenForTest()`. This one **connects** to a broadcast address rather than binding, so it is a *different* failure mode on the same suite. + +**An attempted fix that did not hold (recorded so it is not retried blind).** Deriving the port per run (from the steady clock) instead of the fixed constant cut the receive-path failures but left the send-path case failing ~3 in 20 — because that case never binds the test port, so the port was never its problem. The real fix has to address the send path's socket open on its own terms, not just port choice. + +**Why it matters:** the gate scripts run the suite on every commit and merge, so a 1-in-10 flake interrupts gate lists often enough to train the reader to re-run instead of investigate — the habit that hides a real regression later. + +**Fix (do not paper over it):** treat the two cases separately. For the receive path, bind an **ephemeral** port (bind 0, read the assigned port back) so two runs can never contend. For the send path, first determine *why* the lazy open fails under repetition: whether the broadcast connect is refused or the latch is left set by an earlier case in the same process. A retry wrapper or a sleep is explicitly the wrong fix: it hides the next genuine failure too. `unit_NetworkReceiveEffect*` (a real UDP round trip on localhost) and `unit_SineEffect` (timing) flake the same way, about 1 full run in 3, and deserve the same treatment while the fix is fresh. + +**The quiet-packet case waits on the wall clock** (2026-09-06). `test/unit/core/unit_AudioService_sync.cpp` drives the quiet-packet case with `platform::delayMs(1)` +inside a 100-iteration polling loop, so the assertion that `level` reaches zero depends on real +elapsed time and on loopback UDP delivering within that window. It passes today and has not been +seen to flake, but it is the shape that produces a rare CI failure nobody can reproduce, and it +spends real milliseconds in a suite that otherwise runs on a test clock. + +The fix is the deterministic UDP seam plus `platform::setTestNowMs`, the same pattern every other +timing test here uses: feed the packet, advance the clock explicitly, assert. Raised by CodeRabbit +on PR #96 and deliberately not taken in that pass: it is pre-existing rather than part of that +diff, and swapping a test's transport is a change that wants its own verification. + +## Across modules + +### Headers link the docs by page, not by host (2026-10-21, from the PR 150 review) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086721) + +Seventeen headers under `src/` carry absolute `https://moonmodules.org/MoonLight/...` links, so a docs move edits firmware sources and marks every firmware stale, as PR 150's move from `how-to/` to `reference/` did. A header names the page site-relative (`reference/hardware/panel-cards.md#...`) and `gen_help.py` resolves the host once, so the next docs move touches no source file. + +### Remove the tests' own clock resets, now the listener's job (2026-10-25) + +Kind: Restructure · Platform: desktop · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086775) + +The doctest listener in `test/unit/test_main.cpp` puts the test clock back on the real one before every test case and subcase rerun, so the 90 resets in `test/unit` (`setTestNowMs(0)` in a guard struct or at a test's end) do the same job twice. Remove them: a guard struct and its uses go, a guard that also restores the heap, the WiFi scan, the palette or the boot record keeps that half, and a bare reset in the middle of a test stays wherever the rest of that test reads the real clock. About 150 lines, proven by the full suite. + +### Three decisions left open by the code ratchets (2026-10-25, from the Code ratchets plan) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086853) + +- **Name spread's limit**, 10 files beyond a type's own (`MAX_SPREAD` in `check_code.py`), which no tool defaults: confirm or move. Recommended: confirm, since the count only ratchets down. +- **A gate runner for agentless development**: the gate lists are run by an agent today, and a local-model or agentless workflow wants one runnable command per event. `precommit.py` went on 2026-10-01; whether one returns, and in what shape. +- **Undocumented public declarations**: a generated page shows an empty entry for each, which argues for a docgen rule, while cut-first argues against comments for their own sake. The middle way counts only public declarations in headers that generate a page. + +### A catalog entry extends another (2026-10-18) + +Kind: Feature · Trigger: at the first two entries that share a base · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287086933) + +A shield is a carrier board plus its own pins, as a Serg shield is a D1-Mini32 with more wired. An optional `extends: ""` in the [device catalog](../../../mooninstaller/README.md#the-device-catalog) lets an entry take another's `state`: levels nest (MCU, carrier, device), a child overrides its parent at the same module and control, and modules merge by name. +**Build it** at the first two entries that share a base, as a pass in the installer and MoonDeck before they send the document, with `check_devices` resolving it too. + +### The prose sweep, now counted and ratcheted (2026-09-28) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087002) + +The em-dashes dominate and are the mechanical part: a comma, a colon or a full stop replaces each, +and the same sweep already ran once over `src/**/*.h` when `CComments` was added. The rest is +judgment per line. Do it as its own change with its own review, a directory at a time, rather than +inside a branch about something else: a blanket find-and-replace over comments is how a code +identifier gets rewritten by accident. + +### `check_docgen` reads declarations from the clang AST (2026-10-15) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087063) + +The "public member has no `///`" rule reads a declaration's shape with a regex and tracks access by counting braces. It missed 25 functions carrying `MM_NONBLOCKING` until the pattern learned that macros follow a parameter list, and its comments list the other shapes it once misread. `check_clang_query.py` and `gen_help.py` already parse every header with libclang, which knows each declaration's access, kind and attached doc comment. Moving the rule onto that parse removes the brace counting, the depth stack and the shape patterns, and it reaches `.cpp` files, which the regex cannot read. + +### An example: MoonLight in a pod beside the music server (belrik, 2026-10-21) + +Kind: Feature · Platform: Pi · Stage: Document and commit · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087152) + +belrik runs MoonLight in a Kubernetes pod with PulseAudio, squeezelite and sendspin in containers of their own, playing 96 kHz / 24-bit files through RAOP, each part upgraded on its own. +That is audio-reactive light fed from a music server, and the reference docs do not yet show how containers share a PulseAudio server. +An example page under Reference → Examples, built from belrik's manifest, shows it; asked for on Discord. + +### Retire the OTA fallback repository, once a device has another recovery route + +Kind: Restructure · Stage: Release · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087252) + +`FirmwareUpdateModule.h` names two: `kReleaseRepo` is tried first and `kFallbackRepo` catches a device whose firmware predates the move. It is tempting to call the second dead code after the rename, and it is not: `platform_esp32_ota.cpp:195` retries the alternate address whenever `esp_https_ota_begin` FAILS, which covers DNS, TLS, an outage and a variant whose asset is missing, rather than only a repository that has moved. So a v6.0.0 device on the happy path never reaches it and a v6.0.0 device whose first request fails still does. Removing it deletes that second chance for every board, which wants a replacement rather than a deletion: a configurable update URL, or a retry against the same address, would give a device somewhere to go. The one cost of keeping it is a wasted request on the failure path, which is already the slow path. + +### macOS code-signing (Developer ID) + +Kind: Feature · Platform: desktop · Stage: Release · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087327) + +the release `.dmg` is now ad-hoc signed, which turns Gatekeeper's outright refusal into the "unidentified developer" prompt a user can accept via right-click Open. A paid Developer ID certificate plus notarization would drop that prompt too. + +### Windows code-signing + +Kind: Feature · Platform: desktop · Stage: Release · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087406) + +drops the SmartScreen warning on first run of `MoonLight.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. + +### Publish SHA-256 sums with release assets + +Kind: Feature · Stage: Release · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087501) + +the Defender false-positive guidance can only +say "re-download over HTTPS" today; a checksums file per release lets a user verify a +quarantined installer before restoring it. One sha256sum step in the release staging. + +### Installer UX polish + +Kind: Feature · Stage: Install · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087561) + +clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. + +### Offer MoonLight as a library + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287087985) + +a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from*: the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([the MoonLight plan](https://github.com/MoonModules/MoonLight/issues/193)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. + +### GCC Release-mode truncation warnings (17, non-blocking) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287088042) + +`uv run moondeck/build/build_desktop.py --gcc` reproduces CI's compiler (CI builds **Debug**, and that is clean). Building the same tree **Release** with GCC surfaces **17 further `-Wformat-truncation` / `-Wstringop-truncation` / `-Wstringop-overflow` warnings** in `Control.cpp`, `HttpServerModule.cpp`, `JsonUtil.h`, `MqttModule.cpp` — Release inlines more, so GCC can prove more about buffer sizes and gets stricter. + +They are **not** CI failures (CI is Debug) and each one inspected so far is a false positive of the same shape: a bounds check exists, but GCC cannot prove the degenerate case (e.g. `HttpServerModule.cpp:2400` warns "writing 1 byte into a region of size 0" on a line *guarded* by `if (wsLen + headerLen > sizeof(hdr)) return false;`). Still worth clearing: each is either a genuinely unprovable bound (fix the code) or a bound the code knows but does not state (make it explicit). Doing it in one pass beats letting them mask a real one later. + +Not done with the multi-destination/tab-UI merge because 17 warnings across four core files is its own change, not a tail on someone else's. + +**GCC below 16 needs four warnings demoted, and nothing exercises those versions.** (the demotion and its reasons are in `CMakeLists.txt`). It is a suppression, not an understanding: nobody routinely compiles with 12-15, so a REAL instance of one of these on those versions is now a warning nobody reads. Revisit when the runner's default GCC reaches 16, at which point the whole block can be deleted. + +### British spellings and em-dashes predate the prose gate (319 files) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128769) + +`check_prose.py` reports on ADDED lines only, so the American-spelling rule has been enforced from +the day it landed forward, and everything written before it was never swept. 118 files still carry +`colour`, `centre`, `behaviour`, `recognise`, `initialise` and friends, in comments and in a few +identifiers. + +**Em-dashes are the same story and the larger half**: 10,326 of them across 319 files under `docs/`, +against the same rule and missed for the same reason. They are worse than the spellings to sweep +mechanically, because the right replacement depends on the sentence (a colon where it explains, a +comma for an aside, a full stop between two clauses), so a blind substitution produces prose nobody +proofread. Count them per file and do the biggest offenders by hand. + +A sweep is its own change ([documentation-standards](../../contributing/documentation-standards.md#writing)), with the gate run over the whole tree afterwards rather than over a diff. + +Identifiers first and separately: a rename changes an API, where a comment does not. `centre()` in +the shipped `crosshair.mle` was one and is already fixed, since a shipped teaching script is the +highest-value case. + +### Network round-trip test: drop and reorder measurement + +Kind: Feature · Platform: desktop · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128844) + +`moondeck/scenario/run_network_roundtrip.py` measures desktop→device→desktop **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent color takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the desktop could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain. + +### MoonDeck doc-asset endpoint hardening + +Kind: Restructure · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128928) + +`moondeck/moondeck.py::_serve_doc_asset` accepts any ROOT-relative path and serves the file. Path traversal *is* blocked (`asset_path.relative_to(ROOT.resolve())`), but inside the repo any file is served — including local-only artefacts like `moondeck/build/wifi_credentials.json` if present. MoonDeck binds to all interfaces by design (the existing comment in `main()` explicitly enables LAN reach), so anyone on the LAN can hit the endpoint. + +Two improvements when this matters: +- **Subdirectory whitelist** — only serve under `docs/` (and image asset paths the markdown renderer needs). Reject `moondeck/build/wifi_credentials.json` etc. with 403. +- **Extension whitelist** — only image / CSS / JS mime types via a small allowlist. +- **Optional bind-to-localhost flag** — `--bind 127.0.0.1` for users who don't want LAN reachability. Default stays "" (all interfaces) since the LAN-reach is the documented design. + +Not blocking — MoonDeck is a developer tool, not a production server. Pick this up when MoonDeck is in scope for hardening. + +### A tagged release does not reach the web installer until the next main deploy + +Kind: Bug · Stage: Release · Process: release · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287128996) + +`deploy-pages` in `.github/workflows/release.yml` is gated `if: github.ref == 'refs/heads/main'`, because the `github-pages` environment's protection rule only allows main. The installer's release list is **staged into the Pages site at deploy time** (`install.js` self-hosts the last 5 stable + 5 prerelease releases; the release-asset URLs redirect to a host that sends no CORS header, so the browser cannot read them from the Pages origin). Together those mean **pushing a `vX.Y.Z` tag publishes the release but never updates the installer** — the new version reaches the picker only when something later pushes to main. + +Hit on v4.0.0 (2026-08-24): the release published at 16:15:59, a main deploy ran at 16:16 and enumerated releases *before* it existed, and the installer offered v3.0.0 as newest for hours. Re-running the workflow with the tag fixed it, and that manual re-run is the current workaround. + +Note the device's own OTA picker is unaffected — it reads `api.github.com` live ([app.js](../../../src/ui/app.js) `RELEASES_API`), which is why a device could offer v4.0.0 while the installer could not. Two independent paths to the same release list. + +`restage-pages-for-tag` in the same workflow already covers this: on a `v*` tag it re-invokes the workflow on `main` with `tag=`, so `deploy-pages` runs under the allowed ref and the manifests land in `releases//`. The open item is narrower than the symptom suggested — whether the dispatched run can still lose the race, since it is a *second* run whose release enumeration happens later, and whether relaxing the `github-pages` environment's branch policy (needs repo-admin) would remove the indirection entirely. + +### CI: pin GitHub Actions to commit SHAs (supply-chain hardening) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129085) + +`.github/workflows/release.yml` references all 9 action types by mutable `@vN` tag (`actions/checkout@v4`, `astral-sh/setup-uv@v3`, `softprops/action-gh-release@v2`, `espressif/esp-idf-ci-action@v1`, …). A mutable tag can be force-moved to malicious code by a compromised publisher; pinning each `uses:` to a full commit SHA (with a `# vN` trailing comment) removes that vector. **Not done (this item):** SHA-pinning, because it carries an ongoing cost — pinned SHAs go stale and miss security patches, so it only pays for itself **alongside Dependabot** (or a Renovate config) to auto-bump them. Pick this up as a deliberate "CI hardening + Dependabot" pass, not piecemeal. Low risk today: every action pinned is a first-party `actions/*` or a well-known publisher (astral, espressif, softprops), not an obscure third-party action. + +### An offline test run takes 16 minutes instead of one (2026-10-03) + +Kind: Bug · Platform: desktop · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129170) + +With the host's network down, `test_desktop` ran 16 minutes and the scenarios stalled past the gate's hour; online, no test takes over 4 seconds, so something waits out network timeouts. +Next time the machine is offline, `build/macos/test/mm_tests --duration=true` names the test, which then gets a seam like `setTestJoinFails`. + +### The restructured scenarios lost four things the archive asserted (2026-09-29) + +Kind: Feature · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129247) + +What did not survive the move of the 27 archived scenarios to the per-module set, and nothing else in the tree covers: + +**Every performance contract and bound is gone**, measured rather than estimated: `contract` 40 to 0, `bounds` 20 to 0. +Both mechanisms are still implemented in the runner (`scenario_runner.cpp` enforces a `tick_us` ceiling and a `free_heap` floor, and the `bounds.fps` gate below it). +The new set records `observed` numbers, which is a trend rather than an assertion, so nothing fails on a tick or heap regression. +One document still states otherwise and should be corrected when this is: `docs/reference/performance.md` says each scenario step carries an enforced contract. +The `device/` set carries `observed` blocks from a real ESP32-S3 as of 2026-09-29, which is the window a contract would be set from; none is set yet, since one run per step is not a baseline. + +**The buffer checks run ONCE at the end of a scenario**, already filed under per-step assertions below, so a resize can still only assert that the control changed. +Nothing can say the buffer grew to 1024 lights at 32x32 and shrank back, which is the claim the scenario's own name makes. + +**`HttpServerModule` has no scenario**, though it is a scheduler root on every networked target and the transport the live tier itself runs over. + +What no unit test can reproduce is the cross-board comparative number set the peripheral sweep produced. + +**A measurement taken after a failed allocation is recorded as though the work happened (2026-09-30).** `scenario_Fluid_solver` on a classic ESP32 reads 107 us at 64x64 where an S3 takes 71,441 us, which no float-heavy solver does: the classic is 670x faster only because its grids never allocated. The tell is in the same block, where free heap RISES from 25,760 to 53,728 bytes as the tick collapses, so memory is being handed back while the effect ticks dark. `cube-20-d` reads the same way at 97 us. +Two halves, and neither exists today. `FluidEffect::prepare` refuses a grid it cannot allocate and leaves the effect ticking harmlessly, which is the right behaviour, but it publishes no status, so nothing downstream can tell a refusal from a cheap frame. And `run_live_scenario.py` records whatever tick it measures without asking whether the module producing it is in a state worth measuring. +The fix is a status on the effect (the shape `ParallelLedDriver` already uses for a bus that will not init) plus a runner check that labels or drops the sample. It needs a classic on the bench to verify, since that is the only board small enough to fail the allocation. Until then the affected rows are the classic's `grid-64-h`, `all-jets`, `long-persistence`, `cube-20-h` and `cube-20-d`, and they understate the cost rather than overstating it, so no contract set from them would be too lax. + +### The static set runs on every supported device before the rename (2026-09-29) + +Kind: Feature · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129341) + +The scenarios are the pre-cutover hardware check: a set that passes in-process proves the logic, and the same set against each board proves the platform layer under it. +That is the point of getting the static set solid first, and it is what the device sweep on cutover day reads from. + +Two of the three things it needed are now in place: the light scenarios render, and eight scenarios are marked `live_only` so a sweep asks a board something a laptop cannot answer. +What remains is a runner that takes the device list rather than one host, so the answer is a matrix across the chips we ship rather than a pass on whichever board was plugged in. +The first S3 run on 2026-09-29 found two defects the set had been hiding: a `channelsPerLight` prop that stopped being a control, and peripheral names renamed out from under the scenarios, where `optional: true` turned every wrong name into a silent skip and still reported a pass. + +`moondeck/moondeck.json` carries 24 registered devices and `run_live_scenario.py` takes a `--host`, so the missing piece is the loop and the per-device report, not the transport. + +### A scenario that runs until something breaks (2026-09-29) + +Kind: Feature · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129420) + +A static scenario proves a sequence works once. What it cannot find is the fault that needs a thousand random module additions, or a leak that takes an hour: the runner sets up, asserts and exits, so the long tail of lifecycle bugs has no test that reaches it. +A fuzz mode would run until killed on a device left on a bench, doing randomly chosen legal things and stopping the moment one fails. + +The runner already carries most of it: a step vocabulary, module add, remove and replace, control setting, measurement, and per-step failure reporting. +What it needs is a mode that generates steps rather than reading them, and four things that make a failure worth acting on. + +A **seed**, so a run is reproducible: a failure reprints the seed and the step index, and the same seed replays the same sequence. +A **legal-move set** the scenario names rather than the generator guessing, since a random string in a filepath control proves nothing about the pipeline. +**Halt on the first failure**, keeping the device in its broken state for inspection rather than tidying it away. +And a **floor under heap and tick**, because the faults this is for announce themselves as a slow drift rather than an assertion. + +Worth most where the gaps above are: random add and remove under a live pipeline is exactly what the archived `remove_module` steps covered, and a long run on a Raspberry Pi finds the leak a thirty-second scenario cannot. +Build it after the static set is solid, since a fuzz run is only as trustworthy as the assertions it fires. + + +- **Memory degradation cascade** — the output-buffer *allocation* decision (no buffer for a lone identity layer; a buffer for ≥2 layers or any LUT layer) is unit-pinned (`unit_Layers_container` "Drivers allocates the output buffer only when…"), and LUT-vs-identity is pinned by `unit_Layer_sparse_mapping`. What's **not** pinned is the *low-heap* half of [architecture.md § Degradation cascade](../../reference/hardware/firmware-variants.md#degradation-cascade): under heap pressure the LUT + driver buffer are skipped *together* (`lutSkipped()` true, forced 1:1), and below that the layer buffer *reduces dimensions* (halving to a 8×8 floor) rather than failing. The hook exists — `unit_BlendMap` already uses `platform::setTestMaxAllocBlock` to force allocation failure for the paging test — so a test could cap the block size and assert: (1) LUT+output buffer both skip and `lutSkipped()` flips, (2) the layer buffer shrinks to fit and never goes null. Pre-existing gap (predates multi-layer); the *happy-path* allocation contract is covered, only the OOM-degrade branch isn't. +- **Per-step assertions in scenarios (a framework gap, not a scenario gap).** A scenario step can + assert TIMING and HEAP (`bounds`, `contract`) and the run asserts the final buffer, but it cannot + say "after this step the fixture has 24 lights", "this module's status reports a compile error", + or "the rendered output changed". So a migrated script step proves the pipeline still ticks, not + that the edit did what it claims — the MoonLive scenarios walk break-and-recover cycles whose + most interesting states go unasserted. Wants a small vocabulary (`expect_lights`, + `expect_status`, maybe `expect_pixel`) added to **both** runners together: the desktop/live split + is exactly what let `write_file` exist on one and silently no-op on the other. Raised by + CodeRabbit against scenario_MoonLive_pipeline and scenario_MoonLiveEffect_controls; the sites are + listed there, but the vocabulary has to exist first. +- **UI page load time** — scenario step measuring HTTP response time for `/`, `/api/state`, `/api/system` via the live runner. Verifies acceptable load time on ESP32. +- **Module teardown memory** — scenario that tears down all modules and verifies heap returns to pre-setup baseline. Confirms no lifecycle leaks. +- **`syncVisibleControls` has no test in `test/js`.** `app.js`'s conditional-control DOM logic (`syncVisibleControls` reconciles which control rows are rendered when a `hidden` flag flips) was the source of a real re-render-loop freeze (Network static-IP toggle) caught only on hardware. A `jsdom` test that builds a card, flips a control's `hidden`, runs the reconcile, and asserts the right rows appear/disappear (and that it converges: the unchanged→no-op fast path) would have caught it. It needs an `app.js` seam first: extracting `reconcileControlRows` into a served file, wired through `embed_ui.cmake` + the two HttpServerModule routes like the other UI .js. +- **Browser-level Improv automation** (deferred) — `moondeck/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 + +Kind: Bug · Stage: Test · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129508) + +`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_perf_light`, `scenario_perf_full`) `clear_children` the containers and rebuild, then their cleanup leaves the tree **bare**. +- **Canonical-tree-assuming scenarios** (`scenario_GridLayout_resize`) 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. + +### Promote the one-line // cap to an error, once the 604 .cpp blocks are homed (2026-09-21) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129590) + +`MAX_CODE_COMMENT = 1` is already the rule in both kinds of file, and already an ERROR in a header. It stays a warning in a `.cpp` only because `_blocks()` stages the sweep by file kind, and that function names its own removal: a rule leaves the staging as the tree meets it, which no-hard-wrap already did. + +**The shape of what is left.** 604 blocks across 92 files, 2081 comment lines. By area: 239 in `test/`, 210 in `src/`, 155 under `src/platform/`. By length: 81 are two lines, 321 are three, 124 are four, so 526 of 604 are three lines or fewer and the tail is one 15-line block, two of ten and a handful between. + +It concentrates, which is what makes it approachable. `HttpServerModule.cpp` alone holds 100 and the top ten files hold 312, over half; the other 82 files average under four each. + +**Where the depth goes is the blocker.** Of the 92 files only 19 already carry an `@technical` appendix. 68 have a `///` lead with no appendix and 5 have neither, so most files need the appendix written before a single block can move. + +**The fix is not to split them.** Chopping one comment into two shorter lines leaves the text identical and satisfies nothing, which is why the cap counts a block rather than a line. Each one is depth that belongs in the file lead's appendix with an `@xref` back, or prose that says less. + +**Suggested order.** `HttpServerModule.cpp` first: 100 blocks, 444 comment lines, and it already has the appendix. Then the other nine heavy files for 212 more. Then `test/` as its own sweep, because the argument that a test comment explains why a test EXISTS is strongest there and may deserve a different budget rather than an appendix. Promote once at zero. + +**Cost.** Not a scripted pass. Each block is a judgement between depth that belongs in an appendix, an explanation that should be shorter, and history git already holds. About 35 done by hand in one session is the measured rate, so the first file is a session of its own and the whole sweep is several days. + +### ESP32 clang/LLVM toolchain: extend the clang checks to src/platform/esp32/ + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129673) + +Espressif ships an xtensa LLVM (their fork), but the installed `esp-clangd` package contains +**only `clangd`** — no `clang++` driver — so a clang analysis pass over ESP32 sources needs their +full LLVM installed separately. + +What it would buy: `-Wfunction-effects` (and clang-tidy, clang-query) over `src/platform/esp32/` +— 20 translation units, the only code the desktop build excludes. Everything else, including the +LED drivers, already compiles on desktop and is already checked. + +What it costs: a second ~1 GB toolchain, maintained purely for analysis — the firmware would +still be built by GCC, so the analysing compiler is not the shipping compiler. That is a real +"one rule, one owner" tension, and the reason this is a decision rather than an obvious yes. + +Worth revisiting when either the coverage gap bites (a hot-path bug traced to the platform layer +that the desktop check could not see) or Espressif's LLVM becomes the default toolchain. + +### clang-tidy: triage the remaining 30 findings + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129754) + +`.clang-tidy` runs `*` minus a documented disable list and reaches zero on everything except the +path-sensitive `clang-analyzer-*` family: **30 findings**, led by +`bugprone-unchecked-string-to-number-conversion` (9), `security.insecureAPI.strcpy` (5), +`core.NonNullParamChecker` (4), `deadcode.DeadStores` (3) and `unix.cstring.NullArg` (3). Roughly +half sit in test code (`strcpy` into fixed local buffers, a deliberate divide-by-zero probe). + +The nine `unchecked-string-to-number-conversion` sites were read individually and are all safe: +HueDriver guards on `if (id > 0)`, JsonUtil documents 0-means-absent, MqttModule uses a `v = -1` +sentinel and clamps. They stay reported rather than suppressed — a report shows the real situation, +and a `NOLINT` would hide a genuine class of bug for the next person who writes one of these. + +### Pin the preview cancel over loopback (test) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129830) + +`cancelBufferedSend` closes a preview client left mid-message, so the next frame is never read as the rest of this one, and nothing pins it. The loopback `WsClient` in `unit_HttpServerModule_subscribe.cpp` already drives `/wsp` and reads a frame whole; a client that stops reading mid-frame, then a cancel, then a check that its socket closed while a client that read the whole frame stays open, covers it. + +### MoonDeck scripts crash on Windows whenever their output is redirected (2026-08-22) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287129942) + +62 of the ~64 scripts print `→ ✓ ⚠ —` or box-drawing characters. A REDIRECTED Windows stdout takes `locale.getpreferredencoding()`, cp1252, and the first such character raises UnicodeEncodeError, *after* the real work has succeeded: `collect_kpi.py` measures everything, writes the metrics, then dies printing the summary arrow. An ATTACHED console has been UTF-8 since Python 3.6 (PEP 528), measured on this bench: `utf-8 isatty=True` from a console against `cp1252` through a pipe. So the entry's original framing was backwards. A human at a Windows terminal is the one case that works, and every redirected path fails: a pipe, a tee, a CI log, an agent's captured output. The gate runner that handed children `PYTHONIOENCODING=utf-8` is gone, which is why this surfaces now. + +The READ side breaks too, and that half is worse because `PYTHONIOENCODING` does not mask it: `Path.read_text()` and `open()` without an encoding decode as cp1252. A sweep found 43 unqualified `read_text`/`write_text` plus 47 bare `open()` across `moondeck/`. + +One Windows day (2026-09-29) hit it FOUR times in four different files, each fixed separately at its own call site: `_test_metadata.py` stopped the test runner starting, `test_desktop.py` would have killed a run on one non-ASCII byte from the test binary, `run_live_scenario.py` printed an arrow per step and so recorded a scenario the device PASSED as FAILED, and `preview_installer.py` died writing a staged page, leaving the web installer preview unusable on Windows. Four site fixes in one day is the argument for one home, made by the defect itself. + +The preamble is now copied into five scripts (`setup_esp_idf.py`, `ci/verify_version.py`, `docs/build_docs.py`, `scenario/run_live_scenario.py`, `scenario/run_scenario.py`), each carrying its own comment explaining the same fact. Per-script `sys.stdout.reconfigure()` is the wrong shape at 62 files: every new script would have to remember, and the one that forgets fails in the field. It wants ONE home, and the two candidates do not cover the same ground. `PYTHONUTF8=1` in whatever env MoonDeck's front ends already establish settles BOTH halves, because UTF-8 mode changes the default for `open()` and `Path.read_text()` as well as for stdio. A shared `moondeck/_stdio.py` imported by the entry points settles only the stdio half, since `reconfigure()` touches the standard streams and nothing else, so it leaves every unqualified `read_text`/`open` exactly as broken and those sites still need `encoding="utf-8"` written out. Pick when someone next runs a check by hand and it dies on a tick mark. + +**`run_scenario.py` was the fifth site, fixed 2026-09-30 ahead of a Windows run.** Its sibling `run_live_scenario.py` got the preamble on the Windows day; this one did not, and it relays the output of `mm_scenarios`, which prints an arrow and an em-dash of its own (`test/scenario_runner.cpp` carries 29 non-ASCII characters, several inside `printf`). Nothing had caught it because the in-process tier had only ever run on macOS, where an attached console and a pipe are both UTF-8. Left alone it would have read as a scenario failure rather than an encoding one, which is the misdiagnosis the live runner's fix was written to prevent. Five sites in two days is the count the one-home argument above should now be weighed against. + +### A Windows scenario run recorded tick averages 250x its own repeat, cause unknown (2026-09-29) + +Kind: Investigation · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130012) + +A Windows run of `scenario_Layouts_resize_reallocates_live` recorded 245 ms, 564 ms and 458 ms for its three measure steps, against 41 to 89 us for the same steps on macOS. `tickTimeUs` is a one-second average (`src/core/module/Scheduler.cpp:127`), so those figures describe a second containing roughly one tick, which reads as a blocking call rather than slow rendering. The blocks were reverted rather than committed, since an observation filed at 245 ms teaches a future reader that Windows costs that. + +An immediate repeat on the same machine and binary did not reproduce it. The three steps came back at 913 us, 1097 us and 768 us, and the tick log showed `PanelCard:0us`, `Network:1us` and `Drivers:30us` throughout, with the tick dominated by `MoonLive:3000us`. Nothing was blocking during the repeat, so the first reading stands unexplained. The candidate that fits the shape is `ethBindRawInterface()` calling `pcap_findalldevs` (`src/platform/desktop/platform_desktop.cpp:1299`), measured separately at about 2.3 seconds on Windows against milliseconds on macOS and reached through driver `prepare()` on a resize, but the repeat neither confirms nor kills it: no raw interface was bound on either run. + +The enumeration cost is real and independently measured, so caching it or keeping it off any path a resize can reach earns its place on its own. + +**The second half of this is already answered, from the other side of the same day (2026-09-29).** The ask was for a max-tick beside the average, to tell one blocked tick from a uniformly slow second. The observation block records `min`, `max`, `p50`, `p95`, `n` and the raw `samples` per target, so a repeat of this run reports its own worst frame rather than only its mean: a single 245 ms tick inside a second of good ones now shows as a `max` far above the `p50`, which is the distinction that was missing. + +**Re-measure before reading anything into the old figures.** `scenario_Layouts_resize_reallocates_live` was rewritten the same day (59 lines changed): it now builds a full pipeline and asserts with `expect_control` where it previously only measured, so the Windows numbers above describe a scenario that no longer exists in that form. Whatever a Windows run reports next is the figure to reason from, and it will carry the spread this entry wanted. + +### build_esp32.py reports success without building, under Git Bash (2026-09-28) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130100) + +Run from Git Bash it prints `MSys/Mingw is no longer supported`, stops after `Setting target to esp32s3...`, and **exits 0** ten seconds later with no binary. Run from PowerShell the same command builds correctly. A green exit code for a build that never ran is the worst shape a failure can take, and on a runner it would publish a stale binary while every gate stayed green. Either detect the MSYS environment and fail loudly, or re-exec through the shell the IDF tools expect. + +### build_desktop.py cannot relink while the app is running (2026-09-28) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130177) + +Windows holds an executable open while it runs, so a rebuild fails with `LNK1104: cannot open file ... MoonLight.exe` where POSIX would replace it. `run_desktop.py` already stops a running instance before launching; `build_desktop.py` could reuse that and remove a papercut that costs a confusing error every time someone edits code with the app open. + +### disasm.py cannot run on Windows (2026-08-21) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130264) + +The tool shells out to `c++` and `objdump` to build the per-ISA emitter and disassemble its bytes, and a Windows machine carrying only MSVC has neither. That holds for every ISA, not just the new `x86_64` one — but x86-64 is where it bites, because the host it disassembles is now a Windows desktop and the tool is what turns "the script did nothing" into an answer. The x86-64 backend was brought up without it, using byte-pinning tests plus WinDbg on the emitted image; that worked, and was slower than reading the instructions would have been. + +Closing it is a compiler/disassembler pair behind the two `subprocess.run` calls: `cl.exe` for the build, and for the disassembly either LLVM's `llvm-objdump` (which understands `-b binary` the way the tool already expects, and ships with the VS "C++ Clang tools" component) or a `.obj` wrapper around `dumpbin /disasm`, which does not. Prefer the former — the flags are already right, so it is a lookup rather than a second code path. + +### A preview GIF for every MoonLive script (2026-10-19) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130339) + +The 140 compiled effects each show a GIF on their catalog card (`docs/assets/light/effects/.gif`, 25 MB in all); the 49 MoonLive effect scripts show none. +The pictures belong in `docs/assets/moonlive/effects//.gif`, mirroring the script subfolders, never in `moonlive/` itself, which the build globs and the UI fetches file by file. +One MoonDeck script makes them all: `screenshot_modules.py` learns to step through the MoonLive catalog, capturing from the preview socket the way the hpwit GIFs of 2026-10-18 were made. +At 0.5 to 1 MB each that is 25 to 50 MB more, so a smaller format (128 px, 2 s) is part of the decision. +**Decide first** where they show: a MoonLive scripts page with a card per script grouped by subfolder, or the script picker previewing one before its download. + +### sigrok/fx2lafw cross-check + MoonDeck LED driver test script + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130440) + +- **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([the LED-driver analysis](../../design/leddriver.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it validates the SHIPPED render↔encode split's WiFi isolation (drivers tick on core 1; WiFi lives on core 0). A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve. diff --git a/docs/work/backlog_mirror/services.md b/docs/work/backlog_mirror/services.md new file mode 100644 index 000000000..e477a377a --- /dev/null +++ b/docs/work/backlog_mirror/services.md @@ -0,0 +1,124 @@ +# Backlog: Services + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## InfraredService + +### IR RX live-reconfigure recovery (unconfirmed, park until it recurs) + +Kind: Bug · Platform: S3 · Trigger: it recurs · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130529) + +- **IR RX live-reconfigure recovery — unconfirmed, park until it recurs** (bench 2026-07-13, SE16). IR reception on the SE16 (`IrService` pin 5) went dead mid-session and only a **hard reset** brought it back; a warm/API path did not. **Ruled out:** not hardware (hard reset fixed it, receiver+switch+wiring fine), not LED-count (IR survives the full 16384-light / 8 fps load — a received code still toggled a control at max load), not a regression from the i80 commit (`platform_esp32_ir.cpp` untouched, the 1250 ns glitch-filter fix intact). **Prime suspect (unproven):** the session's live pin churn — including transiently setting the i80 `clockPin` to **5, which IS the IR pin** — left GPIO 5 routed to the wrong peripheral, and the RMT-RX channel (a pin-keyed static behind `platform::irStop`/`ensureChannel`) didn't re-acquire cleanly on the next `irRead`; only a full GPIO re-init (hard reset) cleared it. This may be pure test artifact (nothing in a *normal* user flow points two live modules at GPIO 5). **To conclude:** from a fresh hard reset (IR working), in isolation set i80 `clockPin=5` then restore `clockPin=8` and check whether IR dies and whether it self-recovers *without* a hard reset — self-recovers → no bug (test artifact); stays dead → a real live-reconfigure gap in the IR channel re-acquire worth fixing (per *No reboot to apply a configuration change*). Small robustness/repro work; do it only if IR breaks again in real use. + +## SdCardService + +### An SD card inserted after boot mounts only when its module restarts (2026-10-12) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130634) + +A mount attempt on an empty slot runs the SDMMC card-init sequence to its timeouts, so `SdCardModule` never retries one on the render thread: a card inserted later mounts when the module is switched off and on, on a pin edit, or at boot. A pull is still noticed within a second through a cheap presence probe. Detecting an insertion without that stall needs a card-detect pin where the slot has one, or a mount attempt off the render thread. The timed retry PR #136 carried was dropped for this reason before its merge. + +## Across modules + +### A Service that calls another device's API (robwomp, 2026-10-11) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130713) + +Suggested on Discord: panels driven by another stack take their pixels from MoonLight over DDP or Art-Net, but their settings stay out of reach. +A user-added Service holds a list of HTTP requests, each a method, a URL and a body, and fires one when a control it watches changes, so a button, a MIDI knob or an OSC message drives any device that answers `curl`. +Inbound control already exists (`/api/control`, MQTT, OSC, MIDI); this is its outbound twin, and the generic complement to the typed command half of DevicesModule below, which knows each ecosystem's API. +Requests leave the render thread, since a slow peer must never stall a frame. + +### The Open Stage Control session's labels (2026-08-30) + +Kind: Investigation · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130773) + +Found wiring a real control surface to the [OSC module](../../moonmodules/core/services.md). + +**The shipped Open Stage Control session renders no labels, and its pad matrix draws nothing.** +[`docs/reference/examples/open-stage-control.json`](../../reference/examples/open-stage-control.json) works for every fader, encoder and switch, but the widget names never appear and the `matrix` of pads is an empty box. +Five attempts at the label property failed (`@{}`, `JS{}`, `#{}` with `unit`, an explicit string, omitting it so `"auto"` applies), and notably a session SAVED by Open Stage Control itself carries no `label` key on a fader at all, so the name is drawn from something else. +The session was written by reading a minified app's bundled HTML docs; the reliable fix is to build one widget of each kind by hand in its editor, save, and copy the shape it produces. +Cosmetic: the control path works, only the labels and the pad grid are missing. + +### MoonCloud: share a MoonLive script over MoonTalk (2026-09-10) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130853) + +Sketched 2026-09-10 while Talk was built. **The unique feature nobody else has**: MoonLight ships a scripting language whose programs are about a kilobyte of text, and a message board between devices. Together they mean a script someone wrote on their wall is one tap from running on yours. + +**What makes it plausible.** The scripts are tiny: the shipped `.mle` files run 780 to 1907 bytes and the whole library of twenty-odd is 36 KB. A script is self-contained by design, so the text is the artifact. And the device already compiles and runs arbitrary script text safely, which is the hard half and is done. + +**The design work is that a script is five to seven times a message.** Talk caps a message at 280 characters, deliberately: a chat message is a sentence, and one caller must not fill the table. Three options: + +- **A second endpoint** (`/api/script`) with its own size cap and table, and a message that references it by id. The chat table stays a chat table, a script is fetched when someone wants it, and a board read stays small. Most work, cleanest shape, and the recommendation. +- **An attachment column on the message**, capped separately. One table and one post, at the cost of every board read carrying script text unless the query is careful. +- **Share a URL.** A script already lives in the user's File Manager and a device could serve it over the LAN. Free, and limited to one network, which is the case that makes the feature interesting. + +**A shared script is code from a stranger**, in a way chat text is not. Three questions the design answers rather than discovers: + +1. **Loading is explicit and reversible.** A script arrives as something to look at, and taking it leaves what the user already has intact. `/moonlive` (user) shadowing `/.moonlive` (factory) is the existing trap. +2. **The device is the sandbox.** MoonLive reaches no filesystem and no network, so the blast radius of a hostile script is the LEDs and the render budget. Worth confirming rather than assuming: a script that never yields is a watchdog reset, which is a denial of service on someone's wall. +3. **Attribution and removal.** Any sender id can be claimed, and a message stays once posted. Fine for chat, weaker for code. + +Depends on Talk shipping and on the server being deployed. + +### The sensors an installation needs (2026-09-01) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130923) + +**Why this is a commitment rather than a wish list.** One of the intended uses is art installations, +and an installation people can interact with has to sense them: that is stated in +[the architecture](../../explanation/architecture/index.md#the-problem). Sensing is therefore part of the product, not a +convenience, and an input peripheral is first-class alongside an output driver. The scope is still +narrow on purpose: a lighting controller that senses its audience, not a home-automation platform. + +Beyond the sensors [the input mapping plan](../Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(7%20of%2012).md) carries (PIR, distance, IMU, rotary encoder), the sensors worth having next: + +- **Touch**: the classic ESP32's capacitive touch pins need no external part, so a conductive + surface becomes an input. The Dig-2-Go's own button is on a touch-capable pin, though it reads + fine as a plain digital switch. +- **Light level (LDR or a BH1750)**: an installation that dims itself to the room, and the obvious + companion to a piece that runs day and night. + +**None of these need new architecture**, which is the point of recording them together: each is a +Service under the core `Services` container, reaching the rest of the system by the plan's two paths, events and streams. What they need is a +platform seam per sensing modality (the GPIO seam shipped; I2C exists; a pulse-timing seam does +not), and a module each. + +### Read a vehicle's CAN bus and drive lights from it (2026-09-11) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287130992) + +A MoonLight device in a car, reading the vehicle's own bus and driving aftermarket lighting from what it sees: underglow that pulses with engine RPM, sweeps with the indicator, flares on the brake. The vehicle to develop against is a VW Transporter (the product owner's). + +**Read-only, and that is the design, not a limitation.** Writing to a vehicle's bus reaches systems that are mandated safety equipment (indicators and brake lights are UNECE R48), usually on a segment shared with ABS and airbags, and modifying them is illegal in most jurisdictions. Reading has none of that exposure and gets the interesting half anyway: the car reports its state, MoonLight renders it. Anything the device drives is aftermarket lighting on its own output, untouched by the vehicle. + +**A Service, not a driver.** It senses rather than renders, so it belongs under the core `Services` container beside AudioService and ButtonService, and it produces both shapes the input mapping plan describes: an EVENT (indicator on, brake pressed) drives a control through `Scheduler::setControl`, while a continuous VALUE (RPM, speed) is published as a shared frame the way `AudioService::latestFrame()` is. Effects then read it, the input-mapping layer routes it, and a MoonLive script reads it as a system variable, so "underglow sweeps with the indicator" is a script someone writes and shares rather than firmware. + +**Two tiers, and only the first is write-once.** + +| | Standard | What it yields | How | +|---|---|---|---| +| OBD-II / UDS | Yes, legally mandated (EU: 2001 petrol, 2004 diesel) | RPM, speed, coolant, throttle, engine load | Request/response against ID `0x7DF`, so polling, a few times a second | +| Manufacturer body frames | No, proprietary per model and year | Indicators, brakes, doors, lights | Passive sniffing and reverse engineering | + +J1939 is NOT the answer here despite being the "CAN lighting standard" people reach for: it is the heavy-duty protocol (trucks, agricultural, marine), and a Transporter is passenger-car architecture running VW's own frames. J1939 would only apply if MoonLight targeted commercial vehicle equipment. + +So phase 1 is OBD-II only, which genuinely works on any car and needs no per-vehicle data. Phase 2 adds raw-frame sniffing, and there the frame IDs must be CONFIGURATION rather than compiled-in constants: a Transporter owner who discovers theirs shares a config file, and no firmware release is involved. That is what keeps a per-vehicle feature from becoming a per-vehicle maintenance burden. + +**What it needs:** + +- A `platform::twai*` seam (init, send, receive, stop). TWAI is the ESP32's CAN controller and is present on every chip MoonLight ships: 1 controller on classic and S3, 3 on the P4. +- **An external transceiver**: the ESP32 has the controller but no CAN PHY, and this is the only part that needs buying. An SN65HVD230 board (3.3V native, unlike the 5V TJA1050) plus an OBD-II pigtail to reach pins 6 (CAN-H) and 14 (CAN-L), so nothing on the vehicle is cut. Watch for a termination resistor fitted on the transceiver board: the vehicle bus is already terminated at both ends and a third resistor disturbs it. Power the device from USB while developing rather than OBD pin 16, so a crash cannot load the vehicle supply. +- A `VehicleService` module, and a desktop stub so the logic is testable without a car. + +**Phase 1 is already confirmed on the van**: an ELM327 running Torque pulls PIDs from it today, so the OBD port is live, CAN is reachable there, and the standard request/response layer answers. Nothing further needs discovering before building the seam. + +**Phase 2 is the open question, and one free test decides it.** Torque and ELM327 adapters speak the FILTERED request/response layer: you ask for a PID and the ECU replies, which says nothing about what else is on the wire. Torque itself can do the test: it has a dedicated `canbus monitor` protocol (selected in the vehicle profile, beside a hybrid mode that polls OBD2 and monitors at once) and imports DBC files in the PID editor to decode signals out of the stream. Traffic beyond one's own polled PIDs means the body frames are reachable on that port; only one's own requests and replies coming back means they sit on another segment behind a gateway, so phase 2 needs a different tap point. **The constraint is the adapter, not the app**: monitor-all (`AT MA`) is in the genuine ELM327 specification and many clones omit it outright, which makes them fine for standard PIDs and useless for reverse engineering. An ELM327 identifier app probes which AT commands a given adapter actually recognizes, so that is the thing to check first. Worth the five minutes before designing anything for tier 2. + +**Prior art, checked 2026-09-11.** The building blocks are all open source and the closest match is [can_wizard](https://github.com/okhsunrog/can_wizard) (MIT): an ESP32-C3 plus SN65HVD230 sniffer on ESP-IDF's TWAI driver, with hardware acceptance filters, live bus state, error counters and bus-off recovery, which is close to the seam described above. It uses the legacy `driver/twai.h`, deprecated in IDF 6.x, so the new `esp_twai.h` is what to write against. Also useful: [esp32-slcan](https://github.com/mintynet/esp32-slcan) exposes an ESP32 as a standard slcan device so desktop CAN tooling works against it, and [an Alfa Romeo dashboard project](https://github.com/ClaudeMarais/AlfaRomeoGiulia_DashboardInfo_ESP32-S3) runs an ESP32-S3 on two CAN buses at once in a real car. Driving LEDs from OBD2 RPM is well-trodden ground, which is another way of saying tier 1 carries little risk. + +**What is NOT available, and it shapes the design.** [awesome-automotive-can-id](https://github.com/iDoka/awesome-automotive-can-id), the main community collection, carries VW data for the MQB platform, Golf, Polo, Touran and e-Up, but **nothing for the Transporter and no lighting signals for any VW**: its coverage is drivetrain and engine management, not body control. So tier 2 starts from an empty database however much searching precedes it, which is exactly why the frame IDs belong in configuration a user discovers and shares rather than in a compiled-in table. (T6 owner forums do report the reverse signal traveling over CAN, so the body signals exist on a bus; they are simply not publicly documented.) + +**Related:** a convoy light show across several cars needs nothing from this. That is MoonSync (planned, not built) synchronizing timebase and effect state between devices, with each car rendering locally through whatever driver it has. The two compose for free and neither blocks the other. diff --git a/docs/work/backlog_mirror/ui.md b/docs/work/backlog_mirror/ui.md new file mode 100644 index 000000000..0d720f682 --- /dev/null +++ b/docs/work/backlog_mirror/ui.md @@ -0,0 +1,124 @@ +# Backlog: UI + +Generated from [the Project](https://github.com/users/MoonModules/projects/5) by `moondeck/report/project_mirror.py`: edit the cards, not this file. + +## FixtureProfilesModule + +### Sparse fixture-profile editor + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131084) + +- **Sparse fixture-profile editor.** A FixtureProfiles row currently shows one role Select per channel across the whole `channels` width: including the unmapped `, ` gaps a wide moving head has between its functions. For a fixture you usually only care about the few channels you drive (rgb, pan, tilt). The refinement: show only the *mapped* channels + an "add channel" affordance (pick a role → fills the first gap or grows the fixture), over the unchanged dense `roles[]` storage. A first attempt shipped and was reverted for edit bugs; redo it cleanly (the dense editor is the reliable interim). Prior art: GDTF / QLC+ fixture profiles (a fixture is a sparse `{channel → function}` map, not a dense per-channel array). + +## Across modules + +### The update overlay's Cancel button does nothing on a plain OTA (2026-09-14) + +Kind: Bug · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131157) + +`showUpdateOverlay` shows a Cancel button on every firmware install and wires it to +`POST /api/firmware/cancel` (`src/ui/app.js:1479`). Only MoonBase implements that route +(`moonbase/main/moonbase_main.cpp:532`); the application's dispatch table has none, so the request +404s and the `.catch(() => {})` swallows it. The button therefore works during the MoonBase phase of +an install and is inert for a plain app OTA on a device without MoonBase. + +A file upload still cancels there, but by a different mechanism: `uploadCtl.abort()` drops the +connection on the line above. So the two install sources behave differently behind one button, and a +URL install on a non-MoonBase device is the case where pressing Cancel does nothing at all. + +Two ways out, and the choice is the question. Implement the route in the application, which means an +abort flag the OTA task polls between chunks, the shape MoonBase already uses. Or hide the button +where it cannot act, which is honest but leaves a long download uninterruptible. The first is the +better system and the larger change; the second is a few lines. Found while writing +[Update MoonLight](../../moonwalk.md#update-firmware), which deliberately does not mention Cancel: +documenting a button that half-works is worse than leaving it undocumented until it is one thing or +the other. + +### Split app.js by feature, and take the module names out of it (2026-10-15) + +Kind: Restructure · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131241) + +The UI ships as one minified bundle with ETags, so splitting `app.js` costs nothing at runtime, and at 9,200 lines a feature is hard to find. [The rule](../../contributing/coding-standards.md#per-type-behavior-lives-with-the-type) is that `app.js` dispatches on control types and never on module names; it names about fifteen types today, from `renderFileManager` and the firmware install picker to `ALWAYS_SUBSCRIBED`, the System and Network lookups, the MoonStats and MoonTalk consent paths and the Gamepad and MIDI services. The textbook shape is a registry (`Map`) the modules fill from their own files, each widget a Custom Element with data in through a property and changes out through `/api/control`, so a new module's UI is one file and one registration. The File Manager goes first as the hardest case, and `fillListDetail` learns nested object arrays so a tabular module needs no full widget. The field survey behind this shape, Home Assistant cards and VS Code contribution points: [the UI extensibility analysis](../../design/ui-extensibility.md). + +### File Manager follow-ups + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131328) + +Deferred capabilities of the [File Manager](../../moonmodules/core/system.md#file-manager), each self-contained: + +- **Upload, recursive folder tier.** Multi-file / recursive folder drops: client-side `mkdir` + walk via `dataTransfer.items` `webkitGetAsEntry()`. +- **Download, folder as `.zip`.** Per-file download streams (`` on `/api/file`). A folder download needs the browser to walk `/api/dir` recursively, fetch each file, and build a `.zip` client-side — which means bundling a zip library into `app.js`. That's a permanent app.js size bump, and app.js is embedded in firmware, so it weighs on the flash budget. Gate on real demand; symmetric with the folder-upload tier. +- **Last-modified dates.** Needs a time source (NTP) + LittleFS mtime storage; the tree is name + size until both land. Backlogged with the NTP work. +- **`.ml` syntax highlighting in the editor.** MoonLive source wants *highlighting* (a color layer over the textarea), not the JSON-style reformat — a bigger editor change (a highlight overlay or a small tokenizer), added when MoonLive `.ml` files land on disk. The editor already has an extension seam (`fmPrettify`) for the reformat case; highlighting is the separate, larger tier. +- **Keyboard + screen-reader access for the tree.** The `fm-row` tree entries are mouse-only today (click to select/expand/open). Making them keyboard-operable (focusable, Enter/Space activation) with ARIA tree semantics (`role="treeitem"`, `aria-expanded` from `isOpen`, `aria-level`) is a real accessibility win but adds JS that lands in the firmware-embedded `app.js` (flash cost). Do it when a11y is a stated goal, together across the whole generic UI, not just this panel. + +### UI features deferred from v1 + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131412) + +What v1's frontend had and this UI lacks, each waiting on the engine work named: + +- **Health panel**: a `
` polling `GET /api/test` for a pass/fail table, which needs a runtime `/api/test` that runs the doctest suite; `ctest` covers this for now. +- **Log panel and log channel**: a ring buffer with severity coloring, stick-to-bottom and `GET /api/log` backfill, fed by a WebSocket `{t:"log",m:"…"}` pushed by the server, which needs an engine-side log producer. Both arrive together, when boot, network and persistence logs interest non-developers. +- **A setup dot before a card's name**: needs `setupOk()` and `health()` on MoonModule with a real failure mode; today both would always be `true` and `""`. +- **Module ID beside the name**: when instances need telling apart, such as two effects of one type under one Layer. +- **Category emoji on the card header**: `ROLE_EMOJI` exists in `app.js` for the picker, so showing it per card is a small step if card scannability needs it. +- **A module `category()`**: a taxonomy beyond `role()` for the picker; derived from `role()` until then. +- **Memory split, heap against PSRAM**: splitting `dynamicBytes` further needs `platform::isPsramPointer(p)` or per-allocation tracking. + +Two design questions stay open: + +- **Modifier chain visualization**: the `children[]` order is the apply order (modifiers compose as a chain, M₁∘M₂∘…), so a visual that shows the stacking, and that order matters, helps a user reason about a multi-modifier layer. +- **Canvas or node-graph view**: v2 tried it; powerful for complex setups but it doubles the UI surface, so it waits on user demand. + +### Double-click and multi-click on a button (2026-10-25) + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131477) + +From ESPHome's trigger vocabulary: the same debounce +timers with more states, so one button does several things. Built when a panel needs more actions +than it has buttons. + +### A desktop build reports firmware "unknown", so the update UI guesses from the browser (2026-09-06) + +Kind: Bug · Platform: desktop · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131545) + +Spotted on the container's Firmware card, which showed **Device: macOS arm64** while the image is +Linux amd64. It was not reading the device: `desktopKeyForThisHost()` (`src/ui/install-picker.js`) +and `desktopAssetPrefix()` (`src/ui/app.js`) both sniff `navigator.platform`, which describes the +machine holding the BROWSER. The container makes it visible because there the two always differ, but +the same card lies on any desktop instance opened from another machine. + +The download it offers is the failure mode worth naming: on a Mac the picker serves +`MoonLight-macos-arm64-*.dmg`, a real file that installs and runs perfectly, and updates the wrong +computer. Nothing errors, and the device stays on its old build. + +**Root cause is one missing build flag.** `MM_FIRMWARE_NAME` already exists for exactly this +(`build_info.h`, surfaced as the `firmware` control): `build_esp32.py` passes the variant key, and +the desktop packaging passes nothing, so every published desktop binary falls through to the +`"unknown"` default. `build_info.h:43` even records the assumption that made this safe, "local +builds aren't published" — the container is a published desktop build, which is what retired it. + +**What it would take:** have `package_desktop.py` pass +`-DMM_FIRMWARE_NAME="desktop--"` for the target it packages (the keys the picker's +`DESKTOP_LABEL` map already lists), then delete both browser-sniffing functions and read the +`firmware` control instead. `deviceFirmwareInfo()`'s `isDesktop` test (`firmware === "unknown"`) +needs the same treatment, since a named desktop build no longer matches it: test the +`desktop-` prefix. Local unflagged builds keep reporting `unknown`, where a guess is still better +than nothing. + +Worth doing with the OTA path rather than alone: a desktop cannot install its own archive anyway, so +the honest end state may be a card that names the platform and links the release rather than +offering a Download button that only ever means "fetch a file and swap it by hand". + +### RGBW preview end-to-end + +Kind: Feature · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131627) + +The light `Buffer` already holds `channelsPerLight = 4` (RGBW), and the device output drivers handle it, but the **preview only ever sends/draws RGB** — the W channel is invisible in the UI. Once the self-describing header lands, carry the W channel on the wire and render it in the shader (W as a warm-white tint / brightness lift on the disc). Small, but gated on the header so it isn't another bespoke opcode. + +### Moving-head preview = peer interpreter + +Kind: Feature · Trigger: moving heads land · [Card](https://github.com/users/MoonModules/projects/5?pane=issue&itemId=287131696) + +- **Moving-head preview = peer interpreter.** When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture: the same fixture-profile model physical drivers use, interpreted to screen. This is *why* the increments named the abstraction "interpret the profile" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. diff --git a/docs/work/future/archive_images/moonlight/drivers/Art-Net-In.png b/docs/work/future/archive_images/moonlight/drivers/Art-Net-In.png deleted file mode 100644 index 2cbeaaa4c..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/Art-Net-In.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/ArtNetInControls.png b/docs/work/future/archive_images/moonlight/drivers/ArtNetInControls.png deleted file mode 100644 index 26276898e..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/ArtNetInControls.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/ArtNetOutControls.png b/docs/work/future/archive_images/moonlight/drivers/ArtNetOutControls.png deleted file mode 100644 index 9e9e535b2..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/ArtNetOutControls.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/FastLED-Audio.png b/docs/work/future/archive_images/moonlight/drivers/FastLED-Audio.png deleted file mode 100644 index ebbac7c05..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/FastLED-Audio.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/FastLED-Driver.png b/docs/work/future/archive_images/moonlight/drivers/FastLED-Driver.png deleted file mode 100644 index 3f521622f..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/FastLED-Driver.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/IMUDriverControls.png b/docs/work/future/archive_images/moonlight/drivers/IMUDriverControls.png deleted file mode 100644 index cfa2b966f..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/IMUDriverControls.png and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/drivers/MPU-6050.jpg b/docs/work/future/archive_images/moonlight/drivers/MPU-6050.jpg deleted file mode 100644 index 8f50cc7a0..000000000 Binary files a/docs/work/future/archive_images/moonlight/drivers/MPU-6050.jpg and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Ball2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Ball2D.gif deleted file mode 100644 index 2634919a2..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Ball2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Cosmic3D.gif b/docs/work/future/archive_images/moonlight/effects/E_Cosmic3D.gif deleted file mode 100644 index d7c767364..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Cosmic3D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Geq2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Geq2D.gif deleted file mode 100644 index 5b3bdfc70..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Geq2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Hello1D.gif b/docs/work/future/archive_images/moonlight/effects/E_Hello1D.gif deleted file mode 100644 index b75ce65b8..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Hello1D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Noise2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Noise2D.gif deleted file mode 100644 index 64dd706c5..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Noise2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Noise3D.gif b/docs/work/future/archive_images/moonlight/effects/E_Noise3D.gif deleted file mode 100644 index 51e08a19b..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Noise3D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Orbit2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Orbit2D.gif deleted file mode 100644 index a94234382..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Orbit2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Oscillate2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Oscillate2D.gif deleted file mode 100644 index 2d69f7e64..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Oscillate2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Random1D.gif b/docs/work/future/archive_images/moonlight/effects/E_Random1D.gif deleted file mode 100644 index 4fe658a24..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Random1D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Sweep2D.gif b/docs/work/future/archive_images/moonlight/effects/E_Sweep2D.gif deleted file mode 100644 index 6e304f349..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Sweep2D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/E_Vu1D.gif b/docs/work/future/archive_images/moonlight/effects/E_Vu1D.gif deleted file mode 100644 index 212337c4f..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/E_Vu1D.gif and /dev/null differ diff --git a/docs/work/future/archive_images/moonlight/effects/layers.gif b/docs/work/future/archive_images/moonlight/effects/layers.gif deleted file mode 100644 index 8b5ca66a1..000000000 Binary files a/docs/work/future/archive_images/moonlight/effects/layers.gif and /dev/null differ diff --git a/docs/work/future/audio-dsp-roadmap.md b/docs/work/future/audio-dsp-roadmap.md deleted file mode 100644 index 2209570b4..000000000 --- a/docs/work/future/audio-dsp-roadmap.md +++ /dev/null @@ -1,132 +0,0 @@ -# Audio DSP roadmap — source-seam extensions + adaptive noise gate (design study) - -> Forward-looking design study (backlog, present-tense-exempt). The *shipped* audio path is -> documented present-tense in `src/core/services/AudioService.h`'s `///` (and its generated moxygen -> page); this study holds the **prior-art analysis** and the **not-yet-built** extensions the -> module's `///` credit points at. - -## Prior art studied (credit by name) - -Audio-reactive lighting is a long-standing idea in the LED-controller world (WLED-MM and MoonLight -are the closest lineage). MoonLight's audio path is its own implementation, designed from the INMP441 -datasheet and standard DSP — not traced from any one project — but three people's thinking is studied -here with respect and credited by name (the *Industry standards, our own code* principle: study hard, -write fresh). - -**Frank (softhack007)** — main author of the WLED-MM audioreactive usermod (the most-used open-source -audio-reactive LED implementation), a direct ancestor of the ideas this module learns from. The -product owner worked alongside Frank for years on WLED-SR / WLED-MM before MoonLight and MoonLight. -His concept is the worked example in the *Adaptive noise gate* section below: his idea, our analysis, -written fresh against our architecture. - -**Troy (troyhacks)** — MoonModules team; keeps a WLED-MM fork where he reworked the audioreactive DSP -onto Espressif's **esp-dsp** library ("stupid fast compared to ArduinoFFT"), very low latency on S3/P4. -His contribution has two parts: -- *esp-dsp FFT.* Troy uses esp-dsp's **radix-4** real FFT (`dsps_fft4r_fc32`) with a Blackman-Harris - window. This validates the path MoonLight is already on — **we use esp-dsp too**, the **radix-2** - float real FFT (`dsps_fft2r_fc32`) in `platform_esp32_i2s.cpp`. Same library; the one open - optimisation is **radix-4 vs radix-2** (fewer butterfly stages, log₄N vs log₂N — a measure-then- - maybe tune-up, not a gap; today the float FFT on the FPU is well inside one tick). Two adjacent, - *not-yet-adopted* options for the record: (a) esp-dsp's **int16 / fixed-point** path uses the - **built-in FFT instructions on S3/P4** — the lever for low-power FPU-less chips (C3/S2); we run - float today because our targets have an FPU; (b) Espressif's standalone **`dl_fft`** component does - *only* FFT without esp-dsp's shared twiddle tables — the right pick if a future build wants the FFT - without the rest of esp-dsp (we take the whole esp-dsp because we also want its DSP primitives). -- *Biquad pre-filters.* Before the FFT, Troy runs the samples through **biquad** HP/LP/peaking filters - via esp-dsp's `dsps_biquad_f32`, coefficients designed in EarLevel's Biquad Calculator v2. Squarely - industry-standard (the biquad / second-order section is *the* canonical EQ building block; the Audio - EQ Cookbook is the reference). Our pipeline does one fixed DC-blocker HP (~40 Hz); Troy's work shows - the next step — a **configurable biquad chain** (HP to kill rumble, LP to tame aliasing, optional - peaking to lift the mids the FFT under-reports). **Assessment: the biquad pre-filter chain is the - higher-value idea to adopt** (improves spectral accuracy with off-the-shelf primitives), and it - composes cleanly with the adaptive gate below (a learned gate on a cleanly-filtered signal beats one - on a raw signal). - -**LedFx** (Python, host-side) — a network LED effect engine whose whole purpose is audio reactivity, -running on a PC and streaming pixels to WLED-class devices. Different architecture to ours (the host -renders, the device receives; we already interoperate through Art-Net / E1.31 / DDP in both -directions), so most of it does not transfer. Two pieces of its *analysis* do, and both are studied -in § Band spacing below: its **mel/bark band spacing** with hand-tuned variants, and its -**per-band asymmetric smoothing**. Worth naming because it reached those two independently of the -WLED lineage above, and its own source comments are unusually candid about which variants work. - -**Damian Schneider (DedeHai)** — WLED core dev; WLED's audioreactive usermod carries an integer / -fixed-point FFT path (~1.5 ms on a C3, >10× ArduinoFFT on FPU-less chips). The consensus (Troy + Frank) -is that with esp-dsp FFT + biquads, **fixed-point is not necessary on FPU chips** (S3/P4) — MoonLight's -exact position: float on FPU targets, the int16 / `dl_fft` hardware path reserved for low-power chips. -DedeHai's current audio experiment is a PoC MSGEQ7-based path (offloading the spectrum to a dedicated -analyser chip) — a different point in the same space, noted for completeness. - -## Source-seam extensions (widen what feeds the pipeline) - -All of the following widen the **source seam** — what feeds the pipeline — leaving the DC-blocker / RMS -/ FFT / band analysis untouched. In roughly increasing hardware complexity: - -- **I²S with MCLK, for line-in — largely SHIPPED.** The INMP441 is self-clocked (no MCLK); a line-in - codec needs a master clock the ESP32 drives. The module already carries an `mclkPin` control (the - I²S peripheral drives MCLK for a line-in ADC; a codec board uses the codec's own MCLK). Remaining - source-seam work is the other three types below. -- **PDM mics** — a different I²S sub-mode (the IDF `i2s_pdm` driver), a variant behind the same - platform read. -- **Analog line-in.** DedeHai got analog input working on the S3; Troy got it working in ParrotRadio. - Troy's testing-confidence nuance worth recording: he considers his ParrotRadio analog path - better-exercised (he actually recorded + played back through it), whereas an unlistened-to analog - path "may not be as accurate as it looks." **If MoonLight adopts analog line-in, validate by - listening**, not just by watching the level meter. -- **I²C-configured codecs (e.g. ES8311).** Do **not** hand-roll each codec's register config — pull in - Espressif's **`esp_codec_dev`** component (carries option tables for many codecs), supporting "a - bunch more codecs for free." The *Industry standards, our own code* call applied to codec bring-up. - -Troy also has **DSP boards** (I²S front-ends "way beyond regular codecs") — recorded so the line-in / -codec work leaves room for that class of source. - -## Adaptive noise gate (softhack007's concept, our analysis) - -**Partly built.** `floor` is now a real silence threshold on both the level and the band paths: below -it a band reads zero AND is not learned from, which is what stopped the learner amplifying an empty -room to full scale. What remains from the design below is the ADAPTIVE half: hysteresis, the -asymmetric open-fast/close-slow timing, and a learned threshold rather than a set one. - -The original framing, kept because the remaining work is judged against it: replace the borrowed -`squelch`/`noiseFloor` knob ("a WLED-SR workaround, not a real gate") with a proper adaptive gate. From softhack007 (granted permission to analyse); the assessment is ours. - -**The concept:** a standard [noise gate](https://en.wikipedia.org/wiki/Noise_gate) (below a threshold -the signal is silenced, above it passes), **asymmetric bang-bang timing** (open fast, close slow; -hysteresis avoids threshold chatter), driven by a **new "detect silence" function** (the explicitly -unfinished part). Leave the GEQ/FFT bands untouched (the gate acts on the time-domain signal). The -closing pre-condition should be **relative** ("percentage of average signal"), not an absolute count. -Optionally feed the gate **compressed samples** (sqrt/log) so the threshold behaves perceptually. - -**Five design constraints (the load-bearing part):** (1) samples are signed, arbitrary magnitude — -scaling to an effect range is AGC's job, not the gate's; (2) every `abs()` must be justified (a -rectify discards sign/phase); (3) prefer relative factors to absolute thresholds (the one allowed -absolute: changes < 2 counts are sampling noise); (4) smooth before thresholding; (5) every filter -adds delay — total audio delay must stay < 30 ms. - -**Verdict: yes, directionally — it's squarely industry-standard** (a hysteresis gate with -fast-attack/slow-release is how studio gates, radio squelch, and voice-activity detectors all work), -and moves us off the borrowed `squelch` constant. The relative-threshold insight (constraint 3) is the -valuable core: a gate keyed to a *learned* floor self-calibrates to whatever source is connected. -**Two cautions:** (1) timing is tight — a 512-sample block at 22050 Hz is already ~23 ms, leaving -< ~7 ms of the 30 ms budget; any smoothing must be cheap (one-pole) and proven on hardware; (2) it -overlaps the already-scoped per-band floor, so decompose and adopt in steps, don't overhaul. - -**Per-band floor overlap:** the backlogged per-band noise floor learns each band's idle baseline -(frequency-domain floor — kills a steady tone like the bench's ~258 Hz hum); the proposed gate answers -"is there any sound at all" (time-domain floor). Complementary halves, not competitors. - -**Decomposition (cherry-pick, most value early):** -1. **Per-band noise floor (already backlogged)** — ship first; frequency-domain half, smallest change, - kills the concrete hum. Independent. -2. **Relative thresholds reusing the RMS we already compute** — `computeLevel` already produces a - per-block RMS (an envelope estimate, and the one justified `abs()` under constraint 2). A - learned-floor follower over that RMS with open/close as *factors* of it is a small, host-testable - addition needing no new DSP stage and no extra delay. **The cherry to pick.** -3. **Hysteresis + asymmetric timing** — two time-constants on that follower plus a close-hold; where - the < 30 ms budget gets measured for real. -4. **Defer until proven:** log/dB-domain thresholds (downstream `magToByte` already compresses - perceptually), a true soft gate (0..1 gain vs hard 0/1). - -**Eventually retires:** the `floor` knob's hard-squelch role — `floor` becomes the *display* -noise-floor only (the dB-window bottom in `magToByte`), while the learned gate decides "is there -sound." A clean subtraction, but the *end* of the path, not the first step. diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md deleted file mode 100644 index 562eadee9..000000000 --- a/docs/work/future/backlog-core.md +++ /dev/null @@ -1,2205 +0,0 @@ -# Backlog: core - -Forward-looking to-build items for the **core / infrastructure** domain (`src/core/`, `src/platform/`, build, CI, network, persistence, UI). The light-domain counterpart is [backlog-light.md](backlog-light.md); items that genuinely span both are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](index.md). Completed items are removed. - -### The `.deb` ships the systemd unit, raw Ethernet included (2026-10-22, from robwomp on Discord) - -The package holds only the binary, so a Pi or NanoPi user writes `/etc/systemd/system/moonlight.service` by hand from MoonWalk's "Run at boot" step. That unit grants no `CAP_NET_RAW`, so the panel card driver's `AF_PACKET` socket fails unless MoonLight runs as root, and the `setcap` alternative is lost whenever apt replaces `/usr/bin/MoonLight`. `CAP_NET_RAW` is all MoonLight needs, since it sets no interface flags. The fix: `package_desktop.py` installs the unit with `AmbientCapabilities=CAP_NET_RAW` and a dedicated `moonlight` user, so installing is `apt install` plus `systemctl enable --now moonlight`. MoonWalk's unit step shrinks to that enable, and the `setcap` step stays for a binary run by hand. - -### An HTTPS request on a board without PSRAM (2026-10-24, from the JSON plan) - -A board without PSRAM never fetches over HTTPS: `tlsRoom` asks for the 100 KB internal floor plus a 45 KB TLS session, and a classic ESP32 has 55 KB free at idle. `CONFIG_MBEDTLS_DYNAMIC_BUFFER` allocates mbedTLS's buffers as a connection needs them rather than the worst case up front, which may fit one request on an idle board. To measure on `MM-Olimex` (.207): the peak a session takes with it, what a smaller floor costs WiFi and the HTTP server, and whether a feed then fetches with lights running. Until then such a board gets its data pushed from one with PSRAM. - -### A StadBeest eye reset once by the task watchdog (2026-10-24) - -On 2026-10-23 eye2 (`MM-Eye2`, an ESP32-S3-Zero) reset once by the task watchdog, shortly after its OSC service was added, with no USB attached to read the panic. It has not recurred: both eyes ran the crossfading autopilot without a reset afterwards, and on 2026-10-24 they report `POWERON` after the beast was switched on. Recorded so a recurrence is recognized: `bootReason` on the System card is the one reading the network gives, since the board keeps no crash record; the panic itself needs the eye on USB with `monitor_esp32.py`. - -### What the WiFi buffer copy costs (2026-10-24) - -`CONFIG_LWIP_L2_TO_L3_COPY` copies every received frame so a socket the blocked loop stops draining cannot hold the 32 WiFi receive buffers; without it, every firmware upload stalled at 150 to 860 KB. Its cost is unmeasured: the frame rate on a busy DDP stream with and without it, same scene. If it costs much, firmware uploads move onto the transfer slots so nothing blocks the loop, and the copy goes. - -### An update's connection that would not open until a reset (2026-10-22, from the internal RAM plan) - -On 3.0.0 a board could not open the update's connection (`ESP_ERR_HTTP_CONNECT`) until it was reset, with 80 KB of internal RAM free. TLS has lived in PSRAM since then (`CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC`), which may be what cured it; it has not recurred. Recorded so a recurrence is recognized: the free internal RAM and largest block at that moment are the first readings to take. - -### The HTTP server is audited for the two PHP request-handling bug classes (2026-10-21) - -PHP's critical advisory of 2026-10-21 (CVE-2026-109618, CVE-2026-109619) names two bug classes any C++ server can have. **Out of memory mid-request**: an allocation fails while a request is half built, and cleanup or a later step treats the partial state as valid. That path is live here: a classic ESP32 fell to 916 bytes free during a page load. **State outliving its request**: a reused connection slot (`wsClients_`, `streams_`) keeps a pointer, length or buffer from its previous client. The audit is a read-only pass over `HttpServerModule` and the platform TCP layer, every allocation-failure path and every slot reuse, and each finding is pinned by a desktop test that forces the failure. - -### Headers link the docs by page, not by host (2026-10-21, from the PR 150 review) - -Seventeen headers under `src/` carry absolute `https://moonmodules.org/MoonLight/...` links, so a docs move edits firmware sources and marks every firmware stale, as PR 150's move from `how-to/` to `reference/` did. A header names the page site-relative (`reference/hardware/panel-cards.md#...`) and `gen_help.py` resolves the host once, so the next docs move touches no source file. - -### A Progress total rides the value patch (2026-10-19) - -A Progress control's total travels only in `writeControlMetadata` (Control.cpp), so a changed total needs a schema resync, which `rebuildControls()` triggers because `aux` is in the schema hash. -One line in `writeControlValue` and one in `app.js`'s patch handler would let the total ride the value, and the hash could then skip it, so a track change in FseqPlayerEffect stops resyncing every browser. -Pin it with a test that changes the total, patches, and finds the browser-side max moved with no resync. - -### An SD card inserted after boot mounts only when its module restarts (2026-10-12) - -A mount attempt on an empty slot runs the SDMMC card-init sequence to its timeouts, so `SdCardModule` never retries one on the render thread: a card inserted later mounts when the module is switched off and on, on a pin edit, or at boot. A pull is still noticed within a second through a cheap presence probe. Detecting an insertion without that stall needs a card-detect pin where the slot has one, or a mount attempt off the render thread. The timed retry PR #136 carried was dropped for this reason before its merge. - -### A config written for next boot is overwritten by the next save (2026-10-04) - -Network and Ethernet return false from `appliesConfigLive`, so a file written through `POST /api/file` waits on disk for the next boot. -Any save before that boot writes the module's in-memory values over it, so the upload is lost without a word. -Seen on the S3: a restored `NetworkModule.json` came back with the old `txPowerSetting` until the restore was followed straight away by a reboot. -The fix: `applyConfigFile` marks a module it does not apply live as pending on disk, and the flush skips that module's file until the next boot. -The uploaded file then wins with no UI change; the alternative, answering "applies at next boot" and offering a restart, still loses the file when the restart is declined. -Pin it with a test that writes the file, dirties the module, flushes, and finds the file unchanged. - -### Move more devices to MoonLight over WiFi (2026-10-09) - -The migration image (`moonbase/migrate/`) moves a board running WLED to MoonLight through WLED's own update page, with no cable: verified on WLED 0.15.0 on an ESP32-C3, on a bench board with a power cut at every step and on an Athom bulb. -An image is built for one chip and carries one variant's partition table, so each further reach is its own piece: -- **Every MoonBase variant:** `migrate: True` on `esp32`, `esp32-16mb`, `esp32-wrover`, `esp32-pico` and `esp32s3-zero`, each rehearsed on a bench board of that chip and shipped as a release asset. The variants without MoonBase (the larger S3s, the P4) need another design, the image writing the app itself. -- **Other WLED versions:** 0.13 and 0.14 keep the same files and layout, untested. 0.16 checks an upload's release name, so the user ticks WLED's option to skip it, untested. A WLED build with one app slot cannot move the image aside. -- **Tasmota:** its layout already has a recovery app in the factory slot and one app slot, so the image arrives running where MoonBase goes with nowhere to move aside; it would write over the recovery app instead. Its WiFi lives in a binary settings block in NVS, which needs its own reader. -- **ESPHome:** its update accepts the image, but its WiFi is compiled into the firmware, so MoonBase opens its access point. -- **A generic WiFi source:** ESP-IDF's WiFi driver can keep the last network in NVS under a namespace every framework shares; tried when no WLED files are found, it may carry the network from the others. Bench first, since whether it is there depends on how each firmware starts WiFi. - -### The access point never blocks a WiFi retry (2026-10-09) - -After losing its network, a device opens its access point and retries its known networks every minute (`retryKnown` in `NetworkModule.h`), but skips the retry while a client is on the access point. -A phone that stays on it, or rejoins it by itself, then keeps the device off its network indefinitely. -WiFi has preference, so the retry runs whoever is on the access point: drop the `wifiApClientCount() > 0` check. -- **The cost:** someone on the access point while a known network exists is interrupted for a few seconds once a minute; a first setup has no known network, so it never retries. -- **A test:** a client on the access point, the known network back, and the device joins it within the retry interval. - -### Some devices do not rejoin their WiFi (2026-10-09) - -Now and then a device stays off its network after the router came back, without anyone noticing why. -Two suspects: a client on its access point blocking the retries (the item above), or the path for losing the network while connected, where the WiFi driver can get stuck and need the radio restarted. -Next time one hangs, capture before touching it: -- **The Network card's status:** "No network", "AP: MM-…" or "WiFi STA: …" says which state it is stuck in. -- **The access point's `clients`**, which says whether a phone is holding it. -- **The serial log**, through `monitor_esp32.py`, whose `NetworkModule:` lines say what it tried last. - -### Refresh a multicast membership on a fixed pace (2026-10-09) - -A socket in a multicast group joins again after 3 seconds of silence, which repairs a router that forgets a member, with a gap of about 2 seconds each time. -The GL.iNet Slate AX forgets after about 18 seconds, so audio on the StadBeest drops every 20.6 seconds, measured on the bulb. -The standard fix is the router's multicast querier; this item is for a network that cannot be configured, such as a venue's. -- **The rule:** join again 10 seconds after the last join as well, so a membership is renewed before such a router forgets it. -- **The cost:** a leave and a join, two IGMP packets of about 60 bytes per socket every 10 seconds, and at most one lost packet in the moment between them. -- **Where:** `UdpSocket::keepMembership` in `platform.h`, one more condition, and a test beside the silence re-join's. - -### Back to WLED over WiFi (2026-10-09) - -The migration image moves a WLED device to MoonLight without a cable, but the way back needs one: MoonBase installs MoonLight images only, so a bulb going back to WLED is opened for USB. -Owners and makers of these devices care most about this: a firmware change that cannot be undone without a cable is a bigger step than one that can. -It is ours to build rather than WLED's, since the layout it undoes is MoonLight's, about one to two sessions: -- **A small image that downloads WLED itself** from WLED's GitHub releases while it runs, so MoonLight never redistributes WLED's binary and the image stays far from what it writes. -- **WLED goes into WLED's second app slot**, past the image's own position, so nothing moves aside; then WLED's table, and the boot pointed at that slot. WLED starts, formats its filesystem and opens its own access point. -- **MoonBase installs it** only once the image is stamped as one of MoonLight's, a deliberate change to the one rule that refuses other images (`src/core/util/FirmwareImage.h`). -- **A "Back to WLED" button** in the Firmware card, a release asset per chip, and the bench rehearsal with a power cut at every step that the way in had. -- The WiFi stays behind: WLED opens its access point, where the user sets it. - -### Logical operators in MoonLive conditions (2026-10-08) - -Glow wrote `kick && !kicking` as `kick > kicking`, and the StadBeest effects hit the same gap ([the language roadmap, section 11](backlog-moonlive.md)). -An `if` or `for` condition is one comparison compiled straight into a branch, so `&&`, `||` and `!` are the textbook short-circuit translation into branches, on the branch instructions every backend already has: -- `a && b` sends each failing test past the body, with no extra label; -- `a || b` sends each passing test but the last into the body, one label per group; -- `!a` swaps the two targets. - -The work, in `MoonLiveCompiler.cpp`: -- one condition parser for `if` and `for`: `||` over `&&` over `!` over a comparison, with parentheses, the lexer's saved position deciding whether a `(` opens a condition or arithmetic; -- the positive form of each comparison, which `||` needs, `!=` taking two branches as it does today. - -Tests on the desktop: the truth tables, that the right side does not run when the left decides, precedence, parentheses, and running out of labels. Then the operator section of `moonlive.md`, the roadmap row removed, and a check on the S3 and the C3, where Glow takes `kick && !kicking`. - -Not in this step: a comparison as a value (`bool b = x > y && z;`), which needs a set-on-condition instruction in every backend. - -### Remove the 6.0 network adoption (2026-10-04) - -`NetworkModule::adoptLegacySettings` and the two `adoptLegacy` it calls move 6.0's top-level network keys onto the Ethernet and WiFi cards at the first boot, so an in-place update keeps its network. -Remove all three, their test `unit_NetworkModule_legacy.cpp`, and the "update to this release before a later one" line in MIGRATING's WiFi and Ethernet entries, once a release has carried it and the field has moved past 6.0. - -### Remove the flat config conversion (2026-10-08) - -A config file in the flat format builds before 2026-10-08 saved is read as the state document it describes at the first boot and saved as one, so an in-place update keeps its config. -In the release after next, remove the conversion and everything that only serves it: -- `flatToStateDocument`, `isFlatConfig` and their helpers in `StateDocument.cpp` (`writeFlatNode`, `writeFlatValues`, `takeLive`, `flatName`, `distinctName`, `flatRoot`); -- the conversion in `FilesystemModule::loadSubtree`; -- the flat fallback in MoonBase's `ConfigScrape.h` (`flatChildKey`, `flatChildValue`); -- `configToDocument` and `isFlatConfig` in `src/ui/migrate.js`, after which an older backup no longer restores; -- the test "A config file in the flat format older builds wrote loads at boot and is saved back as a document", and the MIGRATING entry's lines on the conversion. - -`NetworkModule::adoptLegacySettings` calls `isFlatConfig`, so the 6.0 network adoption above goes first or in the same change. - -### The update overlay's Cancel button does nothing on a plain OTA (2026-09-14) - -`showUpdateOverlay` shows a Cancel button on every firmware install and wires it to -`POST /api/firmware/cancel` (`src/ui/app.js:1479`). Only MoonBase implements that route -(`moonbase/main/moonbase_main.cpp:532`); the application's dispatch table has none, so the request -404s and the `.catch(() => {})` swallows it. The button therefore works during the MoonBase phase of -an install and is inert for a plain app OTA on a device without MoonBase. - -A file upload still cancels there, but by a different mechanism: `uploadCtl.abort()` drops the -connection on the line above. So the two install sources behave differently behind one button, and a -URL install on a non-MoonBase device is the case where pressing Cancel does nothing at all. - -Two ways out, and the choice is the question. Implement the route in the application, which means an -abort flag the OTA task polls between chunks, the shape MoonBase already uses. Or hide the button -where it cannot act, which is honest but leaves a long download uninterruptible. The first is the -better system and the larger change; the second is a few lines. Found while writing -[Update MoonLight](../../moonwalk.md#update-firmware), which deliberately does not mention Cancel: -documenting a button that half-works is worse than leaving it undocumented until it is one thing or -the other. - -### The audio-sync test waits on the wall clock (2026-09-06) - -`test/unit/core/unit_AudioService_sync.cpp` drives the quiet-packet case with `platform::delayMs(1)` -inside a 100-iteration polling loop, so the assertion that `level` reaches zero depends on real -elapsed time and on loopback UDP delivering within that window. It passes today and has not been -seen to flake, but it is the shape that produces a rare CI failure nobody can reproduce, and it -spends real milliseconds in a suite that otherwise runs on a test clock. - -The fix is the deterministic UDP seam plus `platform::setTestNowMs`, the same pattern every other -timing test here uses: feed the packet, advance the clock explicitly, assert. Raised by CodeRabbit -on PR #96 and deliberately not taken in that pass: it is pre-existing rather than part of that -diff, and swapping a test's transport is a change that wants its own verification. - -## A request body shares the two upload slots (2026-10-25, from the pre-merge review) - -A request whose body trails its headers waits in a `FileTransfer` slot (`deferRequest`), of which there are two. With two uploads in flight, a `POST /api/control` whose body comes in a second segment answers 503 "every send slot is busy", and a slow-bodied control write holds an upload slot for the 10 s stall window. A slot of its own for `Kind::Request`, or a count kept apart, separates them; it waits for a user who meets it, since two uploads at once is rare. - -## The tests' own clock resets, now the listener's job (2026-10-25) - -A doctest listener beside the test main in `test/unit/test_main.cpp` puts the test clock back on the real one before every test case and subcase rerun, so the 88 resets of 49 files (`setTestNowMs(0)` in a guard struct or at a test's end) do the same job twice. Remove them: a guard struct and its uses go, a guard that also restores the heap, the WiFi scan, the palette or the boot record keeps that half, and a bare reset in the middle of a test stays wherever the rest of that test reads the real clock. About 150 lines, proven by the full suite. - -## Three decisions left open by the code ratchets (2026-10-25, from the Code ratchets plan) - -- **Name spread's limit**, 10 files beyond a type's own (`MAX_SPREAD` in `check_code.py`), which no tool defaults: confirm or move. Recommended: confirm, since the count only ratchets down. -- **A gate runner for agentless development**: the gate lists are run by an agent today, and a local-model or agentless workflow wants one runnable command per event. `precommit.py` went on 2026-10-01; whether one returns, and in what shape. -- **Undocumented public declarations**: a generated page shows an empty entry for each, which argues for a docgen rule, while cut-first argues against comments for their own sake. The middle way counts only public declarations in headers that generate a page. - -## What disable means: release the memory, or keep the module hot (2026-10-20) - -The rule says a disabled module keeps its settings only, and deleting a module already releases everything, so the question is whether disable should release too or keep the module hot for an instant resume. The contract test (`unit_ModuleContract.cpp`) lists the modules that keep runtime memory while disabled, Layer and Drivers with their pixel buffers the largest, and fixing them would settle the answer, so they wait for it. The analysis weighs: presets and DemoReel switching many modules, where kept memory grows with what is added rather than what runs; a quick toggle, where hot means an instant resume; the measured re-enable cost of every module, its `prepare()` time, gathered with the contract test's placement; three shapes, release on disable, keep everything hot until delete, or keep the settings and what is slow to rebuild while releasing the large buffers; and how comparable systems treat a disabled or paused element. The product owner decides, and the rule in coding-standards, the contract's lists and the Layer and Drivers fix follow. - -## Playlist in core: one sequencer for FSEQ, effects and DemoReel (2026-10-19) - -A playlist is a list of items with an optional duration each, played in order or shuffled once per pass, repeated off, one or all, under play, pause, stop, previous and next: the media player's model. Two copies of that sequencing exist: `DemoReelEffect::advance()` cycles effects, and `FseqPlayerEffect` cycles files with its own cursor, shuffle pass and loop mode, written as the same shape. An effects playlist would be the third, so it starts by putting the model in core once, a `Playlist` over core's editable `ListSource` whose owner supplies how to play item *i* and when it ends: the FSEQ player's items are files that end with the track, an effects playlist's are presets applied through the state-document engine that end after their duration, as the presets plan foresees, and DemoReel becomes an effects playlist of effects. The change that builds the effects playlist moves FSEQ's and DemoReel's sequencing into it. - -## A catalog entry extends another (2026-10-18) - -A shield is a carrier board plus its own pins, as a Serg shield is a D1-Mini32 with more wired. An optional `extends: ""` in the [device catalog](../../../mooninstaller/README.md#the-device-catalog) lets an entry take another's `state`: levels nest (MCU, carrier, device), a child overrides its parent at the same module and control, and modules merge by name. -**Build it** at the first two entries that share a base, as a pass in the installer and MoonDeck before they send the document, with `check_devices` resolving it too. - -## A slot whose target is gone sends 0 to its followers (2026-10-08) - -**Found** on the StadBeest after a repower: the legs' walking layer was saved disabled, so the script that `fader2` to `fader6` drive had no controls. -The slots could not follow their targets, stayed at 0, and the OSC feedback sent those zeros to the eyes, whose `bpm` and `sensitivity` then sat at their minimum. -Live slots are not saved, so after a boot only a target or a writer gives them a value. - -**The fix to build:** a slot with no target to follow holds no value rather than 0, and the mirror sends nothing for it until the target exists. -The followers then keep what they last had, and a refresh repairs them once the leader's target is back. -Pinned by a test: a disabled target's slot produces no feedback datagram. - -## A MoonLive script's branch tables are fixed and on the stack (2026-10-08) - -**Found** on the StadBeest eyes: one more `if` in a helper turned a working script into `codegen failed: assembler overflow`. -Each function takes three labels (entry, depth guard, exit) before its own `if`s, loops and divisions by a non-power-of-two. -The assembler's label table holds 96 two-byte code positions in the stack the 48 four-byte ones took (2026-10-10), which let Sauron blink. -The nearer limits are now the compiler's own `kIrLabels` at 40, which reports "too many branches", and the 96 fixups. - -**The fix to build:** the label and fixup tables of all three assemblers move from members to the heap, as the code buffer already did, then `kAsmFixups` and `kIrLabels` rise (to 255 and 96); `Label` stays a byte. -The spill pass and the lowering hold `kIrLabels`-sized arrays on the stack too, so they move with it. -Verified by compiling the shipped scripts on a classic ESP32, an S3 and a P4, whose compile stacks are the ones at risk, and pinned by a host test with a script past 40 branches on each backend. - -## Some devices stop answering mDNS over WiFi (2026-10-08) - -**Found** on the StadBeest: after the legs restarted, their MIDI service could not resolve the bridge's `MM-MIDI-rtp.local` for five minutes, and by address it connected at once. -Browsing from a Mac on the same network listed `_http._tcp` for MM-Eye2 alone: the legs, the left eye and the bridge announced nothing, and no `_apple-midi._udp` record appeared. -The left eye announces nothing new, so the cause is wider than the RTP-MIDI announcement. -The WLED native app finds devices by browsing `_wled._tcp`, so it is the likely reason it does not always list every device. - -**The fix to build:** find why a device stops answering, starting from what differs between MM-Eye2 and the others: WiFi power save dropping multicast between beacons, transmit power (the bridge runs at 8), the responder losing its interface after a reconnect, or the announcement order in `mdnsInit`. -Watch it with a working `dns-sd -B` from a computer and the device's serial log, and confirm the bridge's `_apple-midi._udp` record appears. -Pinned on the bench: every device answering its `.local` name and listed in the WLED app after a restart and after a WiFi reconnect. - -## A MoonLive control past a limit vanishes without a word (2026-10-08) - -**Found** in review: an `addSelect` with a 17th option, or one whose names overflow the 96-character option store, adds no control at all, and nothing tells the script's author why. -The other limits of `addDeclaredControl` behave the same way: past `kMaxCtrls` controls, a member already bound, a slot running past the arena, or names past the engine's 128-character name store (`nameText_`, about sixteen controls of seven characters). - -**The fix to build:** each refusal names itself on the effect's status line, as a compile error does, such as `control "shape": more than 16 options`, so the author sees which declaration lost and why. -Pinned by a test per limit that reads the status. - -## RISC-V emits 1.7 times the code Xtensa does (2026-10-08) - -**Found** on the StadBeest eyes: the script is 7 KB on an S3 and 12.4 KB on RISC-V, where one `smoothstep` call added to `tick` cost 432 bytes on its own. -A call inside a function with many live values saves and restores each of them, so every addition to a drawing loop costs RISC-V 400 to 500 bytes, and large scripts reach the 16 KB code buffer first on the P4 and the S31. - -**The fix to build:** measure where the RISC-V lowering spends its bytes on the shipped scripts (`disasm.py --isa riscv`), starting with the save and restore around host calls, and keep values that live across a call in callee-saved registers rather than saving them per call. -Pinned by a size test per backend on the largest shipped script. - -## The desktop build advertises no mDNS (2026-09-06) - -`mdnsInit` on the desktop platform is a stub returning false, so a desktop instance is invisible to -discovery: it neither announces itself nor answers `.local`. On a board mDNS is what makes a -device findable without knowing its address. - -Nice-to-have rather than blocking, because instances address each other by IP and that is what the -network drivers take (`NetworkSendDriver`'s `ips` list). What it costs today is convenience: a -container on a NAS has to be looked up rather than named, and a fleet of them cannot be enumerated -the way a fleet of boards can. - -**What it costs:** a platform implementation per OS, which is the awkward part. macOS has Bonjour -built in, Linux would want Avahi (a dependency the container deliberately does not have), and -Windows has its own. A small self-contained responder is the alternative, since the announce side is -a handful of multicast records and we do not need the resolver half. - -Worth revisiting if instance-to-instance sync grows: discovery is the difference between configuring -a fleet by address and having it assemble itself. - -## The desktop build renders as fast as the machine allows (2026-09-06) - -Nothing paces the desktop render loop. On a board the LED refresh does; on desktop the loop runs -flat out, measured at 83,000 fps in a container and ~110% of a core per instance. Memory is not the -constraint (about 5 MB), so the practical ceiling on running several instances is one per core. - -That matters now the desktop build ships as a container: an array of instances is a plausible -deployment (a fleet under test, several zones of a show), and today each one costs a whole core for -frames nobody consumes. Nothing downstream benefits from more than the output rate: Art-Net, DDP and -E1.31 all clock at their own wire rate, and the preview socket is throttled separately. - -**What it costs:** a target-fps control on the desktop platform, defaulting to something like 120, -with the loop sleeping the remainder of each frame. The tick path already measures its own duration -(`tickTimeUs`), so the sleep is a subtraction rather than new measurement. Roughly the shape of the -`--port` flag: a desktop-only concern, invisible on a board. - -**Also worth knowing:** it would cut power draw on a single instance too, which matters for anyone -running MoonLight permanently on a NAS or a Pi. - - -## The prose sweep, now counted and ratcheted (2026-09-28) - -`check_prose.py` already named `.js` and `.py` among the files it checks, but `.vale.ini` gave Vale -no parser for either, so both were skipped in silence: the intent was there and the mechanism was -not. A tree-sitter view per language closes that, and the count is written to -`docs/reference/metrics/prose.json` on every run, beside the other metrics, and shown on [the test page](../../reference/tests.md#check_prose). - -The gate stays scoped to ADDED lines, so a change is held to the rules on what it writes. -The report is the debt behind it, and it ratchets: a rise fails the check. - -The em-dashes dominate and are the mechanical part: a comma, a colon or a full stop replaces each, -and the same sweep already ran once over `src/**/*.h` when `CComments` was added. The rest is -judgment per line. Do it as its own change with its own review, a directory at a time, rather than -inside a branch about something else: a blanket find-and-replace over comments is how a code -identifier gets rewritten by accident. - -## `check_docgen` reads declarations from the clang AST (2026-10-15) - -The "public member has no `///`" rule reads a declaration's shape with a regex and tracks access by counting braces. It missed 25 functions carrying `MM_NONBLOCKING` until the pattern learned that macros follow a parameter list, and its comments list the other shapes it once misread. `check_clang_query.py` and `gen_help.py` already parse every header with libclang, which knows each declaration's access, kind and attached doc comment. Moving the rule onto that parse removes the brace counting, the depth stack and the shape patterns, and it reaches `.cpp` files, which the regex cannot read. - -## Distribution - -### The arm64 `.deb` is untested on Raspberry Pi OS bookworm (2026-09-13) - -**Shipped** (`a229be6d`): `build-linux-arm64` publishes `moonlight_X.Y.Z_arm64.deb` and a tarball, -the container image is a multi-arch manifest, and the SBC route is `apt install`. Verified on a -NanoPi R28S (Debian 13 trixie): installed, ran, and reported to MoonStats as `linux-arm64`. - -One piece of that verification was never done, because the bench has no such board. The job runs on -`ubuntu-22.04-arm` **deliberately**, for glibc 2.35: Raspberry Pi OS bookworm ships 2.36, and a -build on `ubuntu-24.04-arm` (glibc 2.39) would install there and then die at startup with -`GLIBC_2.38 not found`, the same failure the Dockerfile records hitting on debian12. The floor is -what makes bookworm work, and **bookworm is the one target nobody has run it on**. - -Two halves are untested together: that the binary starts (the symbol check says the highest -requirement is `GLIBC_2.35`, which is the proxy), and that `Depends: libcurl4 | libcurl4t64` -resolves there. Trixie provides only `libcurl4t64`, bookworm only `libcurl4`, and only the first -half has been exercised. A wrong alternation fails visibly at `apt install` with an unsatisfiable -dependency rather than installing something broken, which is the good failure mode. - -**A user with a Pi settles it in one command.** Closing this needs a report, not a code change. - -### An example: MoonLight in a pod beside the music server (belrik, 2026-10-21) - -belrik runs MoonLight in a Kubernetes pod with PulseAudio, squeezelite and sendspin in containers of their own, playing 96 kHz / 24-bit files through RAOP, each part upgraded on its own. -That is audio-reactive light fed from a music server, and the reference docs do not yet show how containers share a PulseAudio server. -An example page under Reference → Examples, built from belrik's manifest, shows it; asked for on Discord. - -### A flashable SD image with MoonLight already on it (robwomp, 2026-09-08) - -Suggested on Discord while bringing up a NanoPi R28S: most Pi users want to write an `.img` to a -card and be running, not to install a toolchain. Armbian's own build tooling supports exactly this -(`armbian/os` carries per-application "extensions", OMV being a small worked example), so the image -is a spin of a maintained distribution plus our package rather than a distribution to maintain. - -Wants the arm64 `.deb` above first: with it the extension is roughly "install this package, enable -this service", which is the shape those extensions already have. Without it, the image would have to -carry a source build, which is the thing it exists to avoid. - -### OTA upload refuses a normal client: the body must arrive within ~50 ms (2026-09-02) - -`POST /api/firmware/upload` answers `400 {"error":"incomplete request body"}` to an ordinary -`curl --data-binary @firmware.bin`, in 37 ms, before reading the image at all. - -The streaming branch sets `bodyNeeded` to the whole prefix buffer and then polls for it with a -50-iteration, 1 ms budget (`HttpServerModule.cpp`, the read loop). A client that writes its headers -and pauses before the body, which curl does, trips that timeout and is rejected. The browser path -works because `fetch` hands the whole body to the socket at once. - -Worked around by writing headers plus the first 8 KB in a single `sendall` from a small Python -client, after which a 1.97 MB image uploaded in 6.3 s and the device rebooted correctly. So the -transfer is fine; the acceptance test is what is wrong. - -Worth fixing because OTA is the only route to a board whose USB port is unavailable, which is -exactly when a firmware update matters most. The fix is to wait for the CONTENT-LENGTH the client -declared rather than for a buffer to fill, and to time out on stall rather than on total elapsed. - -### Release 2.0 — distribution catches up to the source tree - -1.0 ships ESP32 firmware (4 variants) + macOS arm64 + Windows x64. Still to add: - -- **ESP32-P4** firmware variant: **`esp32p4rev1-eth` (Ethernet-only) shipped**: in `build_esp32.py`'s `FIRMWARES`, the `deviceModels.json` catalog (Waveshare P4-NANO), and CI builds + publishes it to the web installer + releases. **`esp32p4rev1-eth-wifi` now ships too** (2026-08-19): it booted and associated on IDF v6.1-rc1, so it is out of the experimental set in the installer and carries a normal description. On IDF v6.1 it no longer associates (2026-10-12): see § ESP32-P4 round 3. -- **ESP32-P4 v3.x silicon variant (backlog)** — `esp32p4rev1-eth` is built for pre-v3 P4 (`CONFIG_ESP32P4_SELECTS_REV_LESS_V3` + `REV_MIN_0`), because the v6.1 IDF default (v3.1) refused to boot on the bench/field v1.x P4 and rev <3.0 vs >=3.0 are "huge hardware difference" (one binary can't cover both). **DONE (2026-08-19): `esp32p4rev3-eth` and `esp32p4rev3-eth-wifi` ship** (`REV_MIN_300`, which covers v3.0-v3.99), reusing the rev1 board fragment so the partition table and EMAC config stay in one place. **Still open: neither has ever been booted** — both bench boards are v1.3 engineering samples, so the rev3 images are flagged experimental in the installer and need a v3 board to verify. Espressif does not recommend v0.x/v1.x for new designs, so a board bought today is v3.x and needs these. -- **Retire the OTA fallback repository, once a device has another recovery route.** `FirmwareUpdateModule.h` names two: `kReleaseRepo` is tried first and `kFallbackRepo` catches a device whose firmware predates the move. It is tempting to call the second dead code after the rename, and it is not: `platform_esp32_ota.cpp:195` retries the alternate address whenever `esp_https_ota_begin` FAILS, which covers DNS, TLS, an outage and a variant whose asset is missing, rather than only a repository that has moved. So a v6.0.0 device on the happy path never reaches it and a v6.0.0 device whose first request fails still does. Removing it deletes that second chance for every board, which wants a replacement rather than a deletion: a configurable update URL, or a retry against the same address, would give a device somewhere to go. The one cost of keeping it is a wasted request on the failure path, which is already the slow path. -- **Raspberry Pi** — ARM64, cross-built or native. -- **macOS code-signing (Developer ID)** — the release `.dmg` is now ad-hoc signed, which turns Gatekeeper's outright refusal into the "unidentified developer" prompt a user can accept via right-click Open. A paid Developer ID certificate plus notarization would drop that prompt too. -- **Windows code-signing** — drops the SmartScreen warning on first run of `MoonLight.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. -- **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. -- **GCC below 16 needs four warnings demoted, and nothing exercises those versions** - `-Wnull-dereference`, `-Wrestrict`, `-Wstringop-overflow` and `-Wformat-truncation` fire on provably correct code from GCC 12 through 15 (five of the twelve inside libstdc++ and glibc headers, unreachable from our source), so CMakeLists demotes them to non-fatal there and keeps them fatal on 16+. That unblocks CI and from-source builds on Debian and Raspberry Pi OS alike, but it is a suppression, not an understanding: nobody routinely compiles with 12-15, so a REAL instance of one of these on those versions is now a warning nobody reads. Revisit when the runner's default GCC reaches 16, at which point the whole block can be deleted. -- **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. -- **Offer MoonLight/MoonLight as a library**: a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from*: the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([the MoonLight plan](../past/plans/Plan-20260922%20-%20MoonLight%2C%20from%20v5.0.0%20to%20the%20rename%20(shipped).md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. -- **HTTP: a request split across packets, or a body, that arrives a few ms late is dropped, intermittently (2026-08-20).** `handleConnection` runs SYNCHRONOUSLY inside `tick20ms`, so its waits are kept short to protect the render loop. - A connection with no bytes yet is parked and served on a later tick (2026-10-09), but a request whose headers arrive in two parts gets **~5 ms** for the rest, and a body **~50 ms** (`empties > 50`). - A client that misses either budget gets a request processed as truncated, or a `400 {"error":"incomplete request body"}`. Parking these too needs a buffer per parked connection. - - **Observed:** the MoonLive live scenarios fail roughly **1 run in 3** on the S3, always as a - failed `POST /api/file` that cascades (`module not found` for every step depending on that - script). A 40-write burst failed 4/40. Not reproduced on the classic (3/3 clean), so it may be - S3-specific or simply load/timing-dependent. - - **NOT render load — that theory is disproved.** Disabling the heavy driver took the tick from - 2889 us to 472 us (fps 346 → 2118) and made it WORSE: 14/40 failures instead of 4/40. So a - faster tick means more accept batches per second and more chances to hit the window, which - points at the budget itself rather than at contention. - - **Why it matters:** any client on a busy or distant network can lose a write with no useful - error, and the UI's own file saves ride the same route. The fix is not simply a longer wait — - that would stall the render loop, which is what the budgets exist to prevent. It wants the - connection handling moved off the render tick, or a state machine that parks a partial request - and resumes it next tick instead of dropping it. - -- **Desktop backend: a control-arena write is not seen by a second `run()` of an already-compiled - program (2026-08-20).** The live-edit path is "move a slider, the next frame reads the new arena - byte, no recompile". On the host JIT the first render is correct, the write lands (the arena - reads back `232,3` = 1000 at the control's own `offset`), and a second `run()` of the same - compiled program still renders the OLD value. - - **Ruled out — do not re-investigate these:** - - Not `LoadCtrl16` missing: it is implemented, lowers to `a.load16`, and `load16` exists in all - three assemblers including `platform/desktop/moonlive_asm_host.cpp` (`ldrh`, immediate scaled). - - Not uint16-specific: a `uint8_t` member behaves identically. - - Not `defineControls()` or the control sink: a class with an unrelated second function fails the - same way, and one with no members at all (a literal in `tick`) does too. - - Not the arena address or offset: `controlSlot(dc[0].offset)` is the byte the write reaches. - - **A large part of the original symptom was a TEST BUG, not an engine bug.** `run()` without an - entry name starts at the BLOCK START — the first function compiled — which in a class with - `defineControls()` is `defineControls`, not `tick`. So the script rendered nothing and looked - like a broken control read. Naming `kEntryTick` makes the initial render correct in every - variant tried (with/without `defineControls`, with an unrelated second function, `tick` first or - second, uint8 and uint16). Only the re-read after a slot write is still wrong. - - **Reproduce** (in `unit_moonlive_fill.cpp`, which has `kCtrlTable`/`kSys` to hand): compile - `class T { int big = 5; defineControls() { addControl("big", big, 0, 1000); } tick() { - setRGB(0, big, 0, 0); } }`, `run(..., kEntryTick)` → 5, write the slot to 7, run again → still 5. - - **Impact: desktop only, nothing ships broken.** A wide control is hardware-verified on both ISAs - (S3 Xtensa and S31 RISC-V drive ember's `cycle` to 2000 and back). What is missing is DESKTOP - coverage of the live-edit loop — the path users touch most — so no host test can pin it and the - next regression there would surface only on a board. Add the runtime assertion together with the - fix, not before: asserting the current behavior would encode the bug. - -- **ESP32-P4 DHCP hostname not shown by the router (recheck later)** — the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) — the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. - -### A Service that calls another device's API (robwomp, 2026-10-11) - -Suggested on Discord: panels driven by another stack take their pixels from MoonLight over DDP or Art-Net, but their settings stay out of reach. -A user-added Service holds a list of HTTP requests, each a method, a URL and a body, and fires one when a control it watches changes, so a button, a MIDI knob or an OSC message drives any device that answers `curl`. -Inbound control already exists (`/api/control`, MQTT, OSC, MIDI); this is its outbound twin, and the generic complement to the typed command half of DevicesModule below, which knows each ecosystem's API. -Requests leave the render thread, since a slow peer must never stall a frame. - -### DevicesModule — interop plugins + the command half (discovery shipped) - -DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/util/DevicePlugin.h) seam (shipped: MoonLight + WLED plugins). mDNS is advertise-only so MoonLight appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live color + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): - -- **More discovery plugins**: ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only, never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../../moonmodules/light/drivers.md#hue): bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the color-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver**: pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP; do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) -- **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so MoonLight can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync). -- **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. -- **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes MoonLight the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. - -Full design + the reasoned transport split: `Plan-20260629 - UDP device discovery + mDNS advertise-only` (in the plans archive). - -## MoonBase follow-ups - -MoonBase v1 ([MoonBase](../../explanation/architecture/moonbase.md)) -ships exactly one action: install firmware (upload + URL). The name is deliberately broader than -"recovery", these are the candidate next actions, each solving something only a separate boot -image can solve. The budget rule from the partition table applies to all of them: the 896 KB slot -has ~150 KB headroom, sized for one new *component*, so each action must earn its bytes (a few KB -of code is fine; a new IDF component is the expensive kind). - -- **Factory reset**: erase the filesystem (and optionally NVS) from MoonBase's page: recovers a - device whose config crashes the app on boot, without a USB cable. -- **Boot with config disabled**: one-shot flag the app reads at startup to skip loading - `/.config`: diagnose "is it my config or the firmware?" without erasing anything. -- **WiFi re-provisioning**: edit the stored credentials from MoonBase's page (today it only - *reads* them; the AP fallback plus the app's provisioning already covers most of this). -- **Publish SHA-256 sums with release assets**: the Defender false-positive guidance can only - say "re-download over HTTPS" today; a checksums file per release lets a user verify a - quarantined installer before restoring it. One sha256sum step in the release staging. -- **Streamed HTTP responses drain across ticks**: serveFileContents/serveHlsFile block the - render tick for the whole transfer (a slow player fetching a ~1 MB HLS segment every second - can hold it for hundreds of ms), and fsReadAt reopens the file per 1 KB chunk. The named fix - is the writeSome/drain pattern preview already uses, plus a larger chunk. -- **Re-entrant network bring-up**: NetworkModule::setup() (netif create, driver install, the - eth→WiFi→AP cascade) runs once at boot and crashes if re-run live, so it opts out of the - live config apply (`appliesConfigLive() = false`) and a restored NetworkModule.json waits - for the next boot. The real fix is bring-up that reconciles instead of re-creates; until - then the restore dialog offers the restart. -- **Config backup / restore, tiers 2+3**: tier 1 (browser-side bundle over the file API, with - the rename map and restore report) ships in the File Manager. Remaining: tier 2, a - single-archive device endpoint (one request instead of a walk); tier 3, restore hosted on - MoonBase's page, the migration answer for future partition-table moves. -- **Restore clones a device's IDENTITY along with its config** (found answering - [#76](https://github.com/MoonModules/MoonLight/issues/76), 2026-09-04). Backup bundles every - file including hidden `.config`, and restore writes them all back unfiltered, so restoring one - device's bundle onto another copies four things that must differ per device: - - | field | why it must differ | - |---|---| - | `deviceName` | the single network identity: mDNS hostname, SoftAP SSID and DHCP hostname all derive from it (`SystemModule.h`). Twelve clones all answer to `.local`, resolution goes non-deterministic, MoonDeck's device list collapses to one row | - | WiFi `password` | travels in the bundle (the UI button warns), so a shared or attached backup leaks it | - | a static IP | if set, every clone claims one address | - | `universeStart` and the Art-Net/DDP window | exactly what must differ per device in the light-pole case #76 describes: cloned, every pole shows the same thing | - - This turns "clone this pole to the other eleven" from the feature the issue wants into a trap. - **The fix is small and is a prerequisite for the recipe idea rather than a separate job:** restore - treats identity as per-device, either skipping those fields or prompting once with the target's - current values prefilled. Worth deciding whether the bundle should carry the secrets at all, or - keep an identity section the restoring device is expected to supply. -- **A backup is a file bundle, not a recipe** (#76 step 2): nothing binds it to a `deviceModel`, so - restoring a Dig-Quad bundle onto an S3 writes pin maps that do not fit the board. The 28 profiles - in `deviceModels.json` are the missing half. -- **Firmware downgrade guard**: MoonBase installs whatever image it is given; a version display - (read from the incoming image's app descriptor) before flashing would make an accidental - downgrade visible. -- **Hardware diagnostics**: chip/flash/PSRAM identification and a minimal pin tester, for - triaging a board that misbehaves under the full app. -- **Ethernet: shipped for classic RMII (2026-08-26)**. MoonBase reads the eth wiring from the - same config file as the credentials (ethType gates it) and runs ONE interface at a time in - the app's own preference order (eth, else WiFi, else AP), so the browser keeps the address - the app had. Still open here: the P4's IP101/managed-component PHY and the S3's SPI W5500, - which matter only if MoonBase ever goes beyond the 4 MB classics. -- **Static IP for MoonBase**: MoonBase always uses DHCP; a venue network without a DHCP server - (fixed-address rigs exist) would reach the app (static `addressing`) but not MoonBase. Read - the addressing block from the same config scrape when a venue actually asks for it (the app - itself applies static addressing to eth already). -- **MoonBase as the only update mechanism, all boards** (PO, 2026-08-26): would delete the app's - whole in-place OTA path (a real subtraction) and grow every app slot, with the stronger - power-fail story everywhere. Three deciding factors first: MoonBase Ethernet beyond classic RMII (the P4 has no - WiFi of its own and uses a different PHY; the S3 uses SPI W5500), a migration that does not lose config (backup/restore above), and accepting - ~45 s of visible downtime per update where dual-OTA installs in the background. Trigger: - MoonBase proven in the field on the 4 MB boards. - -## Provisioning and live-reconfig gaps (bench, 2026-08-26) - -Both surfaced while bringing up MoonBase Ethernet on the migrated Olimex; neither is MoonBase's. - -- **A migrated device is not fully provisioned until the deviceModel catalog push is re-applied.** - After the MoonBase table migration the board ran for days with `deviceModel` empty and - `ethType` 0: Improv/AP provisioning restores WiFi credentials only, and nothing tells the user - the catalog half (eth wiring, per-board settings) is missing. Candidates: MoonDeck re-pushes - the catalog on discovering a device whose deviceModel is empty, or the UI badges the state. -- **`ethType` does not apply live.** Setting it to 1 over the API (cable in, pins valid) left - Ethernet down; the same config brought it up at the next boot. Every setting applies live is - a core principle (architecture.md, Live reconfiguration), so the eth init path is missing from - the control's apply/build-state sweep. - -## ESP32 performance and memory - -### LEDs on the Teensy flash white, then the board resets (2026-10-13) - -Five times in one session, the 64-light strip on the Teensy 4.1 bench board went full white, and the board restarted with reset reason `UNKNOWN`, no fault mark and no watchdog: a supply dip, which a full-white strip on USB power explains. -Each white flash came while the device built a large state document: switching SdCard's board with the old 46 KB full state, and subscribing to Drivers or the whole tree with the new one; none came with the LEDs disconnected. -A WS2812 strip reads a data line held high as all ones, so suspect the GPIO-DMA output stalling high, or its buffer moving under the DMA when the Layer reduces its buffer under memory pressure ("buffer reduced" was showing). -Reproduce with a logic analyser on the data pin while subscribing to Drivers, before changing code. - -### A Teensy sometimes drops a message the page sends (2026-10-13) - -Choosing Custom on SdCardService's board select reached the Teensy once in three tries in one session, and every time in later ones; the same clicks always reach the Dig-Octa. -A lost write leaves the page showing a choice the device never made, and nothing tells the user. -Suspects, in order: the `/wsp` preview stream filling the send side while a frame from the page waits, a long render tick delaying the poll that reads the socket, and power dips while the LEDs show full white. -Reproduce with the preview open and closed and the LEDs dark and lit, logging each frame the device receives, before changing any code. - -### One board-preset helper for Ethernet and the SD card (2026-10-12) - -EthernetModule and SdCardService each carry the same board-preset machinery: the per-chip filtered option list, selection by label, the applied tracker, applying a preset when the selection moves, and reading the preset back off the pins. -A `core/module/BoardPresets.h` helper would own the options, index, count, selection and applied state, with the module supplying its table and two functions: write a preset into the pins, and match the pins against one. -Do it before a third preset table appears (a display's or a microphone's wiring), so that one becomes a table rather than a third copy. - -### SD card writes over SPI stop the card on the Dig-Octa (2026-10-12) - -On the QuinLED Dig-Octa's SPI slot (SCK 14, MOSI 15, MISO 36, CS 16), a write past about 50 to 200 KB fails mid-transfer, and the card then answers no command until its power is cut. -Reads hold: a 5 MB file reads back byte-exact at 184 KB/s. -The same no-brand 32 GB card writes 5 MB cleanly on the native SD bus of the P4 and of the Olimex ESP32-Gateway. -Ruled out: the clock (20, 10 and 4 MHz fail alike), 30 ms between writes, and the wiring, which Quindor's schematic shows with 10 kOhm pull-ups, its own LDO and decoupling. Turning the LED driver off moved the limit a little without removing it. -Next: the same 5 MB test with an A-brand card, since SPI mode is the part of the spec newer cards treat as secondary. ESPixelStick, the one other firmware driving this slot, throttles its writes into 7 KB blocks with a 30 ms gap and pauses LED output during an upload (forkineye/ESPixelStick issue 802 and `src/FileMgr.cpp`). -Until it is solved, a card mounted over SPI shows a warning status. - -### A classic ESP32 on WiFi stalls while the state document builds (2026-10-12) - -A full state fetch serializes the whole tree into one buffer, 49 KB on the Olimex ESP32-Gateway with an SD card, LEDs and both network interfaces. -On WiFi a classic has about 80 KB free at rest, so a browser's fetch or resync drops the largest free block to 4 to 14 KB, and new connections stall until it is freed: the page stops answering and the LEDs pause. -On Ethernet the same board keeps 121 KB free and a 108 KB block, and never stalls. -The fix is to stream the document: write each module's JSON straight to the socket as it is produced, so the peak is one module rather than the tree. -The same applies to the websocket resync, which sends the same document. -Measured with this PR's SD card against `main`: 1.4 KB more static DRAM, 4 to 7 KB more heap while a card is mounted, so the stall exists on `main` too. -Watch IRAM on the classic as well: the SD and SPI drivers' interrupt handlers raised it from 98 KB to 107 KB of about 128 KB. - -### Size estimates for unbuilt features (reference) - -Estimates, not measurements, so they live here rather than in [performance.md](../../reference/performance.md) which carries measured numbers only. (The 4 MB flash-budget investigation these once fed is resolved: MoonBase's single-app-slot layout grew the classic app slot to 2496 KB, see architecture.md § MoonBase.) - -| Feature | Est. | Rationale | -|---|---|---| -| Mozilla cert bundle trimmed | −40 KB | `CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN` keeps common roots only. `_NONE` saves ~50 KB but breaks TLS. | -| Global IPv6 | small | lwIP's IPv6 is already in, for the link-local address mDNS advertises; global addresses add autoconfiguration. Only if a deployment needs it. | -| WebSocket TLS (`wss://`) | ~0 KB | Reuses linked mbedTLS; certificate handling adds <5 KB. | - -### `ListSource` methods compile twice: ~8.6 KB of thunks (2026-10-01) - -Every module that is `MoonModule` and `ListSource` gets its list methods emitted twice on ESP32: once as the method, once as a "non-virtual thunk" for the second base, and GCC copies the whole body into the thunk instead of a jump. -Measured on the S3 image with `xtensa-esp32s3-elf-nm -S`: 8.6 KB in all, the largest `GamepadService` 1.6 KB, `ButtonService` 1.3 KB, `FixtureProfilesModule` 1.2 KB, `ControlModule` 1.1 KB, `InfraredService` 0.9 KB, `AnalogService` 0.9 KB. -Candidates, to measure rather than assume: a `ListSource` that is a member the module hands out (no second base, so no thunk), or keeping the bodies out of line so the thunk can only jump. -Done when the thunk total in the S3 symbol table is near zero, with the delta in repo-health. - -### An early state push costs 3 to 5 ms on the S3 (2026-10-01) - -`notifyValuesChanged()` patches one module on the next 20 ms tick, at most 25 times a second; with a MIDI desk following a self-playing Pong it took the S3 from about 325 to 295 fps, 3 to 5 ms per push. -Building that patch walks one module, so the time is most likely the WebSocket write on the render thread, but the split is unmeasured: one write per frame instead of two saved nothing. -Time the build and the send separately on the S3, then decide between moving the write off the render thread and a lower cap. - -### E1.31 multicast receive (IGMP join) - -NetworkReceiveEffect accepts E1.31 via unicast only — the same scope MoonLight ships. Multicast senders address the per-universe group `239.255.{universe_hi}.{universe_lo}`, which a receiver must join via IGMP. **The platform half of this now exists**: `UdpSocket::joinMulticast()` shipped with the WLED audio-sync work (2026-08-29) and is used in anger there, on both desktop and ESP32. What is left for E1.31 is the join-per-accepted-universe bookkeeping, not the socket support. Add when a multicast-only sender actually shows up on a bench; until then the spec documents "point sACN senders at the device's IP". - -**The SEND half is the more interesting one, and it's the honest scale answer.** sACN puts the universe number *in the group address*, so with **IGMP snooping** the switch filters per-universe in hardware — each node's NIC sees only the universes it joined. That is broadcast's send-once efficiency *plus* unicast's selectivity, and it's the one addressing mode that beats per-node unicast when many nodes want overlapping universes. `NetworkSendDriver` already knows its universe range, so the group address is a pure function of `universe_start` — a small increment, not a redesign. **The catch that keeps it off the default path:** without IGMP snooping the switch floods multicast exactly like broadcast (and on WiFi it goes out at the lowest basic rate to every station), so it degrades straight back into the starvation regime — and firmware cannot detect whether the switch snoops. So: unicast stays the portable default; multicast is the opt-in optimization for a network the user controls. Do the receive join and the send group together when it lands. - -### A scripted module's DIMENSION chip still comes from its type, not its script - -`writeModuleJson` emits an instance's `tags()` (HttpServerModule.cpp), so a MoonLive module shows -the emoji its loaded script declares. Its DIMENSION does not follow the same path: `/api/types` -carries `dim` per TYPE, captured at boot from a probe with no script loaded, and the module state -carries no `dim` at all. So a script declaring `int dimensions() { return 3; }` renders as 🟦 on the -card while behaving as D3 through `Layer::extrude`: the behavior is right and the chip lies. - -The fix is not a line in the serializer. `MoonModule` deliberately has no `dimensions()`: -`ModuleFactory::registerType` detects one with `if constexpr (requires ...)` on the CONCRETE type -precisely so the light-domain `Dim` enum stays out of core (ModuleFactory.h). Core holds a -`MoonModule*` when it writes state, so emitting a per-instance dim means giving `MoonModule` a -virtual that returns a byte, which puts a light-domain concept on the domain-neutral base for the -sake of a chip. - -Options, cheapest first: a `uint8_t dimByte()` on MoonModule defaulting to 0 (the enum stays in the -light domain, only the number crosses, mirroring what the probe already does); or leave it and -accept that a scripted module's dimension chip reflects its type. Worth doing when someone is -annoyed by the wrong chip, not before. - -### British spellings and em-dashes predate the prose gate (319 files) - -`check_prose.py` reports on ADDED lines only, so the American-spelling rule has been enforced from -the day it landed forward, and everything written before it was never swept. 118 files still carry -`colour`, `centre`, `behaviour`, `recognise`, `initialise` and friends, in comments and in a few -identifiers. - -**Em-dashes are the same story and the larger half**: 10,326 of them across 319 files under `docs/`, -against the same rule and missed for the same reason. They are worse than the spellings to sweep -mechanically, because the right replacement depends on the sentence (a colon where it explains, a -comma for an aside, a full stop between two clauses), so a blind substitution produces prose nobody -proofread. Count them per file and do the biggest offenders by hand. - -The gate keeps it from growing, so this is a one-time sweep rather than a leak. It is deliberately -NOT folded into a feature branch: a whole-repo rename touches more files than any review can read, -and mixing it with real changes is how a review gets declined for size. Do it as its own commit, -mechanically, with the gate run over the whole tree afterwards rather than over a diff. - -Identifiers first and separately: a rename changes an API, where a comment does not. `centre()` in -the shipped `crosshair.mle` was one and is already fixed, since a shipped teaching script is the -highest-value case. - -**Now caught going forward for scripts too**: `.mle`/`.mll`/`.mlm` joined the checker's SUFFIXES -(they were unchecked, which is how `colour` reached ten shipped scripts), so the library cannot -drift again. - -### ESP32 UDP receive is bounded by PACKET COUNT, not bytes - -Reported from other projects working the same ground: the ESP32 family's incoming UDP limit behaves -as a **mailbox of packets** rather than a memory budget, with a hard numerical ceiling, and the P4 -is no better. The observed shape is perfect reception up to that count and then progressively worse -loss as universes climb, rather than a clean cliff. Raising it is tunable but bounded, since the raw -packet buffers are full-frame sized whatever the payload. - -Two consequences worth having in mind: - -- **Pixels-per-packet is the lever, not bandwidth.** Art-Net is DMX512 on the wire, so it is capped - at 512 values per packet whatever the frame size; DDP fills close to a whole MTU. For the same - pixel count DDP therefore needs far fewer packets, which is the resource that runs out first. - This matches our own measurement that ArtNet is where the WiFi limit bites. -- **A P4-specific escape exists.** Have the packet handler DMA the payload to PSRAM and release the - hardware buffer immediately, then parse from another task, so the mailbox drains at memory speed - rather than at parse speed. That is real work and speculative, but it is the shape of a fix rather - than a tuning knob. - -Unverified on our own bench: this is other people's measurement, recorded so the next receive-path -investigation starts from it rather than rediscovering it. Confirm before acting on it. - -### WiFi ArtNet performance (pending investigation) - -128×128 WiFi ArtNet measurements exist (see [performance.md](../../reference/performance.md) "ArtNet over WiFi" and "Build-variant WiFi comparison"). Remaining matrix: - -- WiFi STA 64×64 (4K LEDs, 24 universes) -- WiFi STA 32×32 (1K LEDs, 6 universes) - -This determines the practical LED limit for WiFi-only boards. Until the `sdkconfig.defaults` TX-buffer fix lands (identified in the build-variant table), **prefer wired Ethernet for any ArtNet workload on classic ESP32** — the default `esp32` build carries both stacks, so Ethernet is available even when the original measurements were taken on the old `esp32-eth-wifi` variant. - -### Network round-trip test — drop/reorder measurement (deferred) - -`moondeck/scenario/run_network_roundtrip.py` measures desktop→device→desktop **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent color takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the desktop could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain. - -### Async ArtNet send — decouple the wire from the render tick (PSRAM-only) - -The ArtNet send is synchronous: `NetworkSendDriver::loop()` blasts ~97 universes (a 48 KB frame at 128×128) inline, and the per-universe `send()` blocks on lwIP TX backpressure — the netif/EMAC (or WiFi) drivers throttle to wire throughput. Measured on hardware: **~35 ms over Ethernet, ~90 ms over WiFi**, charged straight to the render tick, so ArtNet alone caps the Olimex at ~15 FPS and the S3 (WiFi) at ~7 FPS at 128×128. This is a transport throughput limit, **not** something a non-blocking socket can shed — verified that neither `O_NONBLOCK` nor `MSG_DONTWAIT` makes lwIP return early for UDP (the block is below the socket API; both flags drop zero packets and cost the same ~35 ms). The earlier "non-blocking recovered it to ~2 ms" reading was a transient external condition (the receiver/switch draining the burst freely in one window), unreproducible under steady load with the exact firmware. - -The real fix is a **dedicated send task**: `loop()` snapshots the corrected frame into a handoff buffer and signals the task; the send task drains it to the wire at its own pace while the render task continues. The tick stops paying the ~35–90 ms — render runs at its own rate (~30 FPS on the Olimex), ArtNet streams independently at whatever the link sustains. - -**Gate this on `platform::hasPsram`. It does not fit non-PSRAM boards at the grid sizes where it would help** — the math is hard: -- The handoff buffer must hold one full frame: **48 KB at 128×128** (16384 × 3), plus a ~4 KB task stack. -- The Olimex (no-PSRAM) at 128×128 runs at **~46 KB free heap, ~18 KB largest contiguous block** (measured). The 48 KB buffer exceeds *both* the total free heap and (by far) the largest block — it can't allocate at all, the same fragmentation cliff the paged MappingLUT just had to work around. -- A double-buffer (so the task reads frame N while render writes N+1) doubles it to ~96 KB — even more out of reach. -- At 64×64 the frame is only 12 KB and *might* fit, but at 64×64 the synchronous send is already fast enough that ArtNet isn't the bottleneck — so the task buys nothing where it's affordable on no-PSRAM. - -So the PSRAM gate isn't conservative; it's a hard requirement. PSRAM boards (S3/S2, Olimex-with-PSRAM variants) have megabytes for the handoff buffer via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)`; non-PSRAM boards keep the synchronous send and the documented "use Ethernet / smaller grid for high FPS at large grids" guidance ([NetworkSendDriver.md](../../moonmodules/light/drivers.md#networksend)). - -Acceptance criteria: `if constexpr (platform::hasPsram)` (or a runtime `hasPsram()` check) selects the async path; the buffer lives in PSRAM; the send task pins to the core opposite the render task (the same pattern as the shipped render↔encode split's worker). Non-PSRAM keeps `loop()`'s inline send unchanged. The single handoff buffer needs an explicit ownership contract, exactly like the shipped render↔encode split's `outputBuffer_`: the render path may write frame N+1 only after the send task signals it has finished reading frame N (a binary semaphore/notification is the handoff), so the two never touch the buffer at once. When the sender still owns the buffer at the next render tick, the render path drops (or coalesces onto) that frame rather than overwriting a live read — a dropped frame is acceptable, a torn one is not. That single-buffer contract is the minimal shape; a second buffer (double-buffering) or a ring of frames is only warranted if measurement shows the drop rate hurts, or a second consumer appears. - -### `esp32-eth` slow Ethernet bring-up vs `esp32-eth-wifi` (investigation) - -On Olimex ESP32-Gateway flashed with `esp32-eth`, Ethernet sometimes takes **a minute or more** to acquire a DHCP lease at boot. The same hardware flashed with `esp32-eth-wifi` brings Ethernet up in seconds. The B1 Idle-recovery fix in `src/core/system/NetworkModule.h` masks the symptom (status correctly transitions to "Eth: " once the lease arrives), but the underlying slow bring-up is a real performance regression on the eth-only build. - -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 `moondeck/build/build_esp32.py:31`). -- `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**. - -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). - -**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. - -### MoonDeck doc-asset endpoint hardening (backlog) - -`moondeck/moondeck.py::_serve_doc_asset` accepts any ROOT-relative path and serves the file. Path traversal *is* blocked (`asset_path.relative_to(ROOT.resolve())`), but inside the repo any file is served — including local-only artefacts like `moondeck/build/wifi_credentials.json` if present. MoonDeck binds to all interfaces by design (the existing comment in `main()` explicitly enables LAN reach), so anyone on the LAN can hit the endpoint. - -Two improvements when this matters: -- **Subdirectory whitelist** — only serve under `docs/` (and image asset paths the markdown renderer needs). Reject `moondeck/build/wifi_credentials.json` etc. with 403. -- **Extension whitelist** — only image / CSS / JS mime types via a small allowlist. -- **Optional bind-to-localhost flag** — `--bind 127.0.0.1` for users who don't want LAN reachability. Default stays "" (all interfaces) since the LAN-reach is the documented design. - -Not blocking — MoonDeck is a developer tool, not a production server. Pick this up when MoonDeck is in scope for hardening. - -### A tagged release does not reach the web installer until the next main deploy (bug) - -`deploy-pages` in `.github/workflows/release.yml` is gated `if: github.ref == 'refs/heads/main'`, because the `github-pages` environment's protection rule only allows main. The installer's release list is **staged into the Pages site at deploy time** (`install.js` self-hosts the last 5 stable + 5 prerelease releases; the release-asset URLs redirect to a host that sends no CORS header, so the browser cannot read them from the Pages origin). Together those mean **pushing a `vX.Y.Z` tag publishes the release but never updates the installer** — the new version reaches the picker only when something later pushes to main. - -Hit on v4.0.0 (2026-08-24): the release published at 16:15:59, a main deploy ran at 16:16 and enumerated releases *before* it existed, and the installer offered v3.0.0 as newest for hours. Re-running the workflow with the tag fixed it, and that manual re-run is the current workaround. - -Note the device's own OTA picker is unaffected — it reads `api.github.com` live ([app.js](../../src/ui/app.js) `RELEASES_API`), which is why a device could offer v4.0.0 while the installer could not. Two independent paths to the same release list. - -`restage-pages-for-tag` in the same workflow already covers this: on a `v*` tag it re-invokes the workflow on `main` with `tag=`, so `deploy-pages` runs under the allowed ref and the manifests land in `releases//`. The open item is narrower than the symptom suggested — whether the dispatched run can still lose the race, since it is a *second* run whose release enumeration happens later, and whether relaxing the `github-pages` environment's branch policy (needs repo-admin) would remove the indirection entirely. - -### CI: pin GitHub Actions to commit SHAs (supply-chain hardening) - -`.github/workflows/release.yml` references all 9 action types by mutable `@vN` tag (`actions/checkout@v4`, `astral-sh/setup-uv@v3`, `softprops/action-gh-release@v2`, `espressif/esp-idf-ci-action@v1`, …). A mutable tag can be force-moved to malicious code by a compromised publisher; pinning each `uses:` to a full commit SHA (with a `# vN` trailing comment) removes that vector. **Done already (cheaper half):** `persist-credentials: false` on every checkout that doesn't push, so the `GITHUB_TOKEN` isn't left in `.git/config` for later steps to read (the `release` job keeps it — it force-pushes the `latest` tag). **Not done (this item):** SHA-pinning, because it carries an ongoing cost — pinned SHAs go stale and miss security patches, so it only pays for itself **alongside Dependabot** (or a Renovate config) to auto-bump them. Pick this up as a deliberate "CI hardening + Dependabot" pass, not piecemeal. Low risk today: every action pinned is a first-party `actions/*` or a well-known publisher (astral, espressif, softprops), not an obscure third-party action. - -### Static IP on WiFi STA — wire the existing fields to the network (backlog) - -NetworkModule exposes `addressing` (DHCP / Static) plus `ip` / `gateway` / `subnet` / `dns` fields, and they persist — but they are **not applied to the WiFi STA interface**. `wifiStaInit(ssid, password)` takes only credentials; the STA always runs DHCP (there is no `esp_netif_dhcpc_stop` + `esp_netif_set_ip_info` on `staNetif_` — that pattern exists only for the AP). So selecting Static and entering an IP currently does nothing: the device keeps its DHCP lease. The fields are display-only scaffolding ahead of the functionality. - -Implementing it needs to answer three UX/safety questions (these *are* the spec): - -- **When is it applied?** NOT per-keystroke — editing the fields must only update the stored values, never reconfigure the live interface mid-entry (a valid `ip` with a still-zero `gateway` would otherwise be applied and break routing). Apply on an explicit commit — safest is **on next connect / reboot**, not a live switch, because changing the STA IP drops the very connection the browser UI is talking to. -- **Validation before apply.** Require all of ip/gateway/subnet present and self-consistent; reject `0.0.0.0` gateway/ip. If invalid, stay on DHCP rather than half-apply. -- **Warn before a live change.** If applied live (not reboot-deferred), the UI must confirm ("about to change this device's IP to X — you'll need to reconnect at the new address") and surface the new URL, since the current socket dies the instant the IP changes. - -Platform work: extend `wifiStaInit` (or add `wifiStaSetStatic`) to take optional ip/gateway/subnet/dns and call `esp_netif_dhcpc_stop` + `esp_netif_set_ip_info` on `staNetif_` when addressing is Static and the config validates. Needs careful hardware testing — a wrong static config locks the device off-network (recovery is the AP-fallback path or a flash erase). Until landed, consider hiding the Static option so it doesn't read as functional. - -### Memory ceiling on non-PSRAM ESP32 with eth-wifi (backlog) - -On `esp32-eth-wifi`, default 128×128 grid, free heap at boot is ~28 KB — not enough for `esp_wifi_init` (needs ~16 KB RX buffers) after the light pipeline allocates ~210 KB. The device stays running but WiFi init fails silently. - -Fix options in increasing scope: -- **Cap the default grid** — drop to 64×64 on `esp32-eth-wifi` (Layer ~32 KB + LUT ~16 KB = 48 KB, comfortably under). Simplest. -- **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 [lessons.md](../../work/past/lessons.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 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. - -## Advanced network settings (2026-10-03) - -Left out of the plan that makes Ethernet, WiFi and the access point Network submodules, and expected to be asked for: - -- **Roaming** between access points of one network while connected, by signal threshold (802.11k/v/r where the chip supports it). -- **Enterprise WiFi**: WPA2/WPA3-Enterprise with a username, a password or a certificate. -- **Pinning a network to one access point** (BSSID) or one band. -- **WiFi power save**: the modem-sleep mode, traded against latency for Art-Net and the UI. -- **The country code**, set by hand where adopting the router's country is not enough. -- **The access point's address**, fixed at 4.3.2.1. -- **IPv6 beyond link-local**: global addresses and a static one; the station and Ethernet carry a link-local address, which mDNS advertises. - -## Architecture - -### A second I2C bus, once a board wires one (2026-10-06) - -`I2cBusModule` drives one bus on controller 0, which covers every catalog board: one bus carries about 112 addresses, so a codec, a sensor and a display share it. -A board needs a second bus for two chips on the same fixed address, devices at different voltages or pull-ups, or a long cable run kept off the onboard devices. -The shape then: the module becomes addable with a controller-number control, the platform keeps one bus per controller, and a device on a bus (`AudioService`'s codec first) gains a bus selector defaulting to 0, so the catalog's boards keep working unchanged. -The trigger is a real board; a touch controller or camera on its own pins next to the codec is the likely first, the Guition M3's touch wiring being unchecked. - -### An offline test run takes 16 minutes instead of one (2026-10-03) - -With the host's network down, `test_desktop` ran 16 minutes and the scenarios stalled past the gate's hour; online, no test takes over 4 seconds, so something waits out network timeouts. -Next time the machine is offline, `build/macos/test/mm_tests --duration=true` names the test, which then gets a seam like `setTestJoinFails`. - -### `applyControlValue` should say whether the value changed (2026-10-03) - -The boot reapply in `FilesystemModule::reapplyNode` detects a changed value by serializing each control before and after the apply and comparing the bytes, a list control included. -`applyControlValue` reports only whether the value parsed and applied. The proposed shape is the apply also reporting whether storage moved, an `ApplyResult::Unchanged` or a flag, so `Scheduler::setControl` can skip the reaction to a write that changes nothing. -Cold path today, so a tidy-up rather than a cost. - -### Group src/core into folders, the way src/light already is (2026-09-17) - -`src/light` keeps nothing at its root: every header sits in `drivers`, `effects`, `layers`, `layouts`, `modifiers` or `moonlive`. `src/core` is the opposite, 61 files flat, with `moonlive` the one folder that exists. The documentation pages already name the grouping, so the folders would only make the tree say what the docs say: `system.md` fronts 13 modules, `services.md` 7, `supporting.md` 4, and `control.md` one. - -**The proposed shape.** `system/` takes the 13 modules on system.md plus `HttpServerModule`, which is a real module with its own `.cpp` and no page today. `supporting/` takes `MoonModule`, `Scheduler`, `Control` and `FilesystemModule`. `services/` takes the 7 service modules. That leaves 36 shared utilities, which want a `util/` of their own so the root ends up empty of headers like the light domain's: the packet and frame types, the maths and noise helpers, `JsonSink`, `JsonUtil`, `ModuleFactory`, `ScratchBuffer`, `Sort` and the rest. - -**What it costs.** 172 include lines across 96 files, 52 in `src` and 44 in `test`, plus the explicit `.cpp` list in CMakeLists and a handful of scripts and architecture pages that name `src/core/` paths. Mechanical, and a compiler error names every line that was missed, but it touches nearly every file in the tree. - -**Why it waits.** A pure rename of this size is unreadable as part of anything else, and it wants its own branch so the diff is one thing. It is also the kind of change that should follow the documentation sweep rather than ride inside it, since the sweep is what settled which page owns which header. The light domain has its own version of this: a `src/light/supporting` for the headers its own supporting page fronts. - -### Filesystem-change notification (live preset refresh) — undesigned - -ControlModule rebuilds its preset list by rescanning `/.config/presets`, and that rescan runs at startup and after every save, rename, delete and reorder. So a preset file **uploaded or deleted through the File Manager** appears only once the module next rescans (a reboot, or any preset action on the surface), not the instant the file lands. Documented as the actual behaviour in [the Control card](../../reference/presets.md). - -The fix is a **core-neutral filesystem-change notification**: FileManagerModule (or the `platform::fs*` write paths) signals "this path changed", and a module with a folder it cares about re-reads. Deliberately not built yet — it is a new core seam serving one caller today, which is the shape [architecture.md § Core primitives, not one-offs](../../explanation/architecture/index.md#core-and-light-domain) warns about. **Build trigger**: a second consumer appears (a scripted-effect folder for MoonLive is the likely one, since live scripts uploaded as files have exactly the same staleness), or the manual-refresh step proves annoying in real use. - -Whatever the design, it stays domain-neutral (a path + a change kind, no preset/light vocabulary in core) and off the hot path — the notification marks a flag, the rescan happens on the owning module's next tick, never inside the writer. (CodeRabbit flagged the staleness; deferred here rather than growing the seam for one caller.) - -### WiFi runtime disable — open design question (undesigned) - -Today the eth-only build profile compiles WiFi out (`MM_NO_WIFI`). Turning WiFi off *at runtime* instead is undesigned: whether the gate should key off detected hardware presence, an explicit control, or a deviceModel-catalog field isn't decided. The eth-only build covers the need until a concrete case forces the choice. (Moved from architecture.md § What we leave undesigned; it's a deferred design decision, not a settled 🚧 one.) - -### Consolidate the two module-by-name tree-walkers (backlog) - -`HttpServerModule::findModuleByName` (`findInTree` recursion) and `Scheduler::firstByName` (`firstInTree`) are two implementations of the same "find the first module in tree-walk order with this name" operation. The duplication predates the `setControl`-to-Scheduler extraction, but that extraction made `Scheduler::firstByName` public *and* added `Scheduler::instance()`, so HttpServer no longer needs its own copy: its ~9 remaining call sites (identify, addModule, removeModule, clearChildren, the WLED/system shims) can call `scheduler_->firstByName()` and the private `findModuleByName`/`findInTree` pair deletes. Purely a *No duplication* cleanup — no behaviour change — worth doing so the walk order/semantics live in exactly one place. (Reviewer note, IrService/setControl branch.) - - -### Pin-uniqueness check across modules (prevents conflicts; replaces a singleton hack) - -**Problem it solves.** Two modules must not drive the same physical GPIO. Today nothing stops it: add two `RmtLedDriver`s with `pins="18"`, or two `AudioService`s with the same `wsPin/sdPin/sckPin`, and they fight over the pin — at best garbage output, at worst (for I2S) endless `i2s_new_channel` driver-error spam every tick. This surfaced when a repeated catalog inject stacked duplicate AudioModules and the device spammed I2S failures (a clean install is fine; the duplicates were the artifact). - -**Why pin-uniqueness, not a per-type singleton.** The first instinct was "make AudioService single-instance" — but that's a crude proxy. The *real* invariant is pin non-overlap: a board legitimately can have **two LED drivers on different GPIOs** (multi-output rigs do exactly this), or even two mics on distinct pin sets. "One mic" isn't fundamentally true; "no two modules on the same pin" is. So check pin conflicts, which both prevents the breakage **and** allows legitimate multi-instance setups. (A per-type singleInstance flag was prototyped and rejected in favour of this.) - -**The clean mechanism — reuse `ControlType::Pin`.** Pins are already their own control type (the `addPin` work). So the check is domain-neutral and needs no per-module declaration: enumerate every `Pin`-typed control's value across the whole tree; a value of `-1` is "unused" (ignored); any other value appearing on two controls is a conflict. Handle the list case: `RmtLedDriver.pins` is a comma list (`"18,19,20"`), so the enumerator expands list-of-pins controls too. - -**Where it runs.** Two sites, because a pin can be introduced at add *or* edit: -- `POST /api/modules` (add): if the new module's catalog/default pins collide, reject. -- `POST /api/control` (pin write): if setting a `Pin` control to a value already used elsewhere, reject (or soft-flag — see below). - -**Open decision (UX).** Conflict on add → reject with a clear message (`"GPIO 18 already used by RmtLed"`). Conflict on a live pin edit → reject is safest but blocks mid-reassignment (you can't swap two drivers' pins without a free intermediate); a **soft-flag** (accept, set a status warning) is friendlier for live editing. Leaning: reject on add, soft-flag on live edit. Product-owner call. - -**Hardware-limit tail (not covered by the pin check).** Pin-uniqueness rejects the common case but not the controller-count limit: the S3 has **2 I2S controllers** regardless of pins, so a 3rd mic on distinct pins passes the pin check yet fails `i2s_new_channel` at runtime. That tail is already handled — the platform I2S init returns false on failure (no panic, module stays `inited_=false`); verified live (4 pinned AudioModules → error spam, no crash). So scope = pin-uniqueness check + the existing graceful-degrade; don't try to make the pin check also model controller counts. - -**Related:** the shipped "disabling releases resources" work (see [past plans](../past/index.md)) — a disabled module freeing its pins is what lets the same GPIO be reassigned live without a conflict-reject. - -### PinsModule — strict reject-on-add mode (the one remaining increment) - -[PinsModule](../../moonmodules/core/system.md) is shipped: the read-only ownership map, reserved/strap severity grading, the conflict **soft-flag** (a GPIO claimed by two controls shows both owners, flagged red, never rejected), live-state dir/level/drive, and the disable-frees-pins cascade all landed — see [pins-analysis-top-down.md](pins-analysis-top-down.md) for the shipped design record. The soft-flag choice obviated the reassignment broker (a live pin swap already works: set A→B's pin, then B→A's, the transient conflict clears). - -The one still-open item is an **optional strict reject-on-add mode** for the installer/catalog path: a "clean tree" that *refuses* a module add whose pins collide, rather than accepting it soft-flagged. This is add-path *validation* (a different feature than the live soft-flag reassignment), wanted only if a stricter installer UX is desired. Spec + `/plan` if picked; the soft-flag default stays for live editing. - -### Runtime board presets (multi-commit, partially landed) - -The firmware-vs-board separation is now in place across the codebase (see [MoonInstaller, firmware vs deviceModel vs board](../../explanation/architecture/mooninstaller.md#the-three-words)). `build_esp32.py --firmware ` picks the compiled binary; MoonDeck deduces the physical board where the firmware uniquely identifies hardware (`esp32-eth*` ⇒ `olimex-esp32-gateway-rev-g`) and lets the user pick from a short hardcoded list otherwise. Firmware variants stay separate — `esp32-eth` saves ~670 KB flash + ~30 KB DRAM vs the default `esp32` (WiFi+Ethernet, measured); merging would erase that win. - -What still needs separation: the eth variants hardcode Olimex Gateway RMII pins in `src/platform/esp32/platform_esp32.cpp::ethInit()`, so they only work on that one PCB. As we add boards with different pins (LOLIN D32 tested 2026-06-02, QuinLED variants planned), runtime pin configuration becomes the next step. - -Pin config moves to runtime (next, separate commit): -- Drop hardcoded `GPIO_NUM_17` from `ethInit()`. NetworkModule reads `Network.eth_rmii_clock_gpio` (new control) and similar pin values, defaulting to current Olimex hardcodes so behaviour is unchanged. -- Same for any other hardware-pin literal in the firmware. - -Board preset catalog + upload (later, when the runtime config has real consumers): -- Add structured per-board files (location TBD — not `docs/` since they're config not docs; `boards/` at repo root is the strong candidate, matches the PlatformIO convention contributors will recognise). -- Each file declares chip, flash, PSRAM, Ethernet PHY + pins, default module config. -- New `/api/board-preset` endpoint accepts the JSON; device persists to LittleFS; bootstrap applies pins + defaults on next boot. -- MoonDeck "Set board" picker reads the catalog to populate the dropdown. -- Pin reassignment requires reboot (ESP-IDF can't hot-reconfigure EMAC pins after `esp_eth_driver_install`); document the constraint. -- A first attempt at this catalog landed and was rolled back during the firmware-vs-board separation work — the catalog only earns its keep once the device reads it, otherwise it's a docs-shaped file in the wrong place. - -**Prior art: the predecessor's per-board pin database** ([ModuleIO.h](https://github.com/ewowi/MoonLight/blob/main/src/MoonBase/Modules/ModuleIO.h)). The predecessor MoonLight (our own earlier project) already models exactly this for ~25 boards across ESP32-D0 / S3 / P4: a `pins[]` array of `{GPIO, usage, index}` plus board-level `maxPower`, `ethernetType`, `ethPhyAddr`, `ethClkMode`. Don't copy the file or paste its tables here: read it when building the catalog and write our own. Its `usage` enum enumerates the hardware functionalities a board preset here *could* drive once the device-side consumers exist (each needs its own module/control before the corresponding `deviceModels.json` / catalog field earns its keep: none exist today beyond `System.deviceModel` + `Network.txPowerSetting`): - -- **LED output pins** — per-strip data GPIOs (1–16 outputs/board); the first real consumer (a Driver pin control) unblocks multi-output boards (QuinLED Dig-Quad/Octa, SE16, LightCrafter). **This consumer now exists** (the `pins` control on every LED driver; e.g. QuinLED Dig-Quad ships `"pins": "16,3,1,4"` in `deviceModels.json`), so the field earns its keep — but only up to **8 lanes** today (`kMaxLanes = 8` / `kMaxPins = 8`). The parallel drivers are moving to **16 lanes (choose 1..16)**; when they do, this becomes a real gap with two halves: - - **Per-model usable-GPIO map (the data).** Identify **which up to 16 GPIOs each device model actually exposes for LED output** — not the chip's full pin count, but the pins broken out to a usable header/connector AND safe to drive (exclude strapping, flash/PSRAM, input-only, and pins already owned by eth-RMII / I²C / the onboard LED). The codebase knows the chip *ceiling* (`MM_MAX_GPIO` from `CONFIG_SOC_GPIO_PIN_COUNT`, [Control.h:13](../../src/core/module/Control.h)) and the live *ownership/reserved* grading ([PinsModule](../../moonmodules/core/system.md)), but NOT the per-board *exposed-and-safe* set — that is board knowledge (schematic/pinout per model). The authoritative source is the **annotated pinout image per model under `docs/assets/deviceModels/`** (e.g. [`esp32-s3-n16r8-dev.png`](../../assets/deviceModels/esp32-s3-n16r8-dev.png)) (and MoonLight's `ModuleIO.h` `pins[]` as prior art — read it, write our own against `deviceModels.json`). **Worked example — ESP32-S3-N16R8 dev board** (from `esp32-s3-n16r8-dev.png`): the 16 safe LED-output GPIOs are **`4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,21`**, derived by excluding — octal flash+PSRAM (the R8/N16 part reserves `26–37`, the `SPIIO4-7/SPIDQS/SPICLK` pins), USB (`19,20`), UART0 console (`43,44`), the onboard RGB LED (`38`), and the strapping pins (`0,3,45,46`). That leaves exactly 16 clean I/O — enough for the full 16-lane target. Do the same read per model. - - **Encode as per-model defaults + coverage (the catalog).** The 16 usable lane GPIOs become the model's default `pins` string, so a fresh flash of e.g. "Serg UniShield V5" comes up with the *right* lane pins pre-filled, not blank/generic. Coverage today is uneven: of 25 models, **7 carry no LED-pin config** (Olimex Gateway, LOLIN D32, Generic ESP32 Dev, ESP32-S3 N16R8 Dev, LightCrafter 16, SE 16 V1, and any bare dev board) — those default to nothing and force the user to guess. Fill every model's LED-capable pin default, and **document the per-model map** (the annotated-pin images the § below already reserves are the natural home). **The per-board usable set is wider than the exposed header:** a pin the board commits to a peripheral it *doesn't mount* is fair game (the Olimex Gateway leaves 6 clean LED pins only if you count the **unmounted micro-SD** pins 4/13/14 — see [gpio-usage § Usable LED-output GPIOs](../../reference/hardware/gpio-usage.md) and the bench note in memory), so the per-model map must record *which non-exposed/repurposable pins are safe on this board*, not just the header breakout. Scope: **16 pins max** — do not over-generalize past the peripheral lane ceilings ([measured lane ceilings](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid): Parlio 65535 bytes/lane single-shot (897 RGB lights at 8 lanes, ~448 at 16), LCD **8 or 16 lanes** on S3 (the widening shipped), RMT up to 8 TX channels). **Note the i80 gotcha:** an `LcdLedDriver` model should pick `clockPin`/`dcPin` clear of its data lanes — an overlap is a *warning*, not a blocker (the driver still runs; that one lane just carries the clock/DC waveform, which is fine for an unused parked lane but garbles an active strand), so a catalog default that overlaps an *active* lane would ship a subtly-broken board. -- **Ethernet PHY config** — LAN8720/RMII (MDC/MDIO/CLK/power-pin/PHY-addr/clock-mode) vs W5500/SPI (MISO/MOSI/SCK/CS/IRQ); the consumer is the runtime `Network.eth_*` controls listed above, replacing the hardcoded Olimex pins. -- **Power budget** — `maxPower` (Watts) per board, for a future current-limit / brightness-cap control. -- **Audio / I2S** — SD/WS/SCK/MCLK pins, the input side of audio-reactive effects (Pi-5 sensor note is the desktop counterpart). -- **Buttons & inputs** — push/toggle/lights-on, PIR, digital-input; needs an input-event concept the firmware doesn't have yet. -- **Relays & power control** — relay / lights-on / high-low pins. -- **Infrared** — IR receive pin (remote control). -- **RS485 / DMX** — TX/RX/DE pins (wired DMX-512 output beyond the current Art-Net path). The driver that consumes this is the [RS-485 / DMX-512 wired output item](backlog-light.md#rs-485-dmx-512-wired-output-future-the-physical-dmx-driver) in the light backlog. -- **Sensing** — voltage / current / battery / temperature ADC pins. -- **Onboard LED / key, exposed / reserved pins** — board-housekeeping and conflict-avoidance metadata. - -Sequencing rule (unchanged): each functionality lands a device-side control first, then its preset field; the catalog grows one earned consumer at a time, never as a speculative pin dump. - -**Module variant + PSRAM within the classic-ESP32 family.** `getChipDescription()` and MoonLight's `ModuleIO.h` both report only the *core* family ("ESP32"), not the *module* (WROOM / WROVER / PICO) — so neither distinguishes whether a classic-ESP32 board has PSRAM. This matters for MoonLight (whose large-LED story leans on PSRAM) in a way it doesn't for MoonLight: e.g. the **QuinLED Dig-Next-2 is built on an ESP32-PICO with 2 MB PSRAM**, but MoonLight's `esp32` build has no `CONFIG_SPIRAM` (see the `#ifdef CONFIG_SPIRAM` gate in `platform_esp32.cpp::psramAlloc`), so it flashes and runs as a no-PSRAM device and hits the non-PSRAM fragmentation ceiling at large grids that the 2 MB would otherwise relieve. A PSRAM-enabled classic-ESP32 firmware variant (e.g. `esp32-psram`) would unlock it; `deviceModels.json` could then carry a `psram` hint per board to steer the picker — but only once that variant exists (no consumer today). `deviceModels.json` currently maps every classic board to the WiFi-only `esp32` variant, which is correct-but-unoptimised for PSRAM-bearing PICO boards. - -### Per-layout coordinate offset for independent placement (backlog) - -`Layouts` stitches multiple child layouts into one physical light space, but only their *indices* are stitched (offset sequentially in `placeLights`) — their *coordinates* are not translated. Two layouts therefore overlap in the same coordinate box: two 64×64 grids both occupy x,y ∈ 0..63, so the Layer's dense bounding-box buffer is 64×64 (4096 voxels) even though the container reports 8192 lights, and the second layout's lights land on the first's positions. `scenario_Layouts_mutation` documents this (its steps assert pipeline liveness, not buffer-size arithmetic). - -When picked up: add `offsetX/Y/Z` (lengthType) controls to `LayoutBase`; `Layouts::placeLights` translates each child's emitted coords by its offset so layouts occupy disjoint regions of the physical extent (a 64-wide grid at offsetX=64 sits beside another at offsetX=0 → a 128×64 combined extent). `Layer::onBuildState` already derives physical dims from the max emitted coordinate, so it would pick up the wider extent automatically. Until then, "multiple layouts" means "multiple layouts sharing a coordinate box", which is only useful when they genuinely overlap (e.g. a sphere inscribed in a grid). - -### Improv as a child of NetworkModule (deferred — needs scheduler work first) - -Architecturally the right shape; attempted in plan-21, reverted. Blocker: `Scheduler::tick()` only walks top-level modules for `loop20ms`/`loop1s` — children silently miss those callbacks. See [lessons.md](../../work/past/lessons.md) "Trying to add a child module to NetworkModule". - -Minimum-scope fix before the move: -1. `MoonModule::loop20ms`/`loop1s` propagate to children (or Scheduler walks them) — pick whichever costs less at runtime. -2. Audit every existing override of `setup`/`loop1s`/`loop20ms`/`onBuildControls`/`teardown` in NetworkModule, SystemModule, FilesystemModule, HttpServerModule to confirm base-chaining. -3. Then the actual move is a one-line `networkModule->addChild(improvModule)` swap. Estimate ~2 h total. - -### Platform API: `std::span` migration (backlog) - -Several `platform.h` APIs still use `(buf, len)` pairs where `std::span` would catch length/pointer mismatches at compile time. Concrete sites: `http_fetch_to_ota`, `improvProvisioningInit`, and friends. ~2 h including ripple updates to callers. Do alongside the next platform-API expansion (Windows socket port or POST /api/firmware streaming). - -### Improv-as-REST follow-ups - -Device-model injection over Improv shipped as **"Improv = REST over serial"** (the `APPLY_OP` vendor RPC pushes the whole `deviceModels.json` entry over serial during install; the device runs the same apply-core the HTTP REST API does, on WiFi *and* eth-only firmware). That subsumed the earlier multi-step "board injection + Improv as a general data injector" plan — the general injector *is* APPLY_OP. What remains: - -**Open follow-up: closed-loop APPLY_OP pacing (read-back ack + retry).** The installer paces APPLY_OP frames open-loop (`sendApplyOpFrame` waits a fixed ~120 ms between ops) rather than reading the device's ack back, because a Web Serial duplex read while the writer lock is held is awkward. The delay covers the worst-case single-buffer consume window with headroom, and each op is idempotent (a lost op re-applies cleanly on a re-flash), so this is robust today. The closed-loop upgrade — read the RPC response, retry once on error `0x82` (buffer busy) — removes the fixed delay (faster install) and makes op-loss impossible rather than improbable. Worth doing if a real install is ever observed dropping an op, or when the config push grows large enough that the cumulative fixed delay is noticeable. Touches only `install-orchestrator.js`. - -**Open follow-up: shared JS helpers across device-UI and mooninstaller.** `safeLocalGet` / `safeLocalSet` (3-line hostile-storage guards) are duplicated in `src/ui/install-picker.js` (device firmware, embedded as a C string via `embed_ui.cmake`) and `mooninstaller/devices.js` (web installer page, served from Pages). The two live in different build contexts so the shared extract isn't trivial — it'd need a new `src/ui/safe-storage.js` plus updates to: `embed_ui.cmake` (embed the new file), `ui_embedded.h` generator (new C array), HTTP server file routing (new path served), `release.yml` workflow staging, `preview_installer.py` staging. Five files for one 3-line helper is too much pre-merge. Worth doing when the next shared helper arrives — `relativeTime` and `formatBytes` are candidates. Two helpers earn the build-glue cost; one doesn't. - -**Open follow-up: P4 Improv scan on a cold WiFi link (bench check).** `improvHandleScan` in `src/platform/esp32/platform_esp32_improv.cpp` calls `esp_wifi_scan_start`, which needs the WiFi driver started. On native ESP32/S3 the driver is up by the time a user provisions; on the P4 the radio lives on the C6 and comes up only after the esp_hosted prelude in `ensureWifiInit()` (triggered by `wifiApInit` / `wifiStaInit`). A scan requested on a P4 that has not initialised WiFi returns an error cleanly rather than scanning a cold link, so nothing crashes. The check: bench-verify whether a P4 provisioned from cold needs the link brought up first, and if so route the scan through the public `wifiAp`/`wifiSta` path. - -### Live scripting — author effects/layouts/modifiers/drivers/sensor logic on-device (multi-commit, design phase) - -Run user-authored scripts on a running device — a scripted effect, layout, modifier, driver, or core sensor rule, pushed as text and live on the next tick with no reflash/reboot — the leap WLED took with ARTI-FX and the heart of the PixelBlaze product. A scripted module **is** a MoonModule (controls, `loop()`, role, generic UI). The engine lives in core (domain-neutral: also "transform sensor data") and serves the light domain specifically. Targets in order: ESP32 classic + S3 first, then P4/other ESP32, then Teensy, then desktop. Must be blazingly fast (runs in the render hot path at 16K+ lights × 50 FPS), memory-smart (IRAM/PSRAM via `platform::alloc`, compile-once), and synced (Scheduler tick, tick-atomic hot-swap, live reconfig). - -The **bottom-up landscape survey** is done — [livescripts-analysis-bottom-up.md](livescripts-analysis-bottom-up.md): deep-reads the [ESPLiveScript fork](https://github.com/ewowi/ESPLiveScript/tree/fix-warnings) (a from-scratch C-like JIT that emits **native Xtensa** machine code — blazingly fast but **Xtensa-only**, so it covers classic+S3 and *not* P4/Teensy/desktop), surveys the field (PixelBlaze bytecode VM + web editor, WLED ARTI-FX AST-walking interpreter, embedded VMs / WASM / lightweight multi-ISA JITs), and extracts the load-bearing decisions (execution strategy, the IR seam ESPLiveScript lacks, the MoonModule binding, the per-pixel contract, memory placement, sync, sandboxing). Its thesis to validate: a **portable bytecode-VM baseline that runs on every target on day one + an optional native back-end for the hot ISAs behind a shared IR**. **Next: the top-down redesign** — the prompt that generates `livescripts-analysis-top-down.md` is at the bottom of the bottom-up doc; it produces the reference architecture + staged spike plan. Implementation is multi-commit, spike-ordered, after the top-down lands. Credits: [friend-repos/hpwit-ESPLiveScript.md](../../friend-repos/hpwit-ESPLiveScript.md). - -### HTTP file serving blocks the render tick (backlog) - -`HttpServerModule::handleConnection()` serves large embedded files (`app.js`, `style.css`) with the blocking `TcpConnection::write` — a page load can briefly stall `loop20ms`. One-shot per load (lower priority than the per-tick preview issue, which is fixed). Fix: serve large HTTP responses through a resumable per-client cursor drained on tick20ms (the shape the preview and full-state sends use). - -### Generic control + state topics over MQTT — the automation escape hatch (backlog) - -The MQTT bridge ships a **semantic** topic surface (`/on/set`, `brightness/set`, `hsv/set` → the `Drivers` controls) sized for Homebridge/HomeKit, which need fixed, typed topics per accessory. That leaves everything else the web UI can reach — effect choice, layout, modifier params, palette-by-index — unreachable from a home hub. The ask that surfaced this: *"expose the whole REST API over MQTT."* - -**It's compatible with Homebridge/HA, because it's a second topic namespace, not a replacement.** Homebridge only ever subscribes to the semantic topics it's configured for; a generic topic family sits beside them and a broker carries both. The two surfaces already share one apply-core — `Scheduler::setControl(module, control, json)`, the same seam `/api/control` and IR funnel through — so a generic MQTT surface is that core under a different topic shape, honouring the module's own "MQTT is a transport, not new control logic" contract. - -But "the whole REST API" over-scopes: of the routes (`/api/control`, `/api/state`, `/api/system`, `/api/modules[/…]`, `/api/dir`, `/api/file`, `/api/firmware/*`, `/api/reboot`, `/api/types`, the `/json/*` shim, `/ws`), only two are genuinely pub/sub-shaped. The rest are request/response or bulk transfer and belong on REST: - -- **`/api/control/set`** ← `{"module","control","value"}` (the exact JSON `/api/control` takes) → `applySetControl`. The real win: any control on any module — the effect/layout/modifier/palette reach HomeKit can't express. The apply-core already validates (range / read-only / module-not-found via `SetControlResult`), so safety is free. -- **`/api/state/get`** → publishes the `/api/state` JSON (retained). For dashboards (fps / heap / tick / current effect across a fleet), offline detection, and alerting — the monitoring half. - -Explicitly **out** (no practical MQTT case, and wrong for the transport): -- **`/api/file`, `/api/dir`, `/api/firmware/upload`** — kilobytes-to-megabytes of binary; MQTT is a small-message bus, REST already streams these correctly. -- **`/api/modules` add/delete/move/replace** — stateful, order-sensitive pipeline surgery a human does once in the UI; an automation reshaping the pipeline is an anti-pattern (the *Robust to any input* guarantee makes it *safe*, not *advisable*). -- **`/api/reboot`** — one narrow action; a single fleet-reboot topic is a mild nice-to-have, not "the API." -- **`/api/types`, `/json/*`, `/`, `/ws`, `/api/firmware/url`** — discovery metadata, the WLED shim, the UI itself, the socket: static, already-served-elsewhere, or a different protocol. - -**Shape:** ~15 lines routing into the existing `applySetControl` + a state publish; gate behind a `generic API` bool defaulting **off**, so a home user's broker isn't a remote-config backdoor and the curated HomeKit surface stays the clean default. **Don't** auto-generate a semantic topic per control — HomeKit/HA need stable typed topics; a generic `palette/set` whose meaning shifts per firmware breaks their discovery. Keep semantic topics hand-curated; let the generic pair be the escape hatch. - -**HA MQTT Discovery — SHIPPED.** The device announces a retained JSON-schema light config to -`homeassistant/light//config` (the Tasmota/ESPHome/Zigbee2MQTT pattern), so HA auto-creates a -wired entity — gated on the `haDiscovery` control, with a Last-Will availability topic. This covered -the "HA can't reach the device over MQTT" incidents. The *generic-topics escape hatch* above (the -whole REST API over MQTT, for power-user scripting) is the remaining unbuilt piece; keep it coherent -with the [DevicesModule command half](#devicesmodule-interop-plugins-the-command-half-discovery-shipped) -(Tasmota-MQTT / zigbee2mqtt as *outbound* control — the mirror of this *inbound* surface) and the -[LightsControl integration point](backlog-mixed.md). - -**Open question — Homebridge example maps both `setBrightness` and `setHSV`.** In `homebridge-mqttthing`'s `lightbulb`, HSV's *value* (V) already carries HomeKit's Brightness characteristic, so mapping both topic pairs may double-drive brightness (mqttthing's docs lean toward using one or the other). The [MQTT § Homebridge example](../../moonmodules/core/system.md#mqtt) currently lists both, to show the device's full topic surface. Resolve on hardware: flash a board, run Homebridge + mqttthing with that config, and check whether the Home-app brightness slider misbehaves — if it does, drop the two `brightness` topics from the example; if not, it's a non-issue. (Flagged by CodeRabbit; parked here rather than changing the doc on an untested hunch.) - -### HA update entity via MQTT discovery — release check (open follow-up) - -**Discovery config + install command wiring: shipped.** [`MqttModule`](../../src/core/system/MqttModule.cpp) now publishes a second HA-discovery component at `homeassistant/update/MoonLight_/config` (same haDiscovery gate, same MAC-stable id, same device card as the light), state on `/update/state`, and subscribes to `/update/set`. HA renders a *"Firmware: "* card in the diagnostic section of the device panel. An install command routes to `platform::http_fetch_to_ota` with the release-artifact URL built from the payload version + `kFirmwareName` (`https://github.com/MoonModules/MoonLight/releases/download/v/firmware--v.bin`), reusing the same OTA task the `/api/firmware/url` route drives. - -**Still to build: device-side release check.** `installed_version` and `latest_version` are equal today (both `MM_VERSION`), so HA renders the entity as up-to-date and the Install button is disabled. What's missing is a periodic poll of `https://api.github.com/repos/MoonModules/MoonLight/releases/latest`: at boot (~30 s post-network-up) and every 24 h thereafter, fetch the release JSON, extract `tag_name`, and if newer than `MM_VERSION` call `MqttModule::publishUpdateState()` with the updated `latest_version` (the publish path is already there: only the caller is missing). ~2 KB per check. - -Two blockers, both platform-layer: -- **`platform::http_fetch_to_ota` is OTA-shaped** — it writes bytes into the next OTA partition; there's no general "HTTPS GET into a buffer" seam. A small `platform::http_fetch_to_buffer(url, buf, cap, statusBuf, size_out)` next to it — ~30 lines wrapping `esp_http_client` on ESP32, a libcurl / URLSession stub on desktop — is the right home for this. -- **JSON parsing on-device** — the existing `mm::json` scalar helpers are flat and key-order-independent; a `releases/latest` payload uses a nested `assets[]` array which the current helpers don't walk. Either extend them (~30 lines to walk one level of array), or scan for the `tag_name` string directly with `strstr` for this one caller. - -Once both land, add a `ReleaseCheckModule` (or a small extension inside NetworkModule / FirmwareUpdateModule — decide when building) that owns the poll timer + last-seen version cache and calls into `MqttModule::publishUpdateState()`. ~50 additional lines. - -## Testing - -### Additional test coverage (pending) - -### The restructured scenarios lost four things the archive asserted (2026-09-29) - -The 27 scenarios moved to an archive and 11 new ones took their place, one per top-level module. -The archive is now empty. Of the 27, four were the core-folder ones whose questions the new network scenarios ask better (`scenario_Network_hardware_reconfigures_live` and `scenario_Network_identity_and_discovery` between them cover the mDNS toggle, the Home Assistant discovery toggle and the Ethernet reconfigure, with read-backs the old files never had). Of the remaining 23, thirteen were deleted as already covered by unit tests, three more once tests were written for them, and the seven whose subject only exists on hardware live in `test/scenarios/device/` marked `live_only`. -The structure is better and the new set asserts things the old one could not: 29 `expect_control` value checks where the old set had none, 9 `reboot` round-trips proving a setting reached the filesystem, and consent defaults pinned as a shipped privacy guarantee. -Four things did not survive the move, and nothing else in the tree covers them. - -**Every performance contract and bound is gone**, measured rather than estimated: `contract` 40 to 0, `bounds` 20 to 0. -Both mechanisms are still implemented in the runner (`scenario_runner.cpp` enforces a `tick_us` ceiling and a `free_heap` floor, and the `bounds.fps` gate below it). -The new set records `observed` numbers, which is a trend rather than an assertion, so nothing fails on a tick or heap regression. -One document still states otherwise and should be corrected when this is: `docs/reference/performance.md` says each scenario step carries an enforced contract. -The `collect_kpi.py` half is FIXED on 2026-09-29: its anchor named a file that exists nowhere in the repo, and now states the measurement instead. -The `device/` set carries `observed` blocks from a real ESP32-S3 as of 2026-09-29, which is the window a contract would be set from; none is set yet, since one run per step is not a baseline. - -**The light scenarios render nothing.** FIXED on 2026-09-29 for the two that did not: `scenario_Layouts_resize_reallocates_live` and `scenario_Drivers_output_and_brightness` now build the whole pipeline, so the runner's buffer checks fire and they report 5 and 8 checks rather than 2 and 5. -Control-tested by removing the effect, which fails `buffer non-zero after render`. - -What remains is the framework half, already filed under per-step assertions above: the buffer checks run ONCE at the end of a scenario, so a resize can still only assert that the control changed. -Nothing can say the buffer grew to 1024 lights at 32x32 and shrank back, which is the claim the scenario's own name makes. - -**Teardown under a live pipeline is untested.** FIXED on 2026-09-29. `scenario_Effects_teardown_under_a_running_pipeline` removes an audio producer while its consumer still renders, then the orphan, then the last effect, then rebuilds, and it clears the Drivers container to prove the non-editable preview survives a clear that takes the driver beside it. -That last was the only `clear_children` on Drivers anywhere, and the skip rule it pins is control-tested. - -**`HttpServerModule` has no scenario**, though it is a scheduler root on every networked target and the transport the live tier itself runs over. - -**Nothing is marked `live_only` any more.** FIXED on 2026-09-29: eight scenarios carry it. -`scenario_Network_hardware_reconfigures_live` took over what `scenario_NetworkModule_eth_reconfigure` did, cycling `ethType` through None, LAN8720, IP101 and W5500 with a live `ethStop` and `ethInit` where the desktop has only a no-op seam, and adds mDNS and Home Assistant discovery. -The seven in `test/scenarios/device/` are the rest: real peripheral silicon and its DMA budgets, internal-heap fragmentation behind `max_alloc_block`, and float cost on an Xtensa with no FPU. - -Most of the rest moved to a tier that holds it: modifier chains, peripherals and MoonLive are covered at unit level, and the MoonI80 no-freeze guard in particular is pinned by `unit_ParallelLedDriver_doublebuffer`. -What no unit test can reproduce is the cross-board comparative number set the peripheral sweep produced. - -**A measurement taken after a failed allocation is recorded as though the work happened (2026-09-30).** `scenario_Fluid_solver` on a classic ESP32 reads 107 us at 64x64 where an S3 takes 71,441 us, which no float-heavy solver does: the classic is 670x faster only because its grids never allocated. The tell is in the same block, where free heap RISES from 25,760 to 53,728 bytes as the tick collapses, so memory is being handed back while the effect ticks dark. `cube-20-d` reads the same way at 97 us. -Two halves, and neither exists today. `FluidEffect::prepare` refuses a grid it cannot allocate and leaves the effect ticking harmlessly, which is the right behaviour, but it publishes no status, so nothing downstream can tell a refusal from a cheap frame. And `run_live_scenario.py` records whatever tick it measures without asking whether the module producing it is in a state worth measuring. -The fix is a status on the effect (the shape `ParallelLedDriver` already uses for a bus that will not init) plus a runner check that labels or drops the sample. It needs a classic on the bench to verify, since that is the only board small enough to fail the allocation. Until then the affected rows are the classic's `grid-64-h`, `all-jets`, `long-persistence`, `cube-20-h` and `cube-20-d`, and they understate the cost rather than overstating it, so no contract set from them would be too lax. - -### The static set runs on every supported device before the rename (2026-09-29) - -The scenarios are the pre-cutover hardware check: a set that passes in-process proves the logic, and the same set against each board proves the platform layer under it. -That is the point of getting the static set solid first, and it is what the device sweep on cutover day reads from. - -Two of the three things it needed are now in place: the light scenarios render, and eight scenarios are marked `live_only` so a sweep asks a board something a laptop cannot answer. -What remains is a runner that takes the device list rather than one host, so the answer is a matrix across the chips we ship rather than a pass on whichever board was plugged in. -The first S3 run on 2026-09-29 found two defects the set had been hiding: a `channelsPerLight` prop that stopped being a control, and peripheral names renamed out from under the scenarios, where `optional: true` turned every wrong name into a silent skip and still reported a pass. - -`moondeck/moondeck.json` carries 24 registered devices and `run_live_scenario.py` takes a `--host`, so the missing piece is the loop and the per-device report, not the transport. - -### A scenario that runs until something breaks (2026-09-29) - -A static scenario proves a sequence works once. What it cannot find is the fault that needs a thousand random module additions, or a leak that takes an hour: the runner sets up, asserts and exits, so the long tail of lifecycle bugs has no test that reaches it. -A fuzz mode would run until killed on a device left on a bench, doing randomly chosen legal things and stopping the moment one fails. - -The runner already carries most of it: a step vocabulary, module add, remove and replace, control setting, measurement, and per-step failure reporting. -What it needs is a mode that generates steps rather than reading them, and four things that make a failure worth acting on. - -A **seed**, so a run is reproducible: a failure reprints the seed and the step index, and the same seed replays the same sequence. -A **legal-move set** the scenario names rather than the generator guessing, since a random string in a filepath control proves nothing about the pipeline. -**Halt on the first failure**, keeping the device in its broken state for inspection rather than tidying it away. -And a **floor under heap and tick**, because the faults this is for announce themselves as a slow drift rather than an assertion. - -Worth most where the gaps above are: random add and remove under a live pipeline is exactly what the archived `remove_module` steps covered, and a long run on a Raspberry Pi finds the leak a thirty-second scenario cannot. -Build it after the static set is solid, since a fuzz run is only as trustworthy as the assertions it fires. - - -- **Memory degradation cascade** — the output-buffer *allocation* decision (no buffer for a lone identity layer; a buffer for ≥2 layers or any LUT layer) is unit-pinned (`unit_Layers_container` "Drivers allocates the output buffer only when…"), and LUT-vs-identity is pinned by `unit_Layer_sparse_mapping`. What's **not** pinned is the *low-heap* half of [architecture.md § Degradation cascade](../../reference/hardware/firmware-variants.md#degradation-cascade): under heap pressure the LUT + driver buffer are skipped *together* (`lutSkipped()` true, forced 1:1), and below that the layer buffer *reduces dimensions* (halving to a 8×8 floor) rather than failing. The hook exists — `unit_BlendMap` already uses `platform::setTestMaxAllocBlock` to force allocation failure for the paging test — so a test could cap the block size and assert: (1) LUT+output buffer both skip and `lutSkipped()` flips, (2) the layer buffer shrinks to fit and never goes null. Pre-existing gap (predates multi-layer); the *happy-path* allocation contract is covered, only the OOM-degrade branch isn't. -- **Per-step assertions in scenarios (a framework gap, not a scenario gap).** A scenario step can - assert TIMING and HEAP (`bounds`, `contract`) and the run asserts the final buffer, but it cannot - say "after this step the fixture has 24 lights", "this module's status reports a compile error", - or "the rendered output changed". So a migrated script step proves the pipeline still ticks, not - that the edit did what it claims — the MoonLive scenarios walk break-and-recover cycles whose - most interesting states go unasserted. Wants a small vocabulary (`expect_lights`, - `expect_status`, maybe `expect_pixel`) added to **both** runners together: the desktop/live split - is exactly what let `write_file` exist on one and silently no-op on the other. Raised by - CodeRabbit against scenario_MoonLive_pipeline and scenario_MoonLiveEffect_controls; the sites are - listed there, but the vocabulary has to exist first. -- **UI page load time** — scenario step measuring HTTP response time for `/`, `/api/state`, `/api/system` via the live runner. Verifies acceptable load time on ESP32. -- **Module teardown memory** — scenario that tears down all modules and verifies heap returns to pre-setup baseline. Confirms no lifecycle leaks. -- **JavaScript test harness** — `vitest` + `jsdom` for the browser UI: pure helpers in `install-picker.js` (`isCompatible`, `parseFirmwaresFromAssets`, `relativeTime`) **and `app.js`'s conditional-control DOM logic** (`syncVisibleControls` — reconciles which control rows are rendered when a `hidden` flag flips). The C++/backend half of conditional controls IS unit-tested (`conditional_controls.h` + per-module tests pin the binding + `hidden` flag), but the **UI re-render half is not** — `syncVisibleControls` was the source of a real re-render-loop freeze (Network static-IP toggle) caught only on hardware. A `jsdom` test that builds a card, flips a control's `hidden`, runs the reconcile, and asserts the right rows appear/disappear (and that it converges — the unchanged→no-op fast path) would have caught it. **Attempted and reverted (2026-06-17):** stood up vitest + 13 passing tests for the install-picker pure helpers, but the high-value half (`syncVisibleControls`) needs either an `app.js` module seam or extracting its reconcile logic into a separate served `.js` (6 embed/route wiring edits for a firmware-served file). Judged not worth adding a whole Node/npm toolchain to a C++/Python repo to test ~3 small pure functions; the toolchain earns its place only once the `syncVisibleControls` DOM test (and a real body of JS logic) lands with it. **Do it as its own focused branch**, deciding the app.js seam first (it's already `type="module"`, so extracting `reconcileControlRows` into a served file — wired through `embed_ui.cmake` + the two HttpServerModule routes like the other UI .js — is the clean shape). Pure-helper `_test` exports + the reconcile extraction are the two pieces; both were prototyped in that reverted attempt. -- **Browser-level Improv automation** (deferred) — `moondeck/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_perf_light`, `scenario_perf_full`) `clear_children` the containers and rebuild, then their cleanup leaves the tree **bare**. -- **Canonical-tree-assuming scenarios** (`scenario_GridLayout_resize`, `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. - -## An explicit `Custom` Ethernet preset is reseeded to a named one - -`ethPresetIsUnset()` returns true for any editable row, and `Custom` is the only editable row, so it cannot tell "nothing has chosen a preset" from "the user chose Custom". A board saved as `Custom` whose pins happen to match a named preset is therefore reseeded to that preset on the next rebuild, and its pin rows are hidden: a hand-wired board silently adopts a board map it was never given. - -The seam is what makes this awkward. `defineControls` runs before the config is restored, so the seed cannot simply latch a flag on the first render: that is the legacy path the seed exists for, a board whose file predates `ethBoard` and carries only pins. Distinguishing the two needs to know whether the restore supplied an `ethBoard` value at all, which is not visible where the seed runs. An attempt that latched on the first render broke the legacy path and was reverted. - -**What it costs today**: nothing on a board the catalog configures, since those name their preset. It bites a user who deliberately picks `Custom` on a board whose pins match a preset, which is the hand-wired case `Custom` exists for. - -**Where to look**: `FilesystemModule::applyNode` overlays values, calls `rebuildControls`, then overlays again. A control that wants to know whether IT was restored needs a signal from that pass, which no control has today. That is the general shape worth solving rather than a special case for one select. - -## Ethernet on the QuinLED Dig-Next-2 (W5500 over SPI) - -The DN2 is in the catalog with no Ethernet block, so it comes up WiFi-only. Adding it is a `deviceModels.json` entry and nothing else: the preset work landed the seam, and W5500 already applies live. - -**RMII is impossible on this board, so the W5500 is the only route.** The DN2 carries an ESP32-PICO-V3-02, whose package leaves GPIO 16, 17, 18 and 23 unbonded, and those are the RMII management and clock pins. This is the same package fact behind the flash-cache wedge recorded in [lessons](../past/lessons.md). QuinLED's own LAN8720 board stacks onto the QuinLED-ESP32 only, not onto a Dig board. - -**The wiring, from [Quindor's firmware source](https://github.com/intermittech/WLED-Dig-Next-2_W5500)**, using the QEXT header for the bus and the Stemma QT header for the two control lines. Fitting Ethernet therefore costs the I2C port: - -| W5500 | connector | GPIO | -|---|---|---| -| SCLK | QEXT green | 25 | -| MOSI | QEXT blue | 32 | -| MISO | QEXT yellow | 0 | -| CS | QEXT white | 33 | -| INT | Stemma QT SDA | 15 | -| RST | Stemma QT SCL | 14 | - -MISO on GPIO 0 is deliberate and sound: GPIO 0 is a strapping pin, and a W5500 leaves MISO high-Z while CS is inactive, which is what Espressif's rule about not driving strapping pins at reset asks for. - -**It stays `Custom`, and that is the finding.** Across the three W5500 boards now known, LightCrafter 16, SE 16 V1 and this one, no two share a single pin on any of the six lines. SPI Ethernet is a module wired to whatever a board left free, so there is no W5500 pattern to make a preset from, and inventing one would be a preset that fits nothing. - -**What is unverified.** The pin table is trustworthy as what that firmware does, because it was read out of the code rather than a description. What rests on one source is that it works: a single-author repository created 2026-09-19, self-labelled experimental, tested on one unit, with LED output, buttons, relays and mic explicitly not exercised. No independent build was reported when this was written. So the open question is not the pins but whether SPI Ethernet and the LED output coexist on this board under load, which is a bench answer. - -## Housekeeping - -### Promote the one-line `//` cap to an error, once the 604 `.cpp` blocks are homed (2026-09-21) - -`MAX_CODE_COMMENT = 1` is already the rule in both kinds of file, and already an ERROR in a header. It stays a warning in a `.cpp` only because `_blocks()` stages the sweep by file kind, and that function names its own removal: a rule leaves the staging as the tree meets it, which no-hard-wrap already did. - -**The shape of what is left.** 604 blocks across 92 files, 2081 comment lines. By area: 239 in `test/`, 210 in `src/`, 155 under `src/platform/`. By length: 81 are two lines, 321 are three, 124 are four, so 526 of 604 are three lines or fewer and the tail is one 15-line block, two of ten and a handful between. - -It concentrates, which is what makes it approachable. `HttpServerModule.cpp` alone holds 100 and the top ten files hold 312, over half; the other 82 files average under four each. - -**Where the depth goes is the blocker.** Of the 92 files only 19 already carry an `@technical` appendix. 68 have a `///` lead with no appendix and 5 have neither, so most files need the appendix written before a single block can move. - -**The fix is not to split them.** Chopping one comment into two shorter lines leaves the text identical and satisfies nothing, which is why the cap counts a block rather than a line. Each one is depth that belongs in the file lead's appendix with an `@xref` back, or prose that says less. - -**Suggested order.** `HttpServerModule.cpp` first: 100 blocks, 444 comment lines, and it already has the appendix. Then the other nine heavy files for 212 more. Then `test/` as its own sweep, because the argument that a test comment explains why a test EXISTS is strongest there and may deserve a different budget rather than an appendix. Promote once at zero. - -**Cost.** Not a scripted pass. Each block is a judgement between depth that belongs in an appendix, an explanation that should be shorter, and history git already holds. About 35 done by hand in one session is the measured rate, so the first file is a session of its own and the whole sweep is several days. - -### Hot path: move blocking work off the render callbacks (architecture) - -`-Wfunction-effects` proves the render path really does block — these are not annotation gaps, -they are synchronous I/O, allocation and lifecycle work reachable from `tick()`. Each needs the -work moved to a worker or made resumable, so each is a design change rather than a lint fix. -Confirmed by external review (CodeRabbit, PR #56). - -**`tick()` — every frame, the sharp ones:** -- `PreviewDriver::tick` — the socket half is FIXED (the pull-model transport only ARMS a message; - every socket byte moves on the transport tick). What remains: `buildCoordTable()` resizes - `keptIdx_` and the staging buffer on a rebuild/adopt tick. Suppressed at the site with the reason. -- `ParallelLedDriver::tick` — `tickSync()`/`tickRing()` reach `busWaitIfBusy()`, which spins for - the DMA peripheral. Deliberate (the driver owns the bus for the frame) but blocking. Suppressed. -- `Drivers::tick` — joins/stops the render-split worker synchronously on the timed-out recovery - path. Wants asynchronous handling that still preserves buffer ownership. -- `HueDriver::tick` / `tick1s` — synchronous HTTP (`pushOneChangedLight`, `pollPairing`, - `fetchLights`, `fetchGroups`) plus allocation. Wants a queue to a worker. -- `Layer::tick` — calls `applyState()` on a modifier rebuild, which runs prepare/release and - resizes ScratchBuffers. Wants the rebuild deferred to the scheduler's non-render prepare path, - keeping today's coalescing of `consumeNeedsRebuild()`. -- `DemoReelEffect::tick` — `advance()`/`swapTo()` create, delete and `applyState()` child effects - from the render callback. Wants a pending-switch flag consumed off-tick. -- `NetworkSendDriver::tick` — sends inline; wants to enqueue. -- `RmtLedDriver::tick` — waits on hardware completion and reset; wants polling or offload. -- `AudioService::tick` — UDP `sendTo`/`recvFrom` every frame (`syncSend`, `syncReceive`, - `syncEnsureSocket`). - -**`tick20ms()` / `tick1s()` — milder, same shape:** -- `HttpServerModule::tick20ms` — inline `handleConnection` transport work, so the 100 ms budget - cannot actually bound it. Wants resumable connection handling. `tick1s` writes WLED state - frames inline; wants the existing resumable sender. -- `MqttModule::tick1s` — synchronous DNS + discovery-buffer allocation. -- `NetworkModule::tick1s` — WiFi/AP lifecycle transitions and tree rebuilds. -- `FilesystemModule::tick1s` — the debounced `flush()` does filesystem work; the poll itself is - fine, the save wants a worker. -- `FileManagerModule::tick1s` — `platform::filesystemUsed()`; wants a cached value. -- `SystemModule::tick1s` — the P4 `coprocessorWifi()` path calls - `esp_hosted_get_coprocessor_fwversion()` synchronously; wants a cached snapshot. -- `ImprovProvisioningModule::tick` — runs the queued APPLY_OP inline; wants a cold task to - execute and publish only the result. -- `Scheduler::tick` — dispatches all of the above, so its own contract is only as good as theirs. - -**Explicitly NOT in scope:** the float-math findings (`BouncingBallsEffect`, `RipplesEffect`, -`SphereMoveEffect`). Review suggested fixed-point, but the float trajectory IS the ported -MoonLight behaviour and fidelity is deliberate. If the FPU-less Xtensa cost is real, that is a -profiling question first, not a lint fix. - -`-Wfunction-effects` reports these but never fails a build; the code check counts them per file, and that count may only fall. - -### ESP32 clang/LLVM toolchain — extend the clang checks to src/platform/esp32/ - -Espressif ships an xtensa LLVM (their fork), but the installed `esp-clangd` package contains -**only `clangd`** — no `clang++` driver — so a clang analysis pass over ESP32 sources needs their -full LLVM installed separately. - -What it would buy: `-Wfunction-effects` (and clang-tidy, clang-query) over `src/platform/esp32/` -— 20 translation units, the only code the desktop build excludes. Everything else, including the -LED drivers, already compiles on desktop and is already checked. - -What it costs: a second ~1 GB toolchain, maintained purely for analysis — the firmware would -still be built by GCC, so the analysing compiler is not the shipping compiler. That is a real -"one rule, one owner" tension, and the reason this is a decision rather than an obvious yes. - -Worth revisiting when either the coverage gap bites (a hot-path bug traced to the platform layer -that the desktop check could not see) or Espressif's LLVM becomes the default toolchain. - -### clang-tidy: triage the remaining 30 findings - -`.clang-tidy` runs `*` minus a documented disable list and reaches zero on everything except the -path-sensitive `clang-analyzer-*` family: **30 findings**, led by -`bugprone-unchecked-string-to-number-conversion` (9), `security.insecureAPI.strcpy` (5), -`core.NonNullParamChecker` (4), `deadcode.DeadStores` (3) and `unix.cstring.NullArg` (3). Roughly -half sit in test code (`strcpy` into fixed local buffers, a deliberate divide-by-zero probe). - -The nine `unchecked-string-to-number-conversion` sites were read individually and are all safe: -HueDriver guards on `if (id > 0)`, JsonUtil documents 0-means-absent, MqttModule uses a `v = -1` -sentinel and clamps. They stay reported rather than suppressed — a report shows the real situation, -and a `NOLINT` would hide a genuine class of bug for the next person who writes one of these. - -These surfaced late for an instructive reason: the report parser's check-name pattern rejected the -`,-warnings-as-errors` suffix clang-tidy appends when `WarningsAsErrors` is set, so **every finding -was silently dropped** the moment the ratchet was switched on. Fixed; recorded here because it is -the sixth silent-zero this tooling has produced, and each looked like a clean tree. - -`WarningsAsErrors` stays empty: clang-tidy reports, it does not gate -([testing.md § Static analysis](../../reference/testing.md#static-analysis)). - - -### Migrate HueDriver's name buffers to `ScratchBuffer` (finish the ScratchBuffer sweep) - -`HueDriver::ensureNameBuffers()` / `freeNameBuffers()` is the alloc-in-build / free-in-release / null-guard bundle that `ScratchBuffer` absorbs — and it does **no** `dynamicBytes` accounting, so the module's memory readout under-reports its Hue-light name buffers. It was skipped in the ScratchBuffer migration (its buffers are `char[]` name tables, not the grid-sized state the sweep targeted). Now that `DriverBase::release()` chains to `MoonModule::release()` (fixed on PR #43), a `ScratchBuffer` member here would free correctly on disable and self-report its bytes. Mechanical, one-file, behaviour-preserving. Flagged by the 👾 Reviewer on PR #43. - -### Link-time optimisation on the ESP32 builds (measure) - -Splitting `HttpServerModule` into three files cost 7,360 bytes on the classic ESP32 (2,129,216 to 2,136,576), the compiler no longer inlining across the files. Link-time optimisation (`-flto`) optimises the whole program at the link and wins that back, and likely more across the firmware. ESP-IDF's current docs offer it as `CONFIG_COMPILER_LTO_LINKTIME`; v6.1, which MoonLight builds with, has no such option. Try it once the ESP-IDF release MoonLight moves to offers it, per board: the image size, the IRAM placement in the map file (an interrupt handler must not inline code from flash), a run on the RMT and parallel drivers, and the build time, which LTO makes two to three times longer. - -### Pin the preview cancel over loopback (test) - -`cancelBufferedSend` closes a preview client left mid-message, so the next frame is never read as the rest of this one, and nothing pins it. The loopback `WsClient` in `unit_HttpServerModule_subscribe.cpp` already drives `/wsp` and reads a frame whole; a client that stops reading mid-frame, then a cancel, then a check that its socket closed while a client that read the whole frame stays open, covers it. - -### Adopting the v6.x ecosystem changes (plan) - -The as-is state of each item is the table in [build-system.md § The v6 ecosystem](../../reference/build-system.md#the-v6-ecosystem); this entry carries the plan and the triggers. - -**Per item, how and when:** - -- **EIM** — adopt any time. It shipped *in* v6.0 so it clears the v6.0-floor rule, and it has a headless CI mode. Add EIM as the **preferred** path in `setup_esp_idf.py` (`eim install`), keep `install.sh` as a documented fallback for one release. Keep the exact-commit pin: EIM's multi-version management helps reproducibility, it does not replace the pin. -- **PSA Crypto** — nothing to migrate while we stay on high-level components. Watch only: if a feature needs hashing or signing directly (signed-OTA verification, a device identity), write it against the **PSA API** from the start, not legacy mbedTLS. Trigger: first direct crypto use. -- **`network_provisioning`** — adopt to close the phone-app + SoftAP gap. It is in v6.0 (clears the floor) and is the IDF-native standard. Add it as a **sibling provisioning module** beside ImprovProvisioning (both wired-by-code System modules; a device offers whichever transports its chip supports), reusing the same WiFi-credential plumbing. Not a replacement for Improv: they cover different front-ends (browser vs phone-app) and a product can want either. Weigh the BLE-stack cost per chip. Spec before code. -- **CMake Build System v2** — watch until GA, not while it is a preview: adopting a preview build system is the opposite of common-patterns-first. Trigger: v2 ships as the default. Then dry-run a build under v2, fix any `idf_component_register()` / Kconfig fallout, switch. Low risk given how little custom CMake we have. -- **Built-in MCP server** — evaluate, do not default to it. The risk is a *second control path* bypassing the script policy, and it is ESP32-only (no desktop), so it cannot be the uniform path. If adopted, wrap it behind a script (`moondeck/run/idf_mcp.py`) so the policy layer still applies. Trigger: a concrete debug workflow the scripts cannot cover. - -**Sequence.** Close the v6.0 gaps one at a time as normal feature commits: - -1. **EIM installer** — rework `setup_esp_idf.py` to prefer `eim install`, keeping `install.sh` as a one-release fallback. Smallest and lowest-risk (build-path only, no firmware change, no hardware re-test), and EIM's multi-version management is what cleanly supports the v6.0-floor / v6.1-fallback juggling, so it sequences first as an enabler. -2. **`network_provisioning`** — the headline capability: the phone-app + SoftAP onboarding flow. Its own plan. -3. The rest (PSA-native crypto, CMake v2, MCP) as their triggers fire. - -**Guardrails.** Platform-generic stays intact: these are ESP32-specific gains and none may regress Teensy or the desktop paths, which do not use ESP-IDF at all. An IDF feature is adopted *inside* the ESP32 platform layer / build tooling, never by leaking an IDF assumption into shared `src/` or the desktop build. And the v6.0 floor holds: adopt only what is in v6.0, so the v6.0 fallback keeps working. - -### ESP-IDF version pinning (pending) - -The build pins ESP-IDF `v6.1` GA, commit `fff9895c`. `setup_esp_idf.py` holds it in `PINNED_IDF_COMMIT` and `PINNED_IDF_VERSION`, warns when the installed tree differs, and offers to check the pin out (`--no-checkout` keeps it a warning). The version facts, the v6.0 floor, the support policy and how to check for a newer tag, live in [build-system.md § ESP-IDF version](../../reference/build-system.md#esp-idf-version); moving the pin is the `bump-idf` process. **Still to do:** a MoonDeck banner or status dot surfacing the same drift, since the warning shows only while `setup_esp_idf.py` runs. **History:** until the move to GA on 2026-09-29 the build ran `v6.1-dev-399-gd1b91b79b5`, a dev-branch snapshot, which cost the missing `ESP_ROM_ELF_DIR` in the post-build gdbinit step (fixed in `build_esp32.py`); the move waited for v6.1 to tag rather than for its calendar date. - -### Clock sync — a shared monotonic clock across devices (committed design, unwired) - -The second half of the core [multi-device runtime](../../explanation/architecture/mooncore.md#multi-device-runtime); discovery ships, this does not. The design: one leader sends its elapsed time over OSC, the protocol that already carries the shared controls, with the same `addressing`; followers compute their offset, targeting sub-millisecond accuracy. A shared monotonic clock is the foundation any cross-device coordination builds on, which is why it is core rather than light-domain. - -The light-domain payoff is a wall of controllers animating in lockstep: effects already animate off elapsed time, so feeding them the leader's synced clock instead of each device's local one is the whole change on the render side. Device-to-device light *distribution* is a separate topology question and rides the existing ArtNet / E1.31 / DDP standards rather than a bespoke protocol. - -### Config provenance: firmware/MCU → deviceModel (catalog follow-ups) - -The model itself is now a shipped design; see architecture.md § Config provenance (deviceModel is the one provenance level, the `txPowerSetting` example, and "default only where the hardware actually fixes it"). The catalog that carries it is `mooninstaller/deviceModels.json` (schema). The remaining forward-looking pieces — a `devices.json`/MCU-layer split and annotated-pin images — stay gated by the sequencing rule (no catalog field ahead of a consumer). - -### Persistence overlay: partial-save / schema-change audit (backlog) - -The absent-key fix (`json::hasKey` guard in `applyControlValue`, so a saved file omitting a key no longer zeroes the control's default — the P4 `ethType` no-DHCP root cause) closed the acute hole. A broader audit would harden the overlay against the rest of the schema-drift surface, now that controls carry meaningful non-zero defaults: -- **Type-change safety (not migration, see MIGRATING.md).** `ethType` changed `int16` → `uint8` (Select). A persisted file written under the old type still loads (the flat parser reads the number either way, and Clamp snaps an out-of-range value to the new bounds). The task is to *confirm* each type transition degrades gracefully through the robust loader (absent → default, stale → clamp, unknown → ignore) — especially Text/Password/IPv4 buffer-size changes — NOT to add migration code. A transition that would lose data silently is documented as a break in the documented break, per the settled policy. -- **Test coverage.** `unit_Control_apply_absent_key` pins the absent-key contract; extend with a type-change round-trip (save int16, load into uint8 Select) and a narrowed-range clamp case so a future schema change can't regress silently. -This is hardening, not a known bug — the shipped fix is correct for the cases that occur today. - -### ESP32-P4 support — rounds 3-4 (in progress) - -Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remaining rounds, each its own plan + commit: -- **Round 3 regression: WiFi associates intermittently on IDF v6.1 (2026-10-12; the PO sees it associate on some boots and not on others, 2026-10-16).** The variant keeps shipping so users can try it. It associated and served traffic on IDF v6.1-rc1 (below). The bench P4-NANO (v1.3, port `usbmodem5ABA0767291`, registry .139) flashed with `esp32p4rev1-eth-wifi` on the v6.1 pin, Ethernet unplugged: Improv delivered the bench SSID over USB serial, the board answered "unable to connect", and it never appeared on the network. Its log does not reach `monitor_esp32` on that USB-JTAG port, so the cause is unread. First step: read the P4's console (UART pins with an adapter) for the esp_hosted / C6 lines, then compare against the rc1 build that worked: the IDF move from rc1 to v6.1 and the esp_hosted version it pulls are the two candidates. -- **Round 3, WiFi via the C6 co-processor. WORKING, BUT THE LINK IS SLOW (2026-08-19).** Boots and associates on IDF v6.1-rc1 (see round 4); the remaining defect is throughput, bench-bisected below. The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4rev1-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` needs no hosted bring-up of its own: esp_hosted self-initializes at boot via a constructor (`ESP_SYSTEM_INIT_FN`), which sets up the SDIO transport, RPC and wifi-remote channels before `app_main`. Calling `esp_hosted_init`/`connect_to_slave` there would be worse than redundant, `connect_to_slave` is a transport *reconfigure* that resets the slave and re-inits SDIO, which fails on a live link. The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../../reference/build-system.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. - - **Hardware results (bench, P4-NANO, 2026-06-12):** - - ✅ **esp_hosted / C6 SDIO comes up at boot.** `host_init: ESP Hosted`, `H_API: ESP-Hosted starting`, `add_esp_wifi_remote_channels`, `H_SDIO_DRV: sdio_data_to_rx_buf_task started`. No NVS error / assert / panic / hang. Device boots fully (~57-60 FPS), `hasWiFi` true, WiFi controls present. esp_hosted **self-initialises at boot via a constructor** (`ESP_SYSTEM_INIT_FN` → `esp_hosted_init`), so no bring-up code is needed in our platform layer — an earlier explicit `esp_hosted_init` + `esp_hosted_connect_to_slave` prelude was *removed*: init was a redundant no-op and `connect_to_slave` is actually a transport *reconfigure* (slave GPIO-54 reset + SDIO re-init). SDIO config confirmed correct on the wire: `CLK[18] CMD[19] D0[14] D1[15] D2[16] D3[17] Slave_Reset[54]`, 4-bit 40 MHz. - - ❌ **WiFi STA connect fails on the SDIO re-init.** The cascade DOES fire correctly (`Ethernet no link, cascading` → STA path), but `esp_wifi_init()` (forwarded to esp_wifi_remote) internally triggers `esp_hosted_reconfigure` → `Reset slave using GPIO[54]` → **`sdmmc_card_init failed` (×15) → `card init failed` → `esp_wifi_init failed: ESP_FAIL`**. So: the boot-time SDIO init succeeds, but a **runtime slave reset can't re-establish the SDIO link**. The C6 doesn't come back after the GPIO-54 reset during operation. This is an esp_hosted/SDIO/C6-slave-firmware level issue (reset timing or slave image), below our application code — the pins and Kconfig are correct. - - **Open issues before this is done:** - - 0. **The esp_hosted build is slower on HTTP — BISECTED to the hosted link, not to us (2026-08-19), and much improved but NOT gone (re-measured 2026-08-21).** - - **Re-measurement on IDF v6.1-rc1**, same board, same commit, same Ethernet interface, same method: - - | build | per-request (`/api/system`) | throughput (76 KB `app.js`) | - |---|---|---| - | `esp32p4rev1-eth` | 10 ms flat | 1,973 KB/s | - | `esp32p4rev1-eth-wifi` | 40 ms typical, one 280 ms outlier in 12 | ~980 KB/s | - - So the penalty is now **4x per-request and 2x throughput**, against 33-60x and 17x when this was written, and **the alternating 0.4/0.8 s pattern is gone** (one outlier in twelve, not every other request). Render is healthy in both (359 fps on the WiFi build). Something between the two IDF versions fixed most of it; what remains is the same shape (per-request, not per-byte) and still worth closing. The original measurement follows. - - **Original (2026-08-19):** Same board, same commit, same application code, measured over **Ethernet on both builds** so the radio is not in the path: - - | build | per-request (`/api/system`) | throughput (73 KB `app.js`) | - |---|---|---| - | `esp32p4rev1-eth-wifi` | alternating 0.434 / 0.788 / 0.446 / 0.813 s | 42 KB/s | - | `esp32p4rev1-eth` | 13 ms flat, 12 consecutive fetches | 708 KB/s | - - Render is healthy in both (121-134 fps), so this is not frame-loop contention. Because the two images share every line of application code, the penalty is **esp_hosted being compiled in and its SDIO link serviced**, degrading traffic on an interface it is not even carrying. The cost is **per-request, not per-byte** (a 1 KB fetch cost nearly as much as a 36 KB fetch), which points at a periodic blocker a request must wait out rather than a slow pipe. - - Already excluded on the bench: SDIO width/clock (already 4-bit/40 MHz, the maximum), WiFi buffer counts (match the S3's), ICMP latency (6.6 ms). An earlier `SystemModule` fix (`coprocessorWifi()` retried an unanswered version query every tick: 1,012,344 us → 257 us) was real and is kept, but it was **not** this: the stutter outlived it and vanished only when the C6 path left the image. - - **Per-module tick timing, both builds on the SAME Ethernet interface (2026-08-19).** The measurement that says what this is NOT: - - | module | eth-only | eth-wifi | factor | - |---|---|---|---| - | HttpServer | 113 us | 3441 us | **30x** | - | File Manager | 279 us | 4686 us | 17x | - | Services | 242 us | 1388 us | 5.7x | - | Audio | 239 us | 1376 us | 5.7x | - | Effects | 172 us | 397 us | 2.3x | - | **Network** | 10 us | **92 us** | (negligible in absolute terms) | - | Drivers | 6601 us | 4215 us | **0.64x (FASTER)** | - | ParallelLed | 7186 us | 6569 us | 0.91x (faster) | - - **Two theories are refused by this table.** It is not a blocking WiFi call in `NetworkModule` (`Network` costs 92 us, and `wifiStaRssi`/`wifiTxPower` are state-gated off entirely in `ConnectedEth`); and it is not periodic WiFi scanning, since no module carries anything like a 0.37 s cost. Instead **modules that touch no network at all** (Audio, Noise, Effects, Layer) slow by the same kind of factor as the HTTP path: a broad, roughly proportional slowdown of ordinary code. - - **Working theory: L2 cache contention.** Both builds are byte-identical in every memory setting (`CACHE_L2_CACHE_128KB`, `SPIRAM_SPEED_200M`, `FLASHFREQ_40M`, `SPIRAM_MODE_HEX` — a full sdkconfig diff shows no cache/PSRAM/flash/CPU-freq difference), and code executes from flash through that shared 128 KB L2 in both. Adding esp_hosted's tasks, ISRs and DMA buffers evicts application code that was previously resident, so ordinary work starts missing to flash/PSRAM. This also explains the otherwise odd inversion: the DMA-bandwidth-bound `Drivers`/`ParallelLed` got *faster*, which is what happens when the CPU competes less for the same bus. **Consistent with the data, not yet proven** — separating cache contention from plain CPU stealing needs a cache-hit-rate counter or a per-tick histogram, neither of which we expose today. - - **The cache theory does NOT explain the visible LED hiccup, and there are TWO effects here (PO observation, 2026-08-19).** Uniform cache contention predicts a smooth frame-rate drop (133 → 96 → ~73 fps), which is what the averages show. It does not predict a *stall*, yet a once-per-second hiccup is plainly visible on the fixture. So the throughput loss and the hiccup are separate problems and must be chased separately. - - **Our instrumentation structurally cannot see the hiccup.** `MoonModule::tickTimeUs_` is a MEAN (`accumUs_ / frameCount`, [MoonModule.h:656](../../src/core/module/MoonModule.h)) and `Scheduler::fps()` is derived from it, so a single 300 ms frame among 70 good ones shifts the average ~4 ms and vanishes. Every per-module number in the table above is an average and none of them can confirm or refute a stall. External evidence of the stall is therefore weak but non-zero: sampling `fps` once per second for 30 s gives a steady 73 with periodic dips to 69-70 (~55 ms lost in those seconds), and the dips are NOT on a clean 1 s period. Ruled out along the way: the degradation is not caused by our own HTTP polling (fps is identical at 76 after 25 s of zero traffic and under continuous polling), and the early 96 → 73 decay is warm-up, not load. - - **Per-TASK CPU, both builds, `--task-cpu-stats` (2026-08-19) — this is the decisive measurement.** Same profiling overhead on both, so the comparison is clean: - - | task | prio | eth-only | eth-wifi | | - |---|---|---|---|---| - | `main` (render) | 1 | **35.4%** | **93.3%** | the same work costs 2.6x the CPU | - | `IDLE0` | 0 | 63.3% | 5.1% | core 0 headroom is gone | - | `ipc1` | 24 | 7.8% | 12.7% | present in BOTH, so not the cause | - | `mmEncode` | 5 | 3.4% | 6.0% | | - | `sdio_read` / `sdio_write` / `sdio_process_rx` / `rpc_rx` / `rpc_tx` | 23 | absent | **0.0-0.1%** | the hosted tasks are IDLE | - - **What this refutes.** Not a busy WiFi task: every SDIO/RPC task sits at 0.0-0.1%. Not periodic WiFi scanning, and not a blocking call in our render path either, since `main` is not *waiting* — it is *running*, and burning 2.6x the cycles for identical work. Not `ipc1` (priority 24, preempts everything), which is present on the eth-only build too. There is no WiFi *activity* to stop, so a "compile it in but don't run it" variant would likely change nothing; the cost is already there with the link idle. - - **What this supports.** The same instruction stream executing 2.6x slower with no extra runnable work is the signature of **memory contention**, consistent with the L2-cache theory above: esp_hosted's footprint evicts application code from the shared 128 KB L2, so ordinary code stalls on flash/PSRAM fetches. Cycles are spent *inside* `main`, which is why every module slowed proportionally and why the DMA-bound drivers (bandwidth-bound, not cache-resident) got faster. - - **Three more theories died on the bench (2026-09-29, IDF v6.1 final).** Each was tested against 30 consecutive `/api/system` fetches over Ethernet, so the radio carried nothing in any of them. - The FreeRTOS tick warning esp_hosted prints at boot (`CONFIG_FREERTOS_HZ is 100, ESP-Hosted recommended 1000`) is an unconditional nag rather than a measurement, and the arithmetic runs the wrong way: IDF's `usleep` busy-waits sub-tick intervals exactly, so the first eight TX retries cost 14.4 ms at 100 Hz against 17.2 ms at 1000 Hz. - Priority inversion is refuted by its own null result: raising our task from 1 to 10 changed nothing measurable, and IDF's own `esp_http_server` runs at priority 5, which would be equally starved if six tasks at 23 were the mechanism. - Nagle with delayed ACK looked compelling, since responses are written as a header then a body with no `TCP_NODELAY` and the median penalty is close to a 40 ms delayed-ACK timer, but setting it moved neither build: eth-wifi stayed at ~55 ms and eth-only was already 8-44 ms. - `CONFIG_LWIP_TCPIP_TASK_PRIO=23` is kept, on the grounds that level-with rather than below is defensible, NOT because it is proven: one run lost its 230-270 ms tail spikes and a later run saw them return. - - **What the penalty actually costs a user, measured per interface (2026-09-29, both fixes in, 30 fetches of `/api/system` each).** - - | build and link | median | spikes | - |---|---|---| - | eth-only, Ethernet | **14 ms** | none | - | eth-wifi, Ethernet | 51 ms | 3 of 30 at ~200 ms | - | eth-wifi, WiFi (RSSI -44) | 57 ms | 4 of 30 at 280-870 ms | - - Over Ethernet the eth-wifi build is DEGRADED rather than unstable, and an earlier note here calling it unstable on every interface was wrong. - A click lands in 51 ms where a person notices around 100, so the interface feels fine and the 3.6x is invisible for single actions. - It surfaces in two places: the ~200 ms outlier roughly once in ten requests, which a page load of eight parallel requests will usually hit, and any client that issues hundreds of requests, which is why the live scenario sweep failed 4 of 6 on this build over Ethernet while a human clicking around noticed nothing. - Over WiFi the median barely moves but the tail doubles and reaches 870 ms, and under scenario load it reaches multi-second timeouts. - So the honest split is: usable on a wired link, unreliable on a wireless one, and the eth-only build is the one to run when a P4 has to be dependable. - - **The interface is noticeably quicker than it was (PO observation, 2026-09-29).** Changing an effect over WiFi went from a visible hang to something that responds, which a fetch loop cannot measure: that gesture is a POST plus a tree rebuild plus a state resync, so it pays the tail several times over where a single GET pays it once. - THREE things changed together and no single one is credited: `TCP_NODELAY` on accepted connections, `CONFIG_LWIP_TCPIP_TASK_PRIO=23`, and a board no longer carrying a failed scenario's leftover driver. - That last one also taints the earlier "20-second timeouts on WiFi" figure recorded here, which was measured on a board in that state; the 4x penalty over Ethernet is clean and reproducible, the WiFi instability was not measured as carefully. - - **Why the co-processor board and not the others.** A native-radio chip carries its WiFi stack in the image the cache was sized around; the P4 has no radio, so WiFi arrives as an additional resident subsystem (six tasks, an SDIO driver, RPC machinery, DMA buffers) on a chip whose 128 KB L2 is shared by everything. The difference is not silicon quality but whether WiFi is extra code competing for that cache. - - **Still unexplained: the visible ~1 s LED hiccup.** Cache contention predicts the steady 2.6x, not a stall. Per-task CPU is cumulative-since-boot and cannot show a spike either. So the hiccup remains unmeasured, and the worst-case instrumentation below is still the next step for it specifically. - - **Instrumentation gap to close first — worst-case tick tracking.** Add a per-module and per-scheduler *max* (and ideally a coarse histogram or a "frames over 2x mean" counter) alongside the existing mean, and expose it in `/api/system`. Cheap (one comparison per tick), and it converts "the PO can see a hiccup that no number shows" into a measurable quantity. Without it, every theory below is unfalsifiable from the host side. Reading the P4 console needs `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` or a UART adapter on GPIO 37/38 (see the dev-loop note in round 4), so serial is not the fast path. - - Then, cheapest first: (a) read the new max/histogram to see whether the cost is uniform or spiky (uniform favors cache contention, spiky favors a blocking call, and the two coexisting is now the leading reading); (b) the P4 cache performance counters for a direct miss-rate read; (c) moving the hot render path to IRAM/internal RAM and re-measuring, which is also a candidate fix rather than only a diagnostic. - 1. **Runtime SDIO re-init of the C6 fails: CONFIRMED a C6 slave-firmware problem (not a guess).** SystemModule now exposes a `wifiCoproc` read-only control (via `platform::coprocessorWifi()` → `esp_hosted_get_coprocessor_fwversion()`), and on the bench it read **`not detected`** at the time (the control now reports **`no version reply`**, and only after a bounded number of attempts): the C6 returns no valid firmware version. **What that means was overstated here.** It was read as the signature of absent / incompatible slave firmware, but the same board later associated and served traffic over that very link while this RPC still went unanswered, so an unanswered version query says the QUERY failed, not that the C6 is absent. The round-3 conclusion below rests on the `sdmmc_card_init failed` re-init evidence, not on this control. Likely a version mismatch on top of that: The host pulled esp_hosted **2.12.9**; Espressif's P4-Function-EV-Board ships its C6 pre-flashed with esp_hosted slave **v0.0.6**, and the **Waveshare NANO is a different board that may carry a different / absent C6 slave image**. The symptom fits: boot inits the host SDIO master fine, but resetting the C6 (GPIO 54) and re-enumerating it as a slave fails (`sdmmc_card_init failed`) because the C6 has no compatible slave firmware responding. **Primary next step: build + flash the version-matched esp_hosted slave firmware onto the NANO's C6.** The slave project is already vendored at `esp32/managed_components/espressif__esp_hosted/slave/` (`sdkconfig.defaults.esp32c6`, `partitions.esp32c6.csv`); `idf.py create-project-from-example "espressif/esp_hosted:slave"` → `set-target esp32c6` → flash. **Caveat / needs PO + bench hardware:** flashing the C6 on the EV board uses an **ESP-Prog wired to the `PROG_C6` header** with the P4 held in bootloader mode (esp_hosted `docs/esp32_p4_function_ev_board.md` §5.2): the NANO's C6-flash path must be confirmed (separate USB? equivalent header? ESP-Prog?), and an ESP-Prog may be needed. An OTA slave-update path exists but needs a *working* link first (chicken-and-egg here). This is a hardware-provisioning task, not application code. Secondary fallbacks if firmware-match doesn't fix it: an esp_hosted option to skip the reconfigure/slave-reset when the transport is already up at boot; a slower SDIO freq or 1-bit mode; verify GPIO 54 reset polarity/timing for the NANO. **(Note: EIM, the build-system v6 ecosystem item, does NOT help here; it's a host-machine installer, unrelated to device-side C6 firmware.)** - - **User lead (2026-07-09) — avoid the WiFi teardown/re-init on hosted targets entirely; it may sidestep the slave-reset failure.** A user hit the mirror symptom on a *different* codebase (ESP32-Sveltekit / WLED-MM, not MoonLight — its `lib/framework/WiFiSettingsService.cpp` doesn't exist here): after a clean C6-flash the P4-NANO WiFi worked, but their app's boot-time `WiFi.disconnect(true)` fully tore down the WiFi stack, and the later AP bring-up then failed with `esp_hosted_transport_config: Transport already initialized` / `esp_hosted_init failed!` / `AP enable failed!`. Their working fix: on `CONFIG_ESP_WIFI_REMOTE_ENABLED` (= hosted) targets, **don't do a full stack reset** — keep STA enabled, reconnect *without* a full teardown/re-init, and never call `WiFi.disconnect(true)` in the disconnect callback. **Why this is relevant to us even though the file differs:** our own comment at `platform_esp32.cpp:796` already documents that the esp_hosted transport is set up **once at boot** and is fragile to re-init (`connect_to_slave` = a transport reconfigure that resets the slave + re-inits SDIO and fails on a live link). Our disconnect *callback* is already safe (`wifiEventHandler` on `STA_DISCONNECTED` only sets a flag — no teardown), BUT our **failover path is not**: `wifiStaStop()` (`platform_esp32.cpp:915`) calls `esp_wifi_deinit()`, and the STA-retry / AP-fallback then re-runs `ensureWifiInit()` → `esp_wifi_init()` — the exact deinit→reinit cycle that on a hosted target triggers the GPIO-54 slave reset (round-3 open-issue #2's `sdmmc_card_init failed`). So the round-3 failure ("runtime SDIO re-init of the C6 fails") and this user's report may be **the same root cause**: the re-init shouldn't happen at all on a hosted target. **Concrete next step to try on the bench:** guard the teardown/re-init on `platform::hasWifiCoprocessor` (already defined = `isEsp32P4 && hasWiFi`) — on hosted targets, do NOT `esp_wifi_deinit()` in `wifiStaStop()` and do NOT re-`esp_wifi_init()` in `ensureWifiInit()` once the boot-time init is up; instead just `esp_wifi_disconnect()` + `esp_wifi_set_config()` + `esp_wifi_connect()` (STA retry) or `esp_wifi_set_mode(APSTA)` for the fallback, reusing the live transport. This is cheaper than the C6 reflash and independent of #18759, so it's worth trying first once the board boots. If it works, it also removes the slave-reset from the normal failover, not just the AP case. Blocked behind the #18759 boot crash like everything else P4-WiFi, but this is the first thing to try when the board boots again. - 2. **Co-processor components no longer compile into `esp32p4rev1-eth` — FIXED.** The gate is now `rules: if "$CONFIG{MM_P4_WIFI} == True"` (the `$CONFIG{NAME}` form, no `CONFIG_` prefix inside the braces — the bare form silently skipped the dependency, see round 4 below) (a Kconfig option declared in `esp32/main/Kconfig.projbuild`, set only by `sdkconfig.defaults.esp32p4rev1-eth-wifi`), so `esp_hosted` / `esp_wifi_remote` are pulled **only** by the WiFi build, never by eth-only. The old `target == esp32p4` gate pulled them into `esp32p4rev1-eth` too; that wasn't merely build-time waste — esp_hosted self-inits its SDIO master at boot, which on the eth-only build interfered with the EMAC bring-up (a red herring chased during the P4 no-DHCP hunt). The eth-only image dropped 1.36→1.12 MB once gated out. The `wifiCoproc` read-out stays compile-gated on `platform::hasWifiCoprocessor` (`isEsp32P4 && hasWiFi`). - 3. **Build reproducibility.** `build_esp32.py` does not yet build this variant reliably: the C6 slave-target Kconfig `default ... if IDF_TARGET_ESP32P4` only fires on `set-target`, and the reconfigure a plain `build` triggers drops it back to ESP32-H2 (no WiFi) → fails on missing `CONFIG_WIFI_RMT_*`. A clean manual sequence works (`rm -rf ` → `set-target esp32p4` → `build`, all with the same `-DSDKCONFIG`/`-DSDKCONFIG_DEFAULTS`); the wrapper needs a fix so the auto-default sticks across reconfigures (see the KNOWN ISSUE comment in `build_esp32.py`). - - **Round 4 — the IDF-update regression (2026-07-03).** After the IDF bump to `v6.1-dev-5215-g0d928780081`, the variant stopped building entirely, then stopped booting. Two distinct causes, both IDF/component-manager side (all our config was correct): - - ❌→✅ **Build: `esp_hosted.h` not found — the manifest `if` syntax changed.** The component manager (now 3.0.3) silently skipped `esp_hosted`/`esp_wifi_remote` (`NOTICE: Skipping optional dependency`) because our `idf_component.yml` rule used the bare `CONFIG_MM_P4_WIFI == True`. The current manager only recognises a Kconfig variable in the **`$CONFIG{...}`** form (its `KCONFIG_VAR_REGEX = \$CONFIG\{([^}]+)}`); the bare `CONFIG_X` falls through to plain string-eval → false → skipped. **Fixed:** `if: "$CONFIG{MM_P4_WIFI} == True"` (note: NO `CONFIG_` prefix inside the braces). Confirmed by [idf-component-manager #104](https://github.com/espressif/idf-component-manager/issues/104) + the official manifest docs. This also supersedes open-issue #3 above — with the correct syntax the pull is reliable (no `set-target` dance needed for the *dependency*). - - **Boot: `sleep_clock_icg_startup_init` aborts with `ESP_ERR_NO_MEM`.** Worked around in the P4 WiFi fragment; tracked in [Issues in other projects](../../reference/issues-in-other-projects.md) as esp-idf #18759. -- **Round 4 — Parlio loopback self-test FIXED (2026-07-09), real long strip still to prove.** The Parlio loopback self-test now passes on P4 hardware at any grid size (verified on MM-P4, jumper GPIO 32↔33, at both 8×8 and 128×128). A real *long* WS2812 strip (not just the bench panel) is the remaining hardware proof. - - **The bug it exposed (fixed):** on IDF `v6.1-beta1` the self-test failed `bad bit 0/0` at large grids — it worked at 8×8 but not 128×128. Two stacked hardware limits, both hit only because the test built a frame sized to the *whole operational grid*: (1) the P4 Parlio has no DMA-EOF, so one non-loop transfer is hard-capped at `PARLIO_LL_TX_MAX_BITS_PER_FRAME` (~0.5 Mbit) — a 128×128 grid (~1.2 Mbit) is rejected outright with `ESP_ERR_INVALID_ARG`, so nothing transmits; and (2) even once the transfer fits, the shared RMT-RX capture can't hold a large symbol count (a big frame → `rx captured 0 symbols`). Both surfaced as the same misleading `bad bit 0/0`. **A wrong first guess** (an IDF `sample_edge`→`shift_edge` clock-edge rename) was tried on hardware and rejected — both edge values fail, ruling the edge out; the serial log (`loopback: N bytes in …`, `rx captured 0 symbols`) is what pinned the real cause. **The fix:** the self-test now caps its frame to a small fixed `ParallelLedDriver::kLoopbackTestLights` (256) instead of the operational grid — the test only needs to prove the peripheral emits correct WS2812 bits, which a few hundred lights exercise fully (encode → fused correct+transpose → single-shot DMA → latch pad), and 256 fits under both the transfer cap and the capture buffer on every parallel driver, so it runs identically at any grid size. **Lesson:** an on-device self-test must size its own probe to the peripheral's real limits, not to the user's configured output size. (This whole diagnosis is a good "verify against the working reference before concluding" case — the PO's memory that it *had* worked at fd6c1d8e is what redirected the hunt from "capability gap" to "regression".) - - **Loopback on the MHC-WLED P4 shield — RESOLVED: the header is not pure GPIO (shield builder confirmed, 2026-07-09).** The shield's 4x-In/Out header (pins **46, 47, 2, 48**; 46 & 2 are P4 boot straps per gpio-usage) never passed the continuity precheck: **46↔47** captured partial/corrupt (206 of 1536 symbols → `bad bit 0/0`); **47↔48** read `jumper not detected` even with the jumper seated. The `mm_loopback continuity …: hi=%d lo=%d` diagnostic read `hi=0 lo=0` (no direct signal path). **Shield builder (Wladi) confirmed the cause:** the header has **no pure GPIOs** — outputs go through a **level shifter**, inputs through **diode protection + a ~16 kHz low-pass filter** (by design, for robust button-style inputs), so the raw drive-and-read continuity probe can't bridge it. Not a firmware bug and not fixable in software; the self-test needs a genuinely direct GPIO pair. **Workaround for a real loopback test on the shield (Wladi's suggestion):** drive the LED signal out one **RS485 output**, configure a 4th channel as an **RS485 input**, and jumper the two with cables — the signal is then readable on the respective GPIO. The frame-size fix itself is **proven independent of this** (MM-P4 NANO, direct 32↔33, PASSes at every grid). The `loopbackJumperOk` continuity read is kept as a permanent bench HAL diagnostic (it's what pinned this) — a `hi=0 lo=0` result now has a documented meaning: a buffered/level-shifted header, not a direct GPIO. - - **Dev-loop note — reading the P4's runtime log over USB.** The P4-NANO's primary console is **UART on GPIO 37/38** (`CONFIG_ESP_CONSOLE_UART_DEFAULT`), not the USB port, so `ESP_LOGI` / `mm_net` lines are *not* visible over `/dev/cu.usbmodem*` by default — only the ROM boot banner and `std::printf`-to-stdout (which routes to the **secondary** USB-Serial-JTAG console) come through. Two workarounds when you need the runtime log over USB: (a) temporarily set `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` (note the JTAG endpoint re-enumerates when the app starts, so a reader must reconnect across the drop — `idf.py monitor` handles it; a plain fixed `pyserial` handle dies); or (b) hang a USB-UART adapter on GPIO 37/38. This cost real time during the P4 no-DHCP hunt; the fastest signal there turned out to be a `printf` of the runtime struct (stdout → secondary JTAG console) plus a `git worktree` bisect (build an old commit, flash, check LAN reachability) to prove code-vs-hardware without needing the log at all. - -### WiFi runtime disable (backlog) - -Compile-time answer already ships: `--firmware esp32-eth` excludes the WiFi stack. The default `esp32` already *cascades* — `ethInit()` runs first, WiFi only comes up if no PHY responds — so a wired board never associates over WiFi. What's still missing is reclaiming WiFi's **heap**: even when Ethernet wins the cascade, `esp_wifi_init`'s RX buffers stay allocated. This item skips that init entirely once Ethernet is up, freeing ~16 KB. Defer until the heap saving is worth the teardown-ordering risk. - - -## UI - -Forward-looking companion to the shipped UI spec, [moonmodules/core/ui.md](../../moonmodules/core/ui.md). The live spec describes the UI as shipped; this file holds what is **not** in it yet: deferred items, open design questions for 1.0, and the gap analysis against MoonLight v1. - -### Split app.js by feature (2026-10-15) - -The UI ships as one minified bundle with ETags, so splitting `app.js` costs nothing at runtime. Almost every card comes from the one generic renderer, so the candidates are the features with their own UI: the control surface and pads, the File Manager, the gallery, the MoonLive editor, the preview, the status bar. Six to ten files, the shape `preview3d.js`, `help.js`, `migrate.js` and `gif.js` already have. Worth it once a feature is hard to find in the 500 KB source. - -### Deferred to 1.x - -- Side nav with drag-reorder of root modules (root order is fixed in `main.cpp` today; not painful — and arguably correct, see the gap-analysis note below) -- Health panel (`
` + `GET /api/test`) -- Log panel (`
` + WS `{t:"log",m:"…"}`) -- Core affinity badge (C0/C1) — only meaningful when core pinning lands -- Module `category()` field — taxonomy beyond `role()` for the picker (decision: derive from `role()` for now) - -### File Manager follow-ups - -The shipped File Manager (see [system.md](../../moonmodules/core/system.md#file-manager)) is a lazy expand/collapse tree over `/api/dir` + a size-capped text editor over `/api/file`, with drag-drop upload (tier 1) + per-file download + a filesystem-usage bar. Deferred capabilities, each self-contained: - -- **Upload — recursive folder tier.** Single-file upload of **any size** streams to the file (`fsWriteStream`, binary-safe, `kUploadMax` + free-space guarded) and downloads stream back (`fsReadAt`), so text/config/binary all work. Remaining: **multi-file / recursive folder drops** — client-side `mkdir` + walk via `dataTransfer.items` `webkitGetAsEntry()`. -- **Download — folder as `.zip`.** Per-file download streams (`` on `/api/file`). A folder download needs the browser to walk `/api/dir` recursively, fetch each file, and build a `.zip` client-side — which means bundling a zip library into `app.js`. That's a permanent app.js size bump, and app.js is embedded in firmware, so it weighs on the flash budget (see the flash-budget item above). Gate on real demand; symmetric with the folder-upload tier. -- **Last-modified dates.** Needs a time source (NTP) + LittleFS mtime storage; the tree is name + size until both land. Backlogged with the NTP work. -- **`.ml` syntax highlighting in the editor.** MoonLive source wants *highlighting* (a color layer over the textarea), not the JSON-style reformat — a bigger editor change (a highlight overlay or a small tokenizer), added when MoonLive `.ml` files land on disk. The editor already has an extension seam (`fmPrettify`) for the reformat case; highlighting is the separate, larger tier. -- **Keyboard + screen-reader access for the tree.** The `fm-row` tree entries are mouse-only today (click to select/expand/open). Making them keyboard-operable (focusable, Enter/Space activation) with ARIA tree semantics (`role="treeitem"`, `aria-expanded` from `isOpen`, `aria-level`) is a real accessibility win but adds JS that lands in the firmware-embedded `app.js` (flash cost). Do it when a11y is a stated goal, together across the whole generic UI, not just this panel. - -### Open design questions - -These don't block the shipped baseline but should be answered before 1.0: - -- **Multi-layer UI** — [the architecture](../../explanation/architecture/index.md) plans for N layers blended into one Drivers. The current card layout shows one Layer. Likely needs a tab/accordion to switch layers, or a per-layer column. -- **Modifier chain visualization** — show the modifier order visually. They're a flat list today, but the `children[]` order **is** the apply order now (modifiers compose as a chain, M₁∘M₂∘…), so a visual that conveys the stacking (and that order matters) would help users reason about a multi-modifier layer. -- **Presets** — save/load named bundles of control values. Persistence already stores them; needs a UI surface. -- **Canvas/node-graph view** — v2 attempted this. Powerful for complex setups but doubles the UI surface. A reasonable v3 follow-up gated on user demand. - -### Gap analysis — v1 features not yet in v3 - -Inventory of v1 frontend behaviours v3 lacks, with a recommendation each. Items already shipped (control types, dragTs, two-timescale inputs, type picker, theme, scroll-shrink preview, status bar, reset-to-default, fps/ms toggle, drag reorder, side nav + drawer + footer) are not repeated. - -Legend: **Adopt-1.0** (small, high value) · **Defer-1.x** (needs engine work or a feature we lack) · **Drop** (not needed). - -### Per-card features - -| v1 feature | v3 today | Recommendation | -|---|---|---| -| Header: setup-dot before name | name only | **Defer-1.x** — needs `setupOk()` + `health()` on MoonModule with a real failure mode. Today both would always be `true` / `""`. | -| Module ID shown separately from name | name only | **Defer-1.x** — add when instances need disambiguating (e.g. two effects of the same type under one Layer). | -| Category emoji badge on the card header | role emoji in the picker, not on the card | **Defer-1.x** — `ROLE_EMOJI` already exists in `app.js`; showing it per-card is a small step if card scannability needs it. | -| Core affinity badge (C0/C1) | core pinning not implemented | **Drop** until core pinning is a real engine feature. | -| Memory split heap vs PSRAM | `static+dynamic` shown on the card | **Defer-1.x** — splitting `dynamicBytes` further needs `platform::isPsramPointer(p)` or per-alloc tracking, neither exists yet. | - -### WebSocket / panels - -| v1 feature | v3 today | Recommendation | -|---|---|---| -| Drag-to-reorder *root* modules (`POST /api/modules/reorder`) | not supported | **Drop** — root order is fixed in `main.cpp` and that's correct: Layouts/Layers/Drivers + system modules are mandatory and ordered. Children reorder via drag already. | -| Log channel `{t:"log",m:"…"}` pushed by server | no server log push | **Defer-1.x** — needs an engine-side log producer. Gate: when boot/network/persistence logs become interesting to non-developers. | -| Schema channel `{t:"schema",modules:[…]}` for tree-shape changes | full `/api/state` push every update | **Drop** — keep the full-tree push; re-evaluate only if WS bandwidth becomes a problem with large trees. | -| System health panel (polls `GET /api/test`, pass/fail table) | none | **Defer-1.x** — needs a runtime `/api/test` that runs the doctest suite; `ctest` covers this for now. | -| Log panel (ring buffer, severity coloring, stick-to-bottom, `GET /api/log` backfill) | none | **Defer-1.x** — pairs with the log WS channel; both arrive together. | - -### Cost / decision table - -| Cost class | Items | -|---|---| -| Tiny (< 30 lines, no backend) | category emoji badge on the card header | -| Medium (minor backend change) | richer `category()` than role()-derived | -| Large (separate plan) | health panel + `/api/test`; log panel + WS log channel; OTA + GitHub-update badge; full multi-layer UI; presets UI | - -### GCC Release-mode truncation warnings (17, non-blocking) - -`uv run moondeck/build/build_desktop.py --gcc` reproduces CI's compiler (CI builds **Debug**, and that is clean). Building the same tree **Release** with GCC surfaces **17 further `-Wformat-truncation` / `-Wstringop-truncation` / `-Wstringop-overflow` warnings** in `Control.cpp`, `HttpServerModule.cpp`, `JsonUtil.h`, `MqttModule.cpp` — Release inlines more, so GCC can prove more about buffer sizes and gets stricter. - -They are **not** CI failures (CI is Debug) and each one inspected so far is a false positive of the same shape: a bounds check exists, but GCC cannot prove the degenerate case (e.g. `HttpServerModule.cpp:2400` warns "writing 1 byte into a region of size 0" on a line *guarded* by `if (wsLen + headerLen > sizeof(hdr)) return false;`). Still worth clearing: each is either a genuinely unprovable bound (fix the code) or a bound the code knows but does not state (make it explicit). Doing it in one pass beats letting them mask a real one later. - -Not done with the multi-destination/tab-UI merge because 17 warnings across four core files is its own change, not a tail on someone else's. - - -## MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 3 findings left) - -Four 🟠 Major boundary findings from the PR #29 review are real but each is its own scoped change, not a tail on the ring branch. The Critical sibling (a `cpl<3` overflow guard in the MoonLive effect's `tick`) landed with the branch it was found on. - -**One of the four is CLOSED (2026-09-07):** the scenario now uses `PreviewDriver`, the in-process -sink, instead of `NetworkSendDriver`. Its `tick_us` half was reviewed and DISMISSED rather than -fixed: a `measure` step asserts nothing, it records, and that recording is what feeds repo-health's -per-commit performance trend. A reviewer reading the file could not see that; deleting the -baselines would have blinded the trend to fix nothing. Recorded here so it is not re-raised. - -The three that remain: - -- **Core includes platform, compiled core in `mm_core`.** `src/core/moonlive/MoonLive.cpp` `#include`s `platform/platform.h` and calls the exec-memory API directly, and the root `CMakeLists.txt` compiles `MoonLive.cpp`/`MoonLiveCompiler.cpp` into `mm_core` and links `mm_core → mm_platform` — violating the header-only-core / no-platform-includes contract both files declare. The runtime exec-memory placement layer wants a core-neutral injected interface (or to move out of `src/core`), so the compiled/platform-dependent surface sits behind `mm_platform` and `mm_core` stays INTERFACE-only. These two are one change (same boundary). -- **W^X disabled in the board default.** `esp32/sdkconfig.defaults.esp32s3-n16r8` turns off `CONFIG_ESP_SYSTEM_MEMPROT_FEATURE` and enables `CONFIG_HEAP_HAS_EXEC_HEAP` for *every* build on that board, even with no MoonLive effect installed. The JIT genuinely needs a writable-then-executable heap, but that belongs in a dedicated MoonLive/JIT opt-in overlay or an explicit build profile, not the board default — so a stock build keeps memory protection on. -## MoonI80 prime-only ring: no stall backstop (sibling-path gap) - -**Found:** 👾 Reviewer, pre-commit on the whole-frame stall fix (2026-07-22). - -The MoonI80 wait-timeout backstop (`moonI80Ws2812Wait`, `platform_esp32_moon_i80.cpp`) now recovers a lost/coalesced EOF on **two** of the driver's three completion paths: the lapping ring (`nSlices > ringBufs`, oracle-gated) and the whole-frame path (`!isRing`). The **prime-only ring** (`isRing && nSlices <= ringBufs`) falls through both conditions, so a lost terminator-EOF there leaves `busy` stuck true with no recovery — and every prime/arm/transmit-ring path refuses under `busy`, the same permanent-wedge class the whole-frame fix just closed. - -Mitigated in practice: prime-only fires exactly one EOF per frame (no intra-frame coalescing), so the lost-EOF trigger is far rarer than on the whole-frame or lapping paths. But it is the same defect, and per the CLAUDE.md sibling-path rule (a cross-cutting recovery that core owns for one path should cover the sibling path, not be re-implemented per case) the backstop should extend to it rather than leave a third path uncovered. - -**Fix:** widen the backstop's condition so a stuck prime-only ring finalizes too — likely a single "any ring frame whose wire time has elapsed with `busy` still set" oracle that subsumes both ring branches, calling the shared `finalizeStalledTransfer`. Verify on the expander wall (prime-only = the small-strand ring config), since the ring recovery is not desktop-testable. - -**Related (same file, same wall dependency):** the whole-frame backstop stops the hardware before draining the FIFO and the EOF ISR drops a firing against an empty FIFO, which rejects a late EOF that arrives after recovery. A tighter guarantee — an ISR that has *already passed* the empty-FIFO guard cannot then clear a freshly-recovered `busy` or give `wireFree` for the next transfer — would need explicit serialization (disable the EOF interrupt across finalize+rearm, or an explicit recovery/rearm state the ISR checks). The window is extremely narrow (an ISR in-flight at the instant of finalize) and stopping GDMA+LCD first already disables the source; the hardened version is an ISR-concurrency change that must be proven on the wall, not landed blind. - -## ESP32-P4 400 MHz on rev-3+ silicon - -**Found:** bench, 2026-07-25 (Quindor Discord question about the P4's 400 MHz). - -The P4 build runs at **360 MHz** because IDF's `Kconfig.cpu` caps a `SELECTS_REV_LESS_V3` build (which we set, so a stock binary boots on the v0.x/v1.x P4 chips in the field) at 360; 400 MHz is IDF's default only for rev ≥ 3. Forcing 400 on our bench P4 (a **rev v1.3** chip) was tried and **bootloops** — `assert failed: esp_clk_init clk.c:105` — so 400 is a genuine hardware limit on pre-rev-3 silicon, not marginal stability. The ~11% compute headroom is left on the table for rev-3+ owners. - -**Two ways to reach it, both deferred until rev-3 P4 hardware is on the bench to validate:** -- **A separate `esp32p4-400` variant** — `sdkconfig` with `ESP32P4_REV_MIN` = rev 3 + `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_400`, so IDF's normal boot-time clock init sets 400 and the bootloader refuses to run on a pre-rev-3 chip (safe). The web installer offers it only to rev-3+ boards. This is the MoonLight-idiomatic per-variant path. -- **A runtime bump** — keep the 360 build (boots everywhere) and, at `app_main`, read `esp_chip_info().revision` and if ≥ 300 call `rtc_clk_cpu_freq_mhz_to_config(400)` + `rtc_clk_cpu_freq_set_config`. One binary, self-selecting. Riskier: a runtime CPU-PLL change on the P4 couples to the flash/PSRAM clock trees configured for 360, so it needs validation on real rev-3 hardware before shipping. - -Neither ships until a rev-3 P4 can prove 400 runs clean — no untested clock config, per the same rule the S31/320 and this P4/400 bootloop both taught. - -## ESP32-P4: esp-dsp's assembly FFT and the hardware-loop erratum - -Tracked in [Issues in other projects](../../reference/issues-in-other-projects.md) as esp-idf #19025 and esp-dsp #119 and #102, with the FFT and HLS soak to run once esp-dsp's alignment release ships. - -## Flaky unit tests: the AudioService sync suite contends on a fixed UDP port - -**Found:** 2026-07-27, caught by `premerge.py`; pinned to the exact cases by looping the suite and keeping the failing logs. Fails roughly **1 run in 10**. - -Two cases in `test/unit/core/unit_AudioService_sync.cpp` fail intermittently, both around the socket rather than the audio logic: - -- `:125` *"…drives frame_, then holds it and reports listening"* — `CHECK(strcmp(status(a), "listening") == 0)`. The receive path **binds** the fixed port `kTestSyncPort` (21988); when a previous run's socket is still in `TIME_WAIT` the bind fails and the service never reaches "listening". -- `:95` *"Local+send: lazy-opens once and reports sending"* — `CHECK(strcmp(status(a), "sending") == 0)` plus `syncOpenForTest()`. This one **connects** to a broadcast address rather than binding, so it is a *different* failure mode on the same suite. - -**An attempted fix that did not hold (recorded so it is not retried blind).** Deriving the port per run (from the steady clock) instead of the fixed constant cut the receive-path failures but left the send-path case failing ~3 in 20 — because that case never binds the test port, so the port was never its problem. The real fix has to address the send path's socket open on its own terms, not just port choice. - -**Why it matters:** the gate scripts run the suite on every commit and merge, so a 1-in-10 flake interrupts gate lists often enough to train the reader to re-run instead of investigate — the habit that hides a real regression later. - -**Fix (do not paper over it):** treat the two cases separately. For the receive path, bind an **ephemeral** port (bind 0, read the assigned port back) so two runs can never contend. For the send path, first determine *why* the lazy open fails under repetition: whether the broadcast connect is refused or the latch is left set by an earlier case in the same process. A retry wrapper or a sleep is explicitly the wrong fix: it hides the next genuine failure too. `unit_NetworkReceiveEffect*` (a real UDP round trip on localhost) and `unit_SineEffect` (timing) flake the same way, about 1 full run in 3, and deserve the same treatment while the fix is fresh. - -## ModuleFactory: registration can fail silently (uint8_t ceiling, unchecked bool) - -**Found:** 2026-07-27, while adding the effects grid sweep; flagged by the 👾 Reviewer. - -`ModuleFactory::registerType` returns `bool` and **appends unconditionally** — it does not dedupe by name, so the same type registered from several places takes several slots (in the test binary `Layer` holds 9 and `RainbowEffect` 8). `count_` is a `uint8_t`, and at the 255 ceiling `grow()` returns false, registration fails, and the return value is discarded at every call site — including the static-init registrars, which cannot check it anyway. The failure is silent: the type is simply absent from `/api/types` and `create()` answers `nullptr` for it, far from the cause. - -Production is not near the ceiling today (~90 types), so this is not urgent. It became visible because a test binary that registers repeatedly can reach it, which is what made the grid sweep construct effects directly instead of registering them. - -**Options, cheapest first:** (a) make `registerType` idempotent — ignore a duplicate name and return true, which fixes the multiplier and is arguably the correct semantic anyway; (b) widen `count_`/`capacity_` to `uint16_t` (a handful of bytes, removes the ceiling as a practical concern); (c) make a failed registration loud in a debug build (assert or a boot-time log) so it can never be silent again. (a) plus (c) is probably the right pair. - -## Network-receive throughput starves HTTP (issue #69) - -**Found:** 2026-08-20, reproducing issue #69 on MM-testbench-S3 (ESP32-S3, 240 MHz, v6.1-rc1). - -Reported as "DDP gets very stuttery once the led count gets over a few thousand, even with no led drivers, and the same stream to WLED plays smoothly". Reproduced, and the threshold matches: HTTP availability while a DDP source runs, sampled 6 requests per point. - -| lights | pkt/s | KB/s | HTTP | -|---:|---:|---:|---:| -| 480 | 30 | 44 | 6/6 | -| 1440 | 90 | 131 | 6/6 | -| 2880 | 180 | 262 | 6/6 | -| 4800 | 300 | 437 | 3/6 | -| 8160 | 510 | 743 | 0/6 | -| 12288 | 780 | 1136 | 0/6 | - -Fully reversible: 8/8 before, 1/8 during, 8/8 after. Art-Net degrades the same way and, per light, sooner — it carries 170 lights/packet against DDP's 480. - -**The limit is throughput, not packet rate.** Art-Net survived 510 pkt/s where DDP failed at 300, but both broke at ~440-480 KB/s. A matched-throughput control (~740 KB/s) gave 3/6 for DDP at 510 pkt/s and 2/6 for Art-Net at 1350 pkt/s — 2.6x the packet rate, same result. So a deeper `LWIP_UDP_RECVMBOX_SIZE` (6 today) is not the lever it looks like; the bytes have to be moved regardless of how they are grouped. - -**Not the render loop either.** MM-S31 at 4096 lights held 6/6 all the way to 1966 KB/s while ticking at 12 fps (82 ms), against the S3's 8.3 ms tick. If the once-per-tick drain in `NetworkReceiveEffect::tick()` were the constraint, the board with the 10x slower tick would fail first. It is the fastest board that fails, which points at contention between the receive path and the HTTP task rather than at drain cadence. - -**Partly fixed 2026-08-20** (`NetworkReceiveEffect`, this branch). The staging→layer `memcpy` ran every tick whether or not a packet had arrived; the Layer does not clear the buffer between frames, so that copy was writing identical bytes. Guarding it on a `dirty_` flag cut the effect's idle tick from **3522 us to ~240 us** at 12288 lights (93%), measured on MM-testbench-S3. HTTP availability under load, same board, before → after: - -| lights | pkt/s | DDP before | DDP after | -|---:|---:|---:|---:| -| 4800 | 300 | 3/6 | 6/6 | -| 8160 | 510 | 0/6 | 5/6 | -| 12288 | 780 | 0/6 | 2/6 | - -Art-Net gains the same way (4800 lights 3/6 → 6/6; 1350 pkt/s 2/6 → 4/6) — the fix is on the shared staging path, not per protocol. The reported threshold ("a few thousand") is now clean; 12288 lights at 780 pkt/s still degrades. - -**Remaining work — measurement, not a fix:** find where the rest of the bytes cost. Candidates are the `recvfrom` copy out of lwIP plus the staging→layer `memcpy` (both O(bytes/s), both on the render thread), and core-0 contention between the lwIP task and HTTP — the LC16 Ethernet starvation entry is the same shape. `handleConnection` running synchronously in `tick20ms` with ~5 ms/~50 ms budgets is the likely victim. - -Related: WLED is smooth on the same stream because it receives via `AsyncUDP` — packets are consumed in a callback from the lwIP task the instant they arrive, rather than polled once per render tick. Moving to that model is the structural fix, and it needs `staging_` synchronized against the render thread. - -## MIDI as a control surface, and the OpenLamp convention (2026-09-22) - -[ControlSurface](../../../src/core/util/ControlSurface.h) was written for MIDI hardware, and [MidiService](../../moonmodules/core/services.md#midi) is its MIDI transport beside OSC: it drives the surface from Mackie Control desks and the APC40 mkII, over the browser's Web MIDI, a board's USB port or RTP-MIDI, and sends the way back through the surface's verbs. What stays open is a controller with no profile: what its notes and CCs mean, which the OpenLamp convention below proposes, and MIDI learn, the item after it. - -**What OpenLamp offers.** [openlamp-spec-midi](https://github.com/openlamp/openlamp-spec-midi) is an MIT-licensed convention for driving WLED over MIDI: notes 59-68 for hues plus black and white, notes 48-56 for off, on, toggle and blackout, CC 1 for brightness, CC 3-4 for hue and saturation, CC 5-8 for effect, speed, intensity and palette, Program Change for presets, MIDI channel for targeting, and MIDI clock for beat sync. The [organization](https://github.com/openlamp) also has an Ableton Link and MIDI-clock tempo library, and a CC0 asset set of 72 palette illustrations and 216 effect previews in eight languages. - -**Why the convention matters more than the code.** Its engine is Python and cloud-free by design, so nothing there ports to a device. The value is in agreeing what a note and a CC *mean*, since a MIDI transport has to answer that whatever we do, and answering it the same way as a project already aimed at WLED costs nothing and buys a user their existing mappings. The palette and effect artwork is CC0 and separately interesting for the catalog, which today has one screenshot per module and no palette illustrations at all. - -**Judge it against principle 2** before adopting: the standard construct beats a bespoke one, and this is a candidate standard. The caution is that the spec says plainly it is a draft, "likely to change, and early on to change quickly", and the organization has low single-digit stars. So the sequence is a MIDI transport shaped by our own `ControlSurface` first, with the OpenLamp note and CC numbers as the default mapping where they fit, rather than a port of their model. Their beat-sync library is worth reading against [the audio work](backlog-light.md), since MIDI clock is a tempo source we do not have. - -**What it is not.** Not a replacement for OSC, which carries labels and arbitrary addresses that MIDI cannot. Not a lighting-control protocol in the DMX or Art-Net sense. This is about a musician's controller driving the show. - -Build trigger: someone with a MIDI controller who wants it to drive a device, or the DMX work reaching a point where a tempo source is the missing piece. - -### MIDI learn: any note or CC onto any control (robwomp, 2026-10-12) - -The MIDI service decodes two known layouts, Mackie Control and an Akai APC40 mkII profile, so a controller sending plain notes or CCs outside them drives nothing: a user with a generic controller on Linux got no note or CC data in. The infrared and gamepad services already map an arbitrary input onto any control with `learn` rows, and MIDI gets the same rows: each one's `learn` arm captures the next note or CC with its channel, a target names the control, and the value scales into the target's range (a CC's 0 to 127, a note's velocity, or a note as a toggle). That is MIDI learn as consoles and DAWs offer it, through the same primitive, so one mechanism maps every input. The two profiles stay for the desks whose layout they describe, since they carry the way back (motor faders, knob rings, lit pads); a learned row has none unless the controller echoes its CCs. - -Build trigger: met, a generic controller that cannot reach MoonLight today. - -### Notes as an effect input, which is the other half - -Routing a keyboard through a device so **each note triggers something inside an effect** is a different feature from the surface above, and the difference is worth stating before either is built. A control surface *sets controls*: a fader moves `brightness`, a pad selects a preset, and the effect never knows a surface exists. Notes are **performance data**: an effect reads them the way it reads audio, so what a note does is the effect's decision rather than a mapping table's. - -The architecture already has the shape this needs, which is why it belongs here rather than as a new concept. [AudioService](../../moonmodules/core/services.md#audio) publishes an [AudioFrame](../../../src/core/util/AudioFrame.h) each block, effects read it through `latestFrame()`, and MoonLive scripts reach the same values through builtins such as `level` and `bands`. `AudioFrame::onset` is already "a hit happened this block", which is exactly a note-on without a pitch. A `MidiService` publishing a `MidiFrame` alongside it would need no new plumbing: the same publish-and-read rule, one more producer. - -What a `MidiFrame` plausibly carries: which notes are held and how hard, which arrived this tick and which left, the pitch bend and modulation positions, and the clock's beat phase. Held notes are the interesting part, because that is what audio cannot give an effect. An effect can then light a pixel per key, place a particle at a pitch, colour by velocity, or hold a shape while a chord is held and release it on note-off, none of which is expressible as "a control moved". - -The open design questions, all cheap to answer badly and expensive to redo: whether a note is a level that decays or an event that fires once, how polyphony maps onto a grid without the effect hardcoding a keyboard's range, whether an effect opts in (as audio effects do, where `hasAudio` gates them) or every effect sees the frame, and what happens on a device with no MIDI attached. The audio path answered all four already, so the honest first step is to read how it did and follow it rather than invent a parallel set of answers. - -**The relationship to OpenLamp:** their spec maps notes to *fixed meanings* (note 59 is a hue, note 48 is off), which is the control-surface half. Notes as performance data is the opposite: the note has no meaning until an effect gives it one. Both can share one MIDI transport, and they want different things from it, so the transport should deliver raw messages and let each consumer interpret, rather than translating to lamp actions on the way in. - -Build trigger: the same MIDI transport as above, since both halves need it and neither is worth building it alone. - -### The rest of the band: drums, guitar, voice - -A keyboard is the easy instrument, because MIDI hands us the notes already decided. Every other instrument in a live setting arrives as **audio**, and the work is deciding what happened in it. That splits into three paths with very different costs, and conflating them is how this becomes a project with no end. - -**Path one: the instrument already speaks MIDI, or can.** Electronic drums are MIDI over a cable, so each pad is a note and the work is zero beyond the transport above. A guitar reaches MIDI through a hex pickup or a converter pedal, a voice through a pitch-to-MIDI box. In every case the hard problem sits in a device the musician already owns and has already paid for accuracy on. **This is the path that reaches the most instruments for the least code**, and it argues for building the MIDI transport before anything else here. - -**Path two: one instrument on its own channel.** A live rig already splits the band across a mixer's channels, so a direct out or an aux send gives one input carrying one instrument. That is a source-seam question, and [the audio roadmap](audio-dsp-roadmap.md) already covers widening the seam with line-in, codecs and multi-channel front-ends. What it does not cover is the analysis, which is the real work: - -- **Drums**: our single `onset` fires on any hit anywhere in the spectrum, so a kick and a snare are indistinguishable. Per-band onsets separate them cheaply, since a kick lives in the low bands and a snare in the mids and highs, and the bands are already computed. This is the smallest useful step and it needs no new hardware. -- **Guitar and bass**: `peakHz` gives a dominant frequency, which tracks a single note and falls apart on a chord. Real polyphonic transcription is a research problem and out of scope; monophonic pitch tracking on a bass or a lead line is not, and the existing peak is most of it. -- **Voice**: a pitch track plus a loudness envelope is achievable and genuinely useful for lighting a vocal line. Recognising words is not, and nothing in a live show needs it. - -**Path three: the whole mix through one microphone.** What the device does today. Source separation to pull a drummer out of a mixed stereo field is a machine-learning problem that does not fit a microcontroller, and pretending otherwise would be the kind of speculative build principle 1 rejects. The honest ceiling here is what the band structure already gives: bass energy, spectral flux, a tempo estimate. - -**The one unifying idea worth keeping.** Whatever the source, the useful output is the same shape: *this instrument did this thing, this hard, just now*. A MIDI note-on is that. A per-band onset is that. A pitch track is that with a pitch attached. So the frames stay separate at the source (`AudioFrame` and a `MidiFrame`) and an effect reads whichever it wants, rather than one merged everything-frame that forces every producer to pretend it is the others. That keeps each producer honest about what it actually knows. - -**The order this argues for**, cheapest and most certain first: the MIDI transport (which reaches electronic drums, MIDI guitars and pitch-to-MIDI voice with no analysis at all), then per-band onsets (drums from a channel, and a better beat for everything), then monophonic pitch (bass and vocal lines), and only then anything about separating a mixed signal. - -Build trigger: a live rig to test against. Every step is measurable in an evening with an instrument in the room, and none of it is worth guessing at without one. - -### Everything as MIDI, and where the conversion should live - -The appealing shape is one where every instrument on stage arrives as MIDI: the drums, the guitar and the voice each converted, so the device reads one kind of event and an effect never asks where a hit came from. It is the right instinct, and the question it turns into is **where the conversion runs**, because that decides whether this is a weekend or a research project. - -**Converted off the device, it is available today.** Drum triggers, hex pickups and pitch-to-MIDI pedals all exist, are well made, and put the hard analysis in hardware the musician already trusts. A mixer's direct outs feed those boxes, their MIDI merges onto one cable, and the device reads notes. **Nothing here needs writing beyond the MIDI transport.** That is the version worth having first, and it is genuinely what a working stage rig already looks like. - -**Converted on the device, each instrument is a different problem.** Calling them all "X to MIDI" hides that the three have nothing in common technically. A drum channel is an onset detector, which we nearly have. A bass line is monophonic pitch tracking, which is a known algorithm and real work. A voice is pitch plus envelope, doable. A strummed guitar chord is polyphonic transcription, which is a research problem that does not fit a microcontroller and should be named as out of scope rather than left as an implied maybe. - -**The cost that bounds it.** One mono audio input measures about **8 ms per tick on a P4**, already the second-largest consumer on the board after the effect itself. Four instruments analysed on-device is four FFTs and four detectors, which is most of a frame budget before a single light is drawn. So the ceiling is not the algorithms, it is the arithmetic: **one or two analysed channels on a device, not a band.** A mixer feeding eight direct outs into one ESP32 is not a design this hardware supports, and saying so early is cheaper than discovering it late. - -**Where a mixer does fit.** A direct out or aux send giving **one clean instrument** is exactly the input that makes path two work, and it is worth more than any algorithm: a kick drum alone on a channel needs a threshold rather than a separator. So the mixer plug-in is valuable for a *small* number of channels, which is also the number the frame budget allows. The seam for that is the multi-channel front-end [the audio roadmap](audio-dsp-roadmap.md) already anticipates. - -**The architectural conclusion.** Rather than converting everything to MIDI on the way in, keep the frames separate and let an effect read what each producer honestly knows: a `MidiFrame` for what arrived as notes, an `AudioFrame` per analysed channel for what arrived as sound. Forcing a per-band onset to pretend it is a note-on adds a fake pitch and a fake velocity, and an effect that reads them cannot tell which are real. Same rule as above, applied to the tempting case. - -Build trigger: a stage with more than one instrument to point at it. The first honest experiment is one drum channel through a direct out, because it answers the cost question and the usefulness question at the same time. - -## The Open Stage Control session's labels (2026-08-30) - -Found wiring a real control surface to the [OSC module](../../moonmodules/core/services.md). - -**The shipped Open Stage Control session renders no labels, and its pad matrix draws nothing.** -[`docs/reference/examples/open-stage-control.json`](../../reference/examples/open-stage-control.json) works for every fader, encoder and switch, but the widget names never appear and the `matrix` of pads is an empty box. -Five attempts at the label property failed (`@{}`, `JS{}`, `#{}` with `unit`, an explicit string, omitting it so `"auto"` applies), and notably a session SAVED by Open Stage Control itself carries no `label` key on a fader at all, so the name is drawn from something else. -The session was written by reading a minified app's bundled HTML docs; the reliable fix is to build one widget of each kind by hand in its editor, save, and copy the shape it produces. -Cosmetic: the control path works, only the labels and the pad grid are missing. - -## POST /api/file reports success while writing an empty file (no Content-Length) - -**Found:** 2026-08-20, on MM-testbench-S3, while uploading an edited MoonLive script. The upload -answered `{"ok":true}` twice while the board kept the previous file, and a compile result was read -from the stale script before the mismatch was noticed. - -`contentLen` defaults to **0** when the request carries no `Content-Length` -(`HttpServerModule.cpp:152`, "declared body length (0 if no Content-Length)"). `handleWriteFile` -then clamps the already-buffered body against it: - -```cpp -const size_t initial = initialLen < contentLen ? initialLen : contentLen; // → 0 -``` - -so `fsWriteStream` streams nothing, succeeds, and the handler answers 200. The body on the socket is -discarded. Reproduced deliberately: an 11-byte file written with `Content-Length: 11` lands as 11 -bytes; the identical request sent `Transfer-Encoding: chunked` answers `{"ok":true}` and leaves a -**0-byte** file, destroying what was there. Any HTTP client that streams without declaring a length -hits this — `curl --data-binary` on a pipe, and chunked uploads generally. - -This is data loss reported as success, which is the part that matters: a caller has no way to know -the write did not happen, and the previous contents are already gone. - -**Options:** (a) reject a body-bearing POST with no `Content-Length` (411 Length Required) — smallest, -honest, and standard; (b) support `Transfer-Encoding: chunked` in the upload source, which is more -work and only worth it if a real client needs it; (c) at minimum, never report 200 for a write whose -byte count does not match what was declared. (a) plus (c) is the pair worth doing. The UI's own -File Manager always sends a length, so this does not affect it — an API caller or a script does. - -Pin with a test that a length-less upload does not report success and does not truncate the target. - -## MoonDeck scripts crash on Windows whenever their output is redirected (2026-08-22) - -62 of the ~64 scripts print `→ ✓ ⚠ —` or box-drawing characters. A REDIRECTED Windows stdout takes `locale.getpreferredencoding()`, cp1252, and the first such character raises UnicodeEncodeError, *after* the real work has succeeded: `collect_kpi.py` measures everything, writes the metrics, then dies printing the summary arrow. An ATTACHED console has been UTF-8 since Python 3.6 (PEP 528), measured on this bench: `utf-8 isatty=True` from a console against `cp1252` through a pipe. So the entry's original framing was backwards. A human at a Windows terminal is the one case that works, and every redirected path fails: a pipe, a tee, a CI log, an agent's captured output. The gate runner that handed children `PYTHONIOENCODING=utf-8` is gone, which is why this surfaces now. - -The READ side breaks too, and that half is worse because `PYTHONIOENCODING` does not mask it: `Path.read_text()` and `open()` without an encoding decode as cp1252. A sweep found 43 unqualified `read_text`/`write_text` plus 47 bare `open()` across `moondeck/`. - -One Windows day (2026-09-29) hit it FOUR times in four different files, each fixed separately at its own call site: `_test_metadata.py` stopped the test runner starting, `test_desktop.py` would have killed a run on one non-ASCII byte from the test binary, `run_live_scenario.py` printed an arrow per step and so recorded a scenario the device PASSED as FAILED, and `preview_installer.py` died writing a staged page, leaving the web installer preview unusable on Windows. Four site fixes in one day is the argument for one home, made by the defect itself. - -The preamble is now copied into five scripts (`setup_esp_idf.py`, `ci/verify_version.py`, `docs/build_docs.py`, `scenario/run_live_scenario.py`, `scenario/run_scenario.py`), each carrying its own comment explaining the same fact. Per-script `sys.stdout.reconfigure()` is the wrong shape at 62 files: every new script would have to remember, and the one that forgets fails in the field. It wants ONE home, and the two candidates do not cover the same ground. `PYTHONUTF8=1` in whatever env MoonDeck's front ends already establish settles BOTH halves, because UTF-8 mode changes the default for `open()` and `Path.read_text()` as well as for stdio. A shared `moondeck/_stdio.py` imported by the entry points settles only the stdio half, since `reconfigure()` touches the standard streams and nothing else, so it leaves every unqualified `read_text`/`open` exactly as broken and those sites still need `encoding="utf-8"` written out. Pick when someone next runs a check by hand and it dies on a tick mark. - -**`run_scenario.py` was the fifth site, fixed 2026-09-30 ahead of a Windows run.** Its sibling `run_live_scenario.py` got the preamble on the Windows day; this one did not, and it relays the output of `mm_scenarios`, which prints an arrow and an em-dash of its own (`test/scenario_runner.cpp` carries 29 non-ASCII characters, several inside `printf`). Nothing had caught it because the in-process tier had only ever run on macOS, where an attached console and a pipe are both UTF-8. Left alone it would have read as a scenario failure rather than an encoding one, which is the misdiagnosis the live runner's fix was written to prevent. Five sites in two days is the count the one-home argument above should now be weighed against. - -## Changing a live MoonLiveEffect's script does not rebind it (2026-09-29) - -**Being worked on the macOS bench; this entry is measurement for whoever has it.** Rewriting the `script` control on an existing `MoonLiveEffect` leaves the module largely rendering what the previous script rendered. The card reports a clean status, the byte count updates, frames keep animating, so nothing in `/api/state` says anything is wrong. Only the pixels do. - -Measured on Windows over the 33 shipped `.mle` effects, same scripts and same instrument, changing only the route. Each script ran 3 s while its preview frames were sampled, then scripts were compared on mean level and on frame-to-frame motion; a pair matching closely on both is a pair that cannot be telling the pixels apart: - -- Reusing one module and rewriting its `script` control: **59 indistinct pairs**, in groups. Nine scripts (noise, octopus, plasma, rain, ripples, sparkle, spectrum, stadbeest-frog-eye, stadbeest-walk) all sat at mean 27.2 and motion 0.16, and six more (fountain, fractal, gradient, lines, metal, mh-ambient) at mean 42.9 and motion 0.33. -- A fresh module per script, which is what the UI's picker does: **1 indistinct pair**, and the values separate as you would expect, with fractal at 108.2, noise at 127.2, mh-aim at 255.0 and stadbeest-frog-eye at 151.5. - -The picker works because it does two things rather than one: `replacePickedType` in `src/ui/app.js` calls `replaceModule` to put a NEW module in the slot and only then `setCardScript`. A user who switches scripts from the dropdown on an existing card gets the broken path; a user who picks from the module picker does not, which is why the same library looks fine one way and stale the other. - -Two scripts render nothing that changes even by the working route, and may simply be correct: `gradient.mle` produces one distinct frame at mean 104.8 with zero motion, and `mh-aim.mle` one distinct frame with every sampled channel at 255. `mh-aim` aims a moving head rather than painting a grid, so full white may be the honest answer. Worth an eye rather than an assumption. - -The check itself is worth keeping. Frame distinctness plus cross-script comparison catches exactly what compiling cannot, and it is what the existing test suite has no view of: `unit_MoonLiveScripts.cpp` proves all 55 scripts COMPILE, and every one of them passed while the pixels were telling this story. - -## Desktop audio cannot capture what the machine is playing (2026-09-29) - -An audio-reactive light show usually wants the music the computer is playing, and there is no way to get it. `platform_desktop_audio.cpp` calls `ma_context_get_devices(&ctx_, nullptr, nullptr, &infos, &count)`, passing null for the playback list and count, so only capture devices are ever enumerated; the device then opens as `ma_device_config_init(ma_device_type_capture)`. Measured on a Windows bench: the OS offers `Microphone (Realtek)` and `Speakers (Realtek)`, the picker offers `default` and the microphone, and the speakers are absent. There is no Stereo Mix on that machine, not even disabled, so no zero-install workaround exists either. - -miniaudio already supports this as `ma_device_type_loopback`, which is WASAPI-only and takes a PLAYBACK device as its target. Closing it is three pieces: enumerate playback devices as well, mark them in the picker so a user can tell a microphone from "what is playing", and open loopback rather than capture when one is chosen. macOS has no equivalent and needs a virtual device such as BlackHole, so the picker should offer what the platform can actually do rather than a row that fails. - -Worth pairing with a status the user can read. A microphone input with nothing plugged into it reports a plausible-looking level (RMS around 20 to 30 at 40 to 170 Hz, which is mains hum), so "audio is working" and "audio is hearing silence on a disconnected jack" are indistinguishable from the card today. - -## The noise floor defaults high enough to look broken (2026-09-29) - -With `floor` at 100 a quiet room reports `level RMS 0`, `flux 0`, `peakHz 0 Hz` on every axis, which reads as a dead audio path rather than a working one below the gate. Dropping the floor to 0 on the same device produced live values immediately. The gate is doing its job, and the default may well be right for a wall in a noisy venue, but the card gives a user nothing to distinguish "gated" from "broken". A status line naming the gate when the raw level is non-zero but the gated level is not would cost one line and save the wrong bug report. - -## RTSP refuses TCP transport, so a player that asks for it gets nothing (2026-09-29) - -Verified on Windows with a real client: `ffprobe rtsp://127.0.0.1:554/` pulls `h264 128x128 30fps yuv444p` over UDP, and the same probe with `-rtsp_transport tcp` fails at `method SETUP failed: 461 (Unsupported Transport)`. Nothing in `RtspDriver.h` or the desktop platform layer mentions interleaved transport or `RTP/AVP/TCP`, so UDP-only reads as an omission rather than a decision. - -It matters because forcing TCP is the ordinary remedy for the two things RTSP over UDP is bad at, a firewall in the way and packet loss on a busy network, and several players try it first or are configured to. The docs point the reader at VLC without saying which transport works. Either implement interleaved transport or say plainly in the driver docs that the stream is UDP only, so the person whose player sits there timing out knows why. - -## A second instance says it is serving, serves nothing, and shares the first one's config (2026-09-29) - -Launching the desktop binary twice leaves a silent zombie. The second process prints `MoonLight is running: http://localhost:8080/` and `HTTP server -> http://:8080`, then listens on no TCP or UDP port at all, logs no bind failure, no error and no warning, and carries on rendering at about 14% of a core. Measured on Windows with a 128x128 layout: the first instance kept the port and kept serving throughout, so the deliberate omission of `SO_REUSEADDR` does its job and the port is never stolen. What is missing is the report. - -The sharper half is the filesystem. The second instance mounts the SAME data root and writes to it: `saved /.config/NetworkModule.json` landed while the first instance was live and serving. Two processes owning one config directory is a corruption waiting for the right interleaving, and neither of them knows the other exists. - -A bind that fails is a startup failure and wants to read as one: report it, and either exit or say plainly that another instance holds the port. The banner should follow what actually bound rather than what was intended. Whether a second instance should be refused outright is the open design question, since the data root is per user rather than per process and two of them cannot both own it. - -## A Windows scenario run recorded tick averages 250x its own repeat, cause unknown (2026-09-29) - -A Windows run of `scenario_Layouts_resize_reallocates_live` recorded 245 ms, 564 ms and 458 ms for its three measure steps, against 41 to 89 us for the same steps on macOS. `tickTimeUs` is a one-second average (`src/core/module/Scheduler.cpp:127`), so those figures describe a second containing roughly one tick, which reads as a blocking call rather than slow rendering. The blocks were reverted rather than committed, since an observation filed at 245 ms teaches a future reader that Windows costs that. - -An immediate repeat on the same machine and binary did not reproduce it. The three steps came back at 913 us, 1097 us and 768 us, and the tick log showed `PanelCard:0us`, `Network:1us` and `Drivers:30us` throughout, with the tick dominated by `MoonLive:3000us`. Nothing was blocking during the repeat, so the first reading stands unexplained. The candidate that fits the shape is `ethBindRawInterface()` calling `pcap_findalldevs` (`src/platform/desktop/platform_desktop.cpp:1299`), measured separately at about 2.3 seconds on Windows against milliseconds on macOS and reached through driver `prepare()` on a resize, but the repeat neither confirms nor kills it: no raw interface was bound on either run. - -The enumeration cost is real and independently measured, so caching it or keeping it off any path a resize can reach earns its place on its own. - -**The second half of this is already answered, from the other side of the same day (2026-09-29).** The ask was for a max-tick beside the average, to tell one blocked tick from a uniformly slow second. The observation block records `min`, `max`, `p50`, `p95`, `n` and the raw `samples` per target, so a repeat of this run reports its own worst frame rather than only its mean: a single 245 ms tick inside a second of good ones now shows as a `max` far above the `p50`, which is the distinction that was missing. - -**Re-measure before reading anything into the old figures.** `scenario_Layouts_resize_reallocates_live` was rewritten the same day (59 lines changed): it now builds a full pipeline and asserts with `expect_control` where it previously only measured, so the Windows numbers above describe a scenario that no longer exists in that form. Whatever a Windows run reports next is the figure to reason from, and it will carry the spread this entry wanted. - -## MoonLight hangs when a USB network adapter is re-plugged (2026-09-28) - -Unplugging and re-plugging a USB Ethernet adapter while PanelCardDriver is active wedges the whole application. Measured: the render task stops logging entirely (0 bytes in 8 s), one thread spins at 74% of a core while the other two sit in `Wait`, the listener still accepts so connections pile up unanswered (11 of them), and HTTP never returns, tested to 45 s. The last line written is always `NetworkModule: Ethernet up (recovered from Idle)`, so it enters the spin somewhere in the Ethernet-recovery path on the render thread rather than blocking on a lock. - -Reproduced once, on a bench with a Realtek USB GbE adapter feeding a ColorLight card. The trigger is ordinary: anyone driving panels from a USB dongle does this. The suspects are the pcap calls that recovery reaches, since `ethBindRawInterface` runs `pcap_findalldevs` and `pcap_open_live` on an adapter that has just re-enumerated, and both are called from `prepare()` on the render task. Worth a second reproduction with a debugger attached before guessing further: a spinning thread with a live listener is a loop, not a deadlock, and the loop has a home. - -## The interface picker re-enumerates adapters on every control rebuild (2026-09-28) - -`rawInterfaces()` caches nothing, and `defineDriverControls()` calls it on every rebuild of a driver that owns an interface Select. On Windows `pcap_findalldevs` opens each adapter in turn and measures ~2.3 s, so every control change on PanelCardDriver, every `/api/types` that builds the type catalog, and every UI refresh paid it. Measured with a panel driver present: `POST fixture` 2.2 to 6.7 s, `/api/types` 2.3 to 4.4 s, `/api/state` 2.2 s. The same payloads on a config with no panel driver: 23 to 36 ms. POSIX is not affected, since it enumerates with `getifaddrs`. - -A cache with a 60 s window took those to 8 to 35 ms and was reverted before the release as too much risk for hardware-critical code. Two things learned from doing it: the window has to outlast a person pausing to think, because a 3 s one leaves every deliberate control change paying full price; and skipping the rebind needs `ethLinkUp()` in the condition, because an open pcap handle whose link is down still wants rebinding and guarding on "already bound" alone removes the only recovery a user has. Scope any cache to Windows, since it buys POSIX nothing and costs it hot-plug latency. - -## NetworkModule treats a panel-output NIC as the device's uplink (2026-09-28) - -A NIC claimed for raw L2 panel output carries no LAN, but NetworkModule enumerates it anyway and reports `Connected via Ethernet` on it. When the panel link renegotiates, every flap runs the full cascade, which drops the browser's WebSocket and makes the UI look dead while the server is healthy. Seven flaps were logged in one bench session. The driver already announces itself through `ethClaimRawL2(true)`, so the claim is there to honor; the change is for NetworkModule to skip a claimed adapter. Crosses two modules, so it wants a decision rather than a patch. - -## The persisted interface label carries the negotiated link speed (2026-09-28) - -PanelCardDriver saves its adapter as `Realtek USB GbE Family Controller, 1 Gb`, speed included. A renegotiation to 100 Mbit rewrites that string, which dirties the config and rewrites `Drivers.json`. Matching already strips the suffix and compares the stable head, so the binding survives, but an identifier that changes when a cable renegotiates is the wrong thing to persist. Store the adapter's stable identity and render the speed for display only. - -## `build_esp32.py` reports success without building, under Git Bash (2026-09-28) - -Run from Git Bash it prints `MSys/Mingw is no longer supported`, stops after `Setting target to esp32s3...`, and **exits 0** ten seconds later with no binary. Run from PowerShell the same command builds correctly. A green exit code for a build that never ran is the worst shape a failure can take, and on a runner it would publish a stale binary while every gate stayed green. Either detect the MSYS environment and fail loudly, or re-exec through the shell the IDF tools expect. - -## `build_desktop.py` cannot relink while the app is running (2026-09-28) - -Windows holds an executable open while it runs, so a rebuild fails with `LNK1104: cannot open file ... MoonLight.exe` where POSIX would replace it. `run_desktop.py` already stops a running instance before launching; `build_desktop.py` could reuse that and remove a papercut that costs a confusing error every time someone edits code with the app open. - -## `disasm.py` cannot run on Windows (2026-08-21) - -The tool shells out to `c++` and `objdump` to build the per-ISA emitter and disassemble its bytes, and a Windows machine carrying only MSVC has neither. That holds for every ISA, not just the new `x86_64` one — but x86-64 is where it bites, because the host it disassembles is now a Windows desktop and the tool is what turns "the script did nothing" into an answer. The x86-64 backend was brought up without it, using byte-pinning tests plus WinDbg on the emitted image; that worked, and was slower than reading the instructions would have been. - -Closing it is a compiler/disassembler pair behind the two `subprocess.run` calls: `cl.exe` for the build, and for the disassembly either LLVM's `llvm-objdump` (which understands `-b binary` the way the tool already expects, and ships with the VS "C++ Clang tools" component) or a `.obj` wrapper around `dumpbin /disasm`, which does not. Prefer the former — the flags are already right, so it is a lookup rather than a second code path. - -## A non-ASCII Windows profile path defeats the desktop settings directory (2026-08-23) - -`std::getenv("LOCALAPPDATA")` returns the ANSI form of the path, so a Windows user whose profile name carries characters outside the system codepage (CJK and Cyrillic on a Western machine; most accented Latin survives cp1252) gets `?` where those characters were. `?` is not legal in a Windows filename, so `create_directories` fails, `fsMount` returns false, and the driver reports `cannot use ..., persistence disabled` naming the mangled path. It degrades visibly rather than corrupting anything, which is the standard Principle 5 asks for, but that user has no working persistence. - -Reading the variable wide is only half of it. `toFsPath` composes a `std::filesystem::path`, which stores wide on Windows, but every open in the layer goes back through `.string()` to reach `std::fopen` (fsRead, fsReadAt, fsWriteAtomic, fsWriteStream, and the mount probe), a narrowing the code comments on deliberately at `fsRead`. So a wide `LOCALAPPDATA` alone would produce a correct path that still cannot be opened: the fix is one `_wfopen`-on-Windows helper shared by all five sites, plus a test with a non-ASCII root. - -Not done with the per-user data directory because it reverses a documented decision across the whole desktop filesystem layer, four of whose five call sites predate that change. What the change did do is make the root capable of containing a username, where it was previously the literal `build`. Worth closing the next time this file is opened for other reasons. - -## Opt-in usage reporting: know which hardware and configurations are actually out there (2026-09-01) - -We build for hardware we cannot see. Which chips people actually run, how big their walls are, which drivers and features they enable, and how many are still on an old version are all questions currently answered by whoever happens to speak up on Discord. That is a biased sample: it hears from people with problems and from people who post, and it is silent about the majority who install something and it simply works. Effort therefore goes where the loudest reports are rather than where the users are, and a decision to drop support for something rests on a guess. - -**What a report would carry.** Enough to answer "what should we build and what may we stop supporting", and nothing else: - -- **The machine.** Chip family and variant for a device (classic ESP32, S3, P4, S31 and so on), flash size, whether PSRAM is present and how much. For a desktop, the OS and architecture instead, which is a question we have no way to answer at all today. -- **The version.** Firmware or application version, release channel, and on an upgrade the version it came from. Whether this was a fresh install or an upgrade. -- **The light setup, in shape rather than content.** Total light count, which layouts are in use and their dimensions, which drivers are active, and how many outputs or buses. Not what the wall shows, only how big it is and how it is wired. -- **Which features are switched on.** The modules present and enabled: audio, MoonLive, MQTT, Art-Net or E1.31 send and receive, panel cards, Ethernet versus WiFi. This is the half that answers "is anyone actually using this", which is what decides whether a feature earns its maintenance. -- **A country**, derived at the server from the address the request arrives from, so the map is by region rather than by installation. - -**What it must never carry**, and this list is a constraint on the design rather than a note on it: device name, IP or MAC address, WiFi credentials, MQTT passwords, any free-text field a user typed, and the contents of a layout or a script. - -**The identifier: a random value per install, dropped after seven days** (settled 2026-09-08). Not derived from the MAC or from anything else about the hardware, so it cannot be reversed to a device even by whoever holds it. It exists to answer one question the aggregates cannot: how many DEVICES, rather than how many reports, and therefore how many installs are still on an old version. The server keeps per-report rows for seven days, de-duplicates, then keeps only the aggregate and drops the id. - -Two options were weighed and rejected. **No identifier at all** was the original text here: simplest to promise, but it makes every per-device question unanswerable, and "how many are still on the old version" is one of the questions this whole entry exists to answer. **A hashed MAC** is what WLED shipped first (`sha1("WLEDUSAGE" + MAC)`) and it buys exactly one thing more than a random id, given reports are one-time: linking one device's reports across months. That is the tracking capability itself, it survives a factory reset and a reflash, an open-source salt over a MAC space that size is rainbow-tablable, and a stable pseudonymous identifier is personal data under GDPR rather than anonymous. WLED's own shipped version moved off it to an anonymous id, which is the same conclusion from people who had it running. - -**Consent.** Opt-in, from a prompt shown after a fresh install or an upgrade, with a decline that is as easy to click as the accept and is remembered. One report per install or upgrade, never a heartbeat. A user who declines transmits nothing at all, rather than transmitting a "declined" record. - -**The privacy policy already commits to this shape** ([docs/legal/privacy-policy.md](../../legal/privacy-policy.md)), including the honest wording about the IP address a server unavoidably sees. Whatever is built has to match what is promised there, and the policy has to be updated with the specifics BEFORE the feature ships, not alongside it. - -**Open questions for whoever picks this up.** Where the server runs and who administers it, since it is the first piece of infrastructure this project would own rather than borrow from GitHub. Whether the dashboard is public, which we would want it to be for the same reason the source is. Whether the desktop reports at all or only devices, given the desktop is the half we currently know nothing about. And what happens to a report from a version whose fields have since changed, because a schema that cannot be read a year later answers nothing. - -**WLED ships this, and the dashboard is public**: [usage.wled.me](https://usage.wled.me). Worth reading before building anything, because it answers two of the questions above by example and disagrees with this entry on a third. - -What it shows: distributions by version, chip, matrix, flash size, PSRAM, release, LED count and filesystem usage; upgrade versus install events over six months, split by chip; which LED features, peripherals, integrations, usermods and bus types are in use; and device count by country. That list is close to what is proposed above, which is reassuring about the shape. - -How it is collected ([PR #5116](https://github.com/wled/WLED/pull/5116), merged 2025-11-27, superseding an earlier [#4342](https://github.com/wled/WLED/pull/4342)): a one-time POST to `usage.wled.me/api/usage/upgrade` when a persisted version file shows the firmware changed, behind a startup prompt offering Yes / Not Now / Never Ask, and suppressed entirely in AP mode. The server is open source ([netmindz/WLED_usage](https://github.com/netmindz/WLED_usage)), which is how they answer "is the deployed code the published code". - -**Where they differ from this entry, and it is the interesting part.** WLED sends a device id: the first design hashed the MAC (`sha1("WLEDUSAGE" + MAC)`, "unique but not reversible"), the shipped one an anonymous `deviceId`. Either way the point is de-duplication, and it is what lets their dashboard say how many DEVICES rather than how many reports. This entry deliberately refuses that, and pays for it in accuracy. Their earlier PR also discussed a retention split (per-device data for 7 days, aggregates longer), which is a middle position between the two: keep an identifier only long enough to de-duplicate, then drop it. Worth considering rather than assuming the strictest option is automatically right. - -### The smallest honest version - -Sketched 2026-09-08. Not started; the blocker is the server, not the firmware. - -**The firmware side is small, because the payload already exists.** Every field is a control or a -module name the device publishes today: `chip`, `cpu`, `flash`, `psram`, `sdk`, `firmware` and -`deviceModel` are SystemModule controls; `version` and the previous version are FirmwareUpdateModule -controls; the enabled modules are the state tree's own top level; the light setup is the Layouts -grid and the Drivers children. So this is a serializer over facts already in memory, not new -instrumentation. Budget it as one module of a few hundred lines, plus the consent prompt. - -**When it sends.** Once, when a persisted version file shows the firmware changed, which is the -shape WLED converged on after starting from a UDP spray every second. Never a heartbeat. Suppressed -in AP mode, where there is no internet and a device is usually mid-provisioning. - -**Consent.** A prompt on the first boot after an install or upgrade: Yes / Not Now / Never, with -Never remembered. A decline sends nothing at all, not even a "declined" record. The prompt says what -is in the report in one sentence, and links the privacy policy. - -**Ask WLED to host it, before building a server at all.** Investigated 2026-09-08, and their server -is closer to a fit than expected. - -[netmindz/WLED_usage](https://github.com/netmindz/WLED_usage) is Kotlin on Spring Boot with a MySQL -schema under Flyway migrations, a Docker compose deploy, and one endpoint: -`POST /api/usage/upgrade`, no auth, with the country derived server-side from an `X-Country-Code` -header. The dashboard is a single static `index.html` against a `/api/stats` controller. - -**It is already multi-project.** The `device` table carries a `repo` column (added 2025-12), there -is a `RepoHistory` entity, and EVERY stats query is written `(:repo IS NULL OR repo = :repo)`. So a -second project reporting under its own repo name is a supported case rather than a change they would -have to make, and our figures would not be mixed into theirs. - -**We adopt their schema unchanged, and drop the one field that does not fit** (settled 2026-09-08). -chip, version, previousVersion, releaseName, ledCount, isMatrix, flashSize, psramSize/Present, -fsUsed/Total, busCount and busTypes mean the same thing in both projects, so those figures are -directly comparable and MoonLight can sit in a pooled overview rather than being a special case. -Our own vocabulary rides the free-form lists: ledFeatures, peripherals, integrations, and `usermods` -for the enabled-module names. That works because migration V2026040301 (2026-04) replaced twenty-odd -fixed boolean feature columns with three comma-separated lists, aggregated by counting whatever -values appear: adding MoonLight's names costs their server nothing. - -The layout dimensions this entry originally wanted (width x height x depth, since we are 2D and 3D) -have no equivalent there, and are DROPPED rather than added. `ledCount` plus `isMatrix` answers most -of what they were for, and being identical to a schema someone else maintains is worth more than one -field. If a real question later needs the third dimension, a list value is the place for it. - -**Their payload is close to what this entry wants.** `UpgradeEventRequest` already carries -deviceId, version, previousVersion, releaseName, chip, ledCount, isMatrix, bootloaderSHA256, brand, -product, flashSize, partitionSizes, psramSize, psramPresent, repo, fsUsed, fsTotal, busCount, -busTypes, ledFeatures, peripherals, integrations, usermods. Every field we listed above maps onto one -of those except the layout dimensions, and `usermods` is the natural home for "which modules are -enabled". Sending it means shaping our report to their names, which is a small price for not owning -a server. - -**What to settle with them before relying on it.** The repository has NO LICENCE file, so -strictly nobody may reuse it, and it was last pushed 2026-05-31, so it is quiet rather than dead: -both are conversations rather than blockers, but they are conversations to have first. Then the real -questions: are they willing to take another project's reports at all, who administers the box and -what happens to our data if that person stops, does the public dashboard gain a repo selector or -would we render our own from their API, and does the seven-day identifier retention this entry -commits to match what their server actually does (their earlier PR discussed it; the shipped schema -keeps a `device` row keyed by id, which suggests it does not). - -If the answer is yes, this feature loses its blocker entirely and becomes a firmware change plus a -conversation. If it is no, the plan below stands. - -**The server is the whole cost, and it is a standing commitment rather than a feature.** It is the -first infrastructure this project would own rather than borrow from GitHub, and it needs an -administrator, a domain, TLS, a retention job that actually runs, and a public dashboard. WLED -publishes their server ([netmindz/WLED_usage](https://github.com/netmindz/WLED_usage)), which is how -they answer "is the deployed code the published code": whatever runs here should be public for the -same reason the firmware is. Until someone owns that, this feature cannot ship honestly, and that is -the reason it is still in the backlog rather than in a branch. - -**Order of work, and the first two are worth doing whether or not the rest ever lands.** Write the -privacy policy revision FIRST, since it is the promise everything else has to match, and the current -page says plainly that nothing of the kind exists. Then the report BUILDER as a pure function over -the state tree, with a unit test asserting that the forbidden fields cannot appear in its output: -that test is the design constraint made executable, and it is worth having even if nothing ever -sends. Only then the consent prompt, the one-time trigger, and last the server. - -## A driven GPIO the Pins map never sees: bus padding, and a hidden clockPin - -**Found:** 2026-08-21, on MM-S31, after a bench session that started as "the LED panel stopped working" and cost hours chasing a firmware regression that did not exist. - -An ESP32-S31 driving a ColorLight receiver card over raw Ethernet showed the panel dark while every diagnostic said the transmit path was healthy: link negotiated at 1000 Mbit, ~4600 packets/s, zero drops, and a byte-for-byte dump of a 128x128 frame matched `ColorLight5A75Packet.h` exactly. The card's own activity LED never blinked. Four firmware versions across two ESP-IDF releases behaved identically. - -The cause is a **GPIO collision that no part of the system could report**. `ParallelLed` carried `clockPin = 10`, and GPIO 10 is `txd2` on the S31's RGMII bus (`platform_esp32.cpp`: the EMAC's fixed IO_MUX pads are 8-19 plus MDC/MDIO on 5/6). The LED driver drove one of the four Ethernet transmit data lines, so every frame left the MAC counted-as-sent and arrived corrupt. Disabling the LED drivers fixed it instantly; moving the clock to GPIO 21 fixed it with all four drivers running. - -Three separate defects made this invisible, and each is worth fixing on its own: - -**1. Pins the map cannot see, because nothing declares them.** The shipped conflict soft-flag grades what modules *declare*, so an undeclared pin is invisible to it however hard the silicon drives the pad. Three cases, two now closed: - -- ✅ **Bus-padded lanes** (fixed): a one-strand board had seven i80 lanes parked on `clockPin`, driven at bus-clock rate and listed nowhere. `spareLanesNeedPad()` stops the padding on a backend that routes its own GPIOs, so the pin is no longer driven and the map is truthful again. -- ✅ **RGMII data pads** (fixed): all twelve are now published by NetworkModule as read-only pin controls from one `platform::ethRgmiiPads` list that `ethInitEmac` also reads. Verified on MM-S31: `gpio 10` reports as `ethTxd2`. -- ✅ **P4 RMII data pins** (fixed): the six lines the EMAC drives are in `platform::ethFixedPads` and reported through `fixedPins()`, same as the S31's RGMII pads. Verified on MM-P4: Network owns all ten of its Ethernet GPIOs (28/29/30/34/35/49 plus MDC 31, MDIO 52, clock 50, reset 51), against four before. -- ✅ **Classic ESP32 RMII data pins** (fixed): TX_EN 21, TXD0 19, TXD1 22, CRS_DV 27, RXD0 25, RXD1 26, entered as a third `ethFixedPads` branch. Sourced from IDF's own RMII Data Plane GPIO table (`docs/en/api-reference/network/esp_eth.rst`): one IO_MUX choice per signal, which is why `ethInitEmac` never sets them. Verified on MM-Olimex: all ten Ethernet pins claimed, `Eth: 192.168.1.210 (100 Mbit)`. -- ✅ **MDC/MDIO on the classic ESP32** (fixed): the chip default was `mdc -1, mdio -1`, so `ethInitEmac` skipped the assignment, IDF applied its own 23/18, and the map claimed neither. `ethConfigDefault` now states 23/18, sourced from IDF's own classic default and our QuinLED Dig-Octa entry. Verified on MM-Olimex: `Eth: 192.168.1.210 (100 Mbit)` with `gpio 23 MDC` and `gpio 18 MDIO` in the map. NOTE: a board with -1 already persisted keeps it until those controls are set once or NVS is erased. - -**The fix is the RGMII one, extended.** Being fixed in silicon does not make `gpioCapability`'s reserved list the right home: reserved means "routing I/O here corrupts the device" (flash, PSRAM, USB), which is unconditional, while an EMAC pad is only held while that interface runs. With `ethType = None` the init returns false and every one of those GPIOs is free for LEDs, so a static reserved list would permanently forbid pins a WiFi-only board can use. What makes a control the right shape is not that the pin is configurable (it is not) but that the claim is CONDITIONAL: published when the interface is selected, released when it is not, which is exactly what the pin map reads. - -So the same `platform::ethRgmiiPads` treatment applies, with one wrinkle. On the **classic ESP32** the RMII data pins are silicon-fixed, so a second per-chip pad list serves them directly. On the **P4** 49/34/35/28/29/30 is the Waveshare NANO's *board* wiring that the IDF macro happens to default to, not a chip constant, so those belong in `deviceModels.json` beside the other per-board eth pins, letting a different P4 carrier declare its own. Both then reach the pin map through the control path already built, rather than a third mechanism. - -Lower risk than the RGMII case (six pins rather than twelve, and nothing of ours currently collides), but the failure mode is identical: a driver claims one, the MAC still reports a healthy link, and every frame goes out corrupt. - -**2. `busPinList()` pads spare lanes with `clockPin`, and MoonI80 routes them as data.** The i80 bus is always 8 or 16 bits wide (`ParallelLedDriver::busWidthPins`), so a board driving one strand gets seven lanes parked on the clock pin, and `configureGpio` (`platform_esp32_moon_i80.cpp`) routes every entry it is given. The padding exists because `esp_lcd` rejects an NC data pin, but the MoonI80 backend does not have that limit: its own comment says *"pins past `laneCount` go nowhere"*. It is simply never told the real lane count. Passing it would free six or seven GPIOs on every direct-mode board and remove the hidden claim at the source. - -**3. `clockPin` defaults to 10 and is hidden.** `MoonLedDriver::clockPin = 10` is a hardcoded default that lands inside the S31's reserved RGMII block, and `addBusControls` hides the control unless `pinExpanderMode()` is on. So on this board the value was invisible on the card, unchangeable through the UI, and still driving a pad. A pin with a real effect must be visible, whatever mode it is in. - -**This also closed the S31 Ethernet defect, open since 2026-07-26.** That entry (removed) blamed an RGMII Tx-clock mismatch at 100M for DHCP never completing, and had concluded "the frames never reach the router". The cause was the same collision: `ParallelLed`'s default `clockPin = 10` is `txd2`, so a DHCP DISCOVER was garbled exactly as the panel frames were. With the clock pin moved off the RGMII block the S31 leases normally, verified on the bench at `Eth: 192.168.1.125 (1000 Mbit)`. Two long-standing bugs, one GPIO. - -## Input transports: foot pedals, USB game controllers, and MoonLive at the pins (2026-09-01) - -`ButtonService` shipped with the [GPIO seam](../present/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(7%20of%2012).md) -(`gpioInputBegin` / `gpioRead` / `gpioWrite`). It names a target as `Module.control` and writes it -through `Scheduler::setControl`, so a press and an OSC message are indistinguishable downstream. -Three follow-ups build on that seam rather than beside it. - -**Foot pedals are two different things**, corrected in -[input-mapping-analysis.md](input-mapping-analysis.md): a **footswitch** is a switch on a TS jack, -which `ButtonService` in momentary mode already covers, while an **expression pedal** is a -potentiometer on a TRS jack, which is an ADC read mapping onto a fader and does not exist yet. -Neither is USB. **Action: document the footswitch, scope the expression pedal as an analog-input -service.** - -**USB game controllers are their own project, and are S3/P4-only.** The mapping half is trivial once -reports arrive (buttons to pads, axes to encoders and faders, all through `setControl`); the -transport half is not: - -- The **classic ESP32 cannot do it at all**: no USB Host peripheral, no USB OTG. So the Dig-2-Go, - and every classic board, is out from the start. -- **S3 and P4** have USB-OTG and the IDF ships a USB Host stack with a HID class driver. A gamepad - is a HID device whose report descriptor must be parsed to learn which byte is which button or - axis, and that parsing is the real work: descriptors vary per vendor, and the well-known - controllers each have quirks. -- **Desktop** would go through the OS gamepad API behind the same seam, a wholly separate - implementation. - -Expect a plan of its own, after the GPIO inputs have settled. The honest scope is "a HID report -parser plus a mapping UI", not "read a controller". - -**MoonLive at the pins** is the piece that makes the seam pay twice. The direction is that MoonLive -gains driver scripts whose hello-world is "read from GPIO, write to GPIO", which needs: - -- `gpioRead(pin)` / `gpioWrite(pin, on)` as builtins, mapping straight onto the platform seam. -- `setControl(module, control, value)` as a builtin. **This one needs a decision before it ships**: - it is the same primitive every transport already uses, so exposing it is consistent, but it also - lets any script write any control on any module. That is power worth granting deliberately rather - than as a side effect. -- A `MoonLiveService` host module, the service twin of `MoonLiveEffect`: a `script` control, the - compile and status path, the picker integration. Mostly a copy of the existing binding, and it is - what turns "MoonLive can read a pin" into "a user can add a scripted service". - -`ButtonService` is then the precompiled sibling of a script anyone could write, exactly the -relationship `ballpit.mle` has with `BallpitEffect`. - -**Double-click and multi-click** (2026-10-25), from ESPHome's trigger vocabulary: the same debounce -timers with more states, so one button does several things. Built when a panel needs more actions -than it has buttons. - -## Relay-gated boards, and what a driver is (2026-09-01) - -Wiring a QuinLED Dig-2-Go found that its LED supply sits behind a relay on GPIO 12, the vendor's -"LED Relay enable pin". Undriven, the data line is perfectly correct and the strip stays dark, which -is a hard failure to diagnose from the firmware side. `Drivers` now carries a `relayPins` list that -follows the master `on` control. - -**A list, because boards do not agree.** The Dig-2-Go has one relay for one LED output; the -Dig-Next-2 has **four relays for two outputs**, all carrying the same `Relay_LightsOn` role in -MoonLight's own model. So a relay maps to neither the device nor a driver, and every one of them -follows master power together. It lives beside `on` rather than on a driver because a relay gates -the SUPPLY, which several drivers share, where a driver's own controls describe that one driver's -output. - -**Still open: the definition of a driver.** Two statements in the repo disagree. -[`drivers.md:3`](../../moonmodules/light/drivers.md) says "A driver sends lights somewhere", while -[`architecture.md:143`](../../explanation/architecture/index.md) frames drivers as the consumer half of producers vs -consumers. The product owner's definition is broader than both and is the one to adopt: **a driver -communicates with hardware or the network**, which explicitly includes talking to GPIOs. - -The concrete tension is in code: `DriverBase` declares `virtual void setSourceBuffer(Buffer*) = 0`, -so every light driver is structurally required to consume the light buffer, and all sixteen do. The -resolution is that there are two families of one idea, split by whether the light buffer is -involved: a **light Driver** (`Drivers` container, consumes the buffer, outputs it) and a **core -Service** (`Services` container, a capability bridge, no buffer). `services.md` already says exactly -this. **Action: scope `drivers.md`'s claim to light drivers, and name the general sense in -`architecture.md`.** Documentation only, no code. - -## A desktop build reports firmware "unknown", so the update UI guesses from the browser (2026-09-06) - -Spotted on the container's Firmware card, which showed **Device: macOS arm64** while the image is -Linux amd64. It was not reading the device: `desktopKeyForThisHost()` (`src/ui/install-picker.js`) -and `desktopAssetPrefix()` (`src/ui/app.js`) both sniff `navigator.platform`, which describes the -machine holding the BROWSER. The container makes it visible because there the two always differ, but -the same card lies on any desktop instance opened from another machine. - -The download it offers is the failure mode worth naming: on a Mac the picker serves -`MoonLight-macos-arm64-*.dmg`, a real file that installs and runs perfectly, and updates the wrong -computer. Nothing errors, and the device stays on its old build. - -**Root cause is one missing build flag.** `MM_FIRMWARE_NAME` already exists for exactly this -(`build_info.h`, surfaced as the `firmware` control): `build_esp32.py` passes the variant key, and -the desktop packaging passes nothing, so every published desktop binary falls through to the -`"unknown"` default. `build_info.h:43` even records the assumption that made this safe, "local -builds aren't published" — the container is a published desktop build, which is what retired it. - -**What it would take:** have `package_desktop.py` pass -`-DMM_FIRMWARE_NAME="desktop--"` for the target it packages (the keys the picker's -`DESKTOP_LABEL` map already lists), then delete both browser-sniffing functions and read the -`firmware` control instead. `deviceFirmwareInfo()`'s `isDesktop` test (`firmware === "unknown"`) -needs the same treatment, since a named desktop build no longer matches it: test the -`desktop-` prefix. Local unflagged builds keep reporting `unknown`, where a guess is still better -than nothing. - -Worth doing with the OTA path rather than alone: a desktop cannot install its own archive anyway, so -the honest end state may be a card that names the platform and links the release rather than -offering a Download button that only ever means "fetch a file and swap it by hand". - -## Verify P4 WiFi on our own board: the C6 slave firmware first (2026-10-19) - -Issue #146 (a P4 `eth-wifi` boot loop) was fixed without anyone seeing WiFi work: the bench P4-NANO's C6 never answers on SDIO (`ESP-Hosted link not yet up`, no `Card init success`), with or without the fix. So every P4 WiFi question waits on a reporter's board. The textbook cause of a silent C6 is a host/slave mismatch: our host side is ESP-Hosted 2.12.13, built into the firmware, while the C6 runs whatever slave firmware the board maker flashed. **Make our own P4-NANO's link come up, then keep it as the WiFi reference.** - -- **The slave image**: `network_adapter.bin` built from esp-hosted-mcu's `slave` example at the host's version (2.12.13), for the C6. -- **Flashing it, with the link down** (our case): directly over the C6's own UART with its boot pin held, through an ESP-Prog or a USB-serial adapter. The P4-NANO's schematic says where the C6's UART and boot pads are; ESPHome's board page names only the SDIO pins (`clk` 18, `cmd` 19, `d0`-`d3` 14-17) and the C6 reset (`GPIO54`). -- **Flashing it, with the link up**: ESP-Hosted's transport OTA, its `host_performs_slave_ota` example, which ESPHome wraps as the `esp32_hosted` update platform. This is the path for users once one board works, and a candidate for MoonLight itself: carry the slave image and offer the update on the WiFi card, as ESPHome does. -- **Say which failure `wifiCoproc` shows**: `no version reply` covers two cases, a link that never came up and a C6 too old to answer the version query (or answering 0.0.0), and only the first stops WiFi. `coprocessorWifi()` in `platform_esp32.cpp` checks the transport state first and reports `link down` or `C6 too old to report`, so a reporter's System card answers what today needs a serial log. -- **Then**: the round-3 regression (associates on rc1, not on the v6.1 pin) gets measured on a board with a known-good link, and the two defects in the next entry get a repro. - -What #146 taught, for any P4 change: before the scheduler starts, the P4 has 148 KB of internal RAM (retention, RTC and SPM; the 384 KB L2 region still holds the startup stacks), and every global constructor allocates from it. ESP-Hosted's took 123 KB until `CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM` moved its buffers to PSRAM. A boot that asserts in `vApplicationGetIdleTaskMemory` is that budget exhausted; a `heap_caps_get_free_size(MALLOC_CAP_INTERNAL)` report from a constructor of priority 101 and an `ESP_SYSTEM_INIT_FN` at stage 999 brackets it. - -## P4 WiFi cascade into a dead co-processor link aborts the board (2026-09-08) - -Bench, MHC-WLED ESP32-P4 shield on `esp32p4rev1-eth-wifi`, no Ethernet cable: the NetworkModule -cascades to WiFi, and the board reboots every ~22 s with `task_wdt: main (CPU 0)` while `IDLE0` -runs. The main task is not spinning, it is BLOCKED: on the P4 every `esp_wifi_*` call is forwarded -over SDIO to the ESP32-C6 by `esp_wifi_remote`, and this shield's C6 never completes the ESP-Hosted -handshake (`E H_API: ESP-Hosted link not yet up` at boot), so `esp_wifi_init` and the calls after it -each wait out their timeout, on the render thread, past the 5 s watchdog. The wait is -esp_hosted's `transport_drv.c` slave-ready loop: 200 ms polls, a slave reset every 50 of them (the -`Reset slave using GPIO[54]` lines at 10 s and 21 s), up to MAX_RETRY_TRANSPORT_ACTIVE = 100 -polls, so worst case ~20.0 s in the caller's task, which is ours. Not the HWLOOP/FFT -erratum: audio was ruled out by the watchdog text itself (a blocked main task with idle running, -not a spinning core). Control experiment, same image on the bench P4 (.139, same shield model, -esp_hosted host 2.12.13): its SDIO card init succeeds (`Card init success, TRANSPORT_RX_ACTIVE`), -it never associates either, but the main task keeps ticking, it falls back to its own AP, and it -rejoins Ethernet when the cable returns. The new shield never prints a card-init success: its C6 -does not answer the bus (no slave firmware, or one that does not match the 2.12.x host). - -Two defects are ours, whatever the C6 carries: - -1. **The cascade does not consult the link.** `coprocessorWifi()` in `platform_esp32.cpp` already - asks the C6 for its firmware version, bounded to two attempts, exactly to detect an absent or - incompatible slave. The WiFi cascade never asks; it walks straight into `esp_wifi_init`. Gate the - cascade on that answer (or on `esp_hosted` reporting the link up) and degrade to "no network, - C6 not answering" as a Network status. -2. **A failing cascade must not abort.** Robustness says degrade visibly, never crash. Even with the - gate, a link that dies later would hit the same watchdog: the forwarded calls need to run off the - render thread, or with a timeout shorter than the watchdog, so the worst case is a status line. - -Practical today: the shield runs the eth-only image without WiFi, or its C6 gets the ESP-Hosted slave -firmware flashed (the `ships: false` note on the variant in `build_esp32.py` says why that is not -yet reproducible). The Improv script's eth-only rule and the catalog's two firmwares for the shield -are already in place. - -**Everything tried on 2026-09-08 was reverted; both P4s now run `esp32p4rev1-eth` and are stable.** -The tree is back to its pre-attempt state except for the catalog, which now lists BOTH P4 firmwares -for the MHC-WLED shield so the installer offers the WiFi variant at all (it listed only the eth one, -which is why the WiFi image could not be picked). What the day established, so the next attempt does -not repeat it: - -- **The C6 link is INTERMITTENT on this shield, not simply dead.** After the vendor C6-update tool - ran (it never completed an OTA: it looped on "Not able to connect with ESP-Hosted slave device"), - the link came up and the board reached a DHCP lease on WiFi, first attempt, no retries. A power - cycle later it was back to `sdmmc_send_cmd returned 0x107` and a reboot loop. Any future fix has - to survive a cold boot, not one lucky session. -- **A patient STA retry (MoonLight's 5 s cadence, ~2 min budget) was implemented and reverted.** It - never fired on either board: both connected on the first attempt when the link worked, and when it - did not the board died before the render loop. Keeping untested robustness was not worth the - surface. The reasoning still holds and is worth redoing WITH a repro: MoonLight retries forever - and never tears the radio down, and its own comment warns that toggling WiFi on a co-processor - board forces costly esp_hosted reinit cycles, which is exactly what our AP fallback does. -- **Espressif does not endorse retrying into readiness.** Their esp-hosted troubleshooting puts - "not able to connect with slave" and SDIO 0x107 down to wiring, pull-ups, signal integrity, or a - host/slave VERSION MISMATCH: "use the same version for master and slave". Our host is 2.12.13; the - shield's C6 is on the factory build the vendor tool calls 0.0.6 (target 2.0.17). Updating the C6 - needs Method 2 (direct USB/UART to the C6), since Method 1 needs the very link that is broken. - -**A fail-fast gate was tried on the bench (2026-09-08) and REVERTED.** The idea was right and both -signals were wrong. `platform::wifiHardwareReady()` gated the STA and AP init, and it STOPPED the -reboot loop dead: the shield ran 80 s with 0 reboots and 59 ticks, logging "WiFi co-processor link -not up" instead of aborting. But on the bench P4 (.139), whose link demonstrably works, the same -predicate read FALSE at its 26 s cascade and refused WiFi on a healthy board. Two signals were -tried, neither is usable as a readiness test: - -- **`ESP_HOSTED_EVENT_TRANSPORT_UP`**, subscribed on the default event loop (both lazily and from - `ensureNetifInit`, i.e. before esp_hosted's task posts it). Never observed arriving. esp_hosted - posts through its own `g_h.funcs->_h_event_post` indirection; where that lands was not chased. -- **`esp_hosted_get_coprocessor_fwversion` returning a non-zero version.** Also false on .139, which - matches what `coprocessorWifi()` already records: on a live link this RPC times out rather than - answering, which is why that function is bounded to two attempts. - -**Not a regression: the RELEASED v4.0.0 image fails the same way on this shield** (bench, same day). -Flashed from the web installer after an erase, it logs `App version: v4.0.0` and then: - -``` -W (13438) H_SDIO_DRV: Reset slave using GPIO[54] -W (13438) gpio: conflict found for GPIO[54] -E (14988) sdmmc_io: sdmmc_io_rw_extended: sdmmc_send_cmd returned 0x107 (ESP_ERR_TIMEOUT) -E (14988) H_SDIO_DRV: failed to read registers -``` - -and reboots without ever reaching the render loop. The same v4.0.0 image serves WiFi on the bench -P4 (.139) with no SDIO error and no GPIO 54 conflict, and no MoonLight config on any P4 entry -references GPIO 54 (esp_hosted drives it as the slave reset). The two boards differ in silicon -revision: this shield is chip rev **v1.0**, .139 is **v1.3**. So the C6 side of this shield does not -come up, which is a board/slave-firmware matter rather than anything in our WiFi path, and the -firmware's job is only to degrade rather than reboot. - -**The product owner reports this same shield ran WiFi under MoonLight on IDF 5.5**, which makes a -dead C6 unlikely and points at host-side SDIO configuration on 6.1. What was checked (2026-09-08): - -- The SDIO data pins are NOT board-preset dependent: they come from the SLOT choice - (`ESP_HOSTED_SDIO_SLOT_1`, fixed silicon pins), so swapping `ESP_HOSTED_P4_DEV_BOARD_*` presets - would not move them. The preset mostly moves SPI pins, which we do not use. -- `ESP_HOSTED_SDIO_GPIO_RESET_SLAVE` defaults to **54 on any P4** regardless of preset, so the - `gpio: conflict found for GPIO[54]` line is esp_hosted resetting the slave twice, not a wrong pin - from our config. No MoonLight P4 entry references 54. - -**Our SDIO configuration is not the difference.** Diffed against a known-good local reference -(`ewowi/FlowFields/sdkconfig.esp32-p4`, an IDF 5.5-era P4 build), every hosted setting is IDENTICAL: -pins (CLK 18, CMD 19, D0-D3 14-17), `GPIO_RESET_SLAVE` 54, `RESET_ACTIVE_HIGH=y`, `CLOCK_FREQ_KHZ` -40000, `SLOT_1`, `4_BIT_BUS`, `RESET_DELAY_MS` 1500, `RX_STREAMING_MODE`. So reset polarity, reset -pin and SDIO clock are all ruled out as differences, and the identical released v4.0.0 image works -on .139 and not on this shield. Same firmware, same config, two boards, two outcomes: what remains -is on the board side (C6 slave firmware, power/strapping, or the SDIO traces on this revision), and -the only MoonLight work left is the degrade-instead-of-reboot fix above. - -What esp_hosted's private `is_transport_tx_ready()` reports is the signal the slave-ready loop -itself polls, but it lives in a PRIVATE include dir (`host/drivers/transport`, not in the -component's `pub_include`), so reaching it means either adding that dir to our include path or -asking upstream to export a readiness getter. That is the next thing to try, and it needs the -.139-still-associates control run alongside the shield-stops-rebooting one: the fix is only right -when BOTH hold. - -## The persisted `firmware` variant survives a flash to a different variant (2026-09-08) - -`SystemModule` writes the compile-time `kFirmwareName` into its `firmware` control at -`defineControls()`, and its own comment states the intent: "written from kFirmwareName on every boot -rather than read from the file: the compile-time constant is the truth". But the control is -`addText`, which is PERSISTED, and the config load runs after `defineControls()`, so a saved value -from a previous image overwrites the compile-time one. - -Bench (MHC-WLED P4 shield, flashed from `esp32p4rev1-eth-wifi` to `esp32p4rev1-eth`): the Firmware -card correctly reported `esp32p4rev1-eth` (it reads the running image), while `/api/modules/System` -still reported `esp32p4rev1-eth-wifi` from the old config. The two disagreed on the same board, and -the stale one is the field an outside reader trusts. - -Why it matters beyond cosmetics: the comment explains this value exists so **MoonBase** can narrow -the recovery image list to one variant. A stale value points a recovering board at the wrong image, -which is the failure that list exists to prevent (picking an `esp32s3-n16r8` build for a Zero -installs a flash layout the board does not have). - -The fix has to keep the value readable by another image (that is why it is persisted at all) while -making the compile-time constant win: re-assert `kFirmwareName` after the config load rather than -only at `defineControls()`, and pin it with a test that loads a config naming a DIFFERENT variant -and checks the control still reads the compiled one. - -## MoonCloud: share a MoonLive script over MoonTalk (2026-09-10) - -Sketched 2026-09-10 while Talk was built. **The unique feature nobody else has**: MoonLight ships a scripting language whose programs are about a kilobyte of text, and a message board between devices. Together they mean a script someone wrote on their wall is one tap from running on yours. - -**What makes it plausible.** The scripts are tiny: the shipped `.mle` files run 780 to 1907 bytes and the whole library of twenty-odd is 36 KB. A script is self-contained by design, so the text is the artifact. And the device already compiles and runs arbitrary script text safely, which is the hard half and is done. - -**The design work is that a script is five to seven times a message.** Talk caps a message at 280 characters, deliberately: a chat message is a sentence, and one caller must not fill the table. Three options: - -- **A second endpoint** (`/api/script`) with its own size cap and table, and a message that references it by id. The chat table stays a chat table, a script is fetched when someone wants it, and a board read stays small. Most work, cleanest shape, and the recommendation. -- **An attachment column on the message**, capped separately. One table and one post, at the cost of every board read carrying script text unless the query is careful. -- **Share a URL.** A script already lives in the user's File Manager and a device could serve it over the LAN. Free, and limited to one network, which is the case that makes the feature interesting. - -**A shared script is code from a stranger**, in a way chat text is not. Three questions the design answers rather than discovers: - -1. **Loading is explicit and reversible.** A script arrives as something to look at, and taking it leaves what the user already has intact. `/moonlive` (user) shadowing `/.moonlive` (factory) is the existing trap. -2. **The device is the sandbox.** MoonLive reaches no filesystem and no network, so the blast radius of a hostile script is the LEDs and the render budget. Worth confirming rather than assuming: a script that never yields is a watchdog reset, which is a denial of service on someone's wall. -3. **Attribution and removal.** Any sender id can be claimed, and a message stays once posted. Fine for chat, weaker for code. - -Depends on Talk shipping and on the server being deployed. diff --git a/docs/work/future/backlog-light.md b/docs/work/future/backlog-light.md deleted file mode 100644 index 2c57aaa5a..000000000 --- a/docs/work/future/backlog-light.md +++ /dev/null @@ -1,1150 +0,0 @@ -# Backlog: light domain - -Forward-looking to-build items for the **light domain** (`src/light/`: drivers, effects, layouts, modifiers, preview) and its sensors. The core/infrastructure counterpart is [backlog-core.md](backlog-core.md); cross-domain items are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](index.md). Completed items are removed. - -- ❌ **Cap the particle frame scale** (open): `FrameTime` spends a whole stall in one frame, so an - 80 ms hiccup moves every particle **6.7x** its usual distance in a single step (measured). That is - the rule working as designed, and it keeps the trajectory correct in real time, but a particle - INTEGRATES the gap where a shader just redraws from the clock and skips a frame invisibly. So - particles are the first effect class that makes system jitter visible, and they did: they exposed - a 1 Hz LittleFS scan on the render thread (since fixed) and they still show the previewer's - socket write and a UI reload. - - The fix would clamp the scale a pool sees to ~2 reference frames, trading real-time accuracy for - smoothness. **Deliberately not done**: it makes motion lie about elapsed time, which - [the tick-rate rule](../../explanation/architecture/index.md) exists to prevent, and every stall it - hides is a real defect somewhere else that would stop being visible. WLED-PS takes the opposite - side (`ParticleSystem2D::update()` advances a fixed amount per call, with no `millis()` anywhere), - so its motion speed is a property of the frame rate. **Build trigger**: a stall we cannot remove - at its source, on hardware a user actually has. - -## Effects - -### Network Receive on a P4 past 3,000 packets a second (2026-10-26) - -Below about 3,000 packets a second a P4 receives every frame; past it packets go, more for Art-Net than for DDP, as [the table](../../reference/performance.md#network-receive) shows. -The measured split, from a `--task-cpu-stats` build: the Ethernet receive task and lwIP's take about 160 µs of core 1 per packet, and Network Receive's task tens of microseconds per socket call on top, a `select` hundreds. -Both network tasks may run on either core, yet stay on core 1 with core 0 idle. -Two levers, each its own change: the network tasks on core 0, where pinning them by `ETH_MAC_FLAG_PIN_TO_CORE` and lwIP's task affinity kept Ethernet from coming up, so that needs its own look first; and lwIP's raw `udp_recv` callback writing into staging from lwIP's task, with no socket, no `select` and no receive task. -Ranking the Ethernet task below the receive task, and lwIP in IRAM, were measured and do not help. - -### The ring's refill jitter (2026-10-22, from the internal RAM plan) - -LCD-MM's refill interrupt fires every 89 µs at 3-row slices, yet almost every second it goes about 1.1 ms without firing. -So `ringAuto` keeps 54 rows of lead (3 × 21 buffers, 37 KB on shiffy), where hpwit's S3 driver runs on 10. - -Measured with `ringDbg`'s `gap` (within a frame, `@` the slice it ended at), `ml` (the fewest slices of lead) and `c` (the interrupt's core). The gap ends mid-frame as often as at the idle tail. - -Ruled out: -- the flash cache: `co` stays 0; -- the core: registered on core 1 it was the same, and it broke the cache guard, so it stays on core 0; -- `mmSnap`: removed. - -Next, the gap measured against the frame's own events, the arm and the snapshot, then against other interrupts on core 0. - -A lead of 10 rows would free most of the ring's internal RAM. - -### A preview GIF for every MoonLive script (2026-10-19) - -The 140 compiled effects each show a GIF on their catalog card (`docs/assets/light/effects/.gif`, 25 MB in all); the 49 MoonLive effect scripts show none. -The pictures belong in `docs/assets/moonlive/effects//.gif`, mirroring the script subfolders, never in `moonlive/` itself, which the build globs and the UI fetches file by file. -One MoonDeck script makes them all: `screenshot_modules.py` learns to step through the MoonLive catalog, capturing from the preview socket (`/wsp`, requests `[0x51][stride][fps]` and `[0x52][stride]`) the way the hpwit GIFs of 2026-10-18 were made. -At 0.5 to 1 MB each that is 25 to 50 MB more, so a smaller format (128 px, 2 s) is part of the decision. -**Decide first** where they show: a MoonLive scripts page with a card per script grouped by subfolder, or the script picker previewing one before its download. - -### Retro looks on a picture: scanlines, chroma bleed, tracking glitches (robwomp, 2026-10-21) - -Asked for on Discord by robwomp, who loves the glitchy analog and digital look of the 90s and 00s and has Rockchip boards with HDMI and CSI inputs. -A captured picture becomes worth watching on lights once it can be degraded on purpose: scanlines, a color channel bleeding sideways, a frame that tears and rolls like a VHS tracking error. -Each is a modifier on what the Picture effect draws, so it works on any source, a capture, a GIF or a browser tab. -Ask robwomp which looks matter most before building, and have them tested on robwomp's capture hardware. - -### Effects and Drivers find their source one way, not two (2026-10-08) - -Effects finds the top-level Layouts, and Drivers the top-level Effects, by type through `Scheduler::topOfType` when nothing was injected, which is what lets a state document build a tree with no wiring. -`setLayouts` and `setEffects` remain beside it for the unit-test rigs, 172 calls in about 40 files, so two mechanisms do one job. -The fix: a rig adds its containers to a Scheduler, as the scenario runner does through `createTopLevel`, and the setters go. - -### Moving-head effects from MoonLight, including two of troyhack's (2026-09-04) - -MoonLight has several moving-head effects that have no equivalent here, two of them troyhack's. -Migrate them all, on the power functions per the standing mandate rather than traced across. - -### RMT over DMA on the S3/P4, with a completion callback (Funkelfetisch, July 2026) - -Funkelfetisch's fork carries a finished branch, `codex/upstream-rmt-rgbw-performance`, that the -classic-ESP32 flicker work of 2026-09-05 makes worth adopting: on chips with RMT DMA -(`SOC_RMT_SUPPORT_DMA`: S3, P4) it sets `with_dma` with the IDF-recommended 1024-symbol block, so -the frame streams from RAM and the refill interrupt that causes flicker on a DMA-less chip does not -exist at all. It replaces the blocking `rmt_tx_wait_all_done` with `rmt_tx_register_event_callbacks` -(`on_trans_done`) plus a per-channel busy flag so the next tick skips while a frame is in flight, -and reports `"RMT DMA"` in the driver status so a user can see which path is live. Files: -`platform_esp32_rmt.cpp` (+105), `RmtLedDriver.h` (+75), `LedDriverConfig.h`, `Correction.h` -(RGBW profiles, a separate topic in the same branch), with unit tests. - -What it does NOT address: on the classic ESP32 the DMA half compiles to nothing, and nothing in it -moves the channel's interrupt off core 0 (the root cause found on the Dig-Next-2, fixed by -creating the channel from core 1). The two are complementary, one driver with the right answer -per chip: DMA where the silicon has it, the core-1 refill where it does not. Adopt his DMA and -callback path and keep the core hop. Study, do not copy: write it against the seam as it stands, -credit the branch. - -Re-read against the wire-byte driver (2026-09-09), which changed two of the assumptions above. The -driver no longer holds pre-expanded symbols at all: it ships the correction's wire bytes and the -peripheral expands them (IDF bytes encoder on the DMA chips, the level-5 refill on classic), so the -frame buffer is ~3 bytes per light rather than 96. So (a) his `mem_block_symbols = 1024` is now the -only symbol memory in play and is cheap, and (b) `rmt_transmit` already takes the bytes, so the DMA -path needs no buffer change, only `flags.with_dma` plus the callback. The `txInFlight_` guard STAYS -whatever happens: the peripheral streams straight out of the driver's frame buffer, so a rebuild -that frees it mid-frame still tears; his busy flag would replace the blocking wait, not that guard. -The win left on the table is the blocking `rmtWs2812Wait` on S3/P4, which today costs the tick the -whole wire time of the longest strand. - -### A script's setControl rebuilds a control subtree on every write (2026-09-06) - -Measured on the P4 at .139: **2 fps**, with `MoonLive-2` at 251 ms and `MoonLive-3` at 245 ms per -tick, together 498 ms of a 506 ms frame, and HTTP down to a 0.5 s round trip because it is served -from the same loop. Both services ran `sweep.mls`, whose `tick20ms` writes four faders. Two copies -at 50 Hz is 400 control writes a second, and `Scheduler::setControl` calls `rebuildControls()` -unconditionally on each one: - -``` -clearControlsRecursive(); // wipes this module's controls AND every child's, recursively -defineControls(); // then rebuilds them all -``` - -So each fader write tears down and re-creates the whole `Control` subtree. A person moving a slider -does this a few times a second and nobody notices; a script at 50 Hz multiplies it by hundreds. - -Two things to fix, and they are independent. **The rebuild should be conditional**: a `live` -control's value change cannot alter the schema, and `rebuildControls` already computes -`schemaSignature()` before and after to decide whether to notify, so it knows. Rebuilding only when -the shape can actually change (what `setLive` and `affectsPrepare` already distinguish) removes the -cost for every value write, scripted or human. **And the script tick is too fast for its job**: a -fader sweep does not need 50 Hz, so `sweep.mls` should run on a slower tick, which also caps the -damage any future script can do through this path. - -Not a regression from the RMT work: it predates it and was found while measuring an unrelated slow -board. - -### Speed up the fluid solver: 4 fps at 128x128, and it is not the divide (2026-09-05) - -Measured on an S31 (RISC-V, 320 MHz, octal PSRAM at 200 MHz), `iterations` 5, depth 1: - -| grid | Fluid tick | fps | cycles per cell-update | -|---|---|---|---| -| 32x32 | 8.5 ms | 109 | 151 | -| 64x64 | 38 ms | 24 | 159 | -| 128x128 | 204 ms | 4 | 205 | - -A cell-update is four loads, three adds, a multiply and a store: under 20 cycles of arithmetic. It -costs **151 even at 32x32**, where the whole working set is small enough to cache, so the loop -itself is roughly 8x more expensive than the work it does. Memory adds a further 35% by 128x128 but -is not the wall: at 4 fps the solver moves ~25 MB/s, about 5% of what this PSRAM delivers. - -**A wrong turn worth recording.** The first diagnosis blamed the 64-bit divide in `relax()`, on the -reasoning that Xtensa has no integer divide instruction. It was implemented (a power-of-two shift -dispatched once per call, bit-exact over 8M values) and measured on the board: **no change, 326 ms -before and after**, so it was reverted. Two errors: the S31 is RISC-V rather than Xtensa, and at -151 cycles per update the divide was never the dominant term. A desktop measurement could not have -caught either, since arm64 divides in hardware; only the board settles it. - -**Allocation placement is worth 1.6x, and nobody chose it.** Same firmware, same grid, same -controls: **326 ms after a fresh boot, 204 ms after resizing the grid to 32 and back to 128**, -reproducible across reboots. The solver's six buffers are ~638 KB and land in PSRAM either way, but -where they land at boot is slower than where they land once the heap has moved. Whatever is done -about speed, this says a boot-time allocation can be paying a large penalty invisibly, and it is -worth understanding before optimizing the loop around it. - -Ordered by expected return: - -1. **Cut the 64-bit arithmetic in the inner loop.** Every cell computes an `int64` shift, an - `int64` multiply and an `int64` divide on a 32-bit core, where each is several instructions and - a register pair. This is the most likely source of the 151 cycles. A 32-bit formulation, or a - narrower intermediate with a proven bound, is the first thing to measure. Precedent: the SWAR - work found the 32-bit pair form bit-identical and 41% smaller. -2. **Hoist `idx()`.** The loop addresses five neighbors per cell through `idx(x, y)`, each a - multiply-add. Walking row pointers instead is the standard fix and removes most of the address - arithmetic. -3. **Understand the allocation-placement effect above**, since it is worth more than most loop - tuning and costs nothing to trigger deliberately once understood. -4. **Solve the pressure at half resolution.** Pressure is smooth, so a half-scale solve with a - bilinear upsample of its gradient costs a quarter of the cells. Same trade `fieldScale` already - makes for noise fields, measured 3.0x there. Changes the picture slightly, unlike 1 to 3. -5. **Fewer iterations, documented per target.** Linear in cost: 5 to 2 is 2.5x, and the picture - gets springier. The card should say what a target can afford rather than leaving a user to find - 4 fps. -6. **Question whether the full solver belongs on this class of board at all.** See the ColorTrails - entry below: a separable noise advection gets a flowing, swirling picture for two passes over - the grid and no solve. The fluid's own header already calls it a desktop and P4 effect. - -**Why `fluid.mle` runs at 13 fps while the compiled effect runs at 3.** They are not the same -algorithm, and the script is not a faster fluid: it is not a solver at all. `fluid.mle` calls -`flowCurl`, which is curl noise, a divergence-free velocity read analytically from noise -derivatives in one advection pass, with no solve. Per frame at 128x128 the script visits ~16k cells -and divides nowhere; the compiled solver visits ~429k, of which 318k carry the 64-bit divide. That -is 27x the work, and the measured gap is only 4.3x because the interpreter gives most of it back in -dispatch overhead. So a COMPILED curl effect would beat both. What curl cannot do is what a solver -does: no pressure, no interaction between jets, and no vortex forming out of the flow's own -history. Whether that is worth 27x is a question for the product owner's eyes. - -**Measure on hardware, not on the desktop**: this is an in-order-core property and the desktop -divides in hardware, which is exactly how the wrong diagnosis above survived a desktop check. -Record before and after in performance.md per target. - -## Drivers - -### A control change reaches a driver at the frame boundary (2026-10-21, from the PR 150 thread audit) - -With the render split on, a driver's `tick()` runs on the encode task while its `onControlChanged`, `onCorrectionChanged`, `tick20ms` and `tick1s` run on the main loop, and only structural changes and `setEnabled` park the encode task first. -An audit of `src/light/drivers/` found the main loop writing state the encode task reads: `DriverBase`'s `correction_` (rebuilt on a control change, ramped by `tick20ms`, `motionHeld` set by `tick1s`), `effectiveBrightness_`, the `wire_` buffer a channel-count change reallocates, the `start` and `count` window; HueDriver's pairing and light tables; NetworkSendDriver's `dest_` addresses on the inline send path and its `corrected_` buffer; PwmLightDriver's `lights_` and `duty_`; RmtLedDriver's and ParallelLedDriver's whole geometry through `parseConfig` and `reinit`, ParallelLedDriver calling `parseConfig` and `setStatus` from the encode task itself; RtspDriver's and ParallelLedDriver's `setStatus` from `tick()`; PreviewDriver's `downscale_` and `keptIdxAllocFailed_`. -The textbook move is the one live reconfiguration already promises, a change applied on the next frame: the main loop records the change, and `Drivers::tick` applies it at the frame boundary, after `quiesceEncode()` and before it notifies the worker, so a driver's state has one writer at a time. The thread sanitizer lane reports none of these today, since no test changes a control while the encode task runs; the first test that does pins the move. -The same pass covers ParallelLedDriver's `peripheral` swap, which disengages the render split through the quiesce hook and, as `peripheral` is not in its `affectsPrepare`, leaves it off until another prepare runs. - -### An identity correction is one copy (2026-10-14) - -Every driver runs `Correction::apply` on every light, even when the correction changes nothing: brightness 255 (global and local), the linear curve, a fixture profile whose channels match the layer's, and no dimmer, white or motion channels. -The hottest-path rule says that case is a copy: `Correction` knows at rebuild whether it is the identity, and a driver that sends its buffer whole (Network Send, the panel cards) copies the layer's bytes instead of looking each one up. -The default CIE curve is not the identity at 255, so the skip applies only with the linear curve. It applies to every driver once the sender already sends the output's byte order; network and panel drivers then send one copy, while LED strips still encode bits but drop the per-byte lookup and reordering. - -### Per-die color trims, white balance on the output (2026-10-19, from #139) - -A trim per LED die corrects a strip's color cast: `balanceRed`, `balanceGreen` and `balanceBlue` turn a die down, and `whiteLevel` trims the white die of an RGBW strip, which the RGB trims cannot reach. [#139](https://github.com/MoonModules/MoonLight/pull/139) built it folded into the same table fill as the brightness curve, so the hot path stays one lookup per channel. It is output calibration for every board, so it is a topic of its own, decided when a strip with a visible cast is on the bench. - -### Drivers' palette and fixture-profile lists, sent once rather than in every view (2026-10-13) - -Drivers is the largest root in the state, 35 KB of the Teensy's view where Services is 10 KB, and two lists are 25 KB of it, mostly repetition. -The `palette` options carry 66 palettes with 16 hex colors each for the dropdown's swatches, which change only with the firmware or a palette script: the state carries the names, and the page fetches the colors from a route the browser caches across reloads. -FixtureProfiles' `profiles` summary is 1.9 KB, and its `detail`, 14.5 KB, repeats each expanded row's editor fields, their name, type and bounds, for every profile: the row schema is sent once, as `optionSets` already sends the role names, and each row carries its values. -Together Drivers falls to about 12 KB, and the largest single control, the block a streamed state still needs whole, from 15 KB to a few. - -### LED output from a Raspberry Pi's own pins (robwomp, 2026-10-11) - -Suggested on Discord after the Teensy port: a Pi runs MoonLight today as the Linux desktop build, so its LEDs are reached only through the network drivers. -Driving a strip from the Pi's own header needs the platform backend that does not exist yet (`src/platform/rpi/`, the same prerequisite as the sensor item below), and a `LedPeripheral` behind it. -The routes differ by model: on a Pi 4 and earlier, DMA through the PWM, PCM or SPI peripheral, the way rpi_ws281x does; on a Pi 5 the header sits behind the RP1 chip, whose PIO blocks are the route. -SPI is the one path every model shares, one lane per SPI bus with each WS2812 bit encoded in SPI bits, and the place to start. -Portable GPIO libraries (WiringPi, libgpiod) are too slow for LED timing, so they serve buttons and relays, not this. -On a Pi 5, own PIO programs reach the hardware through the kernel's PIO driver, which piolib shows; Adafruit's PioMatter proves HUB75 that way, and HUB75 on a Pi is a second backend once this one exists. -The research, with sources: [the LED-driver analysis](leddriver-analysis-bottom-up.md#raspberry-pi). - -### Logarithmic brightness, and a power budget the device knows about (2026-09-02) - -`Drivers.brightness` scales the output linearly, and perceived lightness is not linear: the eye is -closer to logarithmic, so the bottom quarter of the slider spends half the power budget for a modest -visible change while the top half buys little and costs a lot. - -**Measured on MM-StadBeest** (LightCrafter 16, 1440 lights, through the new `power.mls` readout): - -| brightness | rail | current | -|---|---|---| -| 16 | 4.86 V | 0.9 A | -| 60 | 3.9 V | 4.3 A | -| 120 | (browned out) | | - -Current is roughly linear in the control value, so the usable range is compressed into the bottom of -the travel and the top of the slider is a power hazard rather than a setting. The board crashed -twice during this session at brightnesses a user would reasonably try. - -**Two pieces, and the second is what actually prevents the crash:** - -- **A gamma or log curve** from the control value to the output duty, so equal slider steps look like - equal brightness steps. The open question is WHERE: on the `brightness` control (every driver - inherits it, but the number then means something different to OSC, MQTT and every saved preset) or - in each driver's output stage (no meaning change, duplicated per driver). -- **A power budget from measured values.** A board that measures its input voltage or current dims when the voltage sags below a threshold or the current passes a limit (see the power-monitoring entry below). A board without sensing relies on safe mode and the fade-in, which bring it back reachable after any supply failure. - -NOTE the rail sag above is NOT the supply's fault: an LRS-350-5 delivers 60 A, and this browned out -at 4.3 A. Roughly 0.25 ohm of series resistance in the feed, so wiring and injection points, which -is worth measuring before tuning anything in firmware. - -**Measured again 2026-10-09**, full white on all four channels: 4.7 V at 0.5 A, 4.1 V at 9 A, 3.3 V at 18.4 A, held for minutes while USB powered the board. The feed is now about 0.08 ohm (2.5 m of 2 mm² speaker cable each way), about 26 W of heat at full load. Powered from the feed alone, the board hangs under that sag rather than resetting, since the chip's supply stays above the brownout level; raising that level to 2.98 V, the S3's level 3, still hung without a reset. Safe mode counts a power loss within a minute of starting, and the lights rise over five seconds, so two power cycles bring a hung board back dark and reachable (bench-verified). What remains is the device acting on what it measures: dimming when the input sags or the current passes a limit, on boards that measure. - -### SE16 / LightCrafter power monitoring: sense pins now free, module still to build (2026-09-02) - -Both boards carry voltage and current sensors, and MoonLight records the pins -(`MoonBase/Modules/ModuleIO.h`, board presets): **SE 16 V1 voltage GPIO 8, current GPIO 9; -LightCrafter 16 voltage GPIO 5, current GPIO 6**. - -Those pins were occupied by our LED driver's `clockPin`/`dcPin`, which looked like a hard conflict -until the meaning of those controls settled it: they are the **sacrificial** WR and DC lines -`esp_lcd` mandates to build an i80 bus, toggled harmlessly with nothing wired to them -(`MultiPinLedDriver::addBusControls`). Any free GPIO does, so they moved rather than the sensors: -SE16 to 16/17 (it has 4/16/17 spare, so its native USB on 19/20 stays free) and LightCrafter to -19/20 (its only spare pair, and it uses the UART bridge on 43/44 anyway). - -**A view exists:** `moonlive/services/power.mls` reads the LightCrafter's GPIO 5 and 6 with the board's own conversions and shows volts and amps, bench-read on the StadBeest (2026-10-09). It shows and does not act; acting is the power-budget entry above. - -**Still open:** `AnalogService` shipped (plan step 3) and is host-verified, so the consumer exists. -What remains is per board: `voltagePin`/`currentPin` in the two device definitions, "Power -monitoring" moving from `planned` to `supported`, a scale factor for each (the divider ratio and -shunt value, which are NOT in MoonLight's preset and need the schematic), and a reading checked -against a meter. - -Worth doing because it also gives the ADC seam a real bench rig: an on-board analog signal beats a -hand-wired potentiometer. NOTE the pin move is unverified on hardware, both boards being offline -when it was made: confirm LED output still works after the change before trusting it. - -### LightCrafter 16: questions for the maker (2026-10-07) - -The [LightCrafter 16 page](../../reference/hardware/lightcrafter-16.md) holds what the catalog and limpkin's pull requests say. -These are open, to ask limpkin: - -- The module part, the level shifter, the output voltage and the connectors. -- The power input: the connector, the voltage range, the fusing and the reverse-polarity protection. -- Which USB-C connector is the S3's own USB and which the serial bridge, and which chip the bridge is. -- Whether the S3's own port supplies 5 V to a plugged-in device, such as a MIDI desk. -- The RS-485 transceiver and the Hall current sensor parts. -- A schematic and a pin-layout image. - -The device model uses GPIO 19 and 20 as the LCD driver's unused signal lines, which are the S3's own USB data pins; that choice is unverified on hardware, and a USB desk on the board would need them. - -### MoonI80 streaming ring — 48×256 shipped; open instruments and cleanups - -The ring's two regimes ship and are wall-verified through 48 strands × 256 (12,288 lights): prime-only when the frame fits the pool, the clock-oracle lapping ring above it (the near-prime pool — the ISR encodes only `nSlices − ringBufs` slices per frame), with `ringAuto` deriving the geometry per config and `shiftOverclock` trading the fps ceiling against '595 shift margin. The mechanism lives in the code + the technical page; the design arc in `docs/work/past/plans/` (the MoonI80 plans, all marked). Open items: - -- **Last-8-panels white flash, the "44-46 flash" (OPEN: the lanes ≥ 32 encode path).** On the 48×256 wall, brief WHITE/bright flashes (not wrong colors) over an otherwise-correct image, confined to strands 40-47 (the last 8 panels), mostly panels 44-46, wandering within the last 8. **Brightness-gated: clean below 5, flashes at ≥5**: since brightness scales pixel values through a LUT, below 5 the frame collapses to near-all-zero bits, so the gate really means *the flash needs SET (one) data bits*. **The same physical wall runs clean on hpwit's driver, so it is OUR driver, not the hardware.** **A live pin reorder located it:** the reorder moved the strands and the flash stayed with lanes 32 to 47, so it is the lanes ≥ 32 (`maskHi32`) encode path under the wall's exact active set, not the last '595, GPIO 17 or the latch bit. Ruled out by live hardware tests (do NOT re-chase): (1) the encode source (PSRAM vs internal; the ISR-source staging fix did nothing, reverted); (2) the ISR/lapping regime (the flash is on prime-encoded rows too); (3) it being a tail-*rows* phenomenon; (4) shift-clock margin via `shiftOverclock` (already at the 20 MHz OFF setting, still flashes); (5) bus-bit latch adjacency (a `latchBitHigh` diagnostic moved the latch to bit 7, still flashes). The encoder passed a host fuzz over RANDOM masks (`ParallelSlots.h` byte-for-byte correct), which never hit the wall's trigger: next, reproduce on the host with the wall's real geometry, active set and content, starting at the 64-bit mask round-trip (`maskLo32 | maskHi32 << 32` in `ParallelLedDriver.h` against the encoder's re-split in `ParallelSlots.h`). All diagnostics from this investigation are reverted, and the tree is clean. -- **MoonI80 direct mode flickers below ~30 lights on a PSRAM frame buffer (OPEN, cause unproven).** Ten GRBW lights on an SE16 (S3, octal PSRAM, one strand) flicker continuously and periodically report `no LED output` — the dead-frame guard after 8 consecutive `busWait` timeouts, i.e. the transfer never signalled completion. Above ~30 lights it is clean, and **i80 and RMT drive the same wiring perfectly**, so it is not the encoder, the layout, the wire, or the strip. **Established:** moving the buffer from PSRAM to internal RAM makes it stop outright (bench-verified). **Not established:** why only small frames — the stall-to-frame ratio (2.9% at 10 lights), the wait budget (*more* generous at small sizes: 55x the wire time vs 2.8x at 1000 lights) and PSRAM alignment padding (zero; the frame is already 64-byte aligned) were all checked and all fail to explain it. Leading untested theory: a short frame gives the DMA no runway to prefetch through a PSRAM/cache-contention stall. **An internal-RAM fallback for small frames was written, measured and deliberately reverted** — internal RAM is the scarce pool, MoonI80 targets large fixtures, and a short strand is the i80 backend's job; a patch that spends scarce RAM to hide an unexplained cause is worse than the open bug. Next instrument: `loopbackTest` + `loopbackIntrusive`, which captures what the peripheral actually emitted and separates a corrupt frame from a stalled transfer (needs the RX jumper pin — the SE16 routes its LED outputs, so pick one that can read back). Full write-up in [lessons.md](../../work/past/lessons.md). -- **Ring bus init hard-fails instead of stepping down when `ringBufs` is raised past what RAM allocates** (wall went dark until the control was lowered again; the pool alloc steps down but a later allocation, likely the descriptor link list, does not). A control change must degrade, never dark the output. -- **~1-frame white/colored flash every ~5 s** seen at some configs — plausibly fixed by the frame-close latch word (a strand whose last data bit ended HIGH missed its reset that frame); soak-observe on the wall before closing. -- **Prime barrier fps cost.** The ring's prime holds a busy-wait until the previous frame's deterministic wire end (`waitWireDrained` in `primeRingRange` — the barrier that keeps the next prime off buffers the DMA is still draining; the frame's last slices lap into the FIRST buffers, so the prime hits them first and no counter sees the repaint). The wait is ~0 when the snapshot + render gap already span the wire, but on fast frames it serializes wire → prime and caps fps at 1/(wire + snapshot + prime). If the fps work wants that overlap back, the barrier can go finer-grained (per-buffer: buffer b is safe once the drain passes slice `b + nSlices − ringBufs`) — measure first. Do NOT drain-gate `done` in the ISR instead (deadlocks; wall-measured as flicker-then-"no LED output"). -- **Multi-strand loopback**: the instrument drives one `loopbackStrand`, so it is structurally blind to a multi-strand fault (it passed while the wall was visibly corrupt). -- **Shift-mode loopback host coverage**: nothing in the suite drives shift-mode loopback end-to-end through the mock bus — the detection gap that let two loopback bugs live unnoticed. (The stall bug itself is fixed: capture-first alloc + pool step-down; verified on two boards.) -- **Loopback teardown leak + heap fragmentation**: cycling the private-bus loopback drops ~80 KB internal and the heap stays fragmented (measured maxBlock 13–44 KB with 240–310 KB free). Capture-first + step-down made runs reliable despite it, but the leak itself stands — chase when the loopback is next touched. -- **Ride-the-live-ring loopback — parked**: built (`loopbackIntrusive` + the snapshot pattern hold) but an RMT-RX cannot capture on a GPIO the LCD_CAM is actively driving (reads 0 sym). Revival path: briefly output-detach just the RX pin for the capture window. -- **Diagnostic surface** (`ringDbg` incl. tw/ts/tp + sg/se, the dbg statics, the `kCyPerUs = 240` hardcode): kept deliberately through the tuning era; gate/remove when the ring fps work closes. -- **fps header reports the module TICK rate**, not the frame rate (`frameTime` is the real one); any fps claim predating 2026-07-17 reads with that in mind. -- **`ringAuto` "just works" — verify the geometry pick on smaller configs.** `ringAuto` (default on) derives `ringRows`/`ringBufs`/`ringPadUs`, and those manual knobs are already dev-only (`setAdvanced`, expert mode). The pick is wall-verified at 48×256; the smaller common configs (16×256 and below) still need a bench pass. If any has a strictly better geometry `ringAuto` misses, teach it that ONE *measured* rule (no per-effect/per-density heuristics — the flicker was never geometry). `ringAuto` itself stays visible as the recourse until this proves it always picks right, then it can go expert-only too. (The fork-join flicker that once blocked this shipped fixed — snapshot fork removed.) - -### PSRAM-at-shift-clock: verified NOT a viable lever for more lights/strand (research, 2026-07-16) - -A recurring idea is to "borrow from direct mode": direct mode streams a huge frame straight from PSRAM (2048 lights, clean; SE16 drives 8192 lights direct/whole-frame at ~19.5 ms), so could shift mode run a lower pclk and stream its whole frame from PSRAM too, trading fps for unlimited length? **Verified answer: no.** The shift pclk is bounded by the WS2812 waveform, not by the '595 and not by divider elegance: slot = 8 bus words / pclk, and *lowering* the clock lengthens T0H toward the max-white washout. The practical floor is the `shiftOverclock`-OFF rate, 20 MHz (T0H 400 ns — wall-verified; 16 MHz is already all-white), which barely dents the PSRAM demand. There is no shift pclk that is both slow enough to stream from contended PSRAM and fast enough to keep T0H under the 0-vs-1 threshold. - -The bandwidth arithmetic (datasheet-derived): DMA demand = bus-bytes × pclk. Direct 8/16-bit = 2.67/5.33 MB/s; shift 8/16-bit = **26.7 / 53.3 MB/s**. S3 OPI PSRAM (octal, 80 MHz DDR) is 160 MB/s *theoretical* but only **~40–84 MB/s sustained/contended** in practice (Espressif's external-RAM guide: DMA-to-PSRAM bandwidth "is very limited, especially when the core is trying to access external RAM at the same time"; PSRAM shares the flash cache region). So direct demand sits far under the floor (streams fine — proven), while shift 16-bit demand *exceeds* the ~40 MB/s contended floor and shift 8-bit sits inside the underrun zone once WiFi/HTTP/CPU cache traffic competes. Because WS2812 is one unbroken self-clocked stream, one FIFO underrun garbles the rest of the frame. This is **datasheet-consistent with**, and MEASURED to match controlled A/B (board B, same PSRAM/chain, only the clock varied: 2.67 MHz PSRAM drives, 26.67 MHz PSRAM never completes at any size) and the 2026-07-16 `forceRing` re-confirmation (whole-frame at 2880 stalls). **Proven:** the effect (PSRAM stalls at the shift clock, drives at the direct clock). **Not instrumented (needs a bench measurement if ever doubted):** the exact mechanism — contended-sustained-rate FIFO underrun vs PSRAM read latency vs cache/MMU contention — was inferred from the clock being the sole variable, never isolated with underrun/bandwidth counters. - -**Conclusion — this does not open a new path; the proper ring fix already is the path.** The internal-RAM footprint of the ring is NOT set by light count: the ring transposes from a PSRAM-resident source into a small fixed internal buffer pool, so PSRAM is never on the DMA's read path at all. The 240-light wall is the `kRingBufs=16` no-reuse stopgap (the wrap read-while-write race), NOT the ring's design — and "more buffers" is a confirmed dead end. The shipped ring (above) holds internal RAM constant at arbitrary light count, which is exactly the "unlimited lights/strand" the PSRAM-hybrid idea was reaching for — obtained the correct way, at the mandatory shift clock, without PSRAM on the read path. **Action: none — the ring shipped; the "lower shift pclk + PSRAM whole-frame" hybrid is closed as physically blocked and should not be re-attempted.** (If the mechanism is ever contested, the one bench measurement worth doing is registering GDMA underrun/FIFO-empty counters at 26.67 MHz whole-frame-PSRAM to distinguish underrun from latency — but it would not change the conclusion.) - -### RmtLedDriver's out-of-memory status has no test, and cannot have one yet (2026-09-09) - -`frameUnusable_` reports a frame buffer the driver could not get (or, on classic, one that landed in -PSRAM where the refill cannot read it), and two guards keep that error from being overwritten by the -resting "driving N of N" status. All of it shipped unpinned, and not by oversight: the desktop -`allocInternal` is a flat `trackedAlloc` that never refuses and ignores `setTestMaxAllocBlock`, and -`ptrIsPsram` is hard-coded false. A test written against that would pass whatever the driver did. -`unit_JsonSink_overflow` shows the trap: it grows until a 64-bit host might refuse and accepts "no -refusal" as a pass, so it tests nothing on a machine with RAM. - -**What it takes:** a cap inside the desktop `allocInternal` that `setTestMaxAllocBlock` (or a new -`setTestInternalCap`) drives, so a test can make the frame allocation fail deterministically, plus a -`setTestPsramFrom(ptr)` seam so the PSRAM branch is reachable off-target. Then three cases: the -status appears, `prepare()` does not replace it with "driving N of N", and `reinit()` does not clear -it (the bug the bench found: the error retracted microseconds after it was set). A platform change -with its own review, which is why it is here and not in the change that shipped the status. - -### The ring's memory readout counts one buffer, not the pool (CodeRabbit, 2026-09-09) - -`ParallelLedDriver::driverHeapBytes()` derives its DMA figure from `peripheral_->busCapacity()`, -which is `st->cap`: the size of ONE buffer. It then adds a second when `busBuffer(1)` exists, which -is right for the double-buffered path and wrong for the ring, where `createRingState` allocates -`ringBufs` slices (up to 30 at the 48x256 geometry) plus the shared zero-pad. So a ring board -under-reports its DMA memory by roughly the buffer count, on the card that exists to make exactly -that number visible. - -Not a correctness bug: the memory is spent either way, and nothing sizes an allocation from this -value. It is a reporting bug in the one readout a user consults before adding lights. - -**What it takes:** an aggregate size on the peripheral (the ring knows `ringBufs * bufBytes + pad` -at creation) rather than arithmetic in the driver, which cannot see the pool. Wants a ring-mode -accounting test alongside, since the existing ones only cover the non-ring path. Touching -`createRingState` means a bench pass on the wall, which is why this is its own change. - -### MoonI80 ring — boot / first-frame-after-rebuild trips a transient give-up status (2026-07-16) - -A cosmetic residual left after the rebuild-wedge fix (below): on boot, and for a beat after any shift-ring rebuild, the driver shows **"output stalled"** even though `wireUs` reports live completions — the *first* frame after a fresh ring build occasionally misses its completion window and trips the dead-frame give-up before the ring settles, so the stale error latches until the next interaction clears it. It is NOT the old wedge (that stayed dead until reboot; this self-clears on any control edit and the ring is genuinely driving underneath). Two clean fixes to weigh: (a) don't count the very first frame after a rebuild toward `deadFrames_` (give the fresh ring one grace frame), or (b) have the give-up retry re-derive status the moment `wireUs` shows a real completion. Low priority — the LEDs drive correctly; only the status string is briefly wrong. - -### MoonI80 whole-frame async double-buffer corrupts output on the ESP32-S31 (2026-07-23) - -`doubleBuffer` (default on) runs the async deferred-wait path (`tickAsync`): encode frame N+1 into buf[1] while the GDMA clocks frame N out of buf[0], costing `max(encode, wire)` per tick instead of `encode + wire`. On the **MoonI80** backend (our own GDMA below esp_lcd) this is a genuine speed win and is **clean on the S3 and P4** — bench-verified on a P4 driving a 64-light strip on Parlio-adjacent pins, and it's what lifted the P4 whole-board rate 48→76 fps. **On the ESP32-S31 it flickers** (corrupted/torn frames), on **any** pin — bench-isolated on GPIO 60 *and* GPIO 42, so it is NOT pin 60 (the onboard WS2812) and NOT the ring (the ring path is unreached here: expander off → `wantsRing()` false → whole-frame). Turning `doubleBuffer` OFF (single-buffer `tickSync`) is clean on the S31. So the fault is **chip-specific**: MoonI80's hand-rolled async alternation reprograms the GDMA link list per frame and relies on GDMA EOF-in-start-order semantics + a fixed DMA→FIFO settle delay (`esp_rom_delay_us` before `lcd_ll_start`), both tuned on S3/P4; the S31 is a newer RISC-V chip whose GDMA/LCD_CAM revision evidently differs (EOF timing or settle requirement), so the async path displays a still-in-flight or half-encoded buffer. **This is bench-verification territory** — do NOT "fix" it from code reading (the whole GDMA/ring subsystem is bench-proven and code-read corrections to it have been wrong before); it needs a logic-analyzer / GDMA-underrun-counter pass on the S31 to find the exact EOF-timing or settle-delay difference. **Decision (PO, 2026-07-23):** leave `doubleBuffer` default-on (it is a real, correct win on i80 / Parlio / MoonI80-on-S3/P4 — confirmed all three peripherals genuinely honor the second buffer, NOT i80-only), and backlog this. Two fixes to weigh when picked up: (a) **chip-gate** — MoonI80's whole-frame `busInit` declines buf[1] on the S31 only (`if constexpr (platform::isEsp32S31)` → falls to clean `tickSync`), preserving the async win everywhere it works and losing nothing on the S31 (async never worked there); or (b) **root-cause** the S31 GDMA timing so async works there too (recovers the S31 speed, more bench work). Workaround today: on the S31, MoonI80 with `doubleBuffer` OFF, or use `i80`/`Parlio` (both clean with double-buffer on the S31). - -### Graceful blank-on-stall — a stalled bus should DARKEN the strip, not leave it lit with garbage (2026-07-15) - -When the bus stalls mid-frame the WS2812 strip is left holding **random / max-brightness lights** (often all-white — the all-ones failure pattern) that only a **power cycle** clears. That is a robustness gap: WS2812s latch their last received color and hold it until re-clocked or power-cycled, so a frame that dies mid-stream leaves every light past the failure point stuck bright. The give-up guard today stops *spending the render thread* on a dead bus (correct) but does nothing about the *strip's* state, so the user sees a wall of garbage LEDs and reaches for the plug. - -**The fix: on give-up (and on a rebuild that SHRINKS the reachable range), clock out ONE clean all-black frame** — every lane LOW → every light receives 0,0,0 → the strip goes dark. This turns "stall = a wall of random bright LEDs until power-cycle" into "stall = strip cleanly dark," which is the honest *degraded, not crashed* state the *[Robustness](../../explanation/architecture/moonmodule.md#robustness)* rule asks for. It also covers the PO's specific case (drop `ledsPerPin` 256→128 and the abandoned 128–256 range stays lit): a full-length black frame on the shrinking rebuild blacks the whole physical strip once, no boundary to compute. - -**The load-bearing caveat:** if the bus is wedged *because it cannot complete a transfer*, a black frame may not clock out either — so this is a best-effort **attempt**, not a guarantee: try the black frame on give-up; if it clocks, the strip darkens; if the DMA is truly dead, we are no worse off than today (and the rebuild-wedge fix above is the real cure for *that* class). Note it must be a genuine transmitted frame (all lanes driven LOW through the normal encode+transmit), not merely zeroing the DMA buffer — the strip only changes on a clocked frame. Pin it with a test: after `kDeadFramesBeforeGiveUp` dead frames, the driver emits one all-zero frame through the transmit seam (the mock asserts a zero frame was handed to the bus), and a subsequent recovery resumes normal content. - - -### Classic-ESP32 shift-register ring on raw I2S — the high-light-count classic driver (WANTED) - -**The PO wants shift-register output on classic ESP32 boards at the MAXIMUM light count**, which the current whole-frame `I80LedDriver` (esp_lcd i80 → I2S on classic) cannot deliver: its frame buffer must be **internal RAM** (the classic I2S DMA cannot read PSRAM — IDF rejects it outright), so it scales against a ~76 KB wall and caps at **~2048 lights**. Above that, classic needs the **hpwit refill-ring model**: a handful of tiny internal DMA buffers (~1.2 KB total, light-count-independent) refilled by the I2S EOF ISR, which transposes the next pixel row out of a **PSRAM** framebuffer as it goes. The DMA never touches the source array — only the CPU/ISR does — so the source lives in PSRAM and internal RAM stays constant. This is how hpwit (and WLED-MM/MoonLight on his driver) reach **48×256 ≈ 12K lights at ~100 fps on classic silicon with WiFi up**, with the same '595 expander. - -**Why it must be a SEPARATE driver, not a flag on `I80LedDriver`:** `esp_lcd` owns the classic I2S DMA and only does whole-frame, so the ring cannot be bolted onto the esp_lcd path — it needs a second classic driver written on the **raw I2S registers** (`i2s_ll` / the LCD-mode register file directly, below esp_lcd, the way the S3/P4 MoonI80 backend sits below esp_lcd on LCD_CAM). Its ISR is small enough to fit the classic's ~70 KB IRAM (hpwit's does), and — the key move — it registers the interrupt **without** `ESP_INTR_FLAG_IRAM` (his source comment: removed "to avoid Cache Disabled but Cached Memory Region Accessed") so the refill ISR is legally permitted to read the PSRAM framebuffer. The trade it accepts vs. our whole-frame path: it **gives up the whole-frame path's WiFi-underrun immunity** (a WiFi burst that starves the refill ISR can glitch a frame), which the ring mitigates with a tunable buffer-count cushion (`nbDmaBuffer`, hpwit's default 6). For a ≤2K-light WiFi-busy install the whole-frame i80 is still the better choice; the ring is for the >2K-light case classic cannot otherwise reach. - -**Reference (study, don't copy; write fresh against our architecture):** the line-by-line source read is in [led-driver-psram-ring-analysis.md](led-driver-psram-ring-analysis.md); the framing is [MoonI80Peripheral](../../../src/light/drivers/MoonI80Peripheral.h) (which calls the internal-RAM-ring-with-CPU-refill "the only thing that can ever work on the classic ESP32," deferred to a phase 2). The S3/P4 MoonI80 ring is the closest in-tree prior art for the ring mechanics (linear self-terminating chain, per-drain refill, drain-count termination), but its refill is a task and its buffers are internal-only *because the LCD_CAM GDMA can't sustain a PSRAM read at the shift clock*; the classic I2S ring is the inverse (PSRAM framebuffer legal, ISR refill mandatory), so it borrows the *shape* but not the constraints. Do the S3/P4 **ISR-refill + `MM_HOT`** work first (it proves the ISR-refill pattern in-tree on the friendlier unified-DIRAM chips); the classic raw-I2S ring is the next tier up, reusing that pattern where IRAM is genuinely tight. - -**Why MoonI80 cannot serve the classic, and what this driver inherits (2026-09-06).** `MoonI80` -is written against **LCD_CAM**: it drives the GDMA link list and the LCD registers directly -(`gdma_link_*`, `lcd_ll_*`) to bypass `esp_lcd`'s per-transaction peripheral reset. The -classic ESP32 has no LCD_CAM at all; its i80 is the **I2S** block in LCD mode, a different -peripheral with its own register file (`i2s_ll_*`) and its own DMA, so none of MoonI80's code -applies and `MoonLedDriver::lanesAvailable()` reports `platform::lcdLanes`, which is 0 there. The -picker hides the backend rather than gating it, which is why the classic has exactly one parallel -route today: `esp_lcd`'s I2S backend, whole-frame, internal-RAM-only, capped near 2048 lights. -This driver is the second route, and it stands in the same relation to `esp_lcd` on I2S as MoonI80 -does on LCD_CAM: same shape, no shared code. - -Two things it inherits from the 2026-09-06 i80 work, both worth keeping. It should claim **I2S -instance 1** and leave 0 for audio, for the reason recorded in the instance-split entry above -(instance 0 alone carries the PDM converters, nothing needs 1), and owning the peripheral directly -it can simply ASK for instance 1 rather than steering `esp_lcd` by parking instance 0, which is the -workaround the current backend needs. And it inherits the package-aware pin refusal: a pin the -package lacks wedges the flash cache silently, which cost a full day of bisection on the -ESP32-PICO-V3-02. - -### P4 Parlio streaming ring — lift the P4 Parlio ceiling past ~21K to light-count-independent (WANTED) - -**Port the ring concept to the P4 Parlio path**, to drive far more than its current whole-frame ceiling. troyhacks' MoonLight Parlio driver reaches **~21K LEDs RGB (~16K RGBW)** at 16 lanes — but NOT by materialising the whole encoded frame: he stages into a **fixed ~512 KB PSRAM buffer** and DMAs it out in **64 KB chunks** (`max_transfer_size = 65535`), so the DMA never needs the whole frame contiguous. That is the SAME idea as our MoonI80 ring, applied to Parlio on the P4 (where — unlike the S3 shift clock — the DMA *can* sustain PSRAM reads at the WS2812 rate). His ceiling is a *chosen buffer size*, not a hardware wall, so it caps at ~21K. - -**Two tiers, in order:** -1. **Match him (moderate):** chunk our Parlio DMA the way he does (bounded PSRAM staging + 64 KB bursts) instead of one contiguous encoded frame — this alone lifts our P4 Parlio ceiling to his ~16–21K. Our Parlio path already uses PSRAM; the change is the chunked-DMA transfer shape, not a new architecture. -2. **Beat him (the ring):** run our MoonI80 **streaming ring** on P4 Parlio — small internal buffers, refilled per drain from a PSRAM source, holding internal RAM *constant* regardless of light count (the hpwit model our ring already implements). troyhacks' 512 KB buffer is still *bounded*; the ring is not, so this goes past ~21K to light-count-independent. It reuses the ring mechanics we already have (linear self-terminating chain, per-drain refill, drain-count termination); the P4 Parlio backend gets a ring variant beside its whole-frame path, the way MoonI80 has both. - -**Prerequisite / sequencing:** tier 2 wants the **ISR-refill + `MM_HOT`** work done first (same as the classic ring above), since a high-rate Parlio refill benefits from the ISR-grade determinism. Confirm the CURRENT P4 Parlio tested ceiling first (re-measure on the P4 bench) before claiming a head-to-head — the ~21K figure is troyhacks' *buffer-fit* limit, not a verified-on-wire number, and his code steps the clock down at 256/512 LEDs per lane (signal integrity at long strands), which our own measurement must account for. Reference: the parlio source is `src/MoonLight/Nodes/Drivers/parlio.cpp` in the predecessor; our Parlio backend is `platform_esp32_parlio.cpp` + `ParlioLedDriver.h`. - - -### Multicore Step 2b — ping-pong second output buffer (deferred, workload-gated) - -The shipped render↔encode split (Step 2a, `multicore` control) uses one `Drivers::outputBuffer_`, so the composite is serialized at the boundary. A **second** output buffer would overlap even that (core 0 composites into B while core 1 encodes A), at the cost of one full frame buffer (~48 KB at 16K lights). The read-only `stall` KPI was built as the trigger metric, and it splits cleanly: a **heavy effect** (render-bound) shows `stall` ~1 µs (S3) / ~15 µs (classic) — 2b recovers *nothing*, core 0 already fills the encode window; a **light effect on many lights** (output-bound — a solid color or slow gradient across 16K lights) shows `stall` 6–11 ms — 2b recovers that whole wait. So it's a real but narrow win: **gated on the product owner naming that light-effect/many-lights workload as worth the 48 KB**, not on a generic fps gain. (Design context in the shipped Plan-20260713 - Multicore Step 2.) - -### Frame pacing — decided against (record) - -MoonLight targets a fixed 60 fps; MoonLight deliberately does not (settled with the PO 2026-07-12). The architecture is *render-uncapped + time-aware effects* (`beatsin8`/`millis()`-driven, a CLAUDE.md hard rule), so a whole-engine fps cap is redundant with that rule and would only *reduce* quality below the hardware ceiling; the LED wire rate already paces render physically (30 µs/light), and UI/WiFi responsiveness comes from the per-tick `vTaskDelay(1)` yield, not frame-rate control. Parked as a ~15-line opt-in (`targetFps=0` = unlimited default) *only if* a genuinely CPU-starved device ever appears. - -### Brightness belongs on a fixture's DIMMER channel, not only in the color values (WANTED) - -`Correction::apply()` holds a profile's `Dimmer` channel wide open at 255 and puts brightness into -the R/G/B/W values through `briLut`. That is correct output and it is the only rule that serves -every fixture (an addressable strip has no dimmer channel), but it is not how a lighting console -drives a fixture that HAS one, and it costs real quality: - -- **Resolution.** At 10% brightness the colors are driven at 0-25, about 25 usable levels instead - of 255. A slow fade to black steps visibly; the dimmer channel exists so colors keep full 8-bit - range while intensity varies. -- **Dimming quality.** A moving head's dimmer is often 16-bit or curve-corrected for the LED's - response. Linear scaling of 8-bit color values is the crudest dimming there is, and it is worst - at the low end where the eye is most sensitive. -- **Matching.** A PAR with a real dimmer and a strip without one, both at 20%, do not read as the - same brightness: one dims in the LED driver, the other in integer arithmetic. - -**The rule to implement:** when a profile declares a `Dimmer` role, route the driver's brightness to -that channel and drive the colors at full saturation; keep today's behavior (scale the colors) -only where the fixture has no dimmer. The per-light color values still carry the effect's own -shading, so an effect that paints one light dark stays dark. Watch the one case where fixture and -light are not 1:1: several light cells behind ONE master dimmer cannot express per-cell brightness -through it, so those keep the color-scaling path. - -Found on the bench 2026-08-28 wiring the first moving head. Note the dimmer was not written AT ALL -before that day (the shipped `IRGB` profile could never light a fixture); writing it at 255 is the -fix that made a fixture light, not the finished design. - -### Blending adds motion channels, which is meaningless for aim (WANTED) - -`blendMap` treats a light as `n` opaque bytes, so ADDITIVE blending sums pan and tilt across -layers, and the separable modes (multiply, screen, darken, lighten, difference, exclusion) combine them the same way. Two layers driving one moving head aim it at neither position, and saturate to hard-over -as soon as both are moderately positioned. - -Only the combining modes are wrong. **Opacity blending on motion is a feature and must be kept**: it is an -interpolation, so a layer fading in sweeps the head smoothly from the old aim to the new one, -which is what a console does and better than a snap. The rule (architecture.md) is that -interpolating ops are valid on every channel while accumulating ops are valid only on emissive -ones, so the fix is narrow: in the additive and separable paths, ASSIGN motion channels (topmost writer wins) -instead of combining them, leaving the opacity path alone. Lighting consoles call the same split HTP and LTP: intensity merges highest-takes-precedence, attributes latest-takes-precedence. A discrete attribute such as a gobo slot does not interpolate either, so it snaps rather than fades. - -Not yet reachable in a harmful way: it needs two enabled layers on a fixture carrying motion -channels, and the single-layer path is a memcpy. Worth doing with the motion-writer work, since -both need `FixtureChannels`' offsets on the blend path. - -### Zoom, rotate and gobo have no writer (WANTED) - -Pan and tilt now have a writer: an effect sets them through `EffectBase::setPan`/`setTilt` and -`Correction::apply()` maps the layer slots onto the fixture's channels (`MovingHeadEffect` is the -worked example, bench-driven). **`Zoom`, `Rotate` and `Gobo` still have none**: a profile can map -them and `Correction` carries their offsets, but no effect writes them, so they sit at 0. - -This is the other half of driving a moving head, and it is a domain question, not a plumbing one: a -light is a point with a color, while a moving head is a fixture that emits a BEAM in a direction it -controls live. The backlog's fixture-model item ("moving heads, beams", per-emitter targets) is -where the model belongs; this entry records the concrete gap in the meantime. Bench fixture and its -channel map: [light fixtures reference](../../reference/hardware/light-fixtures.md). - -### Pan/tilt travel is hardcoded, and positioning is 8-bit (WANTED) - -Two fidelity gaps in how an aim becomes a real beam, both found on the bench 2026-08-29 reviewing -the preview's beam cones. - -**Travel is assumed, not declared.** `EffectBase` and the preview both hardcode *540 degrees of -pan, 180 of tilt*, which is the common mid-size convention and happens to match the bench fixture. -Real heads vary: 630 pan on larger bodies, 360 or 530 on compact ones, 270 tilt on beam fixtures. -Nothing declares which fixture is plugged in, so the preview draws every rig with the bench head's -travel and silently lies about where a beam points. Zero point varies too (some fixtures put -mechanical zero at DMX 0 rather than centering at 128), and the axis direction is a setting on the -fixture's own display (`rPAN` / `rTIL` on the bench head), so even one fixture has no fixed mapping. - -**The DMX byte is already the right abstraction, and it is why nothing can over-rotate.** An effect -never emits an angle: `MovingHeadEffect::axis()` maps its sweep onto a 0..255 byte and clamps, so -0 and 255 *are* the mechanical stops whatever they are in degrees, and the head cannot be asked -past its travel. A sweep that reaches a stop flattens there (the beam parks) rather than wrapping, -which is what the fixture itself does. Degrees exist only for DRAWING. - -So the fix is scoped to the preview, and the open question is where travel is declared: - -- **`PreviewDriver` controls** (`panTravel`, `tiltTravel` in degrees, sent in the aim message) — - small, live-reconfigurable like every other setting, and honest that travel is a property of the - fixture someone plugged in. The leanest thing that stops the lie. -- **The fixture profile**: correct once two different heads run at once, but a profile is a - *channel-role* layout today, and carrying physical travel widens what a profile means. Belongs - with the [fixture model](#fixture-model-moving-heads-beams-long-term), not before it. - -**Positioning is 8-bit while the fixture offers 16.** The bench head has a fine channel for each -axis ([light fixtures reference](../../reference/hardware/light-fixtures.md)); both sit unused, so pan resolves -to 540/256 = about 2.1 degrees per step. Across a room that is a visible jump on a slow sweep, and -it is the bigger fidelity win of the two. Needs a 16-bit path from the effect's sweep through -`FixtureChannels` to the profile's fine-channel roles, so it is the larger job. - -### Built-in fixture profiles never reach a device that has a saved config (WANTED) - -`FixtureProfilesModule` seeds its built-ins only when the profile list is empty -(`if (count_ == 0) seedBuiltins()`), and the persisted list replaces them wholesale. So a newly -shipped built-in profile appears only on a device that has never saved one: every existing device -keeps the older set forever. Adding the mini moving head's profile on 2026-08-28 needed a hand-patch -of the bench device's `Drivers.json`, which does not scale past one board. - -**The fix:** merge by name at boot, seeding any built-in the saved list does not already carry, so -shipping a fixture profile reaches existing devices on their next update. A user's own profiles and -their edits to a built-in must survive that merge untouched. - -### ArtPoll discovery — know which tubes are alive (next increment on NetworkSendDriver) - -`NetworkSendDriver` now unicasts to a list of receivers (`ips` + `lightsPerIp`), which is the Art-Net-4-conformant model. What it cannot do is **tell whether a receiver is actually there**: UDP is fire-and-forget, so a dead tube is invisible to the sender. The spec's own answer is discovery — *"The transmitting device must regularly ArtPoll the network to detect any change in devices which are subscribed"* — and it is the natural next increment. - -**Why it earns its place (product-owner experience, 2026-07-13):** a dead tube in a prior setup *made the other tubes hiccup*. The mechanism is that a send to an unresolvable address stalls or errors **inside the frame loop**, delaying the packets for every destination after it — so one dark tube degrades the live ones, every frame. The send loop is now written to tolerate that (a failed `sendToAddr` drops that packet and moves on, and under `multicore` the whole send is off the render core), but tolerating is not the same as **knowing**: with discovery we can simply **skip destinations that haven't answered**, which removes the stall at its source rather than absorbing it. - -**Scope (~130–150 lines + tests; the receive half already exists).** `ArtNetPacket.h` already builds+parses, `UdpSocket` already binds and reports the sender's IP: -- **ArtPoll send** — broadcast a 14-byte poll every ~3 s (this is the one packet Art-Net *does* broadcast, by design). -- **ArtPollReply parse** — fixed-layout 239-byte reply: node name, IP, and its subscribed universes. -- **A node table** — IP → last-seen; mark offline after ~9 s of silence (3 missed polls, the spec's own cadence). -- **Use it:** skip offline destinations in the send loop; surface the live/dead list as a read-only status. **Bonus, and arguably the real prize: auto-populate `ips`** — the user stops typing addresses at all, which is how a professional controller behaves. - -Do it as its own increment. The multi-destination unicast it builds on has shipped. - -### RS-485 / DMX-512 wired output (future) — the physical-DMX driver - -MoonLight already speaks DMX **over the network** (Art-Net / sACN via `NetworkReceiveEffect`). The missing half is **wired DMX-512 out**: driving DMX fixtures (moving heads, par cans, wired pixel controllers) directly over an RS-485 differential pair, which is what the RS-485 hardware on carrier boards like the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) is *for*. DMX-512 is a 250 kbps async serial frame (a break + mark-after-break + 513 bytes: start code + 512 channels) shipped over RS-485 — the textbook fixture-control transport. A DMX driver would map the light buffer (or a fixture/attribute model — see the [Fixture model — moving heads, beams](#fixture-model-moving-heads-beams-long-term) item below) to DMX channels and clock the frame out a UART in RS-485 mode. - -**What it needs that we don't have yet:** -- **A `platform::` UART-RS485 seam.** The ESP32 UART has a hardware RS-485 half-duplex mode (`uart_set_mode(UART_MODE_RS485_HALF_DUPLEX)`) that auto-drives the transceiver's **DE/RE** (driver-enable / receiver-enable) line — the thing our current pin handling has no concept of (we drive pins as plain GPIO). A DMX driver is where DE/RE control first earns its place, and only for a **bidirectional** channel: firmware DE/RE toggling is what lets one channel switch Tx↔Rx without a hardware switch. A **fixed-transmit** channel needs none — its transceiver is hard-wired to drive. On the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) that split is physical: GPIO 4, 22, 24 are fixed-transmit (no DE/RE control wanted), and only the switchable GPIO 3 channel is bidirectional — the shield handles it with a *mechanical* slide switch (which is how its loopback works). Firmware DE/RE control is what a board would need to make a channel bidirectional *without* such a switch. -- **The DMX frame timing** — the break/MAB is generated by a baud-rate switch or a GPIO toggle around the UART frame; standard, host-testable as an encoder. -- **A fixture/channel-mapping model** — trivial for a dumb pixel-per-channel strip, real work for typed fixtures (pairs with the moving-head fixture-model item; a wired-DMX driver and a network-DMX(Art-Net) input would share that fixture model). - -**The channel-mapping half is now unblocked.** The per-light encode path handles an arbitrary channel count as of 2026-07-13 (the WS2812 drivers' per-light scratch is heap-sized to `outChannels`, no fixed cap — the fix from the multi-channel-preset bootloop, see [lessons.md](../../work/past/lessons.md)). A DMX universe is exactly that model: a light with `channelsPerLight = ` (16-ch moving head, 7-ch par, …), and the buffer's bytes ARE the DMX channel values. So a DMX driver's "map the buffer to channels" step is now the trivial part — it ships the light buffer's bytes straight into the 512-channel frame. What remains genuinely new is the **transport** (the RS-485 UART seam + break/MAB timing) and the **typed-fixture model** (naming which channel is Pan vs Dimmer — the moving-head fixture item), not the encode. - -**Can a board drive XLR fixtures directly? Yes, with an RS-485 transceiver — that's the one required part.** DMX-512 is RS-485: a *differential* pair (D+/D−, ±2–6 V), not the 3.3 V single-ended UART the MCU emits, so an MCU TX pin can NOT wire straight to XLR. A transceiver chip (MAX485 / SN75176 / THVD-class, ~$0.50) sits between the UART and the connector and drives the differential pair; the DE/RE line (the UART-RS485 seam above) flips it Tx↔Rx. **3-pin XLR** carries it: pin 1 = ground, pin 2 = D−, pin 3 = D+. With a transceiver present, daisy-chaining ~10 moving heads (10 × 16 ch = 160, inside one 512-channel universe) over standard DMX in→out is well within the RS-485 limits (32 unit loads / 1200 m); the last fixture wants a 120 Ω terminator (a fixture/cable concern, not the MCU). So whether a catalog board can drive XLR *directly* hinges on one schematic question: does it carry an RS-485 transceiver + XLR/terminal (then yes, direct), or only the WS2812 level-shifted outputs (then a ~$0.50 breakout is needed). Confirm against the [MHC-WLED ESP32-P4 shield](../../reference/hardware/mhc-wled-esp32-p4-shield.md) schematic before treating direct-XLR as a shipping capability. - -Sequencing: it's a **driver** (`src/light/drivers/`) + a platform UART-RS485 seam + a fixture model shared with the Art-Net path — the buffer→channel encode is already done. Plan when a DMX fixture is actually on the bench and a catalog board's `supported`/`planned` list points at wired DMX. The [PinsModule pin-assignment work](backlog-core.md#pinsmodule-strict-reject-on-add-mode-the-one-remaining-increment) covers the RS485/DMX TX/RX/DE slot; this is the driver that consumes it. - -## Integration with other LED and visuals tools - -Distilled from a Discord thread with panel-card users (2026-08-24), where two people drove ColorLight walls from MoonLight and described the pipelines they already run. - -### Preview does not resume after a long tab hibernation (observed once, 2026-08-27) - -Desktop instance at 1024x1024: after the browser tab sat backgrounded for about an hour, the -preview pane stayed static on return. PreviewDriver ticked at ~1 us (no standing request served) -while a FRESH WebSocket client received frames normally, and the Drivers card showed the -encode-worker-stalled latch. A page refresh reportedly did NOT revive it; toggling `multicore` -(a Drivers re-prepare) did. Suspects: the tab-hide hibernation path's tracked retry vs the -wake-up re-request, or per-driver lease state that only prepare() resets. Needs a reproduction -with the WS uplink logged before it can be fixed. - -### One encode, two readers: HLS and RTSP sharing the encoder (2026-09-22) - -Both video drivers call `platform::encoderStart`, and the encoder is a single instance on every target: one ffmpeg child on the desktop, one hardware session on the P4. The second caller used to reconfigure the first's stream silently, so HLS kept serving at RTSP's geometry and bitrate without saying so. **Shipped instead: a claim**, so the second driver is refused with a visible status, the way a driver already reports a port another module holds. That makes the conflict impossible rather than unlikely, and it costs a guard. - -The better end state is the one the RTSP plan describes and the P4 already half-implements: **one encode, two readers**. `rtspTakeFrame` and `hlsSegment` read the same encoded frame today, so a device serving both would encode once and each reader would take what it needs, which is cheaper than either driver running alone twice over. - -What makes it a design task rather than a patch: the two drivers must **agree** on geometry, fps and bitrate, and nothing makes them agree now. HLS derives its bitrate from the grid and picks an encoder by name; RTSP takes the frames raw and names no encoder. The open questions are whose configuration wins when both are enabled, what happens when one changes `targetFps` mid-stream, and whether a reader that stops should relax the shared settings. A refcount alone answers none of those, which is why the claim ships first. - -Build trigger: someone wanting both streams from one device at once. Until then the claim is the honest behavior, and the refusal message names the other driver. - -### HLS upscaling is cache-hostile on large walls (measured, 2026-08-28) - -`HlsDriver`'s `scale` control replicates each light into a scale x scale block. Measured on the -bench P4 at 128x128: **~1 ms per frame at 1:1, ~60 ms at scale 4** (a 512x512 output). The frame -is 16x larger, so ~16 ms would be the honest cost; the extra 4x is the access pattern. The loop -walks LIGHTS and writes each block as `scale` separate short rows scattered across a 786 KB -buffer, so consecutive lights touch distant addresses and every write misses cache, where the -1:1 path writes straight through sequentially. - -**Not urgent, because the default path never hits it:** auto-scale only engages on walls below -the encoder's 80-pixel floor, where the output is small by construction (a 20x10 wall becomes -160x80, 38 KB). The expensive case is a manual scale on an already-large wall, which is also -where upscaling has the least to offer. - -**The fix when it earns its place:** iterate the OUTPUT rows rather than the input lights, so -writes are sequential: for each output row, walk its source row once and emit `scale` copies of -each light's color, then `memcpy` that finished row to the remaining `scale - 1` rows of the -block. Same output, one pass through the destination in address order. - -### Sprite follow-ups (draw::sprite + FlyingToasters shipped; spec + plan in the plans archive) - -Deliberately deferred when sprites landed: P4 PPA acceleration behind the same `draw::sprite` -signature (the 2D-DMA blitter the WLED-MM-P4 world uses via LovyanGFX; ours would sit in the -platform layer, no vendored GFX library), Porter-Duff alpha when a real consumer arrives, and -MoonLive sprite data (needs the stage-3 builtin table + arrays). - -### MoonLight as a video source — NDI first, Spout/Syphon only if proven (open) - -Users asked for MoonLight's rendered output to feed *their* tools, not the other way round. One runs OBS → Spout → his own VLAN-tagged card driver; he asked whether MoonLight could be a Spout source. Input is not the gap: `NetworkReceiveEffect` already binds Art-Net, E1.31/sACN and DDP at once and answers ArtPoll, so any controller can already drive MoonLight. - -**NDI is the recommended first implementation.** It is the AV industry's standard for video over IP, one implementation covers Windows, macOS, Linux and ARM, it discovers by name, and it crosses machines. Spout (Windows, DirectX/OpenGL) and Syphon (macOS, Metal/OpenGL) share a GPU texture zero-copy, so they are lower latency and bit-exact, but they are **same-machine only**, are **two** platform implementations, and leave **Linux and the Pi with nothing**. At LED-wall pixel counts (a 256x256 wall is 65K pixels) the latency difference is far below one frame of the render loop, so it does not decide the choice; coverage does. A Spout user is also reachable through NDI in one hop, since OBS, Resolume and TouchDesigner all speak both. - -**The license shapes the design, and the shape is already established here.** MoonLight is GPL-3.0 and the NDI runtime is proprietary, so MoonLight must not *redistribute* it: bundling would require MoonLight's own license to carry NDI's restrictions downstream, which GPL-3 forbids. The user installs the NDI runtime themselves, exactly as they already install **Npcap** for the panel-card driver, and MoonLight calls whatever is present. - -That is the arrangement `platform_desktop.cpp` uses for Npcap today: resolve the library with `LoadLibrary`/`dlopen` rather than linking it, declare the handful of functions with the library's own signatures rather than including its headers (so the SDK never becomes a build requirement for CI or contributors), and report the feature unavailable when it is absent instead of failing to link. Two independent installs that talk to each other, like Resolume on the same desktop. - -Also note MoonLight renders into a CPU buffer, so a Spout/Syphon path would upload to the GPU purely to hand off, spending the zero-copy advantage it was chosen for. - -### M5Stack Tab5 as a display target — MIPI-DSI, not the H.264 path (open) - -The Tab5 is an ESP32-P4 with a 1280x720 MIPI-DSI panel, and a P4 is already a supported target, so -the question is what its *screen* would show. The P4's H.264 block does not answer it: that encoder -exists to compress an incoming MIPI-CSI camera feed, and the P4 has no hardware H.264 **decoder** -at all (Espressif's own FAQ points at software decode, which will not hold 720p). Driving the panel -is the **MIPI-DSI** peripheral plus the PPA / 2D-DMA blitter, which take raw pixels and never touch -a codec. So the three things a Tab5 could be are separate pieces of work, and only the first is free: - -- **An HLS source**, like any other P4: it encodes its own rendered grid and streams to a TV. Its - panel is incidental, and this needs nothing beyond the P4 HLS work itself. -- **A local wall preview or touch console** — the interesting one, and the real ask: a `platform::` - MIPI-DSI display seam plus a UI on the panel. Related to the PPA acceleration noted under sprite - follow-ups above (same 2D-DMA block), and it is a display *output* seam MoonLight does not have - today; the nearest prior art is the WLED-MM-P4 world's LovyanGFX usage, which we would not vendor. -- **An HLS/video player**, showing another device's stream: blocked on the missing hardware decoder, - so not worth planning. - -### Multi-card walls — does a daisy chain work today? (open, ask before building) - -The ColorLight format has **no card addressing in the PIXEL path**: the destination MAC is a fixed -constant and every card filters on it, so every card on a segment shows the same image. A user with -six cards on a switch observed exactly that. - -The DISCOVERY path does distinguish them. A discovery reply (0x08) carries a controller number at -payload offset 0x62, and the acknowledgement echoes it plus one, which is how a sender tells several -cards apart. Documented by a reader of [Harald Kubota's protocol -write-up](https://hkubota.wordpress.com/2022/01/31/winter-project-colorlight-5a-75b-protocol/) and -confirmed by its author. That is an identity, not a destination: it does not let a sender aim pixel -data at one card, so the same-image behavior above stands. It is what a per-card brightness or -color-temperature feature below would key on. - -The industry-standard answer is **daisy-chaining** — a sending card's ports each drive a chain, and each card takes its region by position in the chain. That user works around it with per-card VLANs and a managed switch instead, which he built for throughput and for per-card color-temperature grouping across mixed panel batches; he described it as his own solution, not a standard. - -**Establish first whether a daisy chain already works with MoonLight** (one contact has a 96K daisy-chained rig). If the cards self-assign by chain position, the standard multi-card case is already solved and nothing is needed. Only if it does not work is there a feature here, and it should follow the daisy-chain standard rather than the VLAN workaround. 802.1Q tagging is technically a clean fit for a raw-L2 sender (the tag is part of the Ethernet header, the switch strips it before the card, so card firmware is unaffected), but it serves one bespoke architecture. - -### Smaller asks from the same thread - -- **Read the wall layout from the ColorLight cards.** The cards can report their configuration and at least one user's own tool already does it; it would remove the manual layout step. -- **Per-card color temperature and brightness**, via the ColorLight sync-packet bytes, grouped by sync group — used to color-match mixed panel batches live. - -### Configure a ColorLight card without Windows (2026-10-16, asked) - -Asked on Discord by robwomp: a way to set up ColorLight receiving cards from a Linux command line, so a ready-made MoonLight image (a Pi running the desktop build with PanelCardDriver) could configure the card itself and make a card and its panels plug and play. - -Today the card's configuration (panel size, panel count, scan, driver IC) is written once with ColorLight's LEDVision, and its firmware with LED Upgrade, both Windows-only ([panel cards §4 and §7](../../reference/hardware/panel-cards.md)). The protocol for those writes is ColorLight's own and undocumented; the pixel and discovery frames are known from [Harald Kubota's write-up](https://hkubota.wordpress.com/2022/01/31/winter-project-colorlight-5a-75b-protocol/), the configuration frames are not. - -The route: capture what LEDVision sends while it writes a known configuration (Wireshark on the card's Ethernet), decode the frames, and replay them. [REA](https://github.com/morluto/rea) (MIT) does the decode step: its packet mode reads the capture through TShark and reports repeated values and field candidates, run from the terminal (`npx rea-agents …`) rather than installed into the agent's configuration. Its static .NET inspection could read how LEDVision builds the frames, if LEDVision is .NET and its license allows it. Then replay them from PanelCardDriver as a "configure card" action taking the wall's panel size, count, scan and IC. Reading the configuration back (the "read the wall layout" ask above) shares the same decoding. The firmware path stays with LED Upgrade: a bad write bricks a card, which a configuration write does not. Ask before building: it needs a card on the bench and a Windows machine running LEDVision for the capture. - -## Sensors and audio-reactive input - -### The sensors an installation needs (2026-09-01) - -**Why this is a commitment rather than a wish list.** One of the intended uses is art installations, -and an installation people can interact with has to sense them: that is stated in -[the architecture](../../explanation/architecture/index.md#the-problem). Sensing is therefore part of the product, not a -convenience, and an input peripheral is first-class alongside an output driver. The scope is still -narrow on purpose: a lighting controller that senses its audience, not a home-automation platform. - -What shipped, and what a piece can already react to: **audio** (AudioService: RMS level and a 16-band -FFT, I2S mic or line-in, or a peer's stream over the network), **IR** (IrService, a learnable -remote), and **a button** (ButtonService on the GPIO seam, which covers foot pedals in momentary -mode). An **IMU** exists on an unmerged branch (GyroDriver, below). - -The sensors worth having next, in the order an installation asks for them: - -- **Motion / presence (PIR)**: the single most common interactive trigger: someone walks up, the - piece wakes. Electrically a digital pin, so `ButtonService` almost covers it; what differs is the - semantic (a level, not a press) and the hold time. MoonLight models it as its own pin role - (`pin_PIR`, "HIGH = lights on, LOW = lights off"), which is the shape to follow. -- **Distance (ultrasonic HC-SR04, or a ToF like the VL53L0X)**: turns presence into a continuous - value: a hand's distance drives brightness, a visitor's approach drives an effect parameter. The - ToF is I2C, which today means SCANNING only: the register-level read and write a sensor needs is - the unmerged GyroDriver prerequisite (see input-mapping-analysis.md and the scripted-sensors - plan), not something that exists. The ultrasonic is a trigger pulse and an echo-width measure, - which needs a timing seam the GPIO one does not yet provide. -- **Touch**: the classic ESP32's capacitive touch pins need no external part, so a conductive - surface becomes an input. The Dig-2-Go's own button is on a touch-capable pin, though it reads - fine as a plain digital switch. -- **Light level (LDR or a BH1750)**: an installation that dims itself to the room, and the obvious - companion to a piece that runs day and night. -- **Rotary encoder**: the physical knob for an installation without a screen, and the one input - that maps onto ControlModule's encoder bank directly. - -**None of these need new architecture**, which is the point of recording them together: each is a -Service under the core `Services` container. How it reaches the rest of the system depends on what -it produces. A THRESHOLD is an event ("closer than 50 cm"), and an event drives a control through -`Scheduler::setControl`, exactly as the button and infrared rows do. A continuous VALUE is not an -event: an effect reading a distance per frame needs the number, and pushing one through a control -per sample would serialize a stream through a settings path. That is published as a shared frame -the way `AudioService::latestFrame()` already does, and a sensor typically does both. What they need is a -platform seam per sensing modality (the GPIO seam shipped; I2C exists; a pulse-timing seam does -not), and a module each. - -### Read a vehicle's CAN bus and drive lights from it (2026-09-11) - -A MoonLight device in a car, reading the vehicle's own bus and driving aftermarket lighting from what it sees: underglow that pulses with engine RPM, sweeps with the indicator, flares on the brake. The vehicle to develop against is a VW Transporter (the product owner's). - -**Read-only, and that is the design, not a limitation.** Writing to a vehicle's bus reaches systems that are mandated safety equipment (indicators and brake lights are UNECE R48), usually on a segment shared with ABS and airbags, and modifying them is illegal in most jurisdictions. Reading has none of that exposure and gets the interesting half anyway: the car reports its state, MoonLight renders it. Anything the device drives is aftermarket lighting on its own output, untouched by the vehicle. - -**A Service, not a driver.** It senses rather than renders, so it belongs under the core `Services` container beside AudioService and ButtonService, and it produces both shapes the sensors entry above describes: an EVENT (indicator on, brake pressed) drives a control through `Scheduler::setControl`, while a continuous VALUE (RPM, speed) is published as a shared frame the way `AudioService::latestFrame()` is. Effects then read it, the input-mapping layer routes it, and a MoonLive script reads it as a system variable, so "underglow sweeps with the indicator" is a script someone writes and shares rather than firmware. - -**Two tiers, and only the first is write-once.** - -| | Standard | What it yields | How | -|---|---|---|---| -| OBD-II / UDS | Yes, legally mandated (EU: 2001 petrol, 2004 diesel) | RPM, speed, coolant, throttle, engine load | Request/response against ID `0x7DF`, so polling, a few times a second | -| Manufacturer body frames | No, proprietary per model and year | Indicators, brakes, doors, lights | Passive sniffing and reverse engineering | - -J1939 is NOT the answer here despite being the "CAN lighting standard" people reach for: it is the heavy-duty protocol (trucks, agricultural, marine), and a Transporter is passenger-car architecture running VW's own frames. J1939 would only apply if MoonLight targeted commercial vehicle equipment. - -So phase 1 is OBD-II only, which genuinely works on any car and needs no per-vehicle data. Phase 2 adds raw-frame sniffing, and there the frame IDs must be CONFIGURATION rather than compiled-in constants: a Transporter owner who discovers theirs shares a config file, and no firmware release is involved. That is what keeps a per-vehicle feature from becoming a per-vehicle maintenance burden. - -**What it needs:** - -- A `platform::twai*` seam (init, send, receive, stop). TWAI is the ESP32's CAN controller and is present on every chip MoonLight ships: 1 controller on classic and S3, 3 on the P4. -- **An external transceiver**: the ESP32 has the controller but no CAN PHY, and this is the only part that needs buying. An SN65HVD230 board (3.3V native, unlike the 5V TJA1050) plus an OBD-II pigtail to reach pins 6 (CAN-H) and 14 (CAN-L), so nothing on the vehicle is cut. Watch for a termination resistor fitted on the transceiver board: the vehicle bus is already terminated at both ends and a third resistor disturbs it. Power the device from USB while developing rather than OBD pin 16, so a crash cannot load the vehicle supply. -- A `VehicleService` module, and a desktop stub so the logic is testable without a car. - -**Phase 1 is already confirmed on the van**: an ELM327 running Torque pulls PIDs from it today, so the OBD port is live, CAN is reachable there, and the standard request/response layer answers. Nothing further needs discovering before building the seam. - -**Phase 2 is the open question, and one free test decides it.** Torque and ELM327 adapters speak the FILTERED request/response layer: you ask for a PID and the ECU replies, which says nothing about what else is on the wire. Torque itself can do the test: it has a dedicated `canbus monitor` protocol (selected in the vehicle profile, beside a hybrid mode that polls OBD2 and monitors at once) and imports DBC files in the PID editor to decode signals out of the stream. Traffic beyond one's own polled PIDs means the body frames are reachable on that port; only one's own requests and replies coming back means they sit on another segment behind a gateway, so phase 2 needs a different tap point. **The constraint is the adapter, not the app**: monitor-all (`AT MA`) is in the genuine ELM327 specification and many clones omit it outright, which makes them fine for standard PIDs and useless for reverse engineering. An ELM327 identifier app probes which AT commands a given adapter actually recognizes, so that is the thing to check first. Worth the five minutes before designing anything for tier 2. - -**Prior art, checked 2026-09-11.** The building blocks are all open source and the closest match is [can_wizard](https://github.com/okhsunrog/can_wizard) (MIT): an ESP32-C3 plus SN65HVD230 sniffer on ESP-IDF's TWAI driver, with hardware acceptance filters, live bus state, error counters and bus-off recovery, which is close to the seam described above. It uses the legacy `driver/twai.h`, deprecated in IDF 6.x, so the new `esp_twai.h` is what to write against. Also useful: [esp32-slcan](https://github.com/mintynet/esp32-slcan) exposes an ESP32 as a standard slcan device so desktop CAN tooling works against it, and [an Alfa Romeo dashboard project](https://github.com/ClaudeMarais/AlfaRomeoGiulia_DashboardInfo_ESP32-S3) runs an ESP32-S3 on two CAN buses at once in a real car. Driving LEDs from OBD2 RPM is well-trodden ground, which is another way of saying tier 1 carries little risk. - -**What is NOT available, and it shapes the design.** [awesome-automotive-can-id](https://github.com/iDoka/awesome-automotive-can-id), the main community collection, carries VW data for the MQB platform, Golf, Polo, Touran and e-Up, but **nothing for the Transporter and no lighting signals for any VW**: its coverage is drivetrain and engine management, not body control. So tier 2 starts from an empty database however much searching precedes it, which is exactly why the frame IDs belong in configuration a user discovers and shares rather than in a compiled-in table. (T6 owner forums do report the reverse signal traveling over CAN, so the body signals exist on a bus; they are simply not publicly documented.) - -**Related:** a convoy light show across several cars needs nothing from this. That is MoonSync (planned, not built) synchronizing timebase and effect state between devices, with each car rendering locally through whatever driver it has. The two compose for free and neither blocks the other. - -### Audio-reactive follow-ups - -The manual level + 16-band FFT spectrum has shipped (AudioService; what landed and why is in [lessons.md](../../work/past/lessons.md)). These are the deferred follow-ups, each its own increment: - -- **Adaptive conditioning** — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual `floor`/`gain` is the shipped baseline. Reinvent from scratch when wanted, and **tune it in a quiet room** — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.) -- **Adaptive noise gate** — replace the borrowed `squelch`/`floor`-as-gate with a real noise gate: asymmetric bang-bang timing (open fast, close slow), a relative "detect silence" test (thresholds as factors of a learned floor, not absolute sample counts), keying off the RMS envelope we already compute, GEQ/FFT bands left untouched. A softhack007 concept; analyzed and judged in full (good idea, industry-standard, but tight on the <30ms budget; decompose into steps rather than overhaul) in AudioService.md § Adaptive noise gate. The recommended sequencing: the per-band floor above is step 1 (its complementary frequency-domain half), the relative-threshold-over-RMS is the cheap high-value cherry-pick as step 2, hysteresis/timing step 3, log-domain + soft-gate optional. Eventually retires the manual squelch. -- **Pin auto-scan** — detect the mic's `sdPin` with `wsPin`/`sckPin` fixed (a noise-prompt + confirm convenience); ships today with explicit pin controls. -- **Beat / onset detection** beyond the raw peak; more audio effects (2D / palette-driven frequency-reactive). -- **A second, decimated FFT for the low bands (2026-10-05).** At the default 22.05 kHz a 512-sample block gives 43 Hz bins every 23 ms, so the bands below about 130 Hz own one bin each and two bass tones a semitone apart land in the same band. A longer FFT alone buys resolution at the cost of time for every band (2048 samples: 10.8 Hz, but 93 ms). The textbook multirate answer keeps both: low-pass and decimate by 4 to 5.5 kHz, run a 512-point FFT on that for the bands under about 2.7 kHz (about 11 Hz bins), and keep the 23 ms FFT for the rest. Cost is one more 512-point FFT and a small decimation filter per block. Pinned on the desktop by two synthesized bass tones (50 and 60 Hz) landing in different bands, then measured on an S3 against the Audio module's tick time. -- **Bass, mid and treble energies on the audio frame (2026-10-05).** Which band carries the kick depends on the song, the sample rate and the band edges, so an effect keyed on band 0 misses it whenever it lands in band 1 or 2. Three sums over fixed ranges (20 to 150 Hz, 150 Hz to 2 kHz, above 2 kHz) beside the 16 bands give an effect "the bass" independent of the layout. Three sums per block, and a MoonLive system variable each. - -### GyroDriver → core Service move + AudioService-consistency pass (branched, not merged) - -A working **GyroDriver** (MPU6050 IMU over I²C) exists on an unmerged branch (commit `11f8eb7`, "Add GyroDriver (MPU6050) + generic platform I2C layer"); it is not in this branch's tree. This entry reverse-engineers that commit so the move is tracked now. **Verify against the real implementation when the branch merges, then delete this entry.** - -What the commit contains (reverse-engineered): - -- `src/light/drivers/GyroDriver.h` — reads an MPU6050 over I²C and surfaces five read-only telemetry controls (`gyroX`/`gyroY`/`gyroZ` rates in °/s, `pitch`/`roll` tilt angles). Polls the sensor in `loop20ms()` (50 Hz), formats the display strings in `loop1s()`. WHO_AM_I probe + wake on `setup()`, big-endian 14-byte burst parse, `atan2`-based tilt (no fusion filter). -- A **generic, domain-neutral platform I²C master** (`platform::i2cInit`/`i2cWriteReg`/`i2cReadRegs`, 7-bit addressing) so future sensors reuse it; ESP32 impl on the IDF v6 `i2c_master` driver in a new `platform_esp32_i2c.cpp`, plus an MPU6050-shaped desktop simulation so the UI and host tests see live values without hardware. -- `unit_GyroDriver.cpp` — WHO_AM_I probe, simulated burst parse, control formatting, time-ramp tracking. - -The move: it currently masquerades as an input-only **driver** under the Drivers container (a no-op `setSourceBuffer(Buffer*) override {}` is the tell). It belongs as a **Service** — a user-added child of the `Services` container, exactly like AudioService — both are sensor services that poll hardware and publish read-only telemetry. On the move, make it consistent with AudioService (the established sibling pattern): - -- **Relocate** `src/light/drivers/GyroDriver.h` → `src/core/` and its spec `docs/moonmodules/light/drivers/GyroDriver.md` → `docs/moonmodules/core/`; change `role()` to `Service`; delete the `setSourceBuffer` no-op; rewrite the doc's "input-only driver under the Drivers container" framing. (Rename to `GyroService` too, to match the `AudioService`/`IrService` convention — a Service names itself by its subcategory.) -- **Pin controls + rebuild path.** GyroDriver hardcodes SDA/SCL (`static constexpr` 21/22, with its own "Hardcoded until BoardModule exposes I2C pin mapping" comment). AudioService already shows the pattern: editable `uint16` pin controls + `controlChangeTriggersBuildState` + a `reinit()` on `onBuildState`. Adopting it retires the hardcoded-pins TODO and satisfies the robustness rule (reconfigure in any order). -- **Lifecycle.** GyroDriver has `setup()` only — no `teardown()`. Add teardown for symmetry with AudioService's setup/teardown/reinit (the shared I²C bus has little per-instance state to free, so this is consistency, not a leak fix). -- **Document the cadence difference.** GyroDriver polls in `loop20ms()` (50 Hz is plenty for tilt); AudioService reads in `loop()` every tick because I²S DMA must be drained promptly or it overflows. Both are correct; add a one-line "why this cadence" comment at each so the two siblings aren't "harmonised" into a bug. -- **Add it** to the deviceModel catalog under `parent_id: "Services"` (user-added per board), the same shape as AudioService — a gyro sensor is optional per board, so it's a Service, not a wired-by-code System child. - -Already done on this branch (the reverse direction): AudioService's two live read-outs were switched from `addText`+`setReadOnly` to `addReadOnly` (the display-only type, matching SystemModule and the way GyroDriver already does it correctly) — so the telemetry idiom is consistent before the gyro branch even lands. - -### Sensor input on Raspberry Pi 5 — microphone, IMU, line-in (post-1.0, multi-commit) - -Audio-reactive lighting (and motion-reactive) is core to what WLED-MM / MoonLight are known for. The Pi 5 is the right host for it: it has the CPU and RAM for real FFT-based audio analysis that the Xtensa ESP32 struggles with, and a full Linux audio + I²C stack. None of this exists today — the codebase has no sensor, audio, or IMU concept, and the Pi currently runs the **desktop** platform backend (there is no `src/platform/rpi/`), which has no hardware access. So this is a domain expansion built on a real platform-backend prerequisite, not a small add. - -**Target sensors and their Pi 5 interfaces:** - -- **Microphone** — I²S MEMS mic, or a USB audio device read via ALSA. The high-value one: FFT → frequency bands + beat detection drive audio-reactive effects. -- **Line-in** — the Pi 5 has no native analog input, so this is a USB audio interface / DAC HAT feeding the same audio pipeline as the mic; only the source differs. -- **IMU / gyro** — an I²C device (MPU-6050 / 9250-class) on the Pi's I²C bus; tilt / motion → effect parameters. - -**How it fits the architecture (the load-bearing part):** - -1. **The module category exists — `ModuleRole::Service`.** Services are user-add/deletable children of the `Services` container (a gyro `Service` lands there via the GyroDriver→core move). What's missing for audio-reactive is the *consumption* side: a sensor reads hardware and *produces* values (audio bands, IMU axes) that effects consume — the producer side of the [producer/consumer data-exchange model](../../explanation/architecture/moonmodule.md#data-exchange-between-modules) (a sensor produces an `AudioFrame` / `ImuState` the way effects produce a buffer that drivers consume). Define the producer struct domain-neutrally so it isn't audio-specific. Today's services are display-only; wiring them into effects is the new work. -2. **All hardware access stays behind the platform boundary.** New `platform::` APIs (e.g. `readAudio()` returning PCM/FFT, `readImu()` returning axes) with the ALSA / I²S / I²C implementation in a real `src/platform/rpi/` backend — which is itself the prerequisite that doesn't exist yet (the Pi uses the desktop backend today). No ALSA/I²C include or call outside `src/platform/`. -3. **Effects consume sensor data the same way they read the layer.** An audio-reactive effect reads the current `AudioFrame` (bands/level/beat) the way `PreviewDriver` reads what `Layer` produces — through a plain data structure wired in `main.cpp`, not a direct hardware call. - -**Increments (each a normal domain addition, picked up one at a time):** - -1. A real `src/platform/rpi/` hardware backend (GPIO/I²C/I²S/ALSA) — the prerequisite; until it lands, the Pi runs the desktop backend with no sensors. -2. The producer struct(s) (`AudioFrame` / `ImuState`) + the `platform::read*` APIs. (The `Service` role + Services-container add/delete already exist.) -3. The first audio service — **MicrophoneModule** (canonical, highest value: FFT bands + beat). -4. The first audio-reactive effect(s) consuming it. -5. IMU and line-in slot into the same source-module + platform-API shape afterwards. - -Study the proven audio pipeline in MoonLight / WLED-MM (FFT band layout, AGC, beat detection) to inform our own — reference the approach, don't port their code, per [history](../past/index.md) practice. Specs before code: a `MicrophoneModule.md` (and the source-category contract) get written and reviewed before implementation. - -## Effects and preview - -### DemoReel: extrude hosted lower-D effects (pending) - -DemoReel hosts one effect at a time and drives its `loop()` directly. A hosted **D1/D2** effect only writes its own slice (D1 → the x=0 column; D2 → the z=0 plane), and — unlike a normal Layer child — it does NOT get `Layer::extrude()` applied, so on a 2D/3D grid its output stays on one column/slice instead of spreading. A first fix (call `layer()->extrude(child->dimensions())` after the child's `loop()`, mirroring `Layer::loop`) **crashed the test suite with a heap/vtable smash** — `Layer::extrude` copies within `buffer_` using the Layer's `width_/height_/depth_`, and something in the reel's host path leaves those out of sync with the allocated buffer (root-cause not yet pinned). Redo it carefully: verify the Layer's dims match its buffer at the extrude call, add a bounds guard in `extrude` (or a reel-local extrude that reads the child's real dims/buffer), and pin it with a DemoReel test that hosts a D1 and a D2 effect on a 3D grid and checks the spread. Until then the reel renders D1/D2 hosts on a single slice (visible but not full-grid) — acceptable, not a crash. - -### BlurzEffect — a compounding blur has no rate carry (open) - -`Layer::fadeToBlackBy` takes a rate per reference frame and the Layer scales it once, the pattern every fade uses ([architecture § Where the machinery lives](../../explanation/architecture/index.md)). `draw::blur` does not fit it: blur COMPOUNDS, so applying it twice at half strength is not one blur at full strength, and the fractional carry that makes a fade frame-rate independent produces the wrong result. BlurzEffect is the effect this bites. The open question is what the right construct is — a per-frame blur budget, a single blur at an accumulated strength, or leaving blur explicitly frame-gated and documenting it as not-a-rate. - -### A real 2D/3D PacMan (pending) - -The migrated 1D PacMan and 1D Ant effects were removed — a chase rendered on a single strip reads as a blob of moving dots, not a game, so they weren't worth keeping. A proper **2D (or 3D) PacMan** — an actual maze, Pac-Man and ghosts navigating it, power dots, the blue-ghost flee state — would be a genuinely fun signature effect. Build it fresh as a grid game (not a strip port); the WLED/MoonLight 1D versions are reference for the state machine only, not the rendering. - -### Add real z-axis variation to 2D effects (pending) - -Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. The other honest-D2 effects use `Layer::extrude` to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its `dynamicBytes` budget for the full 3D buffer. - -### Self-describing preview frame header (mid term) - -The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the color payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below. - -### RGBW preview end-to-end (mid term) - -The light `Buffer` already holds `channelsPerLight = 4` (RGBW), and the device output drivers handle it, but the **preview only ever sends/draws RGB** — the W channel is invisible in the UI. (The full-res fast path no longer penalises a cpl≥3 buffer — see the short-term fix — but it still drops W on the wire.) Once the self-describing header lands, carry the W channel on the wire and render it in the shader (W as a warm-white tint / brightness lift on the disc). Small, but gated on the header so it isn't another bespoke opcode. - -### Fixture model — moving heads, beams (long term) - -Today a "light" is a point at a static coordinate with a color. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus color, beam-width, etc. — per-light **vector** state, not just color, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + color-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench. - -**Sub-item — per-emitter targets, and fixing the Yellow/UV RGB-synthesis placeholder.** `Correction::apply()` today synthesizes every non-RGB emitter (White, WarmWhite, Yellow, UV) from RGB via the one `whiteMode` gate. That conflates two different physics: **White/WarmWhite are broadband illumination** with a real achromatic basis (`min(R,G,B)` is a sound approximation — warm vs cold differ only in phosphor CCT a byte value can't express), but **Yellow/Amber (~590 nm) and UV (~400 nm) are saturated/out-of-gamut emitters with no honest RGB pre-image.** Yellow's `min(R,G)` stand-in reads greener than a true amber die AND fires on far too much (any red+green content — yellows, whites, skin tones — muddies the fixture); UV's blue-excess is an eyeball hack. Amber is a *real, common, targetable* emitter (RGBA / RGBAW PARs ship a dedicated ~590 nm die), so the right model is an **effect that targets amber DIRECTLY** (a typed attribute), not RGB-and-synthesize. The fixture model is where that lands. **Decision to make when built:** whether Yellow/UV should default to **off** (0, like `whiteMode::None`) until an effect drives them — more honest than always firing a wrong approximation — vs. keeping the "light it up to eyeball the wiring" placeholder. **Practical test to run then:** on a real RGBA/RGBAW+UV PAR, compare the synthesized amber against a directly-driven amber die (does `min(R,G)` look acceptably yellow, or muddy?), and confirm the White subtraction under `Accurate` reads correct with all emitters present. Rationale + the current formulas live in `Correction.h`'s emitter-field comment. - -### Mixing light types in one Layouts — open design question (undesigned) - -Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). (Moved from architecture.md § What we leave undesigned; a deferred design decision, not a settled 🚧 one.) - - -## LCD / DMA driver work - -### Drop the i80 WR/DC sacrificial pins — done for MoonI80, open for I80 - -**Shipped for `MoonI80LedDriver` (2026-07-14).** Owning the GPIO matrix is what bought it: the matrix is a routing fabric, so a peripheral signal that is never connected to a pad simply stays inside the peripheral. `dcPin` is **gone entirely** (DC separates command from data bytes for an LCD panel; WS2812 has no such concept, and the peripheral holds it at a constant level), and WR is routed **only** when a '595 needs it as SRCLK — in direct mode WS2812 is self-clocked, nothing reads WR, and the pin stays free for a strand. Same trick frees the *spare data lanes*: they are simply not routed, rather than parked on a "ghost" GPIO. So a direct-mode MoonI80 board spends its GPIOs on strands alone. - -**Still open for `I80LedDriver`, and it cannot be fixed there.** IDF's `esp_lcd` hard-requires both (`esp_lcd_panel_io_i80.c`: `wr_gpio_num >= 0 && dc_gpio_num >= 0`) and rejects an NC data pin, which is *why* it must park spare lanes on a real GPIO. Reclaiming the two pins on that driver means leaving `esp_lcd` — which is precisely what MoonI80 already is. So this is not a change to make to `I80LedDriver`; it is a reason to prefer MoonI80 once it is proven. Parlio (P4) never needed the pins (`clk_out_gpio_num = GPIO_NUM_NC`). - -### Parlio DMA frame buffer → PSRAM (free internal SRAM for big frames) - -For driving **lots of LEDs**, internal SRAM is the scarce resource and the parallel-driver DMA frame buffer is the biggest consumer (8 lanes × lights × outCh × 24 slot-bytes + latch pad). The **i80 driver already allocates PSRAM-first on the LCD_CAM chips** (S3/P4) — `platform_esp32_i80.cpp` tries `MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM` under `#if SOC_LCDCAM_I80_LCD_SUPPORTED`, falling back to internal — which is why the SE16 reaches the full 16384-light frame (see [performance.md § Multi-pin](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid)). The classic-ESP32 I2S i80 backend stays internal-only (its DMA can't reach PSRAM — a hardware limit, not a TODO). **Parlio still allocates internal-only** (`platform_esp32_parlio.cpp`), so a large Parlio frame can exhaust DRAM while PSRAM sits unused; the IDF confirms Parlio's GDMA can burst from PSRAM (`esp_driver_parlio/src/parlio_tx.c` sets `access_ext_mem = true // support transmit PSRAM buffer`). (RMT already does the right thing — its symbol buffer goes through `platform::alloc`, PSRAM-first with an internal fallback.) - -**The change (Parlio only):** allocate the Parlio buffer `MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM` first, falling back to internal when PSRAM is absent/full, using the **external-memory alignment** the IDF requires (`gdma_get_alignment_constraints` → `ext_mem_align`, typically the cache line) and keeping the buffer cache-aligned + its size a multiple of that alignment. **Why its own increment:** it changes the proven hot DMA path, PSRAM DMA has real caveats (cache-line alignment, write-back/coherence on the encode→DMA handoff, and lower PSRAM bandwidth that the IDF guards with a CPU-MAX DFS lock during transmit), and it **must be re-proven on P4 hardware** (the loopback self-test bit-verifies it, then a real strip). It also raises the Parlio ceiling toward the [chunked-transfer](#led-drivers-deferred) goal. Measure the bandwidth headroom too: a very wide, long frame at speed may want internal SRAM regardless. - - -## LED drivers — deferred - -The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32: [`RmtLedDriver.h`](../../moonmodules/light/drivers.md#rmtled), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3: [`LcdLedDriver.h`](../../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../moonmodules/light/drivers.md#parallelled), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [lessons.md](../../work/past/lessons.md), the [driver docs](../../moonmodules/light/drivers.md#rmtled), and the [analysis docs](../../work/future/leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else. - -- **RMT `int_ena` read-modify-write race (classic ESP32, level-5 refill).** `RMT.int_ena` is one register written by both the render task (arming a frame) and the level-5 refill handler (disarming a finished one), through `rmt_ll_enable_interrupt`'s `|=` / `&=`. A handler firing between the task's read and its write loses the task's update, leaving a channel armed or silent. Never observed: the window is a few instructions and the two writes rarely target one channel, so the symptom would be a stuck channel after hours rather than anything the bench shows. Left unfixed deliberately, because the two obvious guards are both wrong here and each was tried on hardware: `portENTER_CRITICAL_ISR` spins on a lock the level-5 handler has just preempted (it runs above `XCHAL_EXCM_LEVEL` 3 by design) and deadlocks the core, and a compare-and-swap builds but crashes, since `S32C1I` addresses only data memory and a peripheral register raises `EXCCAUSE` 3. The remaining candidate is masking to level 5 (`XTOS_SET_INTLEVEL`) around the two-instruction update, which needs no lock and no atomic bus access; it compiles but is unproven on hardware and wants a soak before it displaces firmware that is flicker-free on two boards. - -- **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([analysis §5.3](../../work/future/leddriver-analysis-top-down.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it validates the SHIPPED render↔encode split's WiFi isolation (drivers tick on core 1; WiFi lives on core 0). A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve. -- **Chunked transfer (Step 4) — the 16K lever, and now the ONE mechanism behind three separate ceilings.** Split a frame into transactions the DMA can actually swallow, feeding them back-to-back. It was scoped as a Parlio fix; it is really a **core-path** fix, and the shift-register expander is only its third beneficiary. - - **The three ceilings it lifts, all unshifted-first:** - 1. **P4 Parlio: ~4,096 lights.** The 2026-07-12 16-lane sweep found the single-DMA ceiling at 256/lane × 16, reproduced within 0.3% on a second P4 — and the cause is **not** the 65,535-byte cap (256/lane is far under the 897/lane limit). The P4 has 33 MB free heap but the largest *contiguous internal block* is ~368 KB, and a single-shot 16-bit DMA buffer needs one contiguous block: at 512/lane init fails outright. So chunking is **the only path to the 16×1024 = 16,384 lights the 16-lane widening promised.** This is the headline win and has nothing to do with shift registers. - 2. **Classic ESP32: 2,048 lights.** Its I2S DMA cannot reach PSRAM at all, so the frame must fit internal RAM. *(This entry previously said chunking "would not lift that" — that assumed chunking a PSRAM frame the DMA still had to read. It does not hold for the **staged** form below: if the DMA only ever reads small INTERNAL chunks that the CPU fills from a PSRAM frame, the classic chip is lifted too.)* - 3. **The 74HCT595 expander.** Currently capped at ~96 lights/strand because its ×8 frame only renders correctly from internal RAM ([§ 7.5](../../work/future/shift-register-driver-analysis.md)). An add-on, and explicitly **not** the reason to build this. - - **Two distinct limits, one idea — keep them straight.** For **Parlio** the constraint is *transaction size* (contiguous block + 65,535 bytes), so chunking means smaller transactions. For **i80** there is no single-shot cap at all (it chains DMA descriptors) — there the constraint is *where the DMA reads from*, so the win comes from **staging**: keep the frame in PSRAM, but have the CPU copy it a chunk at a time into small internal-RAM buffers that the DMA reads. Same mechanism, different reason, and conflating the two is what muddled the shift-register investigation. - - **Prior art, and why staging is the shape.** hpwit's drivers never DMA from PSRAM: the pixel data may live there, but an EOF ISR transposes it into small **internal bounce buffers** that the DMA clocks out (`owner_check = false`, a fixed ring, built once). Espressif do the same thing under a different name — the RGB-LCD driver's **bounce buffers**. The one place hpwit *does* DMA straight from PSRAM is the **P4 Parlio** path, and he chunks it (≤64 KB via IDF's parlio driver) — which is consistent with our own finding that the P4 handles a PSRAM whole-frame shift transfer fine while the S3 does not. The staged form gets hpwit's underrun immunity **without leaving `esp_lcd`** (still IDF-maintained, still one code path across classic/S3/P4, no raw-register driver) and **without putting the CPU back on a per-LED ISR deadline** — a bulk sequential PSRAM→internal copy per chunk is what PSRAM is good at, unlike a real-time streaming read. - - **The two mechanisms do DIFFERENT jobs, and the 100 fps target needs both.** The WS2812 wire time is a physical constant — 30 µs per light, serial down each strand — so the *only* lever on frame rate is **lights per strand**, never the chip: - - | strands × lights | total | wire time | fps ceiling | - |---|---|---|---| - | 16 × 1024 (direct, 16 GPIOs) | 16,384 | 30.7 ms | **33 fps** | - | **48 × 256 (6 pins × '595)** | **12,288** | **7.7 ms** | **130 fps** ✅ *(hpwit + PO proved this in practice — StarLight)* | - | **64 × 256 (8 pins × '595)** | **16,384** | **7.7 ms** | **130 fps** ✅ | - - So: **chunking/staging buys LIGHTS (memory); the expander buys FRAMES PER SECOND (short strands).** Neither substitutes for the other, and 12–16K at 100+ fps needs both — the expander's own ~145 KB frame is exactly the one that does not work from PSRAM today, so it *depends* on the staging fix. (A third piece is needed too: at 130 fps the CPU encode must finish in <7.7 ms, and it measures ~24 ms at 16K — that is what the multicore render↔encode split exists for.) - - **The classic ESP32 is a target, not a write-off.** It is routinely dismissed for work like this, and the dismissal is wrong: hpwit and the PO have *run* 48 strands × 256 at ~100 fps on classic silicon (StarLight), with the same '595 expander, while WiFi was up. 240 MHz, two cores, and a 30 µs/light budget is a lot of headroom. The thing that would stop us is **our own encode cost**, which is software we control, not a property of the chip — and the ~24 ms/16K figure quoted in the multicore analysis is (a) measured on a **P4**, not a classic, and (b) **pre-dates the SWAR transpose** that shipped since. Do not carry that number into a classic-ESP32 feasibility argument; measure the real one on the real chip. What the classic genuinely needs is the **staged** form of chunking (its I2S DMA cannot reach PSRAM at all), which this item provides. - - **Build and prove chunking on the unshifted path first** (Parlio 4,096 → 16,384 is the measurable win, on proven code), then let shift mode inherit it — that is a sequencing rule about *where to de-risk the mechanism*, **not** a claim that the expander is optional. It is not: it is the only route to 100 fps at this scale without spending 48+ GPIOs. Correct WS2812 inter-chunk timing is the one hard constraint: the lines must idle LOW for < 300 µs between chunks or the strand latches mid-frame. The driver already rejects an over-limit frame with a loud status. Measured detail: [performance.md § Multi-pin](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid). -- **`rmtWs2812Show` fuller error handling** (deferred from PR #17 / 🐇 CodeRabbit). The shipped path has a finite `rmt_tx_wait_all_done` timeout (1 s) so a wedged DMA can't hang the render tick forever, and a dropped frame self-heals (the driver re-encodes the whole frame next tick). The fuller version — `rmt_transmit` return check, `rmt_tx_stop` to cancel an in-flight transfer on timeout, `show()` returning failure so `loop()` won't reuse `symbols_` mid-transmit — belongs with the **core-1 driver-task** work, since that task owns the buffer lifetime and in-flight state the cancel logic needs. -- **Auto-derived DMA buffer count** (7 / 30 / 75 per [analysis §7.4](../../work/future/leddriver-analysis-top-down.md)), **shift-register expander stubs** ([§7.5](../../work/future/leddriver-analysis-top-down.md)). -- **16 bits at the output: what remains (2026-10-05).** Fine roles and the 16-bit curve table shipped, as [FixtureProfiles](../../moonmodules/light/supporting.md#fixtureprofiles) describes; the 16-bit Layer stays stashed, since it measured +56% frame cost. - - Verify on a WS2816 strip that the dark end of a fade is smooth, with the built-in `GRB 16-bit`. - - TM1814 and TM1914: an inverted line and a settings preamble before the pixels (8 bytes of current, 6 of mode, each followed by its inverse), as NeoPixelBus sends them; a driver protocol, not a profile. - - UCS7604: its RGB or RGBW mode is set by configuration, so it needs its datasheet before a profile. - - WS2916A-RGBW (Worldsemi): 16-bit GRBW with a 5-bit current gain per channel. Worldsemi's ESP32 example sends a 32-bit gain header once per frame, then 64 bits per pixel, MSB first. - - The header holds the G, R, B and W gains, 5 bits each, with the remaining 12 bits set to 1. - - A bit is 1.3 µs (T0H 300 ns, T1H 700 ns), and the reset is longer than 280 µs, past WS2812's. - - Needs a per-frame header in the LED drivers, the gains as controls, and a `GRBW 16-bit` profile; check our reset against 280 µs. - - WS2915 (Worldsemi): five channels, RGB plus two independent whites, 5-bit gain and 16-bit data, 5 to 48 V. Its frame layout is unpublished; the WS2916A's header with a fifth gain is the likely shape, to confirm from the datasheet. - - WS2916B: RGB at 16 bits (48-bit pixels), 3.3 to 5.5 V. - - Sources: [Worldsemi product list](https://www.world-semi.com/) and [Worldsemi's examples](https://github.com/WorldsemiLED/worldsemi-led-examples); the 4 kHz PWM reported for the WS2915 is unconfirmed. - - Temporal dithering on an 8-bit wire, fed by the same 16-bit table. - - Pan and tilt beyond 8 bits, once a layer slot carries motion wider than 8 bits; today their fine byte repeats the coarse one. -- **Stop reading `fixtureRef` after the release that follows 2026-10-05.** A driver saves its profile's name in `fixture` since then, and `DriverBase` still reads the hidden `fixtureRef` an older config saved beside a row number. With the next release out, remove the control and `DriverBase::adoptLegacyName` with its two calls, and add a `migrate.js` entry mapping `fixtureRef` onto `fixture` so an older backup still restores. -- **IR RX live-reconfigure recovery — unconfirmed, park until it recurs** (bench 2026-07-13, SE16). IR reception on the SE16 (`IrService` pin 5) went dead mid-session and only a **hard reset** brought it back; a warm/API path did not. **Ruled out:** not hardware (hard reset fixed it, receiver+switch+wiring fine), not LED-count (IR survives the full 16384-light / 8 fps load — a received code still toggled a control at max load), not a regression from the i80 commit (`platform_esp32_ir.cpp` untouched, the 1250 ns glitch-filter fix intact). **Prime suspect (unproven):** the session's live pin churn — including transiently setting the i80 `clockPin` to **5, which IS the IR pin** — left GPIO 5 routed to the wrong peripheral, and the RMT-RX channel (a pin-keyed static behind `platform::irStop`/`ensureChannel`) didn't re-acquire cleanly on the next `irRead`; only a full GPIO re-init (hard reset) cleared it. This may be pure test artifact (nothing in a *normal* user flow points two live modules at GPIO 5). **To conclude:** from a fresh hard reset (IR working), in isolation set i80 `clockPin=5` then restore `clockPin=8` and check whether IR dies and whether it self-recovers *without* a hard reset — self-recovers → no bug (test artifact); stays dead → a real live-reconfigure gap in the IR channel re-acquire worth fixing (per *No reboot to apply a configuration change*). Small robustness/repro work; do it only if IR breaks again in real use. -- **Moving-head preview = peer interpreter.** When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture: the same fixture-profile model physical drivers use, interpreted to screen. This is *why* the increments named the abstraction "interpret the profile" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. -- **Sparse fixture-profile editor.** A FixtureProfiles row currently shows one role Select per channel across the whole `channels` width: including the unmapped `, ` gaps a wide moving head has between its functions. For a fixture you usually only care about the few channels you drive (rgb, pan, tilt). The refinement: show only the *mapped* channels + an "add channel" affordance (pick a role → fills the first gap or grows the fixture), over the unchanged dense `roles[]` storage. A first attempt shipped and was reverted for edit bugs; redo it cleanly (the dense editor is the reliable interim). Prior art: GDTF / QLC+ fixture profiles (a fixture is a sparse `{channel → function}` map, not a dense per-channel array). - -- **`worley` cellular noise, when an effect needs it** (deferred 2026-08-07, PO). The one power function the plan names that stayed unbuilt on purpose. Worley (cellular / Voronoi) noise measures the distance to the nearest of a set of scattered feature points, which is what produces the look nothing else does — cracked mud, scales, stained glass, a caustic. The fields we have cannot fake it: `fbm` and `warp` are smooth by construction, so their creases never form cells. - - **Why it waits.** An industry-standardness audit (Fable, 2026-08-07) put it explicitly on the "do NOT add yet" list: a practitioner does not consider it table stakes, and every other field function in the library earned its place by having a caller. Building it now would make it the only entry whose justification is "the canon has one". The trigger to build it is an effect that wants cells — at which point it lands with that effect, the way `hashInt` landed with Dissolve and `sampleWrap` with Echo. - - **What it costs when it comes:** the standard cheap form is a 3×3 neighborhood scan around the sample's cell, hashing each neighbor's feature point and keeping the nearest distance — nine `hashInt` calls and nine distance tests per pixel, so meaningfully dearer than one noise sample. `dist16` and `hashInt` already exist, so the function itself is short; the cost question is what it does to a per-pixel budget on a large fixture. - -- **16-bit noise: tuning still open** (2026-08-08). The tier ships (`inoise16` 1/2/3D, `fbm16`), and two defects found by review are fixed: `lerp16`'s product is 64-bit (the 32-bit form was signed overflow, undefined behavior, on roughly a quarter of samples), and `fbm16` sums its octaves at full width instead of shifting each down by 8 — which had made the output 8-bit wearing a 16-bit type (195 distinct values over 20,000 samples; now 15,118). - - **What is still open.** It is VALUE noise, like the 8-bit tier: cell corners are hashed and interpolated. Gradient (Perlin) noise hashes a gradient per corner and takes a dot product, which removes the faint axis-aligned grid value noise leaves — visible on a large smooth field, which is the case this tier exists for. Also unbuilt: a 3D `fbm16`, and `warp16` / `turbulence16` to match the 8-bit compositions. The trigger is an effect that wants a large smooth field; it lands with that effect and its measurements, the way the 8-bit compositions landed with theirs. - -- **Clear the grid on an effect's FIRST frame** (2026-08-08). Fourteen effects still show the previous effect's picture on the frame right after a switch: BouncingBalls, Fireworks, FreqMatrix, FreqSaws, GEQ, GEQ3D, GameOfLife, Lissajous, NoiseMeter, PaintBrush, Random and three more. Most predate the power-function branch. - - **What the user sees.** Switching to one of these leaves the old frame underneath for a moment — an audio effect with no signal yet, or a simulation still seeding, paints nothing and inherits whatever was there. `Layer::tick` deliberately does not clear (the documented break: an effect may fade its own last frame for trails), so owning the background is each effect's job. - - **Why it waits.** It is fourteen effects' worth of change across audio-reactive and simulation families, each needing its own judgement about whether to clear, fade, or seed differently — not a mechanical sweep. `unit_Effects_gridsweep.cpp` already measures it (`afterFirst`) and asserts only the settled frame, so the number is visible without blocking. - -- **A scripted modifier that animates every frame** (2026-10-11): `MoonLiveModifier` compiles a `modifyLive()` entry, so a script moves lights per frame with `t` (scroll, wobble, sweep) as the compiled Rotate does. Geometry only: dimming is a blend mode or an effect's `mulRGB`, per [the layer compositing plan](../present/Plan-20261011%20-%20Layer%20compositing%2C%20masks%20and%20transitions%20(2%20of%203).md). Built once a script needs it; the cost to measure is one compiled-script call per light per frame, on an S3 at its largest grid. -- **A scripted modifier needs a way to drop a light** (2026-08-10). A coordinate slot is a byte, so a script that computes past 255 wraps: `shift.mlv` with a large `amount` lands lights back at the left edge instead of walking them off it. The Layer already drops an out-of-bounds position, but a script has no way to SAY out-of-bounds — every value it can write is a valid coordinate. Needs a sentinel the binding recognizes (or a wider coordinate slot), at which point the "walks off the edge" behavior a scroll modifier wants becomes expressible. - -- **A scripted layout loses its control values across a reboot** (2026-08-11). Persistence SAVES them correctly (`/.config/Layouts.json` holds `"0.width":64`), but on boot they are loaded into a control set that does not exist yet: a script's controls are created by `defineControls`, which can only publish what the ENGINE declared, and the engine does not compile until `prepare()` — Scheduler phase 4, after the phase-2b re-bind. So the loaded value has nowhere to land and the script's declared default wins; a saved 64×64 grid comes back 16×16. Verified on the P4 bench. - - Compiling inside `defineControls` is NOT the fix (tried): it makes the default script's controls exist before `setSource` runs, and swapping the source then re-seeds every control from its new declared default — the same value-loss, moved. The real fix is ordering: the engine must compile once the persisted `source` is in place but before controls are published, which is a Scheduler-phase question (the same parent-before-child ordering the `const_cast` in `MoonLiveLayout::compile` already works around). Affects all three MoonLive bindings, not just the layout. - -- **A scripted modifier that reshapes the grid** (2026-08-10). `ModifierBase::modifyLogicalSize` lets a modifier change the logical `width`/`height`/`depth` — a Multiply kaleidoscope grows the grid, a crop shrinks it — and a compiled modifier uses it. A SCRIPTED one cannot: system variables are read-only, so `MoonLiveModifier` writes the box in and never reads it back. Needs a writable system variable — the binding reads the slots after the script returns and reports the result through `modifyLogicalSize` — which is a new `SysVarKind` (or a mutable flag on `SysVar`) plus the read-back, not a new builtin. Until then a scripted modifier can fold coordinates but not resize the grid they live in. - -- **The compile-failure latch is not provable on the host** (2026-08-18). `MoonLiveScript::sync` - refuses to re-attempt a script that failed until its (name, content) changes. The latch exists for - a device-only reason: each attempt is two LittleFS reads (~5 ms on an S3), a layout is asked from - `lightCount()`/`placeLights()` as well as `prepare()`, and the pipeline asks repeatedly while - sizing a fixture, so the retries starve the render task until the watchdog resets the device. - - On the host a re-read costs microseconds and nothing observable differs. Four test shapes were - tried and each still passed with the latch REMOVED ENTIRELY, so none was kept: what survives pins - only that a script fixed in place compiles without a rename, which is control-checked. Closing - this needs either a counting seam (a compile counter the test can read) or a platform fake whose - reads are observable. Until then the latch is protected by its comment and by hardware, not by a - test. - -- **Catch device-backend operand defects on the host** (2026-08-18). Two array-codegen bugs shipped - to an S3 while all 1313 host tests stayed green, and both were control-checked: reintroducing - either one leaves the suite fully passing. `IrInst::c`/`d` are VREG fields the spill pass - renumbers, so a width parked there becomes a register number; and `sourcesOf` writes its sources - back POSITIONALLY, so reporting `kArg4` first shifts every real operand one place along. arm64's - register map absorbs both, which is exactly why the suite cannot see them. - - Three test shapes were tried and deleted for failing their control run: emitted-bytes difference - tests (wrong bytes still differ from other wrong bytes) and a register-liveness walk (the index - satisfies it whatever happens to the value). What DOES work is asserting an assembler primitive - directly, as `unit_moonlive_codegen_xtensa.cpp` now does for `addImm`. The general form is - probably an IR-level invariant check rather than a bytes-level one: assert that no op reports a - fixed ABI vreg among its positional sources, and that non-register operands never occupy c/d. - That is a property of the IR the host CAN evaluate, unlike the emitted code. - -- **Size the MoonLive control arena to the script** (2026-08-18). `kCtrlBytes` is a fixed 64 bytes - per engine (three engines per pipeline), so a script declaring one byte pays for 64 and one - wanting a 128-light array is refused. The arena is already `platform::alloc`'d, so the constant is - habit rather than necessity, and the member byte count is known at compile time. - - What blocks it: the system variables sit ABOVE the script region at compile-time constant offsets - (`kSysWidth = kCtrlBytes + 0`), baked into emitted code as `LoadCtrl` immediates and cached as - slot POINTERS by the bindings. A script-sized region moves every one of them. Closing it means - putting the system variables BELOW the script region so their addresses stop depending on it, - which touches the sysvar table, the bindings and every emitted immediate. The hard ceiling stays - 255 either way, since an arena offset is a `uint8_t` in the record, in `controlSlot` and in the - instruction. Until then, raising the constant is one edit and the failure is a clear compile - error naming the arena, so hitting it is visible rather than silent. - -- **Drain MoonLive's `print()` through a queue** (2026-08-09). `print(v)` writes to serial directly, and an EFFECT script runs on the render tick — so a print inside one blocks the frame for as long as the UART takes. The burst cap bounds it (a handful of writes per compile, then a compare and a return), but bounded is not free, and `tick()` is annotated `MM_NONBLOCKING`. - - **What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for. - -- **A bus pin that belongs to another peripheral is accepted, and takes the board off the - network** (2026-09-06). ParallelLedDriver's classic-ESP32 WR/DC defaults are 18/23, which are - IDF's Ethernet MDIO/MDC defaults on the same chip, so an Olimex ESP32-Gateway that added the - driver lost its Ethernet link within seconds and looked crashed (the firmware kept ticking on - serial; it stayed reachable only through its WiFi fallback). A DC pin of 5, the Gateway's PHY - reset line, did the same. The driver's own comment claims 18/23 are "unused by the catalog's - boards", which is false for every RMII board. Two fixes, both wanted: (1) classic defaults that - collide with nothing common (the RMII set 0/16-19/21-23/25-27, flash 6-11, straps 0/2/12/15, - and the Dig-Next-2's relays 5/20-22 and I2C 14/15 all excluded); (2) the pin registry already - detects a double claim (`PinsModule::flagConflicts`) but only colors the edge, so a control - write that lands on a GPIO another control owns should be refused with a status naming the - owner, the way a reserved flash pin already is. A collision with a network pin is worse than - most: it ends the session that could have fixed it, and the SMI pins stay re-muxed until a - reboot even after the driver releases them. - -- **Bench-verified classic parallel setups, not yet in the catalog** (2026-09-06). Every classic - board in `deviceModels.json` ships `RmtLedDriver`, so none carries a `ParallelLedDriver` entry, - and the driver's defaults (`clockPin` unset, `dcPin` 33) work on a classic board without one. - Two setups are proven on hardware and worth recording before they are lost: the **Olimex - ESP32-Gateway** drives 64 lights on GPIO 16 with `clockPin` 32 / `dcPin` 4, and its free pins are - scarce enough that a catalog entry should pin them explicitly rather than inherit a default (its - Ethernet PHY holds 18/23 as MDIO/MDC and 5 as reset, all of which the old defaults collided - with); the **QuinLED Dig-Next-2** drives 256 lights on GPIO 2 with `clockPin` unset / `dcPin` 33. - Add them when a classic board actually ships parallel output as its default, rather than - speculatively across the other 16 classic boards, none of which has been tested this way. - -- **Classic-ESP32 I2S instance split: LEDs on 1, audio on 0** (2026-09-06, SHIPPED, kept as the - rationale). The classic ESP32's parallel LED bus IS an I2S peripheral, so it contends with the - audio input, and the 2026-09-03 "hangs in `esp_lcd_new_i80_bus`" entry is closed: that was a pin - fault (the ESP32-PICO-V3-02 has no GPIO 18/23, which were the WR/DC defaults), not contention. - The split is fixed in silicon rather than chosen: instance 0 alone carries the PDM converters - (`I2S_LL_PDM2PCM_SUPPORTED_PORT_MASK` is `1U << 0`), and NOTHING on this chip requires instance - 1, so the LED bus is the one consumer that can always yield. It therefore takes 1 unconditionally - and audio takes 0, which removes the boot race entirely, leaves 0 for every audio source (PDM, - standard I2S, line-in ADC, codec), and costs nothing. `esp_lcd` picks the first FREE instance - rather than taking one by number, so 1 is claimed by holding 0 across bus creation. For contrast, - hpwit's I2SClocklessLedDriver hard-codes `I2S_DEVICE 0` and would take the PDM instance instead. - Both sides also retry once a second while they want a busy instance, so the loser of any - contention recovers without the user touching a control. - -- **Bench-verified classic parallel setups, not yet in the catalog** (2026-09-06). Every classic - board in `deviceModels.json` ships `RmtLedDriver`, so none carries a `ParallelLedDriver` entry, - and the driver's defaults (`clockPin` unset, `dcPin` 33) work on a classic board without one. - Two setups are proven on hardware and worth recording before they are lost: the **Olimex - ESP32-Gateway** drives 64 lights on GPIO 16 with `clockPin` 32 / `dcPin` 4, and its free pins are - scarce enough that a catalog entry should pin them explicitly rather than inherit a default (its - Ethernet PHY holds 18/23 as MDIO/MDC and 5 as reset, all of which the old defaults collided - with); the **QuinLED Dig-Next-2** drives 256 lights on GPIO 2 with `clockPin` unset / `dcPin` 33. - Add them when a classic board actually ships parallel output as its default, rather than - speculatively across the other 16 classic boards, none of which has been tested this way. - -- **Classic-ESP32 parallel LEDs and a PDM microphone are exclusive** (2026-09-06). The - 2026-09-03 "hangs in `esp_lcd_new_i80_bus`" entry is closed: the QuinLED Dig-Next-2 carries an - ESP32-PICO-V3-02, whose package has no GPIO 18/23 (its pads serve the in-package flash and PSRAM), - and the classic WR/DC defaults were exactly 18/23. The driver now refuses a pin the package lacks - and defaults both lines to unset (sunk onto input-only pads). What remains: IDF's LCD mode exists - on I2S0 only, and `esp_lcd` falls through to I2S1 when I2S0 is taken, which wedges the chip the - same silent way. A PDM microphone is also I2S0-only in hardware, so the platform refuses the bus - with a named status when I2S0 is held. Two follow-ups: report the I2S1 fallthrough upstream (it - should return an error), and note that the raw-I2S classic driver above (the ring, on I2S1 as - hpwit's driver runs) is what lets the two coexist, on top of lifting the 2048-light cap. A - standard I2S microphone (INMP441) is content on I2S1 and coexists today when the LED bus claims - I2S0 first. - -## FixedPoint fades outside the Layer's aggregation (2026-09-07) - -`FixedPointEffect` calls `draw::fade` directly where every other fading effect calls -`layer()->fadeToBlackBy`, so it skips what the Layer adds: the per-frame aggregation (N effects on -one layer cost one buffer pass, gentlest rate wins), the framerate scaling, the sub-unit carry, and -the buffer-generation bump. Invisible until a second effect shares the layer. - -The swap is one line and was tried: it makes the effect **3.1x brighter at high framerate than at -low**, against the 1.35x band `unit_Effects_framerate` enforces. The two calls mean different -things. `draw::fade(cv, n)` applies n every frame; `fadeToBlackBy(n)` is a RATE per reference frame -that Layer scales by elapsed time. The `fade` control (default 70, range 0..255) was tuned against -the first meaning, so moving it needs the default re-tuned and the trail looked at on a device, not -a silent swap. - -Worth doing, as its own change: it is the last effect outside the shared fade path, and while it -stays outside, "every fade goes through the Layer" is not true. - -## Downloading a scripted palette can repoint the active one (2026-09-06) - -Found while testing the palette-download fixes. A `.mlp` that sorts BEFORE an already-installed one -silently changes what the current selection points at: with `fire.mlp` active at index 60, -downloading `beat-flash.mlp` re-sorts the scripted tail and index 60 becomes `beat-flash.mlp`. The -stored value never moved; what it means did. - -[Palette.h](../../src/light/util/Palette.h) already reasons about exactly this and solves half of it. A -palette selection is an index: it persists, it rides `seg[0].pal` over the WLED API, and Home -Assistant renders `paletteNames` positionally, so scripted palettes sort AFTER the built-ins and the -sixty built-in indices are fixed forever. What is not solved is the scripted tail among itself, and -that tail now grows at runtime, which is what the download path made reachable. - -Nobody has reported it, and the blast radius is small: a rig with one scripted palette cannot hit it, -and the value re-reads correctly the moment the user picks again. It matters most where a preset or -an MQTT/HA automation stores the index and replays it later, which is the case where nobody is -watching the LEDs when it changes. - -**What it would take:** persist the scripted palette by NAME alongside the index and re-resolve the -index on load, so a stored selection survives a re-sort. `paletteScript` already holds the resolved -file name for the editor, so the value exists; what is missing is using it as the authority when the -list changes. The alternative, appending new scripts rather than sorting them, keeps indices stable -but makes the picker unreadable as the list grows, which is the trade the sort was chosen over. - -## Classic-board memory: the wins are system-level, not per-module (2026-09-09) - -The Dig-Octa on a clean boot with RmtLed has **88,284 bytes free internal, 86,016 largest block**. -Under load and after driver swaps that largest block fell to 24,576, so fragmentation matters as -much as the total. - -What the modules hold is NOT where the RAM went. The whole tree reports **9,342 bytes** of -`dynamicBytes` (Layer 3,586, Preview 2,560, RmtLed 1,536, Drivers 1,536). So per-module trimming has -almost nothing left to win, and the remaining ~230 KB of internal RAM in use is WiFi, lwIP, FreeRTOS -task stacks and the binary's static data. - -Where to look, in order of likely return: - -- **Task stack sizes.** Every task allocates its stack from internal RAM at creation, and the - defaults are generous. The Tasks module already reports them, so the measurement exists. -- **lwIP pool sizes** (`CONFIG_LWIP_*`): TCP PCBs, pbuf counts, and the send/receive windows are all - sdkconfig knobs, sized for a general-purpose device rather than a controller with a handful of - connections. -- **WiFi buffer counts** (`CONFIG_ESP32_WIFI_*_BUFFER_NUM`): the static RX/TX buffer pools are the - single largest tunable block on a classic board. -- **Fragmentation, not just totals.** A 24 KB largest block with 50 KB free is a placement problem; - allocating the long-lived buffers early and together is what fixes that. - -Each is an sdkconfig change measurable on the bench in minutes (`freeInternal` and `maxBlock` on a -clean boot), and each risks a different failure: too few WiFi buffers drops packets under load, too -small a stack overflows under a rare path. So measure one at a time on a board that is actually -serving the UI, not idle. The payoff is real: at 88 KB free a classic board is one large allocation -away from trouble, which is what drove both driver decisions on 2026-09-09. - - -## HUB75, native (2026-09-16) - -The [Hub75Driver](../../moonmodules/light/drivers.md#hub75) drives a panel from the board's own pins, as against [PanelCardDriver](../../moonmodules/light/drivers.md#panelcard) which sends ColorLight frames to a receiving card. It has run on one panel: a 64x64 1/32-scan panel on a MoonHub75, LCD_CAM at 1,508 Hz at 6-bit, on 2026-10-06 (the bench section of the HUB75 plan). WLED-MM does not implement HUB75 itself; it wraps [mrcodetastic/ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) v3.0.12 in about 600 lines (`wled00/bus_manager.cpp:677`), so the items here are measured against a library with years of hardware debugging behind it. - -### The shift-register driver init - -A HUB75 "panel" is a family, and the differences are not discoverable from its dimensions. FM6124 and ICN2038S panels need a different init sequence; WLED-MM picks FM6124 for "outdoor" panels, and its comment says the symptom of the wrong choice is a dark panel or pastel colors. Build trigger: a panel showing that. The clock edge is already a control (`clockEdge`), and latch blanking was tried on the bench and changed nothing. - -### Repaint only what changed - -`tick()` corrects and encodes the entire frame every time, whatever moved. The library tracks a dirty bit per pixel in `setPixelColor` and repaints only those in `show()`. For a full-frame effect the two are comparable; for a sparse overlay, a clock, or a mostly-static scene, the difference is the whole encode. - -Worth measuring before building: our encode is a tight loop over a packed buffer, and a dirty-bit test per pixel is not free either. The win is real only where the frame is mostly unchanged. - -### Depth that degrades instead of failing - -`bitDepth` is a user control with a hard ceiling, so asking for more than the wall can hold fails init with a memory error. WLED-MM reduces depth automatically by pixel count (8 bits under `MAX_PIXELS_8BIT`, then 6, 4, 3), so a large wall gets a dimmer picture rather than no picture. - -That fits the allocate-and-degrade rule every other output seam here follows. It interacts with the weighting item below, since the library's `lsbMsbTransitionBit` is the same trade expressed as refresh rather than as memory, so build them together rather than adding two overlapping automatic reductions. - -### Four-scan panels whose physical layout is not their logical one - -Some panels scan in a pattern where the physical row order does not match the logical image, and driving them without remapping produces a scrambled picture that looks like a wiring fault. The library handles this with `VirtualMatrixPanel` and `setPhysicalPanelScanRate` (FOUR_SCAN_32PX_HIGH, FOUR_SCAN_64PX_HIGH), and WLED-MM exposes them as bus types 105 to 108. - -Our `Hub75Geometry::rowsPerScan()` handles a panel driving more than two rows per address step, which is the common multi-pair case, but not an arbitrary remap. Build trigger: someone owns such a panel. The remap belongs in the Layout rather than the driver, since it describes where lights physically are, which is what a Layout is for. - -### Repeated planes: full top brightness and 8-bit depth - -The planes are weighted by their lit window (since 2026-10-06): plane p lights 2^p as long as plane 0 inside one pass, and brightness shortens every window. That costs top brightness, since the lower planes are dark for most of their pass: full white is about 47% of the row time at 4-bit and 33% at 6-bit. It also caps depth at 6, where plane 0 of a 64-column row still lights 2 words. The reference library repeats the upper planes instead, and windows only the lowest (`lsbMsbTransitionBit`, chosen automatically for a refresh floor): about 95% top brightness and 8-bit depth, at roughly 290 Hz for one 64x64 panel. - -**The approach: repeat DMA descriptors, not data.** Plane `p` is stored once and the descriptor chain points at it 2^p times, so the weighting costs descriptors rather than frame memory. mrcodetastic's ESP32-HUB75-MatrixPanel-DMA does exactly this (`configureDMA`, the `for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++)` loop), and it carries a refinement worth taking with it: `lsbMsbTransitionBit` is a dial, not a constant. Planes below it show once each; planes above get full weighting. The library raises it until a configured minimum refresh is met, trading exactness at the low end for rate. That makes depth and refresh one control instead of two fighting ones. - -**What it costs here: a new LCD_CAM backend.** The blocker is not the weighting loop, it is that `esp_lcd_panel_io_tx_color` owns its descriptors and gives no way to aim several at one buffer. The library programs the silicon directly instead (`gdma_new_channel`, `gdma_connect(GDMA_TRIG_PERIPH_LCD)`, hand-linked `dma_descriptor_t.next`). So this is the same step this project already took for WS2812, from `platform_esp32_i80.cpp` (675 lines, esp_lcd) to `platform_esp32_moon_i80.cpp` (1,952 lines, own GDMA), with the same hazards that took bench bisects to find there: re-mounting the chain per frame, terminator placement, producer/consumer headroom. - -**Parlio cannot express it.** Its only repeat primitive is `loop_transmission`, which repeats the whole buffer forever with no per-plane control. Weighted depth would be an LCD_CAM capability, and the `peripheral` select has to say so rather than offering a choice that silently changes the picture. - -Build trigger: a wall that reads too dim at 6-bit, or a user who needs 8-bit gradients. It stays automatic, chosen from `bitDepth` and a refresh floor, with no new setting. - diff --git a/docs/work/future/backlog-mixed.md b/docs/work/future/backlog-mixed.md deleted file mode 100644 index 7f3acb12e..000000000 --- a/docs/work/future/backlog-mixed.md +++ /dev/null @@ -1,80 +0,0 @@ -# Backlog: mixed (core + light) - -Forward-looking items whose work genuinely spans **both** the core and light domains: a core mechanism interacting with a light driver/effect/modifier, where assigning it to one side would misrepresent it. Core-only items are in [backlog-core.md](backlog-core.md), light-only in [backlog-light.md](backlog-light.md). Index + overview: [README.md](index.md). - -## Cross-domain - -### Switching a MoonLive script from the card fails intermittently (2026-09-29) - -Picking a second script for a `MoonLiveEffect` leaves the module running the first one, with nothing reported: the card shows the new name, the effect keeps rendering the old one. -Measured over six consecutive picks on the desktop: two switched, four did not, and the fourth failure recovered on its own. - -**What is established.** The file is on the device and complete (`noise.mle`, 560 bytes, `/.moonlive`). -The first pick after the card opens usually works, and later ones fail in runs rather than randomly. -The picker dialog opens on a failing attempt, so the failure is inside row selection rather than in reaching the widget. -No alert fires, which is why the UI looks like it simply did nothing. - -**The strongest lead.** An editor pane is present after the first pick, and the change handler does `await editor.save()` and refuses to switch while the pane is still dirty. -That path is unproven: the next step is to instrument the handler and read `editor.isDirty()` on a failing pick. - -**Ruled out.** The duplicated download logic in the dropdown, which called `mlDownloadScript` directly instead of the shared `mlEnsureLocal`. -That was a real defect and is fixed, pinned by `test/js/ui-script-picker-download.test.mjs`, and it did not change this symptom. - -**Worth deciding alongside the fix**: whether the script control should be editable at all, or whether a scripted module's script is chosen once through the module picker. -The module picker path works reliably, so a read-only control would remove the failing surface rather than repair it. - - -### LightsControl: not building it (decided 2026-09-01) - -The idea was one module owning global light state (master on/off, brightness, palette, bpm) and -acting as the integration point for external controllers, modelled on MoonLight's -`ModuleLightsControl`. **It is not being built**, and the two reasons are worth keeping: - -**The generic ControlModule is already the hub.** It carries the surface (switches, encoders, -faders, pads) that every transport writes: OSC, MQTT, the WLED bridge, the web UI, and now the -physical inputs. A second hub beside it would be the split brain the OSC plan forbids. - -**A global light param belongs to the module that uses it.** `palette` lives on `Drivers` because -that is where it is consumed (effects read it through `Palettes::active()`), and the same holds for -`brightness` and `on`. Moving them into a hub separates a value from its consumer and buys nothing: -encapsulation, not centralisation. - -So the surface reaches INTO those modules rather than owning their state, which is already how it -works: `fader1` targets `Drivers.brightness`, `switch1` targets `Drivers.on`, and `encoder1` targets -`Drivers.palette`. Those bindings are hardcoded today and become user-assignable as a UI plus -persistence job (see [power-functions-analysis-top-down.md](power-functions-analysis-top-down.md), -per-control assignment). - -**What an encoder bound to a select needs:** the option NAME under the knob, not the index, so -turning through palettes reads "Rainbow" rather than "37". One rule in the generic UI (an encoder -bound to a select renders its target's option label) rather than per-module code, which is how the -rest of the UI already works. A `uint8_t` encoder covers 256 options, comfortably past the palette -count, and whether it wraps or clamps at the ends follows the bound control's type: a palette ring -wraps, a brightness does not. - -### Light presets: save / load / loop a whole effect configuration - -MoonLight's preset system (part of `ModuleLightsControl`): **64 named slots**, each capturing the *complete* effect-tree configuration (all module control values), with save / load / delete, **preset looping** on a timer (rotate between a first/last slot), and Home-Assistant registration for external recall. The product owner has flagged this as **definitely needed**. - -**Partly shipped since this was written.** `ControlModule` owns the preset system: a pad grid of slots, save and apply, and `capture` controls that choose which subtrees a save includes. So the home question is settled (the surface, not a hub), and what remains from the description above is **preset looping** on a timer, rotating between a first and last slot, plus Home Assistant registration for external recall. Persistence already round-trips control values, so the mechanism is there; the loop timer is the missing piece. Complements the [presets UI note](backlog-core.md) (control-value bundles). - -### MultiplyModifier mapping-LUT memory at large grids (investigation, re-verify on classic) - -`scenario_perf_full` on the S3 (2026-06-17) measured the MultiplyModifier's cost across grid sizes. The finding, stated correctly: the modifier **reduces compute** (with the default 2×2 kaleidoscope the effect renders only the ¼-size logical quadrant: Noise+Multiply at 16K is 29,647µs vs 50,555µs for Noise alone), and its real cost is **memory**: the mapping LUT's destinations array. Measured modifier heap cost on the S3: 16²→1.7KB, 32²→10.8KB, 64²→23.5KB, **128²(16K)→93KB** (`nrOfLightsType` is `uint32_t` on a PSRAM board). On the S3's 8MB PSRAM this is trivial. Under the physical→logical fold build each physical light contributes ≤1 destination, so the destinations array is bounded by the real light count regardless of chain depth, so there is no build-time fan-out. - -**This is NOT a no-PSRAM blocker:** 16K Noise + Multiply has run on a classic ESP32 (no PSRAM, 320KB internal) before at **10–20 FPS** (WiFi vs Ethernet), sending frames out over **ArtNet to a display, not physical LED drivers**. It works there because classic's `nrOfLightsType` is `uint16_t` (half the LUT size) and the modifier shrinks the logical render grid. So the action is **re-verify the working classic setup when a classic board is connected** (find the config, its grid, mirror and ArtNet target, that reproduces the historical 10–20 FPS), not "fix an impossibility." Worth investigating only if that re-verification shows the LUT memory has regressed since: the destinations array is the obvious lever (it stores a `nrOfLightsType` per physical destination; a 2× kaleidoscope is 1:1 in *count* so the LUT need not store fan-out > the physical count: confirm it isn't over-allocating to `maxMultiplier()` when the effective fan-out is 1). Capture the classic numbers into performance.md's multi-board table first. - -### NoiseEffect simplex cost on ESP32 (investigation) - -With mirror XY at 128×128, NoiseEffect renders the 64×64 logical quadrant in **~11 ms/tick** on the Olimex (measured), as the simplex math dominates, since the Xtensa LX6 has no FPU and float math is software-emulated. (RainbowEffect on the same pipeline is much cheaper.) This is correct, non-degraded behavior; it's only worth revisiting if a deployment needs Noise faster than ~11 ms at this grid. - -Worth investigating if so: - -- **Q16 fixed-point simplex** instead of float (kills the software-float emulation cost). -- **Lower-precision hash:** current simplex uses a 256-entry permutation lookup; a smaller / SIMD-friendly hash may be faster on Xtensa. -- **Strided sampling + interpolation:** render at 32×32, bilinear up to 64×64. Visual quality cost; needs A/B comparison. -- **Inline / unroll the inner per-pixel loop** to keep the simplex state in registers. - -None of these are obviously free, and a fixed-point port may shift the visual signature. Defer until there's a real use case: on the no-PSRAM Olimex at large grids the tick is dominated by the synchronous ArtNet send (~35 ms), not Noise, so the effect is rarely the bottleneck there. - -**S3 render-only data point (2026-06-17, `scenario_perf_full`):** on the PSRAM S3 with **no output driver**, Noise is the dominant cost at every grid and there's no ArtNet floor to hide it: 16²→738µs, 32²→2,831µs, 64²→11,235µs, **128²(16K)→50,555µs (~20 FPS)**, clean ~linear-in-pixels (67×), so no fragmentation/realloc pathology, just raw simplex compute. The light effect (Checkerboard) on the same sweep is 6–11× faster (16K→7,949µs, ~128 FPS). So on a PSRAM board the heavy effect IS the 16K bottleneck (where on the Olimex the network send was). This is the strongest case for the fixed-point/strided-sampling ideas above, since a PSRAM board can run 16K grids that the network-bound Olimex never reaches. The S3 has a real FPU (LX7), so the win is less about software-float emulation and more about per-pixel simplex work; profile before committing. diff --git a/docs/work/future/backlog-moonlive.md b/docs/work/future/backlog-moonlive.md deleted file mode 100644 index b74cd8fa0..000000000 --- a/docs/work/future/backlog-moonlive.md +++ /dev/null @@ -1,566 +0,0 @@ -# Backlog: MoonLive - -> **Forward-looking backlog document, an exception to the CLAUDE.md present-tense rule.** Third in a -> family: [livescripts-analysis-top-down.md](livescripts-analysis-top-down.md) designs the ENGINE -> (grammar, IR, codegen), [power-functions-analysis-top-down.md](power-functions-analysis-top-down.md) -> designs the BUILTIN SURFACE the engine calls into. This one is the ordered plan for closing the -> gap between them: which language features to add next, and why that order. Every limit quoted -> below was measured against the shipped compiler, not read off the source. - -## What this is for - -MoonLive compiles a C-subset to native code on the device. The subset is deliberately small: it -started at "fill a buffer with a color" and grew a feature at a time. This document is the ordered -list of what to grow next, and why that order. - -**The bar is a corpus, not one effect.** The predecessor MoonLight publishes a body of live scripts at -[ewowi/MoonLight/livescripts](https://github.com/ewowi/MoonLight/tree/main/livescripts), -and the goal is that MoonLive can **run them**, not that we migrate them into this repo. They are -written against a language with floats, structs and a wide builtin surface, so they are an honest -external measure of how far the subset still has to grow: each one that compiles unchanged is a -feature below that landed, and each one that does not names the next gap. - -The measure throughout is **what a real effect needs**. A simulation effect (particles, physics, -anything where this frame depends on the last) is the honest test, because it exercises state, -arithmetic and control flow together rather than one at a time. `moonlive/effects/particles/balls.mle` is the -worked example: bouncing balls, written against the language as it stands today, and every -compromise it had to make is a line item below. - -## The ceilings, measured - -Five hard limits, all found by hitting them: - -| limit | value | where | -|---|---|---| -| script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h` | -| distinct members | **16** ✅ | `kMaxCtrls`, same file | -| branch labels | **96** in the IR, **255** labels and **255** fixups in the assembler, counted across the whole script (an `if` or `for` takes up to 2, and each `||`, `!` and `!=` one more) | `kIrLabels`, `kAsmLabels`, `kAsmFixups`, `MoonLiveIr.h` | -| frame slots | **32**, shared by live variables, loop counters and staged call arguments ✅ | `kMaxLocals`, `MoonLiveIr.h` | -| native code | **16 KB** per script, the same on every chip; the RISC-V backend emits the most, and the largest script is at 13.7 KB there | `kCodeCap`, `MoonLiveBuiltins.h`; see § The code cap, per platform | -| ~~numeric types~~ | ~~`uint8_t`, `uint16_t`, `int16_t`~~ → **`int`, `byte`, `bool`, `fixed`, `string`** ✅ | still no float: `fixed` is Q16.16 | -| ~~builtin table~~ | ~~16, and 16 used~~ → **96** ✅ | `BuiltinTable::kMax`, raised, with an overflow assert | - -The branch budget was binary-searched with generated scripts: **6 `if`/`else` + 2 `for` compiles, -7 does not.** The state budget is what caps the balls effect at 4 balls rather than 25: six fields -per ball needs ~150 bytes and six members. - -The frame-slot budget was hit writing `aurora.mle` (2026-09-04), the first two-layer shader: it -compiles only after folding intermediates back into the expressions that use them, which is the -readability the language exists to give. Factoring the loop body into a helper would relieve it, -once helpers can take arguments and return values (§ 6). See § 8b. - -### The code cap, per platform (backlog, 2026-10-16) - -Measured across all 53 shipped scripts with `moondeck/moonlive/disasm.py` (asked on Discord by hpwit, author of ESPLiveScript): compiled bytes per source byte run Thumb-2 1.4x, Xtensa 1.7x, arm64 2.2x, RISC-V 2.8x. The largest script, `stadbeest-sauron.mle` (135 lines, 5.7 KB of source), compiles to 7.8 KB on Xtensa and 13.7 KB on RISC-V, so a script about 20% bigger is refused on a C3 or a P4. - -A script's code meets three ceilings and the lowest wins: `kCodeCap`, the staging the compiler reserves per token (`codeCapFor`, capped at `kCodeCap`), and the executable memory `allocExec` finds. Only the last is the hardware: IRAM on a classic ESP32 holds tens of KB, while a P4's 768 KB of internal memory is executable and a desktop's is unbounded. The fix is the code cap as a per-platform value from the platform layer: 16 KB on a classic ESP32 and a C3, 32 KB on an S3, 64 KB on a P4 and the desktop. Pinned by a test that a script past 16 KB compiles on a platform whose cap allows it and is refused with a clear message where it does not. The members, frame slots, entry points and labels are separate budgets, costing memory per script on every chip, so they stay until a script needs more. - -## What a simulation effect gives up today - -Each row is a compromise the balls effect makes, and the language feature that would remove it: - -| forced to | because | wants | -|---|---|---| -| 4 objects, not 25 | 64-byte arena | a bigger arena, or a pool handle (shipped for particles) | -| whole-pixel motion | no fractional type | fixed-point or float | -| one flat color | no `hsv()` builtin | `hsv()` | -| one array per field | no structs | structs | -| the helper reads a member for its index | functions take no arguments | arguments | -| guards folded into `mod()` | 16 branch labels | a bigger label budget | - -## The library is already built; the engine is what is missing - -This is the part worth stating plainly: **the power-functions library exists and is largely -shipped** (particle kernels, the typed math surface, twelve showcase effects). It was built to make -MoonLive rich, and today almost none of it is reachable from a script. - -Its top-down spec already records what it needs from the engine, in -[§ 4 MoonLive requirements](power-functions-analysis-top-down.md), deferred at the time so the -library could finish compiled-side first. That list is the real roadmap, and it is more specific -than "add a language feature": - -1. **A builtin table of ≥ 64 entries.** ✅ *shipped*. `BuiltinTable::kMax` was **16, and the light - domain registered exactly 16**: the table was FULL, `add()` returned false past the limit, and - nothing checked the result, so the seventeenth builtin was dropped silently and the script - failed later with "unknown function". `kMax` is now 96, and an overflow is loud rather than - silent (`MM_ASSERT_NO_BUILTIN_OVERFLOW`). -2. **A larger branch budget (`kIrLabels`, today 16).** Every `if` costs up to two labels and every - `for` costs two, counted across the WHOLE script including its helper functions, and labels are - never reused once a scope closes. A script hits "too many branches in one script" well before it - feels complex. **Concrete case (2026-08-20):** giving `balls.mle` the wall bounces its own name - promises (four `if`s, one per wall) exceeded the budget and would not compile, even reduced to - one branch per wall by writing the direction bit arithmetically instead of toggling it. The - effect is 4 balls on a 64-byte arena. It ships bouncing only because the motion was rewritten as - a `beatsin()` per axis, where the reversal is inherent in the sine and costs no branch at all. - That worked out better here, but it was a workaround found under the ceiling rather than the - obvious way to write the effect, and the next script will not always have one. - **The cost is stack**, already measured and documented at `kIrAsmLabels`: ~4 bytes per label in - `lowerWith` (a local in the assembler), plus `Loop loops[kIrLabels]` and `int32_t - labelAt[kIrLabels]` in the spill pass. Raising 16 → 32 is a one-constant change; the number to - watch is `lowerWith`'s frame on the classic ESP32, which the existing note records going 480 → - 1120 bytes when the assembler tables were last raised. Reusing a label once its scope closes - would lift the ceiling without the stack, but that is a real allocator change rather than a - constant. -3. **Typed multi-argument host calls** (≤ 6 args, optional return). Today a builtin takes one - `uint32_t` and returns one, which is why `line()` had to be given a bespoke seven-argument - staging path and why most of the library is inexpressible. -4. ✅ **Script symbols**: SHIPPED as `t`, `width`, `height`, `depth`, `xPos`, `yPos`, `zPos`. -5. **Two entry shapes:** `frame()` for composing kernels (the scalable path) and `pixel(x,y,z)` - for per-pixel ergonomics (honest ceiling around 32×32). -6. **Stateful handles** (a `Pool`, a `BeatPhase`): script-declared, arena-allocated at compile - time, passed as an opaque first argument. No script-side memory management. - -Item 5 is worth noting against the arena work below: a particle pool as a HANDLE means the script -does not spend its own 64 bytes on particle state at all. That is a better answer than widening -the arena, and it is already designed. - -## The type system: ✅ *shipped* - -Shipped as designed, with three things worth carrying forward that the design did not anticipate: - -- **`fixed` needed three new assembler primitives**, not the one the design implied. A Q16.16 - multiply is `mulhi` + `mul` + two shifts, and the JIT had no shift instruction and no - multiply-high at all: `mulReg` is a plain 32-bit multiply. `shlImm`, `sarImm`, `shrImm` and - `mulhi` went into all four backends, every encoding checked byte-for-byte against the real - assembler. Worth it: the alternative made every fixed multiply a host call inside a per-pixel - loop, and made `int`↔`fixed` conversion (one shift) a function call. -- **An integer literal ADOPTS fixed at a meet point.** The design said any mix is an error; that - made `v * 2` and `if (v < 0)` unwritable. A bare literal now converts at compile time by - patching its own `Const` (free at run time), while a *variable* still names its conversion, - because a literal's meaning is visible at the site and a variable's scaling is not. -- **`movImm` truncated above 16 bits on arm64 and Xtensa.** A latent bug the whole time, masked - `& 0xffff` under a comment warning about exactly that failure mode: invisible while literals - capped at 65535, and fatal the moment a Q16.16 literal (2.0 is 131072) rode a `Const`. Both - backends now materialize the full 32 bits. - -`bool` truncates rather than normalizing (`flag = 256` reads false), because normalizing needs a -compare-and-select the IR has no op for: there is no `Sub` and no bitwise op. Waiting for a script -that writes a non-boolean expression into a bool. - -The design as agreed follows, unchanged. - -## The type system: the settled design (2026-08-23) - -Decided with the product owner after the signed-values work, whose four bugs were all -storage-width plumbing at the scalar boundary (a wrapped uint16 member, a sentinel read through a -16-bit window, a one-byte store into a two-byte member, a sign-blind array load). The design -removes that boundary rather than patching it again. This section supersedes the per-width -thinking in items 3, 5 and 10 below; those entries stay for their history and their measurements. - -**Five types, each usable as scalar or array; one storage rule each way:** - -> `int`, `byte`, `bool`, `fixed`, `string`. Every SCALAR occupies one uniform 4-byte slot, -> whatever its type. ARRAYS pack by element type. Strings are literals. - -A type is a SEMANTIC, not a storage width, for scalars: a `byte` scalar is an int the compiler -masks to 8 bits on assignment (one AND), a `bool` normalizes to 0/1 (one compare), `fixed` is an -int with scaled literals and a shifted multiply, `int` is bare. That single move is what buys -orthogonality without resurrecting the width machinery: one scalar load, one scalar store, no -sign windows, no width-matched bindings, no alignment rules. The width question survives only in -arrays, where it pays for itself in memory, and the array access path already carries an element -width (`idxPack`), so packed arrays are existing machinery rather than new. - -| Type | Min | Max | Step | The range is enough because | -|---|---|---|---|---| -| `int` | -2,147,483,648 | 2,147,483,647 | 1 | The domain's biggest integers: the ms clock (2^31 ms = 24.8 days, the known wrap), 12,288 lights, squared on-grid uv distances (<= 2^30). Anything wider lives in 64-bit inside a builtin, as escape() already does. | -| `byte` | 0 | 255 | 1 | Exactly a hardware channel, a palette index, a heat cell. The LED's range, not a chosen one. | -| `bool` | 0 | 1 | - | Normalized on store. | -| `fixed` (Q16.16) | -32,768.0 | +32,767.99998 | 1/65,536 | Coordinates top out near 256 px and uv at +-4.0, orders of magnitude inside the range. The step gives 16 fraction bits against the 8 a channel displays, so two chained factors still land at the display floor. One turn as 1.0 steps at exactly angle16's resolution, the precision sin16 already consumes. Q16.16 is libfixmath's fix16_t, the de-facto embedded standard. | -| `string` | - | one of the script's literals | - | An index into the compile-time pool. | - -**Cost on the hot path: none where it matters.** Scalar loads/stores are plain 32-bit forms -(on Xtensa `l32i.n`, 2 bytes against `l8ui`'s 3, so smaller than today). A `byte[]` element's -zero-extend and truncation happen INSIDE the load and store instructions; the only added -instructions are one mask on a `byte` scalar store and one normalize on a `bool` store. A `fixed` -multiply costs the mul-high + shift pair, which is the inherent price of fixed-point math the -builtins already pay inside their Q13/Q8.8 conventions; it just becomes visible and uniform. - -**`int` <-> `fixed` conversion is EXPLICIT, never implicit.** The conversion itself is one shift -(`<<16` / `>>16`), so the cost is trivial; the rule exists so a script never silently mixes a -pixel count into a Q16.16 expression and gets a number 65,536 times off. A mixed expression is a -compile error naming the conversion to write. - -**The two edges of `fixed`, stated so they are conventions rather than traps:** -- **Time never goes into `fixed` raw**: 32,768 seconds is 9.1 hours. `t` stays `int` milliseconds - and rhythm reaches a script through `beat()`/`beatsin()`, which is already the idiom. -- **Deep fractal zoom bottoms out at ~16 bits of magnification**, where the step becomes a pixel. - The same class of limit Q13 has at 13 bits, and better than float32 past that point (its 23-bit - mantissa is shared with the integer part). Extreme zoom is a 64-bit-builtin problem, not a - scalar-type problem. - -**`float` stays out, with a clean conscience.** The whole compiled library, the particle kernel -and escape() are integer/fixed already; math16.h supplies sin/atan/dist/sqrt; the hot path makes -float promotion a fatal warning. The FPU story splits the boards (LX6/LX7/P4 have one, the RISC-V -line does not), so JIT float either runs wildly differently per board or softfloats, against the -one-language-four-ISAs-identical rule. Fixed-point is bit-identical everywhere, which is also what -makes an effect reproducible. If the predecessor's corpus (written with floats) ever forces the issue, -the answer is compile-time float-literal-to-fixed translation, not runtime float. - -**Per-type notes:** -- `bool[]` ships byte-backed first (elements are 0/1 either way), and the 8-per-byte packing lands - later as a pure memory optimization when an automaton effect pays for its read-modify-write - codegen. Same rule as every constant here: against a script that needs it, not on speculation. -- `string` is a literal reference: assignable from literals, comparable, passable to a builtin, - one int of storage. NO concatenation, ever: runtime string building means allocation inside the - render loop. -- Where arrays LIVE: small arrays stay in the arena (internal RAM); grid-sized state goes through - ScratchBuffer handles (item 9b's model), which prefer PSRAM and fall back to internal heap on - the classic ESP32, whose fixtures are small by construction. `byte[]` is what keeps a classic - heat map at 1x rather than 4x. - -**Migration**: 26 shipped scripts spell C widths (`uint8_t`, `uint16_t`, `int16_t`). MoonLive is -not launched, so this is a clean mechanical break now and a compatibility program forever after. -That timing is a large part of why the decision was taken when it was. - -## The order, and why - - -Ordered by **what removing it buys**, not by implementation cost. - -### 1. A bigger builtin table: ✅ *shipped* - -`BuiltinTable::kMax` was 16 and the light domain registered exactly 16: **the table was full**, -and it failed SILENTLY: `add()` returned false, no caller checked it, and the next builtin would -have surfaced as "unknown function" in a script with nothing pointing at the cause. - -Now 96 (what the power-functions spec asks for), with `MM_ASSERT_NO_BUILTIN_OVERFLOW` so a dropped -registration is loud at startup rather than silent. This gated every other builtin; the palette -work below went in immediately behind it. - -### 2. Typed multi-argument host calls: ✅ SHIPPED - -SHIPPED: a builtin takes `const uintptr_t* args` with arities up to seven, plus typed flags -(`fixedArgs`, `fixedReturn`, `byRef`, `byStr`), and `emit` uses all seven. The paragraph below is the -problem as it stood before that. - -A builtin took one `uint32_t` and returned one. `line()` needed a bespoke seven-argument staging -path to exist at all, and most of the power-functions surface cannot be expressed without this. -The spec asks for ≤ 6 arguments plus an optional return. - -Doing #1 and #2 together is what actually opens the library; either alone leaves it stranded. - -### 3. A bigger arena and more members: *check the handle route first; storage rules now in the type-system design above* - -64 bytes of member data is why an effect holds four objects rather than twenty-five. - -**More members: ✅ shipped (2026-10-07).** `kMaxCtrls` is 16, measured at +280 bytes per scripted module on the S3 (class size 1312 → 1592 B). The StadBeest legs need 11 members, 6 controls and 5 values kept between frames, and before the raise packed four of those values into one `int` by bit ranges. The arena stays at 64 bytes, and the rest of this section is about the arena. - -**The two limits bind at very different points, and it is the COUNT that bites first.** -`fractal.mle` wanted 4 controls plus 4 scratch members plus a loop counter: 9 members costing -**12 of the 64 arena bytes**. It compiled once the counter was dropped (a `for` counter does not -have to be a member), so the script lost nothing, but the ceiling it hit was `kMaxCtrls` with 81% -of the arena still free. Any script with a handful of controls and a handful of intermediates -meets the same wall. If only one of the two moves, the count is the one worth moving: the four -tables it sizes were `DeclaredControl[8]` at 24 B each; the raise to 16 measured +280 B per -scripted module on the S3. - -**But check the handle route first.** The power-functions spec's item 5, a particle pool as an -arena-allocated HANDLE, means a simulation effect stops storing its own particle state entirely, -which removes the pressure without touching these constants. Widen the arena for the scripts that -genuinely hold their own state; do not widen it as a substitute for handles. - -Not purely a constants bump, and the blockers are known: - -- `kCtrlBytes` / `kMaxCtrls` are both `uint8_t`, so the arena caps at 255 bytes before any type - change. 150 bytes of particle state fits under that; much more does not. -- `static_assert(kCtrlBytes <= 64, "seeded_ is a 64-bit mask, one bit per script arena byte")` - (`MoonLive.h:355`) is the real gate. Past 64 bytes the seeded-member mask needs re-indexing, - and there is a worked example, because it was widened 16 → 64 once already. The assert exists - because the earlier `uint32_t` version silently aliased members mod 32. -- Watch `sizeof(MoonLive)`. It is held BY VALUE in every scripted module. `registerType`'s probe - builds one on the heap, since a probe on the boot stack is what boot-looped the P4 at 1440 bytes. - Growing the arena grows every scripted module. - -### 4. `setPaletteColor()`: ✅ *shipped* - -A script now writes one call where it used to write three: - -```c -setPaletteColor(x, y, index, brightness); -``` - -Three per-channel palette calls shipped first and worked, but the shape was wrong: three host calls per -pixel, and, because the compiler evaluates each argument independently, three evaluations of -whatever expression produced the brightness. A per-pixel radial falloff made that visible. - -**Measured on an S3, 64x64 grid: 2331 us → 1838 us, 21% off the effect's own cost**, and the call -site went from three copies of a six-term expression to one. It also takes x/y rather than a flat -index, so the buffer layout (`mod(x, width) + mod(y, height) * width`) stops being open-coded in -every script. - -No engine change was needed: `line()` already proved a Call builtin can write pixels through the -per-run draw canvas, so this rode the existing seam. `paletteRGB(i, bri)` answers the color as an `RGB` for a script that wants the value rather than a pixel (#4b). - -### 4c. Insights from porting a per-pixel effect: *the cost is CALLS, not math* - -A second port (`moonlive/effects/waves/octopus.mle`, a polar spiral) measured very differently from the -first, and the difference is the useful part. - -| effect | shape | S3, 64x64 | -|---|---|---| -| balls | four small discs, ~500 lit pixels | **1278 us** | -| octopus | every pixel, every frame | **21762 us** | - -A third port (`metal.mle`, an SDF shader) put a number on the most expensive builtin, measured on -shiffy's 80x48: - -| variant | tick | vs plasma | -|---|---|---| -| plasma (9 calls/px, no sqrt) | **16031 us** | baseline | -| metal, 2 `polarR`, no `uv` | **32843 us** | 2.0x | -| metal, 2 blobs + `uv` | **46221 us** | 2.9x | -| metal, 3 blobs + `uv` | **59600 us** | 3.7x | - -**`polarR` costs ~13400 us per call site per frame at 3840 pixels: about 3.5 us per pixel, one -builtin.** It wraps `dist16`, a real square root, and `draw.h` already measures a sqrt-based SDF at -~108 cycles/px against ~14 for the squared form. A squared-distance builtin (`polarRSq`, or letting -a script compare against `r * r` as `ripples.mle` does) is the cheap fix, and it is the same trick -the compiled effects already use. - -Also measured and **disproved**: hoisting the four loop-invariant `beatsin` calls out of the inner -loop into members moved 59600 to 58459 us. Call overhead per se is NOT the cost here: the square -roots are. Worth recording because it contradicts the natural first guess. - -That is ~5.3 us per pixel, and it is not the arithmetic; it is **~32,000 host calls per frame**. -Each `polarA`/`polarR`/`sin`/`scale`/`beat` is a real call through the builtin ABI, and a -whole-canvas effect makes eight or so per pixel. - -Three things follow, and they sharpen the priorities above: - -1. **No common-subexpression elimination.** `polarR(x - cx, y - cy)` appears twice in one - expression and is CALLED twice. The compiler has no way to name an intermediate, so a script - cannot hoist it either, the same limitation that made a palette pixel cost three brightness - evaluations before #4. A single-assignment local (part of #4b/#6's territory) would remove a - third of this effect's calls on its own. -2. **Per-pixel builtins want to be inline ops, not calls.** `setPaletteColor` is a Call and that is - fine at ~500 pixels; at 4096 the call overhead dominates. The `Inline` kind already exists - (`setRGB` uses it); the polar pair and the palette write are the candidates. -3. **A `frame()` entry shape would sidestep it entirely.** The power-functions spec's item 4, - compose kernels over a whole frame rather than script per pixel, is exactly the answer for - this class of effect, and octopus is the concrete case that argues for it. - -Worth stating plainly: 35 fps for a full-canvas 64x64 effect is usable, and the port needed no -new language features beyond two builtins. The ceiling here is call overhead, not expressiveness. - -**A naming lesson, learned by breaking two scripts:** the polar builtins were first called -`angle`/`radius`, which silently broke `ring.mll` and `rose.mll`: both declare a `radius` member, -and a builtin shadows a member name. Builtins share one namespace with every script's own -variables, so a new builtin should take a name a script would not: `polarA`/`polarR` rather than -the obvious ones. The compile-every-script test caught it immediately, which is the argument for -keeping that test cheap to run. - -### 4b. Predefined structs: `Coord3D` and `RGB`, together: ✅ *shipped* - -A struct is a named fixed-size array the compiler expands into scalar operations: a member, a local, a parameter and a return value, with field access, whole-struct assignment and a constructor. A struct argument fills one position per field, so every three-channel builtin takes an `RGB`. `getRGB` and `paletteRGB` answer one. The language reference holds the rest: [RGB and Coord3D](../../moonmodules/light/moonlive.md#rgb-and-coord3d). - -**No `HSV` struct.** A hue wheel is how an effect picks color while ignoring the user's palette, so `HSV` stays in `setPalEntryHSV`, which authors a palette rather than bypassing one. - -What remains: `setColorFromPalette(pos, index, brightness)` taking a `Coord3D`, and arrays of structs (§ 10). - -### 5. Fractional arithmetic: *superseded by `fixed` in the type-system design above* - -Every remaining visual compromise traces back to this: smooth motion, real velocities, a bounce -that conserves speed, and any falloff term that makes a shape look round rather than flat. Without -it each is a separate workaround. - -Two routes, and the choice matters more than the schedule: - -- **Fixed-point** (a `q8_8` over the existing `uint16_t`): no new backend work: it lowers to the - integer ops all three ISAs already have. Enough for positions, velocities and most ramps. -- **True `float`**: closer to a precompiled effect's source, which is the near-verbatim porting - goal the top-down analysis sets. Costs an FPU path per backend; the P4 and S3 have one, the - classic ESP32 does not, so it would soft-float or refuse. - -Fixed-point first is the pragmatic order: most of the quality at a fraction of the cost, and it -does not foreclose float. - -**Settled** by the type-system design above: `fixed` is Q16.16 on a uniform int slot, float -stays out, and the range analysis lives there. - -### 6. Function arguments and return values: *moderate, removes a real footgun* - -`draw(i)` instead of setting a member the helper reads. The current shape is not just verbose: -caller and callee agree by convention and nothing checks it, so a helper called from two places -with different state silently does the wrong thing. It is also what makes helpers composable. - -Script functions DO exist and ship: `balls.mle` calls `drawBall()`, `crosshair.mle` calls three -helpers, and every layout and modifier is one. Recursion works. Three limits sit on top of them, and -`aurora.mle` hit all three at once writing a `bright(v)` helper to apply one contrast window to two -layers (2026-09-04). **The return value shipped the same day, and eight parameter words with structs on 2026-10-12**, so one remains: - -| limit | what the compiler says | -|---|---| -| no forward calls: a helper must be declared ABOVE its caller | `unknown function`, with the column but not the name | - -A function may now RETURN a value: `int f() { return ...; }` and the call is an expression. Each -backend's `callLabel` preserves the whole vreg pool and delivers the result into the destination -register, so a value survives the calls that follow it in the same expression. - -The forward call is the cheapest to build and the most confusing to meet, since the message is the -one an actual typo produces: a second pass over the class body resolves it, which the code comment -names as the reason it is refused rather than half-supported. - -Parameters and returns also relieve § 8b: a helper's variables are live only inside it, so factoring -a loop body into a function is how a script stays under the frame budget. - -### 7. Signed values: ✅ *shipped* - -`int16_t` members, signed comparison, signed `/` and `%`, and `uvX`/`uvY` returning a signed -coordinate with no bias to subtract. - -**The framing this item had was wrong about the cause, which is worth recording.** It described the -problem as `a - b` wrapping, and prescribed signed comparison. Writing a Mandelbrot effect produced -four bugs in one session and **not one of them was a comparison bug**: no `<` or `>` ever produced a -wrong picture. Three were the *biased-unsigned* convention, where a builtin returned a value centered -on 32768 and the author had to subtract that bias, which is exactly the subtraction unsigned -arithmetic breaks. The fourth was a byte argument truncating instead of saturating. Every one -presented as "the effect renders nothing", never as an error. - -So what shipped is smaller than "make the language signed" and removes more than it adds: - -- `signedArg`'s undocumented **16-bit window** is gone. It was the inverse of `uint16_t` member - truncation, written down in neither place, and it is what made `d = 60000` read as -5536. -- The **bias on `uvX`/`uvY`** is gone: a coordinate has an origin, so the center is 0. -- `sin`/`cos` **keep** their bias, deliberately. A wave has no origin, and `scale(sin(a), n)` - sweeping a full axis is the idiom 14 shipped call sites use. A script wanting a signed wave - writes `sin(a) - 32768`, which works now. -- **Comparison** is a separate `BranchGeS` op, not a change to `BranchGe`: the array-index clamp - and the loop guards need unsigned, and a negative index arriving as a huge value is what lets one - branch catch both ends of a range. -- **`int8_t` is deliberately absent.** Xtensa has no signed byte load, so it would need a - sign-extend sequence the other three ISAs do not, for a width no script has asked for. - -`escape()` stays a builtin regardless: its Q13 squaring needs 64-bit intermediates, which a 32-bit -script value cannot express however signed it is. - -### 8. More branch labels: ✅ *raised to 96 IR labels and 255 assembler labels and fixups, the tables on the compile-time heap* - -16 was tight enough that a straightforward nested draw did not fit. At 40 IR labels and 48 assembler labels with 96 fixups two scripts still hit them on 2026-10-07: the StadBeest legs with a bass envelope, and the StadBeest eye with darts, blinks and a ripple, each at roughly 20 to 25 `if`s across all its functions. Both fit only after branches were rewritten as arithmetic (§ 11). Raising `kIrLabels` costs -compile-time table space and nothing at run time. Measure what a realistic effect needs before -picking a number: the balls port wanted ~12 and had to be folded down. - -### 8b. More frame slots: *the encoding is NOT the limit; measure the stack* - -**32 live variables**, shared by a script's named variables, its loop counters, and the arguments it -stages for a call. The budget is what is live AT ONCE rather than a total: a call hands its staging -slots back, and an `if`, `else` or `for` block hands its locals back at the closing brace -([MoonLive](../../moonmodules/light/moonlive.md) documents both). A script that exceeds -it fails with "too many variables in this function", "too many arguments to hold" or "too many loop -variables". - -Sixteen is enough for the shipped corpus and was not enough for the first two-layer shader: -`aurora.mle` wants an oscillator per layer, the grid centre, a polar angle and radius, and a field -sample per layer, all live in the same scope inside two nested loops, with the loop counters and -each call's staged arguments drawn from the same 16. - -`kMaxLocals`'s own comment says raising it "means widening the frame on all three backends together, -the slot index is an instruction field". **Measured on the shipped assemblers, that is not so:** - -| backend | how a slot is addressed | slots the encoding allows | -|---|---|---| -| Xtensa | `s32i/l32i`, an 8-bit offset counting 4-byte words | 256 | -| RISC-V | `sw/lw`, a 12-bit signed offset from the frame pointer | 2048 | -| host (arm64, x86-64) | an offset from a parked frame pointer | not encoding-bound | - -So the real cost is **stack**, not encoding: each slot is 4 bytes of frame on the render task, and -`kAsmLabels`/`kAsmFixups` next door carry the warning that this project has already bootlooped a P4 -on an oversized stack frame. 16 slots is 64 bytes; 32 would be 128. That is a cheap change on its -face, and the measurement settled it: **`kMaxLocals` is 32** (2026-09-04), 128 bytes of frame, and -the encoding was never the limit on any backend. - -### 9. Division: ✅ *shipped* - -`/` and `%` are operators, at multiplication's precedence. Both lower to a host call the way `mod` -already did, so the operator costs nothing the capability did not already cost: the divide itself -is the expense, and it is a host call wherever it appears: fine on a cold path, deliberate -per-pixel. The parser resolves both through the builtin table (`div`, `mod`) rather than knowing -either by name, so core stays domain-neutral and a domain that registers neither simply has no -operator. `mod(a, b)` stays registered: it is the name the cyclic case reads best under. - -### 9b. A ScratchBuffer pool handle, ✅ *shipped for particles* - -A script sizes its own particle pool with `pool(n)` from `defineControls()`, and the buffers live in -`MoonLiveParticles` (six `ScratchBuffer`s the binding owns) rather than in the 64-byte arena, which -would have held about five particles. Sizing is reachable ONLY from that one moment: the sizing sink -is installed around the `defineControls` run, so `pool()` from `tick()` is a no-op reporting the live -count and no allocation ever reaches the render path. - -Nine builtins, all whole-pool passes: `pool`, `emit`, `gravity`, `drag`, `step`, `age`, `render`, -`bounce` and `collide`. The last two shipped after measurement: collide is an N-body check (3.2 us -at 48 particles against 0.1 us without, 53.6 us at 200), so the quadratic is real but the absolute -cost at ball-pit sizes is not, and the numbers ride the builtin so an author knows what a big pool -would cost. -The cost model is the point. `fountain.mle` measures **9 us** on a 128x96 desktop grid against -`metal.mle`'s **1557 us** on the same grid: the first script vocabulary whose cost scales with the -OBJECTS rather than with the grid. - -**Structs were NOT needed, and that is a finding rather than a deferral.** Every pool operation is -whole-pool or takes plain scalars, so a script never names a particle field. #10 below is about -`ball[i].x` INSTEAD of parallel arrays, which a pool removes the need for; #4b is `Coord3D`/`RGB` -for per-pixel shader signatures, whose real prerequisite is #2. - -Not exposed, each with a reason: `spray` -(`emit` with a wide cone is one), `spawn` (per-particle in a whole-pool API), `force`/`forceSmall` -(needs the `acc` buffer for wind nothing needs yet), `attract`, `wrap`, `liveCount`, `clear`. - -### 10. User structs, and arrays of them: *readability, once the arena is bigger* - -`ball[i].x` instead of parallel arrays. Genuinely nicer and closer to how a precompiled effect -reads, but parallel arrays work the moment the arena is big enough. Last because #1 removes most -of the pain, not because it does not matter. - -Arrays of the five scalar types already ship (`byte heat[16]` in `ember.mle`), so what is missing -here is arrays OF STRUCTS, and it splits in two. An array of a PREDEFINED struct (§ 4b) is the -smaller half and the one an effect reaches for first: a script holding `Coord3D pos[8]` is exactly -the parallel-array flattening this item exists to remove, and the element width the array path -already carries (`idxPack`) is the machinery it needs. An array of a USER struct needs the general -declaration machinery above it. Worth building in that order if this is picked up. - -### 11. What the StadBeest legs and eyes hit (2026-10-07) - -Two effects written in one session on real hardware: `stadbeest-walk.mle` (a walk cycle on 10 tubes, dancing to music) and `stadbeest-frog-eye.mle` (a frog's eye on a 241-light ring disc). Besides the member and label limits above, each of these cost a workaround: - -| limit | what it cost | wants | -|---|---|---| -| "codegen failed: assembler overflow (branch range, slot, or immediate)" | names neither the limit nor the line; finding it took a desktop bisect with `disasm.py` | the message names the table that ran out and the function it was filling | -| "too many arguments to hold" | fires when the eye's `tick()` held too many live locals, so the wording sends the author to the calls | a message about live variables when that is the cause | -| a failed recompile leaves the effect dark | on a live installation every edit that does not compile switches the light off until a fixed one arrives; it happened three times in the session | keep running the last program that compiled, and show the error beside it | -| `setPaletteColor` clamps the palette index | a palette is circular, but `hue + offset` past 255 sticks at the last color unless the script writes `mod(..., 256)` | wrap the index, as the palette itself does | -| an effect visits every grid pixel | the eye computes 1600 pixels to light 241, and the legs' grid is mostly empty between 10 tubes | a way to iterate only the layout's lights, for sparse layouts | -| a `bool` control takes a min and a max | `addControl("pulse", pulse, 0, 1)` | `addControl(name, flag)` for a bool | - -### 12. A one-to-one port of hpwit's scripts, for a like-for-like comparison (2026-10-19) - -hpwit's compiler (`hpwit/new-parser`, `asmparser2`) runs whole C-like programs. His `multi_effect_controller.sc` (800 lines) and the five `ProceduralEffects` were ported to MoonLive on 2026-10-18 (`moonlive/projects/hpwit/`), but as the same LOOK rather than the same program, so code size and frame time do not compare like for like. A one-to-one port needs the language to accept his programs as written. Each construct was compiled on the desktop on 2026-10-19: - -| his scripts use | MoonLive today | what it takes | work | -|---|---|---|---| -| ~10 KB of state: struct arrays of 160 to 220 objects, a 40×80 trail, 12 KB heat and angle tables | 64 bytes of members, 16 members | an arena sized from the declarations, on the heap or PSRAM, past the `uint8_t` and 64-bit seeded-mask limits; item 3 has the blockers | L, 3 to 5 days | -| ~30 functions: struct methods and free functions | 8 per class (`kMaxEntryPoints`) | a function table sized per script; free functions outside the class | M, 1 to 2 days | -| `struct` with fields, methods and a constructor, arrays of them | refused | item 10, user structs, then their methods and arrays | XL, 1 to 2 weeks | -| `float`, `uint8_t`, `uint32_t`, casts like `(int)(x)` | refused | `float` (every target but the RP2040 has an FPU) or float mapped onto `fixed`; the sized integer names as aliases; casts | S for the names and casts, 1 day; M to map `float` onto `fixed`, 2 days; XL for a real `float` on five backends, 1 to 2 weeks | -| `grid[y][x]` (his `[y, x]`) | refused | 2D arrays over the existing element-width path | M, 1 to 2 days | -| `while`, `break`, `continue`, `i++`, `+=`, `?:`, `switch` | all refused | the statements and operators, each a parser and IR addition | S each for `while`, `++` and `+=` (rewritten into what exists), 1 day together; M each for `break`/`continue`, `?:` and `switch`, 3 to 5 days together | -| `#define` constants | refused | a constant declaration (`const int`) rather than a preprocessor | S, half a day | -| `main()` with `while (true)`, `show()`, `delay()` | `tick()` per frame | a shim: the loop body becomes `tick()`, `show()` and `delay()` become no-ops, `millis()` reads `t` | M, 1 to 2 days | -| `hsv()`, `rand(mod)`, `atan2`, `hypot`, `sin` on radians, `printfln`, `pinInterrupt` | palette, `random16`, `polarA`/`polarR`, `sin` on 0..65535, `print`, none | host bindings with his signatures, under his names, in a compatibility table | M, 2 days, once `float` is decided | -| code past 16 KB | `kCodeCap` 16 KB | the cap per platform (§ The code cap, per platform) | S to M, 1 day | - -Work is in days of agent implementation with the product owner's review, for the parser, the IR and all five backends, each with its tests; S is under a day, M one to two, L a week, XL two. In all about five weeks with `float` mapped onto `fixed`, about seven with a real `float`, the structs and the arena being most of it. - -Most rows are general language growth the MoonLive scripts want anyway; the `main()` shim and his host bindings exist only for the comparison, and stay behind a compatibility table rather than entering the standard vocabulary. **Decide first** whether the comparison is worth the compatibility layer, or whether comparing on the ported looks, now in `moonlive/projects/hpwit/`, is enough. - -## How to know a step landed - -Two measures, one local and one external. - -**Local:** re-port a simulation effect after each step and delete the row it removes from the -compromise table above. A port with no rows left (dozens of shaded, colored objects with -fractional velocities on a full-size grid, at a frame rate the bench can measure) means the -language carries a real simulation. - -**External, and the harder bar:** take the -[predecessor's livescripts corpus](https://github.com/ewowi/MoonLight/tree/main/livescripts) and -count how many compile and run unchanged. That number is the honest progress metric, because those -scripts were written without regard for our subset. Track it per step; a feature that moves it is -worth more than one that does not, whatever this document guesses. diff --git a/docs/work/future/generative-fields-analysis-bottom-up.md b/docs/work/future/generative-fields-analysis-bottom-up.md deleted file mode 100644 index 60f0ade65..000000000 --- a/docs/work/future/generative-fields-analysis-bottom-up.md +++ /dev/null @@ -1,237 +0,0 @@ -# Generative fields: bottom-up analysis - -> **Forward-looking research document, the exception to CLAUDE.md's present-tense rule.** A Stage-1 bottom-up analysis of the effect family this document names *generative fields*: images computed from noise fields over a coordinate mapping, either fresh every frame (a shader) or by transporting the previous frame through a velocity field (advection). Part 1 states what we want to build in the terms of the field's primary sources. Part 2 lists implementations that do similar things and where each fills in a spot. Part 3 places the family in MoonLight's architecture: what exists, measured, and what is missing. The **top-down** companion (to be written from the prompt at the end) turns Part 3 into the implementation spec. Written 2026-09-03. Modeled on [power-functions-analysis-bottom-up.md](power-functions-analysis-bottom-up.md), which this extends: the fields, shader and particle families it cataloged are taken as read. - -## TL;DR - -> **Status (2026-09-05): the three gaps this document names are closed.** Advection shipped as `draw::advect`/`advect16`, gradient noise replaced value noise behind the same names, and color state above 8 bits shipped as effect-owned 16-bit planes rather than a wide Layer (the top-down's § 11 records why). The "What we need to add" list in Part 3 is therefore built, and the builtin count below (55) is now 67. The rest of the document is a Stage-1 snapshot and is left as written. - - -- **The target is two textbook techniques over one block set.** A **procedural shader**: color as a function of (position, time), computed per pixel from noise sampled through a coordinate transform. **Advection**: the previous frame transported by a velocity field and decayed, with sources drawn into it each frame. Both stand on the same primitives: a polar coordinate mapping, gradient noise with fractional Brownian motion and domain warping, low-frequency oscillators, a contrast window and palette, anti-aliased sub-pixel rasterization, a bilinear resampler, framerate-independent exponential decay, and fixed-point arithmetic with quantization last (Part 1). -- **Every algorithm has a name and an originator.** Perlin noise (1985, 2002), fBm, domain warping (Quilez), semi-Lagrangian advection and stable fluids (Stam 1999, 2003), curl noise (Bridson 2007), flow-field particle tracing (Hobbs; Shiffman), Wu's anti-aliased lines (1991), coverage from signed distance, exponential half-life decay, LFO modulation. None needs more than adds, multiplies, a sine table and a noise function (Part 1). -- **The cost model is arithmetic and decides the design.** A shader costs samples per pixel and scales with area × samples; advection costs a few loads and lerps per pixel per pass and scales with area × channels, plus memory for color state above 8 bits. For both, framerate is part of the rendering method: transport must stay sub-pixel per frame, decay must not step, oscillators must not alias. Throughput in pixels per second, not frames per second, is the honest metric (Part 1 § Cost). -- **The field has converged on the same shapes.** A polar-noise shader engine with an oscillator bank and a coordinate mapper; an emitter-plus-flow advection engine with six flow types, a modulator bank and a half-life fade; both now available in Q16.16 fixed point with bit-exact tests, on the ESP32 class we target; anti-aliased canvases; wave and fluid solvers; particle-trail systems. These are similar to what Part 1 describes, not identical, and each fills in a spot: measured throughput, parameter vocabularies users expect, fixed-point choices that work, and precision decisions (Part 2). -- **MoonLight has most of the blocks and lacks three.** Present: polar addressing, 16-bit value noise with fBm/warp/turbulence, 16-bit oscillators, palettes, a GLSL-vocabulary shader runner, SDF coverage, a 24.8 sub-pixel splat, particles, a persisting Layer buffer, and 55 MoonLive builtins that already express a polar-noise pixel. Missing: **a bilinear resampler of the previous frame** (so no advection), **color state above 8 bits** (so trails posterize and sub-integer accumulation is impossible), and **gradient noise** (value noise reads coarser at low frequency). The MoonLive gap is structural: per-pixel host calls cost ~5 µs per pixel measured, and a script has no frame of state; both families want whole-frame kernels a script composes (Part 3). -- **The ESP32 budget is known.** ~293 cycles per pixel at 128×128 @ 50 fps on 240 MHz; one noise sample per pixel measures ~750 cycles on the S3 today. A rich shader is a panel-class effect on any MCU; advection scales to walls in cycles and needs PSRAM for state. Every target has an FPU, the P4 and S31 add SIMD and hardware loops, the P4 measures ~3× the S3 and a desktop core 20-40×, so the P4 and S31 are where this family shines on an MCU: the ESP32 class stops at about one noise sample per pixel on a 128² wall, and the desktop continues on the same contract and drives the wall over the network. The levers are standard: LUTs, fixed point, fewer samples, a field below output resolution, a field below frame rate, per-target FPU and SIMD behind one contract (Part 3 § Budget, § Per-target headroom). -- **Out of scope for Stage 1.** API names and signatures; the 8-bit versus 16-bit buffer decision; where advection state lives; the MoonLive frame-kernel shape; which showcase effects come first. All Stage 2 (Part 3 § Bridge). - -## Why this document exists - -The product owner's goals for this family, recorded 2026-09-03: - -1. **MoonLight supports the building blocks of these effects**, via compiled functions and especially via MoonLive effects, on the power-function library ([power-functions.md](../../moonmodules/light/power-functions.md)), the same way it carries particles and SDFs. -2. **A few genuinely beautiful showcase effects** are built on those blocks, as the proof the blocks are right. -3. **The ESP32 is CPU-bound; memory is not the constraint.** Every choice is made against the per-pixel cycle budget, and the fact that these effects look better the higher the framerate is a design input. -4. **Industry-standard terminology and algorithms throughout**, per [CLAUDE.md § Principles](../../CLAUDE.md#principles): the textbook construct, named by its textbook name, from the primary source. - ---- - -# Part 1: What we want to build - -Stated from the primary sources only. Where a name is given, it is the algorithm's name in the literature; where a person is named, it is the originator of the algorithm, not an implementation. - -## The two techniques - -**A procedural shader** computes each pixel's color as a function of its position and the current time: `color = f(x, y, t)`. Nothing has to survive between frames, which is what makes it composable; state is welcome whenever it saves work: a polar table cached per geometry, a field rendered below the frame rate and interpolated, the dithering error carried forward, a value fed back into the next frame's parameters. Composition happens on the *coordinate*: transform where the pixel samples from (rotate, scale, fold, displace by another field) and the image transforms with it. This is the shader model of the GPU tradition, applied on a CPU one pixel at a time. - -**Advection** transports a quantity (here: the color already in the frame) along a velocity field. Each frame: sources add color where they are drawn; every pixel's new value is the old value found by stepping *backward* along the velocity (the semi-Lagrangian method: Stam, *Stable Fluids*, SIGGRAPH 1999; *Real-Time Fluid Dynamics for Games*, GDC 2003), read with bilinear interpolation; and the result decays. The frame buffer is the simulation state. - -They differ in one property that decides everything downstream: a shader needs no state and its cost is samples per pixel, with state used to cut samples; advection is all state and its cost is a resample per pixel per pass plus the memory to hold color above 8 bits. They share every block below the top. - -## The blocks - -### Coordinate mapping - -- **Cartesian to polar.** For every pixel, `r = hypot(x − cx, y − cy)` and `θ = atan2(y − cy, x − cx)` about a center. Precomputed once per geometry into two tables; nothing trigonometric runs per pixel at render time except the sine and cosine of a *modulated* angle. Polar addressing is what makes motion rotate around a center rather than slide across the panel. -- **Polar transforms.** Add to θ to rotate; multiply r to zoom; add `k·r` to θ to twist into a spiral; fold θ into n mirrored wedges for a kaleidoscope (`θ' = |((θ mod 2π/n) − π/n)|`); take `1/r` for a tunnel. Each is one operation on the coordinate before sampling. -- **Domain warping.** Replace `f(p)` by `f(p + g(p))` where `g` is itself a noise field (Quilez, *Domain warping*). The canonical forms are `fbm(p + fbm(p))` and `fbm(p + fbm(p + fbm(p)))`; the intermediate displacement vectors are free color inputs. Applied in polar space, warping the angle makes the field swirl and warping the radius makes it breathe. -- **Normalized shader space.** Center the pixel and scale by the short side so a circle stays circular on a non-square panel; the GLSL `uv` convention. - -### Noise - -- **Gradient noise** (Perlin, *An Image Synthesizer*, 1985; *Improving Noise*, 2002): a pseudo-random gradient at each lattice corner, a dot product with the offset, a quintic fade, trilinear interpolation. Smooth to the first derivative, statistically isotropic, zero-mean. Simplex noise (Perlin 2001) is the same idea on a simplex lattice, cheaper in higher dimensions. **Value noise** interpolates random values at lattice corners instead; it is cheaper and visibly lumpier at low frequency because its extrema sit on the lattice. -- **Fractional Brownian motion (fBm).** Sum octaves at doubling frequency and halving amplitude (`lacunarity = 2`, `gain = 0.5`); structure at every scale. **Turbulence** sums the absolute value instead; the creases read as flame and smoke. Octave count is the primary cost knob. -- **Noise as a source of motion.** The third noise dimension advanced with time evolves a 2D field without scrolling it. A noise value mapped to an angle (`θ = 2π · noise`) is the classic flow-field construction (Hobbs, *Flow Fields*; Shiffman, *The Nature of Code*, autonomous agents). -- **Curl noise** (Bridson, Houriham, Nordenstam, *Curl-noise for procedural fluid flow*, SIGGRAPH 2007). In 2D the velocity is the perpendicular gradient of a scalar noise potential, `v = (∂ψ/∂y, −∂ψ/∂x)`: exactly divergence-free, so transported color swirls and never piles up, with two finite differences per pixel and no solver. - -### Time: oscillators and modulation - -- A **timer bank**: N independent clocks, each `phase_i = (t + offset_i) · ratio_i`, all scaled by one master speed so a single knob scales the whole animation. -- From each clock the four standard **LFO shapes**: a ramp (the phase itself, for scrolling and zoom), a wrapped phase in `[0, 2π)` (rotation), a bipolar sine in `[−1, 1]` (breathing), and a noise-driven angle `2π · noise(phase)` (wandering). Unipolar variants in `[0, 1]`. -- **Modulation**: a parameter bound to a clock and a depth, applied multiplicatively about its base value (`p' = p · (1 + depth · lfo)`), so nothing in the image is constant and every parameter can breathe. The synthesizer model, by its synthesizer names. -- **Framerate independence.** Every rate is per second, integrated with the measured `dt`; a virtual clock `t += dt · speed` keeps oscillators and decay in step under one speed control. - -### Color - -- **Contrast window**: clamp the field value to `[low, high]` and rescale; a black point and a white point, which is how a soft noise field becomes shapes with edges. -- **Palette mapping**: the windowed value indexes a gradient; or three field layers drive R, G, B directly; or a **cosine palette** (`a + b·cos(2π(c·v + d))`, Quilez) gives a whole ramp from twelve constants. -- **Gamma** as the last nonlinearity before output, and **quantization last**: compute in more than 8 bits, round once, with **temporal dithering** (error spread across frames) where the output is 8-bit. - -### Sources: anti-aliased rasterization - -Anything drawn directly into the frame, at fractional coordinates, additively: - -- A **disc** by coverage from its signed distance: `cov = clamp(radius + 0.5 − dist, 0, 1)` per pixel; edges anti-alias for free. -- A **line** by Wu's algorithm (1991) or its generalization: step along the segment and distribute each step over the 2×2 neighbors with bilinear weights `(1−fx)(1−fy), fx(1−fy), (1−fx)fy, fx·fy`. The same 2×2 **splat** places a point source at sub-pixel precision. -- Sources move on the oscillators above: orbits, Lissajous curves (`x = A·sin(a·t), y = B·sin(b·t + φ)`), swarms with Reynolds steering, and borders or masks that are static geometry. - -### Transport: the velocity field and the resampler - -- **Velocity as a rule per pixel**, evaluated at sample time, not stored: a uniform wind (direction, speed, optional rotation); radial in or out; a spiral (rotate by α, move radially by ρ, in polar space); zoned rings (smoothstep-blended radii, each with its own swirl or drift); noise-driven shift (two independent 1D noise profiles, one per axis, applied separably); curl noise; or a stored field from a solver. -- **The resampler**: for each destination pixel, `src = dst − v·dt`; read the previous frame at `src` with **bilinear interpolation** (four loads, three lerps per channel); **wrap** at edges for a seamless tile or **clamp** so color flows off. A separable field (row shift then column shift) halves the work: two 1D passes through a scratch buffer. -- **Partial transport**: `out = (1 − b)·current + b·sampled` mixes what arrives with what was there; it reads as viscosity without a solver. -- **Stable fluids** (Stam 1999) when real fluid behavior is wanted: diffuse velocity, project to divergence-free (Jacobi or Gauss-Seidel iterations on a Poisson equation), self-advect, project again, optionally reinject lost swirl by **vorticity confinement** (Fedkiw, Stam, Jensen 2001), then diffuse and advect the dye. Orders of magnitude costlier than a velocity rule; curl noise gets most of the look for none of the solve. - -### Decay - -- **Exponential decay by half-life**: `k = 0.5^(dt / t½)`, multiply every channel by `k` each frame. Framerate-independent by construction; a half-life in seconds is the user-facing knob. A per-frame constant multiply (the 8-bit "fade by" idiom) is the same thing only at one fixed framerate. -- **Blur** as a second decay, separable, for softening trails. - -### Arithmetic - -- **Fixed point** throughout the hot path: Q16.16 for coordinates, velocities and color state; angles as a fraction of a turn in an integer (a 16-bit or 24-bit turn) so sine and cosine are table lookups and wraparound is free; square roots avoided by comparing squares or by an integer `isqrt`. -- **Lookup tables** for everything per-geometry (polar), per-turn (sine, cosine), per-curve (fade, gamma). -- **Precision graded by role**: the noise inner loop tolerates 8 fractional bits at a quality cost; color state does not. - -## Cost - -Per pixel, a shader pays `samples × noise_cost + transform + window + palette`; rich compositions run 3 to 12 samples. Cost is `area × samples`. Advection pays, per pixel per pass, four loads, three lerps and one multiply per channel, and per frame the field rule; cost is `area × channels × passes`, independent of noise, plus color-state memory of `area × channels × 4 bytes` (and a scratch copy, and any stored field). A 16-bit or wider color state is not optional for advection: an 8-bit state cannot hold sub-integer accumulation from splats or a slow decay, and a trail fading through 256 levels posterizes at the low end. - -**Framerate is part of the rendering method.** Transport per frame is `v · dt`; when it exceeds about a pixel the bilinear filter smears instead of moving and edges tear. A decay of `0.5^(dt/t½)` with a short half-life becomes a strobe at low fps. Temporal dithering averages only above the eye's integration rate. Oscillators sampled below twice their rate alias. The consequence is a design rule: the per-frame cost must stay small enough that the frame rate stays high, and the metric to report is **pixels per second**, which is what the frame rate at a given area actually measures. - -The standard levers, in the order they are usually pulled: lookup tables; fixed point; fewer samples (octaves, warp depth); render the field below output resolution and upscale bilinearly (a smooth field hides it); update the field below the frame rate and interpolate; vectorize the noise inner loop where the CPU has SIMD; use the hardware FPU for the kernels that are float by nature (square roots, arctangents, a reference algorithm kept in float), behind the same contract; move the field update to a second core; and above all of these, run the field on a machine with the cycles and feed the lights over the network. - ---- - -# Part 2: Examples from the field - -Implementations that do similar things to Part 1. None is the specification; each fills in a spot: a measured number, a parameter vocabulary users expect, a fixed-point choice that is known to work, a precision decision. - -| Example | What it is | Similar to | What it fills in | -|---|---|---|---| -| **ANIMartRIX** (Stefan Petrick; also in FastLED master as `fl::Animartrix`) | A polar-noise shader engine: per-pixel polar tables, a timer bank yielding ramp / phase / bipolar sine / noise-angle signals, a "5D coordinate mapper" (`newx = (offset_x + cx − cos θ·r)·scale_x`, likewise y, `z` from a ramp), gradient noise per layer, a low/high contrast window, per-channel or palette color, gamma, sanity clamp; 50 named animations of 1 to 12 layers | Part 1 shader, polar transforms, LFO bank, contrast window | The author's throughput on the Teensy 4.0 the engine was tuned on: ~730 k RGB pixels/s per layer at 600 MHz ("20 fps on 36k LEDs"), 53 k/s on an ESP32 core, 110 k/s on two; "3 layers at 400+ fps, 10 layers at ~50 fps" on a 16×16 class panel. Float throughout, quantized to 8 bits "in the very last step", temporal dithering. The parameter names users of this style know: `scale_x/y/z`, `offset_x/y/z`, `z`, `center`, `low_limit`, `high_limit`, `master_speed`, `ratio`, `offset` per timer | -| **FastLED master's fixed-point migration of the same engine** (`3e77a096`, 2026-09-02) | Q16.16 (`s16x16`) throughout; angles as A24 (24-bit turn) into `sincos32`; four gradient-noise precisions side by side (`s16x16` reference, `q16`, an `i16`-optimized inner loop "2× faster multiplies", `s8x8` "4× faster, trades accuracy"), a 4-wide SIMD noise; **bit-identical tests** against the float engine | Part 1 arithmetic | Proof that the whole shader family is exact in Q16.16 with a table sine and a table-driven gradient noise, on the ESP32 class; the precision-by-role grading; a fixed-point type family documented for MCUs ("integer math is 5-100× faster … no rounding errors; results are exact and reproducible") | -| **FlowFields** (4wheeljive, from a 2026 concept post by Petrick; forks: `ewowi/flowfields`, Petrick's `ColorTrails`) | An advection engine: **emitters** ("anything that is drawn directly") plus **flows** ("an invisible wind that moves the previous pixels and blends them together"). Six flows as displacement rules (noise via two decoupled 1D profiles, radial, directional wind with a perpendicular wobble, three-zone rings with swirl and drift, spiral, and a full stable-fluids solver with vorticity confinement), eight emitters (orbital, swarming and audio dots, Lissajous line, rainbow border, noise kaleidoscope, cube, fluid jet), a 20-timer modulator bank with multiplicative "breathing", float RGB grids, a half-life fade `0.5^(dt/persistence)`, dithered 8-bit output, a virtual clock under one `globalSpeed` | Part 1 advection, velocity rules, sources, decay, modulation | The flow vocabulary and defaults users expect (`persistence`, `blendFactor`, `windStep`, `angularStep`/`radialStep`, `innerSwirl`/`outerSwirl`/`midDrift`, `xShift`/`yShift`, `noiseFreq`, `viscosity`, `vorticity`, `gravity`; ~35 named controls); that separable noise transport with decoupled axes reads as diagonal flow; that partial transport reads as viscosity; the solver's cost (5 Jacobi iterations per solve, three solves per frame) as the upper bound | -| **`fl::FlowField`** (FastLED master, from the above, 2026-03-21) | The same engine distilled to two emitters and the noise flow, in a float variant and a **Q16.16 variant** with `i32` color state ("allowing sub-integer color accumulation during splats") quantized at output; coverage discs and 2×2 bilinear lines; a "noise punch" impulse into the profiles | Part 1 resampler, sources, decay, arithmetic | The measured share: **advection ~80% of frame time** in fixed point, with the hoisting and `restrict` tricks that get it there; six 32-bit grids (384 KB at 128²), hence "memory is large"; defaults `persistence` 0.86 s, `flow_shift` 1.8 px, `noise_freq` 0.33/0.32 | -| **`fl::gfx` canvas** (FastLED master) | An anti-aliased 2D canvas: line, disc, ring, stroked line with caps, additive by default, float / int / fixed-point coordinates | Part 1 sources | The API shape a sub-pixel rasterizer converges on | -| **Fixed-point sub-pixel graphics** (Sutaburosu; the anti-aliased canvas demo in MoonLight as `FixedPointCanvasDemoEffect`, and in FastLED master as the fixed-integer drawing `fl::gfx` credits him for, the SKIPSM Gaussian blur behind `fl::gfx::blur`, and the Elias water effect `FxWater`) | Twelve sub-demos on a Q16.16 anti-aliased canvas (clock, orbiting discs, star web, spirograph, Lissajous, thin and thick cube, organic walkers, boids, hypotrochoid, branching tree), "blazing fast fixed integer drawing"; a binomial two-pass Gaussian blur; a 2D ripple simulation after Hugo Elias | Part 1 sources, arithmetic, blur | That anti-aliased sub-pixel drawing is fully deterministic and FPU-free in Q16.16 at panel-to-wall sizes; the blur kernel shape a trail idiom converges on; a working catalog of source geometries (orbits, Lissajous, spirograph and hypotrochoid curves, boids, a recursive tree) for the emitter set. The canvas demo itself is parked by the product owner as a port candidate | -| **`fl::WaveFx`** (FastLED master, after Shawn Silverman) | A 2D wave-equation simulation with 2× to 8× supersampling, mapped to color by gradient | Part 1 stored-field transport | Supersampling as the artifact lever for a stored field; the comment that 2× "gives the best results for the CPU consumption" | -| **`fl::Luminova`** (FastLED master) | 256 particles with per-frame fade and blur, "soft white trails" | Part 1 decay + blur, with particles as sources | Fade-plus-blur as the trail idiom for point sources | -| **Flow-field generative art** (Hobbs; Shiffman) | A grid of angles from noise; particles or pen strokes follow the angle in small steps | Part 1 noise-as-motion | The tracing form of a flow field, where the particle is the source and the field never touches the frame; Hobbs's advice to distort with something other than Perlin noise once the look is familiar | -| **Stable fluids** (Stam 1999, GDC 2003) | The reference solver and its "linear backtrace" advection | Part 1 transport | The algorithm every advection effect above descends from, and the reason semi-Lagrangian advection is unconditionally stable at any `dt` | -| **Earlier 8-bit work in the same idiom** (FunkyNoise, FunkyClouds, 2014; a self-modulating simplex noise gist) | Polar angle per pixel shifted by noise; noise modulating the offsets, scale and palette index of the next noise pass | Part 1 warping, feedback modulation | That the idiom predates FPUs and was first done in 8-bit FastLED terms | -| **MoonLight's port of ColorTrails** (2026-03) and **FastLED-MM's FlowFields sketch** (2026-04/05) | The advection engine on `fl::CanvasRGB` + `s16x16`; the same engine at 128×128 inside MoonLight's earlier module runtime with 35 registered controls | Part 1 advection, in our own prior work | That the family has already run on our hardware and behind our UI; a bench reference for the advection showcase | - -Two things the examples agree on that Part 1 states as requirements: **compute above 8 bits and quantize last**, and **report pixels per second**. One thing none of them does yet: curl noise as a velocity rule, which Part 1 includes because it is the standard cheap answer to fluid-looking flow. - - ---- - -# Part 3: How this fits MoonLight - -## What we have - -Against Part 1's blocks, measured on this tree: - -| Part 1 block | MoonLight today | Status | -|---|---|---| -| Polar mapping | `atan16`, `dist16`, `kaleido` ([math16.h](../../src/core/util/math16.h)), computed per pixel; `PolarNoiseEffect` uses them | ✅ per pixel; ⬜ no precomputed LUT type | -| Polar transforms, `uv` | `shader.h`: `uv`, `rotate`, `repeat`, `mirror`; `kaleido` | ✅ | -| Noise | `inoise8`/`inoise16` **value** noise; `fbm8`/`fbm16`, `turbulence8`, `warp8` ([noise.h](../../src/core/util/noise.h)) | ✅ fBm, warp, turbulence; ⬜ gradient noise; ⬜ curl noise | -| Oscillators | `BeatPhase`, `beat`/`beatsin`, `sin16`/`cos16`, `smoothFollow`, easings, `hashInt` | ✅ single phase; ⬜ timer bank with the four LFO shapes and modulation binding | -| Contrast, palette | `map32`, `smoothstep`, `colorFromPalette`, `cosPalette` | ✅ | -| Gamma, dithering | `Correction` curves at the driver | ✅ gamma at output; ⬜ temporal dithering | -| Sources | `coverage` (SDF), `draw::splat` (24.8 sub-pixel, additive), `draw::line` (Bresenham, not anti-aliased), `particles.h` | ✅ disc, splat; ⬜ anti-aliased line | -| Resampler | `draw::scroll` (integer shift, wrap or clear) | ⬜ **no bilinear sample of the previous frame** | -| Velocity rules | none | ⬜ | -| Decay | `Layer::fadeToBlackBy` (8-bit multiply, once per frame, MIN across effects) | ✅ 8-bit; ⬜ half-life form on wide state | -| Blur | `draw::blur`, separable, every axis | ✅ | -| Color state | `Layer` buffer, `uint8_t` per channel ([Buffer.h](../../src/light/layers/Buffer.h)), persisting between frames | ✅ persistence; ⬜ **no state above 8 bits** | -| Fixed point | 16-bit contract, uint8 angle / `angle16` turn, 24.8 positions, 16.0 noise coordinates ([power-functions-analysis-top-down.md § 2](power-functions-analysis-top-down.md)) | ✅ | -| Shader runner | `shader.h`: `each` (one function of position and time, the loop, mapping and write handled) | ✅ | -| Particles | pool, gravity, drag, bounce, collide, splat render | ✅ | -| Stored-field simulation | none | ⬜ (out of scope unless the top-down wants fluid) | - -The persistence contract advection needs already exists: the Layer does not clear between frames, and "a read-prior effect reads last frame's pixels via `draw::get` / `draw::blur`; the persistence *is* its state" ([MoonLight, buffer persistence](../../explanation/architecture/moonlight.md#buffer-persistence-the-layer-keeps-what-it-drew)). What is missing is the resampler and the bit depth. - -`PolarNoiseEffect` is the Part 1 shader already: polar addressing, `warp8` in polar space (the angle warped by noise), `kaleido`, a palette, with `octaves` and `warp` exposed as the cost knobs and the header stating the cost ("~4 samples/pixel at octaves=2 … on a large wall drop `octaves` to 1"). - -## What MoonLive has - -55 builtins ([MoonLiveBuiltins_light.h](../../src/light/moonlive/MoonLiveBuiltins_light.h), [MoonLiveBuiltins_common.h](../../src/core/moonlive/MoonLiveBuiltins_common.h)): `noise`, `polarA`/`polarR`, `sin`/`cos`, `beat`/`beatsin`, `smoothstep`/`step`/`smin`, `uvX`/`uvY`, `scale`, `mod`/`div`/`fdiv`, `setRGB`/`setXYZ`/`setPaletteColor`/`fill`/`fade`/`line`, the audio set, and the particle set. A script can write a Part 1 shader pixel today (`octopus.mle` is one). The measured cost is the call rate, not the arithmetic ([backlog-moonlive.md § 4c](backlog-moonlive.md)): `plasma.mle` at 9 host calls per pixel is 16,031 µs on a 3,840-pixel S3 fixture (~4.2 µs/px); `polarR` alone ~3.5 µs/px because it wraps a real square root; the particle vocabulary, "one call per FRAME rather than per pixel", is 54× cheaper on the same fixture. The roadmap's conclusions apply unchanged: "per-pixel builtins want to be inline ops, not calls" and "a `frame()` entry shape would sidestep it entirely". - -Advection is not expressible from a script: the 64-byte arena holds no frame of state and no builtin resamples the previous frame. The particle pool shows the shape that works for stateful families: a handle to native state, whole-pool passes per frame, the script composing them. - -## The budget, measured - -Per-pixel cycles at 240 MHz ([power-functions-analysis-bottom-up.md § Shaders](power-functions-analysis-bottom-up.md)): ~15,600 at 16×16 @ 60 fps, **~293 at 128×128 @ 50 fps**. The S3 render-only sweep ([performance.md](../../reference/performance.md)), µs per frame: - -| Effect | 16² | 32² | 64² | 128² | per pixel at 128² | -|---|---:|---:|---:|---:|---:| -| Noise (simplex, 1 sample/px) | 913 | 2,951 | 11,661 | 51,230 | 3.1 µs (~750 cycles) | -| Plasma | 352 | 1,020 | 3,744 | 20,020 | 1.2 µs | -| Metaballs | 462 | 1,757 | 6,108 | 28,576 | 1.7 µs | -| LavaLamp | 309 | 974 | 3,612 | 21,243 | 1.3 µs | - -On the classic ESP32 (no FPU, no PSRAM) the Noise effect runs 1,117 / 324 / 71 / 17 fps across the four sizes. So one noise sample per pixel is already 2.5× the wall budget, which is consistent with the field's own numbers in Part 2 once clock and FPU are normalized: **the shader half is a panel-class family on every MCU, and the engineering is in Part 1's levers.** Advection scales to walls in cycles (no noise per pixel; the field's noise is per row and column) and its cost is state: six 32-bit grids at 128² are 384 KB, PSRAM territory, and a panel-class family only on a PSRAM-less classic. - -Framerate protection is therefore the rule for both, in different currencies: **samples per pixel** for a shader, **cycles per frame and bytes of state** for advection. - -## Per-target headroom: FPU, SIMD, clock, and where the desktop takes over - -What each shipped target brings, from the IDF SoC capability headers and our own measurements: - -| Target | Cores × clock | FPU | SIMD | State memory | Measured against the S3 | -|---|---|---|---|---|---| -| classic ESP32 (Xtensa LX6) | 2 × 240 MHz | single precision | none | internal only, or 4 MB PSRAM on WROVER / 2 MB on PICO | slower; beats the S3 only on memory-bound loops (internal RAM vs PSRAM latency) | -| ESP32-S3 (Xtensa LX7) | 2 × 240 MHz | single precision | 128-bit PIE (`SOC_SIMD_INSTRUCTION_SUPPORTED`) | 8 MB octal PSRAM | the reference row in this document | -| ESP32-P4 (RISC-V) | 2 × 400 MHz | single precision | PIE + hardware loops (`SOC_CPU_HAS_PIE`, `SOC_CPU_HAS_HWLOOP`) | 32 MB PSRAM | ~3× on heavy compute ([performance.md](../../reference/performance.md)) | -| ESP32-S31 (RISC-V) | 2 × 320 MHz | single precision | PIE + hardware loops | PSRAM | between the S3 and the P4 | -| desktop | GHz class | double and single | NEON / SSE / AVX | unbounded | 20-40× an S3 per core, plus SIMD ([performance.md](../../reference/performance.md), the `collide` measurement) | -| Teensy 4.x (Cortex-M7), a future target | 1 × 600 MHz | single and double | none (DSP instructions) | 1 MB internal, no PSRAM | not measured; listed in [architecture.md § Scaling to available memory](../../reference/hardware/firmware-variants.md#scaling-to-available-memory) as a supported class | - -**Can the FPU help?** Every target has one, so a float kernel is legal everywhere, and the repo already has the precedent: `raymarch.h` is compiled only where the SoC declares an FPU, as "the one bounded exception to the integer-only render path", while `shader.h` stays fixed point and runs everywhere ([power-functions.md § Raymarching](../../moonmodules/light/power-functions.md#raymarching-one-technique-inside-a-shader)). The honest expectation: on these cores a float multiply costs about what an integer multiply costs, so an FPU does not make a noise sample cheaper; it makes square roots, arctangents and trig cheap enough to skip the tables, and it lets a float reference algorithm run unconverted where an exact fixed-point port is not worth writing yet. The portable contract stays fixed point; the FPU is a per-target acceleration behind it, per the standing decision. - -**Where the family shines on an MCU.** The S3 is the baseline this document measures against because it is the bench board with numbers, not because it is the target. The P4 is the natural home of the shader half: the highest clock, four-lane SIMD, hardware loops and 32 MB of PSRAM put a 64² composition and a 128² single-layer field inside its budget, and the S31 sits next to it on every axis. The classic is the portable floor, the target that keeps the contract honest. The top-down should size the showcases for the P4 and S31, keep them running on the S3, and let the classic degrade by the cost knobs. - -**Can SIMD help?** Yes, and the P4 is where it pays: PIE processes four 32-bit lanes per instruction and the hardware loop removes the branch per iteration, which is exactly the shape of a noise inner loop, a bilinear lerp across four channels, or a separable advection pass. The S3 has the same width. This is the lever the field has already pulled (a 4-wide fixed-point noise exists in the wild), and it is a per-target implementation of the same function, never part of the contract. The classic has neither, which is why it is the target that decides the portable budget. - -**Where the border is.** Per pixel at 50 fps, the cycle budget is `clock / (area × 50)`; one noise sample costs ~750 cycles on the S3 today, ~250 on the P4 by the measured ratio, ~20-40 on a desktop core. Samples per pixel affordable at 50 fps, rounded down: - -| Grid | S3 (240 MHz) | P4 (400 MHz, ~3×) | desktop (one core, ~30×) | -|---|---:|---:|---:| -| 16×16 | 24 | 72 | hundreds | -| 32×32 | 6 | 18 | ~180 | -| 64×64 | 1 | 4 | ~45 | -| 128×128 | 0 (one sample = 19 fps) | 1 | ~11 | - -So the ESP32 class stops at one sample per pixel on a 128² wall (S3) to one or two (P4), and carries a rich 3-to-12-layer composition only up to about 32×32 (S3) or 64×64 (P4). Beyond that line the desktop continues without a change of code: it is the same effect on the same contract, with SIMD and the clock on its side, and it already drives lights over the network as a processing node ([MoonLight, drivers](../../explanation/architecture/moonlight.md#drivers): ArtNet, DDP, E1.31). Advection moves the border differently: its per-pixel cost is fixed and small, so the S3 and P4 carry it to a 128² wall as long as the wide color state fits PSRAM; the classic without PSRAM stops at panel size for lack of memory, not cycles. - -## What we need to add - -In Part 1's order, against the table above: - -1. **Gradient noise** beside value noise, in the existing 16-bit fixed vocabulary, as the quality upgrade for every field effect; keep value noise as the cheap inner-octave option (Part 2's precision-by-role grading). -2. **A polar LUT type**: angle and radius per pixel, built on the cold path, invalidated on geometry change; `PolarNoiseEffect` and every polar effect read it instead of calling `atan16`/`dist16` per pixel. -3. **An oscillator bank**: N clocks with offset and ratio under one master speed, the four LFO shapes, and a modulation binding (`parameter · (1 + depth · lfo)`), as one power function family rather than per-effect members. -4. **Curl noise** as a velocity rule, and the plain rules (wind, radial, spiral, rings, separable noise) as small functions of (pixel, t). -5. **The resampler**: bilinear sample of the previous frame at a fractional source, wrap and clamp variants, separable and full 2D forms. This is the advection kernel and the single largest missing block. -6. **Half-life decay** and an **anti-aliased line**, completing the sources-and-decay set beside `splat` and `coverage`. -7. **Color state above 8 bits** for advection and for dim trails: either a 16-bit Layer buffer (the LED-driver analysis already backlogs a "16-bit pipeline + dither", [backlog-light.md](backlog-light.md)) or effect-owned Q16.16 state quantized into the 8-bit Layer each frame. The examples are unanimous about *where* quantization goes (last) and split on *where the state lives*; the top-down decides. -8. **Temporal dithering** at the quantization step, once the state is wide. -9. **MoonLive frame kernels**: the prepare / emit / advect / decay passes and the polar-shader inner loop exposed as whole-frame builtins (inline ops or handle-based native passes), so a script composes frames the way it composes particle passes. Depends on the multi-argument host-call and arena work the language roadmap already names. -10. **Showcases**, three: a pure shader in the polar-noise idiom beyond `PolarNoiseEffect`; an advection effect with the noise, spiral and directional rules, two sources and the oscillator bank; and one that feeds a shader's output into a flow. - -## Bridge to the top-down - -The bottom-up settles what the top-down can take as given: the family is two techniques over one shared block set, every block has a textbook name and algorithm, the cost model is arithmetic and makes a shader a samples-per-pixel problem and advection a cycles-per-frame-and-memory problem, framerate is the rendering method for both, the field has proven the whole set exact in Q16.16 on our silicon class, and MoonLight lacks three blocks (resampler, wide color state, gradient noise) plus the bank, the LUT and the kernels that make them composable. What it leaves open is design: names and signatures in our fixed-point vocabulary; where wide state lives and whether the Layer goes to 16 bits; the MoonLive frame-kernel shape and its dependency on the host-call ABI; which per-target accelerations sit behind the one contract; the three showcases to the control level; the tests, the timing contracts, and the order the work lands in. The top-down owns those decisions and their resource accounting. - -### Prompt for the agent that writes the top-down - -> Read `CLAUDE.md`, `docs/explanation/architecture/index.md` (§ Hot path discipline, § Effects and Layer, § Buffer persistence, § MoonLive), `docs/contributing/coding-standards.md`, `docs/work/future/generative-fields-analysis-bottom-up.md` (this document, in full: Part 1 is the specification language, Part 2 the examples, Part 3 the gap), `docs/work/future/power-functions-analysis-top-down.md` (the shape and the standing decisions: dimension-generic, one contract everywhere with per-target acceleration, fixed point invisible to the writer, the 16-bit contract, particles as the stateful precedent), `docs/moonmodules/light/power-functions.md` (what exists), and `docs/work/future/backlog-moonlive.md` § 4c and § 2 (the per-pixel call cost and the multi-argument host-call blocker). -> -> Write `docs/work/future/generative-fields-analysis-top-down.md`: the implementation spec for the generative-fields family in MoonLight, for compiled effects and for MoonLive scripts, on the power-function library. Use the house format of the power-functions top-down (status legend, TL;DR, numbered sections, decisions for sign-off, out of scope). Present tense for what exists, forward-looking only under the banner. American spelling, no em-dashes. Specify every kernel from Part 1's primary sources by its textbook name and algorithm; use Part 2 only for measured numbers, parameter vocabularies and precision choices, never as a source of code, and record prior art per module the way `PolarNoiseEffect.h` does. -> -> Decide, with rationale and resource accounting per target (classic ESP32 without PSRAM, S3, P4, desktop): (1) the power functions to add for Part 3's ten items, with signatures in the repo's fixed-point vocabulary; (2) where wide color state lives (a 16-bit Layer buffer versus effect-owned Q16.16 state quantized into the 8-bit Layer), with the memory table per fixture size and the dithering plan; (3) the MoonLive frame-kernel shape: how a script composes whole-frame passes and reads a per-pixel shader through inline ops rather than per-pixel host calls, and which language-roadmap blockers it depends on; (4) the performance plan against Part 3's measured budget and per-target headroom table: samples-per-pixel targets for shaders, cycles-per-frame and bytes-of-state targets for advection, the field-below-resolution and field-below-frame-rate levers, which kernels get FPU or SIMD (PIE) variants per target behind the one contract, where the desktop takes over as the processing node, and how framerate is protected as the rendering method; (5) the three showcases, specified to the control level; (6) the tests that pin each kernel (golden frames on the desktop, per-target timing contracts in scenarios) and the bench criteria the product owner judges by eye on the S3 and on a wall; (7) the order the work lands in, each step verifiable on the desktop first. -> -> Where a decision needs the product owner, list it under decisions for sign-off with the options and your recommendation rather than deciding silently. The bar is the measured quality and cost of the effects themselves. diff --git a/docs/work/future/generative-fields-analysis-top-down.md b/docs/work/future/generative-fields-analysis-top-down.md deleted file mode 100644 index 36187dd71..000000000 --- a/docs/work/future/generative-fields-analysis-top-down.md +++ /dev/null @@ -1,582 +0,0 @@ -# Generative fields: top-down build spec - -> **Forward-looking design document, the exception to CLAUDE.md's present-tense rule.** Stage 2 of the generative-fields work: turns the [bottom-up analysis](generative-fields-analysis-bottom-up.md) (Part 1 the specification language, Part 2 the examples, Part 3 the gap) into an implementable spec: homes, types, signatures, the MoonLive surface, budgets, tests, and a step-by-step plan. Written 2026-09-03 against the five product-owner decisions in § 0. Where this document makes a NEW decision it is marked **(proposal)** and listed in § 10. Companions: the [power-functions top-down](power-functions-analysis-top-down.md) owns the library's homes, types and standing decisions, which this document extends rather than restates; the [MoonLive roadmap](backlog-moonlive.md) owns the engine. - -## Status legend - -| | Meaning | -|---|---| -| ✅ | **Done.** Built and in the tree. | -| 🔨 | **To do.** Open, with the reason stated. | -| ❓ | **Unsure.** A claim or number not yet verified; a question, not a fact. | - -An unmarked line is context or rationale. - -## TL;DR - -- **Five decisions taken by the product owner (2026-09-03)** (the first was later reversed on measurement, § 11): the Layer buffer goes to 16 bits per channel as the one format, subject to the memory analysis in § 2; gradient noise replaces value noise in place (one solution, the goldens move once); MoonLive gets each kernel in the same phase as the compiled function, with a shipped script; the stable-fluids solver is in scope as the last phase, a P4 and desktop showcase; every effect that does not run on the power functions is rewritten on them (algorithmic effects such as Game of Life excepted), judged against how it ran before and never made worse. -- **(proposal) The Layer width is a per-layer property fixed on the cold path, one template instantiated twice, never a per-light branch.** 16-bit is the default wherever it fits; 8-bit is the degradation step the existing adaptive-allocation cascade already has, so the classic without PSRAM keeps its proven 128² pipeline. The pipeline stays 16-bit end to end and quantizes **once, per driver, in `Correction`**, to the width that driver's wire takes: 8-bit with temporal dithering for WS2812-class LEDs and every network, Hue, video and preview output, 16-bit straight through for a 16-bit LED. Memory doubles per layer and for the output buffer (98 KB each at 128² RGB), bandwidth doubles in blend, extrude and preview, and the S3's PSRAM latency makes that measurable on cheap effects; the KPI gate reads it per batch (§ 2). -- **(proposal) Seven kernels, in the existing homes.** `core/noise.h`: gradient noise in place, plus `curl16`. `light/polar.h` (new): `PolarLut`. `core/oscillators.h` (new): `Oscillators`, the timer bank with the four LFO shapes and modulation. `light/draw.h`: `advect` (the bilinear previous-frame resampler, separable and 2D, wrap and clamp), the velocity rules, `decay` by half-life, `lineAA`, `disc`, and the 16-bit quantize with temporal dithering. `light/fluid.h` (new, last): the Stam solver. Every function names its canonical source; nothing is ported (§ 3). -- **(proposal) MoonLive operates on the Layer, not on a handle.** With the Layer at 16 bits the frame buffer IS the wide state, so advection, decay and the emitters are whole-frame builtins on the script's own layer, the shape `fill` and `fade` already have; the polar LUT and the oscillator bank are handles declared in `defineControls()`, the shape `pool()` has. Multi-argument host calls already ship (`emit` takes seven), so nothing here waits on the engine; the one engine change is the pixel-store inline ops learning the 16-bit element (§ 4). -- **Budgets are the bottom-up's, restated as targets per target.** Shaders: samples per pixel against ~750 cycles per sample on the S3, ~250 on the P4; advection: cycles per frame plus bytes of state, PSRAM-class for walls. Framerate is protected by rule: every kernel is `dt`-driven, transport stays sub-pixel per frame, and a showcase must hold its stated fps on its stated fixture or its cost knob is the default (§ 5). -- **Four showcases, three plus fluid**, specified to the control level: *Aurora* (a pure shader), *Trails* (emitters and flows), *Nebula* (a shader feeding a flow), *Fluid* (the solver, P4 and desktop) (§ 6). -- **Eight phases, desktop first, bench last; the product owner decides when enough is done to commit.** Noise swap; fields and Aurora; three dimensions everywhere; Coord3D in MoonLive; the 16-bit Layer; advection and Trails; composition, dithering and SIMD; fluid. The catalog sweep that decision 5 mandates is a follow-on plan, fed by the [effects × power functions inventory](effects-power-function-inventory.md); only the effects a phase touches directly (the noise effects in phase 0, PolarNoise in phase 1, the raw-byte writers in phase 2) are rewritten inside this plan. Each phase lands with its unit tests, its goldens, its scenario, its MoonLive builtins and script, and its catalog cards (§ 8). - -## 0. Inputs and the decisions taken - -From the bottom-up: two techniques over one block set; every block named from its primary source; the cost model; MoonLight's three missing blocks (previous-frame resampler, color state above 8 bits, gradient noise) plus the LUT, the bank and the kernels that compose them; the per-target headroom table with the P4 and S31 as the MCU home of the family. From the power-functions top-down, carried unchanged: dimension-generic; one contract everywhere with per-target acceleration; fixed point invisible to the writer; the 16-bit contract (`angle16`, `frac16`, `pos_t` 24.8); particles as the stateful precedent; determinism (pure function of position, time, seed; stateful kernels declare a resync point); golden frames as hashes; the `Canvas` descriptor. - -Product-owner decisions, 2026-09-03: - -1. **Wide color state: the 16-bit Layer**, as the cleanest, least-code, fastest alternative; decided per Layer at run time from free memory with 8-bit as the fallback (option B, agreed); and **quantized once per driver in `Correction`**, so the pipeline is 16-bit end to end and a 16-bit LED is a driver, not a pipeline change (agreed). Detail in § 2. -2. **Gradient noise replaces value noise in place.** One solution; no second function kept for compatibility (CLAUDE.md minimalism). -3. **MoonLive per phase, same PR.** -4. **Stable fluids in scope**, as the last phase; if it costs CPU it is a P4 and desktop showcase. -5. **Every effect runs on the power functions for whatever they cover; an effect that does not is rewritten.** Most effects were developed quickly to demonstrate a mechanism, and keeping them rendering the same is not a goal. The rule is a mandate, not an option: an effect whose mechanism is one the library provides (motion, fields, noise, polar addressing, trails, particles, oscillators) is rewritten on the library's kernel. Bouncing balls run on `particles`; every noise-like effect runs on the noise this plan builds; every trail decays by half-life; every polar effect reads the LUT. The one exception is the **algorithmic** effect, where a rule set *is* the effect rather than a natural motion: Game of Life is the type case, and it keeps its logic and uses the library only to draw. The guard that came with "behave identically first" stays: an early Game of Life port was much worse than its MoonLight origin, so a rewrite is compared against the effect as it ran and lands only if at least as beautiful on the panel. The aim is effects at a level no other LED firmware reaches, not a 1:1 rendition of another project's catalog. - -## 1. Homes and style ✅ *(the rule; the headers are new or grown)* - -The power-functions rule stands: grow existing homes, one style, free functions over `const Canvas&`, fixed-point vocabulary, a doc block naming the canonical source. **(proposal)** - -| Home | Gains | Notes | -|---|---|---| -| `core/noise.h` | gradient noise behind the existing `inoise8/16`, `fbm`, `warp`, `turbulence` names; `curl16` | Domain-neutral. The name promises the field; the algorithm changes underneath (decision 2). | -| `core/oscillators.h` **(new)** | `Oscillators`: N clocks, four LFO shapes each, modulation binding, one master speed, `dt`-driven | Domain-neutral: audio meters and services want the same LFOs. | -| `light/polar.h` **(new)** | `PolarLut`: `angle16` and radius per pixel, built at `prepare()`, 8-bit default with a 16-bit opt-in (the power-functions decision) | Was planned there and never built; `kaleido` stays in `math16.h`. | -| `light/draw.h` | `advect` (separable and 2D), the velocity rules, `decay`, `lineAA`, `disc`, `quantize` with dithering | The frame-ops family, which is where `scroll` and `blur` already live. | -| `light/layers/Buffer.h`, `BlendMap.h`, `Layer.h` | bytes per channel; the 16-bit format and its quantize-last | § 2. | -| `light/fluid.h` **(new, last)** | `Fluid`: velocity grids, diffuse, project, advect, vorticity confinement, dye transport | Stateful kernel with a resync point, like particles. | -| `light/moonlive/MoonLiveBuiltins_light.h` | one builtin per kernel, per phase | § 4. | - -## 2. The 16-bit Layer - -### What the codebase says - -[MoonLight, memory strategy](../../explanation/architecture/moonlight.md#memory-strategy): all buffers are raw `uint8_t*` arrays sized `channelsPerLight × nrOfLights`; "there is no fixed channel layout: `channelsPerLight` is a runtime value, so RGB, RGBW and multi-channel DMX fixtures all use the same code path; the buffer simply gets wider". Adaptive allocation checks heap before every allocation with a 32 KB reserve, and the degradation cascade "reduces layer dimensions until the buffer fits, minimum 8×8". The architecture "does not assume PSRAM"; the classic without PSRAM is "proven up to 16 K lights (128×128 measured live on Olimex)". CLAUDE.md: minimal memory, fastest hot path, "the standard, complete construct beats a hand-rolled special case", and "no `#ifdef`, no per-light virtual call, data over objects". The LED-driver analysis already planned this as its mode 3, "16-bit pipeline (incl. dither): doubles RAM; best gradient quality; required for 16-bit-native LEDs (UCS7604, HD108)", with the driver declaring its input width and the pipeline building Layer buffers accordingly ([leddriver-analysis-top-down.md § 7.3](../../work/future/leddriver-analysis-top-down.md)). - -So the width is a property the architecture already treats as runtime data (like `channelsPerLight`), the cascade already knows how to shrink a layer that does not fit, and the pipeline decision was already made in principle. What is new is doing it. - -### Memory, per fixture, RGB, one layer - -The last column is an **illustration** from the classic's measured free heap today (~104 KB running at 128² with mirror, Ethernet and mDNS up), not a threshold: the decision is made at run time from free memory, never from the grid size, so the same layout may run 16-bit on one device and 8-bit on another depending on what else is loaded. - -| Grid | 8-bit | 16-bit | Illustration: would it fit today's measured classic heap without PSRAM? | -|---|---:|---:|---| -| 16×16 | 768 B | 1.5 KB | yes | -| 32×32 | 3 KB | 6 KB | yes | -| 64×64 | 12 KB | 24.6 KB | yes | -| 96×96 | 27 KB | 55 KB | yes, one layer | -| 128×128 | 49 KB | 98 KB | **no** as a second allocation beside the 49 KB output buffer: the reserve is 32 KB | -| 48×256 (the wall) | 37 KB | 74 KB | PSRAM already required by `nrOfLightsType` | - -The driver output buffer, where one exists (two or more layers, or a mapping LUT), follows the Layer's width, so the composite case costs two 16-bit buffers; it is part of the same runtime allocation check. Advection needs a scratch copy for the separable pass (a second buffer of the same size) and, for the fluid solver, velocity, pressure and divergence grids (four more `int32` planes). Those are effect-owned `ScratchBuffer`s, allocated at `prepare()`, reported through `dynamicBytes()`, PSRAM-preferred; the memory ladder shows them exactly as it shows a particle pool. - -### Can 8 or 16 be decided dynamically without hot-path cost? Yes, and it is the existing mechanism - -The width is decided **per Layer at `prepare()`**, on the cold path, by the same allocation check that decides whether a LUT or an output buffer exists: the Layer attempts the 16-bit allocation against the free heap after the 32 KB reserve, keeps it when it succeeds, and otherwise takes 8-bit as the first step of the cascade (before dimension reduction). It is a measurement, not a table: no grid size is special, and the answer re-decides on every `prepare()` (a layout change, an added layer, a freed module). Every per-pixel loop that reads or writes Layer bytes is written **once as a template on the sample type** and instantiated for `uint8_t` and `uint16_t`; the choice happens **once per pass per frame** (a function pointer or a branch at the top of `blendMap`, `extrude`, `fadeToBlackBy`, `advect`), never per light. That is the "data over objects, no virtual call per light" rule satisfied, and it is one algorithm, not two implementations. The price is code size (two instantiations of the frame-op set) and a second configuration to test, which the goldens cover by running both widths. - -**(proposal)** Two options, with the recommendation: - -- **A. 16-bit only.** One format, the cascade shrinks a layer that does not fit. Least code and one test configuration; the classic without PSRAM loses its stated 128² full pipeline (it drops to 96² single-layer, 64² with two layers), which is a documented capability regression on a shipping board class. -- **B. 16-bit default, 8-bit as the first cascade step** (recommended). One template, two instantiations, chosen per layer on the cold path; the classic keeps 128²; the hot path pays nothing per light. Minimalism is respected in the way that matters here: one algorithm with one home, and the 8-bit path is a fallback the architecture already promises, not a second feature. - -### Quantize last: once, per driver, in `Correction` (agreed) - -The pipeline is 16-bit end to end: Layer → `blendMap` → output buffer, all at the Layer's width. Quantization happens **once, in `Correction::apply`, per driver**, which every driver already runs through `DriverBase` (LED, ArtNet/DDP/E1.31, PanelCard, Hue, NDI, HLS, Preview): white balance, brightness, the power limiter and the perceptual curve run at 16-bit input, and the last step narrows to the width the driver's wire declares. **8-bit with temporal dithering** for WS2812-class LEDs and every network, video and preview output; **16-bit straight through** for a 16-bit LED. This is mode 3 of the LED-driver analysis ("driver input 16-bit, wire 8-bit, the driver downsamples and dithers; required for 16-bit-native LEDs"), and its `inputBitsPerChannel()` stub becomes real: the driver declares its wire width, `Correction` quantizes to it. - -Why here and not at `blendMap`: quantizing earlier throws the extra bits away before the perceptual curve, which is exactly where 8-bit input crushes the low end, and it makes a 16-bit LED impossible. One site for the whole system also means one dither state (per driver, one byte per channel of carried error, or a stateless ordered matrix) and one place to test. - -Two consequences. The zero-copy path (a single layer with no LUT, the driver reading the Layer directly) needs no view: `Correction` reads the Layer's width like any other source. The `Correction` LUTs stay 256 entries with interpolation on the 16-bit input (the shape `sin16` uses), not a 64K table. In phase 2 the narrowing is `>> 8` so every existing effect renders bit-identically and no golden moves; dithering is the phase 4 switch. - -**The four combinations, and the hot path.** The Layer's width (decided by memory) and the wire's width (declared by the driver) are independent facts that meet in one `Correction` template; the combinations fall out, none is a mode to maintain: - -| Layer | Wire | `Correction` does | When | -|---|---|---|---| -| 16 | 16 | curve and brightness at 16, no quantization | the full pipeline: a PSRAM board with a 16-bit LED | -| 16 | 8 | curve and brightness at 16, quantize once with dithering | the common case: 16-bit fits, WS2812-class LEDs, and every network, video and preview output | -| 8 | 8 | today's path, byte for byte | a board where memory forced the 8-bit fallback | -| 8 | 16 | widen (`v · 257`), curve and brightness at 16, out at 16 | the fallback board driving a 16-bit LED; smoother dimming than 8-bit scaling, the effect's own 8-bit steps remain | - -No path you have today gets slower: the 8-bit instantiations are the same code compiled for `uint8_t`, chosen once per pass per frame, so the per-light instruction count on an 8-bit Layer is unchanged. The 16-bit paths cost what they carry: twice the bytes moved (the S3's PSRAM latency shows this on cheap effects; the KPI gate reads it per batch), the same ALU ops on a 32-bit core, one interpolation per channel in `Correction` on top of the LUT lookup, and dithering as one add and one compare per channel, only on a 16-to-8 wire and only when switched on. No per-light branch, no virtual call, no allocation on the path. - -**16-bit LEDs are enabled, not built.** HD108 (16-bit per channel, clocked SPI) and UCS7604 (16-bit per channel, one-wire) are the known families; APA102/SK9822 are 8-bit color with a 5-bit current gain and do not qualify. Each is its own driver increment: verify the datasheet first, then an encoder that takes 16-bit wire values from `Correction` (an SPI driver for HD108, a wider symbol table for UCS7604). - -### The surface, counted - -What touches Layer bytes and therefore changes in phase 2: `Buffer` (bytes per channel, 1 or 2), `Canvas` (a sample-width field), the 25 `Canvas` and 12 `Buffer` overloads in `draw.h` (one template each), `BlendMap` (identity and LUT paths, both widths), `Layer::extrude` and `fadeToBlackBy` (bytes-per-light already, width-agnostic after the template), `Correction::apply` (16-bit input, quantize to the declared wire width; the one quantization site), `DriverBase` (the declared wire width), `PreviewDriver` (quantized through the same path), `NetworkReceiveEffect` (widens), the **7 effects that write raw bytes** (AudioVolume, BouncingBalls, Fire, Lines, NetworkReceive, Rainbow, Noise), `colorFromPalette` (unchanged: returns 8-bit RGB, widened at the write), the MoonLive `StoreElem` / `FillElems` inline ops on all three backends (element = `channelsPerLight` × the Layer's bytes per channel, NOT a fixed 3 × 2: the ops already store N bytes rather than a fixed RGB, and a phase that hardcoded three channels would break the RGBW and multi-channel DMX fixtures the same buffer serves), and the zero-copy driver path. Modifiers are coordinate-only and untouched; the mapping LUT is index-only and untouched. - -## 3. The kernels - -Signatures are shapes, not final names; the PR is the spec. All fixed point, all `dt`-driven where time enters, all pure functions of (position, time, seed) unless stated. **(proposal)** - -### 3.1 Gradient noise, in place (decision 2) - -`inoise8/16(x[,y[,z]])` keep their names, their 16.0 coordinate convention and their output ranges; the implementation becomes **Perlin's improved noise** (2002): a 256-entry permutation table, gradients from the 12 edge directions of a cube (8 in 2D), the quintic fade `6t⁵ − 15t⁴ + 10t³`, integer throughout, output rescaled to the full range the header already promises. `fbm`, `warp` and `turbulence` sit on top unchanged. The seven noise effects (Noise, NoiseMeter, Noise2D, PolarNoise, Wave, Tunnel, and EffectBase's users) change look in one commit; their goldens move in that commit with the reason in the message. Under decision 5 the question on the panel is whether they look at least as good as before; any that look worse are re-tuned before the swap is accepted, since a noise effect that ran beautifully on value noise may not degrade on gradient noise. The header's note that ports from FastLED "look slightly different for a reason the author cannot see" is deleted: they no longer do. - -Cost target: within 1.3× of the current value noise per sample (the gradient dot products replace the value lerps; the fade is the same), measured by the micro-bench in phase 0 before the swap is accepted. - -### 3.2 `PolarLut` (`light/polar.h`) - -```cpp -polar::Lut lut; // POD over ScratchBuffer -lut.build(dims, center, precision); // prepare(): angle16 + radius per pixel; 8-bit each by default, 16-bit opt-in -lut.angle(i); lut.radius(i); // hot path: two loads per pixel, no atan, no sqrt -``` - -Rebuilt on geometry or center change through `affectsPrepare`; reported as `dynamicBytes`. `PolarNoiseEffect` migrates to it (pixel-identical at the 16-bit precision, golden-pinned; the 8-bit default is a deliberate, bench-judged divergence). A polar modifier consuming the same LUT is the modifier-first path the earlier decision named; it is not in these phases. - -### 3.3 `Oscillators` (`core/oscillators.h`) - -```cpp -Oscillators osc; // N clocks; N is the effect's choice (4 to 20) -osc.set(i, ratio, offset); // per clock, at prepare() or on control change -osc.advance(dtMs, masterSpeed); // once per frame: phase_i += dt · ratio_i · master -osc.ramp(i); osc.phase(i); // 32-bit ramp; angle16 wrapped phase -osc.bipolar(i); osc.unipolar(i); // sin16 of the phase; 0..65535 form -osc.noiseAngle(i); // angle16 from inoise16(ramp), the wandering angle -osc.modulate(base, i, depth); // base · (1 + depth · bipolar(i)), the multiplicative binding -``` - -Frame-rate independent by construction (`dt`, never a frame count, per coding-standards § Animate on elapsed time). Determinism: the phases derive from the shared time origin, so two devices agree (§ 6b of the power-functions top-down). `BeatPhase` stays for single-tempo effects. - -### 3.4 Velocity rules and `curl16` (`draw.h`, `noise.h`) - -A velocity rule is a function `(x, y, t, params) → (vx, vy)` in `pos_t` per frame; none is stored. The set from Part 1: `flowWind` (direction, speed, rotation rate, perpendicular wobble), `flowRadial` (in or out), `flowSpiral` (angular step, radial step), `flowRings` (three zones with smoothstep boundaries, per-zone swirl and drift), `flowNoise` (two 1D profiles, one per axis, decoupled), and `curl16(x, y, t, scale)`: the perpendicular gradient of a scalar noise potential by two central differences, divergence-free by construction (Bridson 2007). The profiles of `flowNoise` are computed once per row and column per frame, not per pixel. - -### 3.5 `advect` (`draw.h`) - -```cpp -draw::advect(dst, src, rule, Edge::Wrap|Clamp); // backward sample, bilinear, per channel -draw::advectSeparable(cv, scratch, xProf, yProf, Edge); // two 1D passes through the scratch plane -``` - -**Shipped (2026-09-04), with the 3D cost measured.** The signature took `dst, src` rather than -`cv, scratch` and dropped `dtMs`: the two planes must differ (reading and writing one buffer samples -pixels the same pass already moved, smearing along the walk order instead of the flow), and the rule -returns sub-pixel units per frame, so only the caller knows the cadence to scale by. `sampleWrap` -already existed and covered the Wrap half; `sampleClamp` and `sampleEdge` are new, and Clamp is what -a wind wants, since a trail leaving the panel must not reappear on the far side. - -**The PO's prerequisite, that 3D awareness must not cost the 2D path, holds: 1%.** Desktop, median -of 11, every destination byte verified written: a 20x20 panel is 16.98 ns/light, the 20x20x20 cube -17.09. The z loop runs once at depth 1, so a panel pays a loop counter. In absolute terms the cube -advects in **0.14 ms**, under 1% of a 20 ms frame, so transport is cheap and the flow rules' -noise sampling is what will set the cost. - -The semi-Lagrangian step (Stam 1999): for each destination pixel, `src = dst − v · dt`, read the previous frame at `src` with bilinear interpolation (four loads, three lerps per channel), write to `scratch`, swap. On the 16-bit Layer the color state is the Layer itself; `scratch` is one effect-owned plane of the same width. Partial transport (`blend` fraction) is a parameter. Budget: ~40 cycles per pixel per channel per pass on the S3 (the field's 80% share is what this is measured against). - -### 3.6 `decay` (`draw.h`) - -`draw::decay(cv, halfLifeMs, dtMs)`: multiply every sample by `k = 0.5^(dt / t½)`, `k` computed once per frame from a 16-bit `pow2` table (`mm::halfLifeKeep`). Framerate-independent by definition; a unit test pins `decay(2·dt) == decay(dt)²` within one LSB. - -**Shipped, with a measured limit (2026-09-04): an 8-BIT plane cannot hold this decay at a high framerate, and no rounding rule fixes it.** Decaying 200 over a 500 ms half-life in 500 ms of frames, exact answer 100: truncating gives 96 at 50 ms frames, 73 at 5 ms and **0** at 1 ms; rounding gives 100, 100 and **200**, the trail frozen solid. Both are the QUANTIZATION rather than the weight, the value being re-rounded to a byte hundreds of times a second. A 16-bit accumulator holds 100/101/102. So `decay` on a byte canvas is honest at a slow cadence and `decay16` on a wide scratch plane is what a trail uses: the precision belongs in the ACCUMULATOR, not the frame buffer, which is the same conclusion phase 2 reached from the other side. The Layer's collected `fadeToBlackBy` remains the one 8-bit-per-frame decay for effects that want it; `decay` is the half-life form on wide state. - -### 3.7 `lineAA`, `disc` (`draw.h`) - -`lineAA` steps the segment at ~3 samples per pixel and splats each through the existing 2×2 bilinear `splat` (Wu 1991). `disc(cv, center, radius, color)` writes coverage `clamp(r + 0.5 − dist, 0, 1)` per pixel in the bounding box, using the squared-distance `coverage` path already in `draw.h`. Both additive with saturation, the particle default. - -### 3.8 `quantize` with dithering (`draw.h`, phase 4) - -`draw::quantize(src16, dst8, ditherState)`: `>> 8` in phase 2; in phase 4 the truncation error is carried per pixel into the next frame (one byte per channel of state, effect-owned, or a 4×4 ordered matrix with no state, selectable). Applies at every 16-to-8 boundary listed in § 2. - -### 3.9 `Fluid` (`light/fluid.h`, phase 5) - -The Stam 1999 solver over effect-owned `int32` Q16.16 grids: `diffuse(velocity)`, `project()`, `advect(velocity by itself)`, `project()`, then dye advected by `draw::advect16` along the stored field. Wall boundaries; `addVelocity(x, y, dvx, dvy, z)` and the dye splat are the source terms. SHIPPED with three differences: the relaxation is Gauss-Seidel rather than Jacobi (it converges in fewer sweeps for the same code, and the sweep count is the `iterations` control, default 5); vorticity confinement was not built; and the dye is the EFFECT's own 16-bit plane rather than the Layer, which is what kept the buffer 8-bit (§ 11). Declares a resync point (re-seed to rest). Sized for panels; the P4 and desktop are its home. - -## 4. MoonLive: the frame kernels, per phase (decision 3) - -**The Layer is the state, so the kernels are whole-frame builtins on the script's layer**, the shape `fill(r,g,b)` and `fade(amt)` already have: no handle, no arena bytes, one host call per frame. Stateful helpers that are not the frame (the polar LUT, the oscillator bank, the fluid grids) are **handles declared in `defineControls()`**, exactly like `pool(n)`: sized once, allocated by the binding outside the arena, whole-object passes per frame. Multi-argument host calls ship (`emit` takes seven), the builtin table holds 96, so nothing waits on the engine. **(proposal)** - -**What shipped, against this table.** The vocabulary below is the design proposal; the names in the -code differ where implementation taught otherwise, and the third column says which. Read the -registered set in `MoonLiveBuiltins_light.h` as the authority. - -| Phase | Builtins | Script shipped | -|---|---|---| -| 1 fields | SHIPPED as `osc(rate, ms, shape)` alone, which is STATELESS: two oscillators sharing a rate hold their phase relationship without a bank, so the handle and its eight accessors were not needed. `polarA`/`polarR` still compute per pixel (no LUT handle). The gradient-noise swap is invisible to scripts, as planned | `aurora.mle` | -| 3 advection | SHIPPED as `trail(1)`, `flowNoise(zoom, strength)`, `flowCurl(zoom, strength)`, `trailDecay(halfLifeMs)`, `emitTrail(x, y, z, index, bri, radius)`. The flow builtins ADVECT rather than setting a rule for a separate `advect()`, which halves the host calls; `decay` became `trailDecay` because a plain `decay` is a name scripts already declare. `flowWind`/`flowRadial`/`flowSpiral` exist in `draw.h` with no script binding; `lineAA` shipped earlier (2026-08-07) and has no script binding either; only `flowRings` is absent | `trails.mle` | -| 4 composition | SHIPPED as `fieldRate(n)`, the frame-skip lever a per-pixel script needs on a large fixture. `fieldScale` stayed compiled-only: a script cannot own the second plane it needs | `nebula.mle` | -| 5 fluid | NOT BUILT: the solver has no script binding, so `fluid.mle` is jets pouring into a curl flow through the phase-3 builtins, which looks like the effect without being it. Exposing `Fluid` to scripts is open | `fluid.mle` | - -The per-pixel shader path improves without new builtins: the LUT turns `polarA`/`polarR` from a 3.5 µs square root into two loads, and the roadmap's inline-op work (§ 4c) is what removes the remaining call cost; that work is the roadmap's, not this document's. - -The one engine change this plan requires: the `StoreElem` and `FillElems` inline ops lower a 16-bit element on all three backends when the layer is 16-bit (phase 2). The lowering already carries the element width as "N bytes"; the width becomes a per-binding constant. - -## 5. Performance plan - -**Currencies.** Shaders: samples per pixel, against ~750 cycles per sample on the S3 and ~250 on the P4 (bottom-up, Part 3), with the gradient-noise swap held within 1.3× of that. Advection: cycles per frame and bytes of state. The target is ~40 cycles per pixel per channel PER PASS, and separable advection is two passes (x then y), so a 128² RGB advect is 128·128·3·40·2 cycles: **~16 ms on the S3** at 240 MHz and **~10 ms on the P4** at 400 MHz. (An earlier draft said 4 ms and 1.3 ms, which matched neither one pass nor two: the arithmetic gives 8.2 ms for a single S3 pass. The numbers below follow from the corrected figures.) That is 60 fps on the P4 with room for the emitters, and on the S3 it is the whole frame at 60 fps, so a 128² wall runs advection at 30 fps or drops to 64² for 50. The phase-3 scenario sets the contract per target from measurement rather than from this estimate. - -**Framerate as the rendering method, by rule.** Every kernel takes `dt`; no kernel takes a frame count. `advect` clamps the per-frame displacement to one pixel and reports when it clamps (the `frameTime`-style status), so a slow frame smears rather than tears. `decay` is a half-life. The oscillator bank is `dt`-integrated. A showcase's stated fps on its stated fixture is a scenario contract; when it fails, the default of its cost knob moves, not the contract. - -**Levers, in the order the plan pulls them.** LUTs (phase 1); fixed point (already); cost knobs as controls with honest defaults (every showcase); field below output resolution with bilinear upscale (phase 4: a `fieldScale` control, the shader renders into a smaller scratch plane and `draw::upscale` bilinearly fills the layer); field below frame rate (phase 4: `fieldRate`, the shader updates every N frames and the oscillators still advance every frame); SIMD (phase 4: a PIE variant of the gradient-noise inner loop and of the bilinear lerp for the S3 and P4, behind the platform boundary, selected by `platform_config.h` capability flags, bit-identical to the scalar form and tested as such); FPU (a float variant of `curl16` and the fluid solver where `hasFpu`, the `raymarch.h` precedent, same bit-identical-within-tolerance test); a second core (the field update as a `multicore` producer, not in these phases). - -**Where the desktop takes over.** The same effects, the same controls; the desktop as processing node feeds a wall over ArtNet or DDP. The per-target headroom table in the bottom-up is the guide: rich compositions above 32² (S3) or 64² (P4) are desktop work, and a showcase's card says so. - -## 6. Showcases - -Each is a compiled effect and a MoonLive script of the same look, so the script proves the builtin surface. Controls are the cost knobs and the look knobs, with defaults that hold the stated fps on the stated fixture. **(proposal)** - -**Aurora** (phase 1; shader; 💫🖌️ 3D). Layers of gradient noise over a `PolarLut`, each layer's transform driven by its own oscillators; which layer wins picks the palette region, and a contrast window decides what is visible at all. SHIPPED controls: `speed`, `scale`, `layers` (1 to 4, the cost knob), `warp`, `twist`, `segments` (kaleidoscope), `contrast`, `octaves`, plus `PolarLut`'s `polarTable`, `polarTable16` and `mapping`. - -**Trails** (phase 3; advection; 💫🖌️ 3D). Emitters drawn into the effect's own 16-bit plane, then advected and decayed. SHIPPED controls: `speed`, `dots` (a density, scaled by the fixture), `scale`, `persistence` (a half-life), `breathe`. The planned `emitter`, `orbit`, `flow` and `blend` controls were not built: one Lissajous walk and one flow rule proved enough, and a flow SELECT is open. - -**Nebula** (phase 4; composition; 💫🖌️ 3D). A noise field, thresholded against its own measured range so most of it is black, is the emitter; a curl flow carries it. SHIPPED controls: `speed`, `scale`, `contrast`, `persistence`, `octaves`, `fieldScale` (compute the field at half or quarter resolution), `fieldRate` (recompute it every N frames). - -**Fluid** (phase 5; solver; 💫🖌️ 3D; P4 and desktop). Jets pour velocity and dye into a Stam solver, one independent medium per depth slice. SHIPPED controls: `jets` (1 to 4), `force`, `swirl`, `viscosity`, `persistence`, `iterations` (the pressure solve, and the cost knob). `angle`, `vorticity` and `gravity` were not built. - -Each showcase gets its card in [effects.md](../../moonmodules/light/effects.md) in the PolarNoise form (one line per control, the cost knob named as such), and its numbers in [performance.md](../../reference/performance.md). - -## 7. Tests and bench criteria - -- **Unit, per kernel, behavior-named.** Gradient noise: zero-mean over a large sample, range, continuity across cell boundaries, the same value on every ISA (a fixed-seed table of 64 samples). `PolarLut`: the LUT equals per-pixel `atan16`/`dist16` at 16-bit precision; the 8-bit default within one LSB of angle. `Oscillators`: `dt`-independence (two steps of `dt` equal one of `2·dt`), wrap, modulation bounds. `advect`: a single lit pixel moves exactly `v·dt` (sub-pixel, so two neighbors share it by the bilinear weights), wrap and clamp edges, the separable form equals the 2D form for an axis-aligned field, no write outside the buffer. `decay`: `decay(2·dt) == decay(dt)²` within one LSB, and a value never rises. `curl16`: divergence of the sampled field is zero within tolerance on a grid. `lineAA`/`disc`: total coverage of a disc equals its area within 2%; a line's coverage is independent of direction within 5%. `quantize`: `>> 8` bit-identical to the 8-bit path; dithering averages to the 16-bit value over 256 frames. 16-bit Layer: every `draw::` primitive tested at both widths. -- **Goldens.** A golden pins the plumbing, not the look (decision 5): phase 2 moves none, because a bit-identical quantize is the proof that widening the Layer is mechanical; phase 0 and phase 6 move goldens deliberately, with the reason in the commit and the product owner's comparison against the old frame on the panel, and that is the harness working. Each showcase adds its golden at both widths. -- **Scenarios, per family.** Fields: a `PolarLut` sized at prepare and rebuilt on a grid resize, with the memory ladder. Advection: Trails on a live pipeline at 16², 32², 64², 128² with the scratch plane in the ladder and the tick within its per-target contract. The 16-bit Layer: the existing `scenario_Layer_memory_1to1` and `scenario_MultiplyModifier_memory_lut` at both widths, and the cascade stepping from 16 to 8 bits on a constrained heap. Fluid: its grids in the ladder and its tick contract on the P4. Every showcase's fps target is a `contract` in its scenario. As shipped the contracts are -`desktop-macos` only and expressed in `tick_us`; no device contract exists yet, because the S3 and P4 -numbers have not been taken (§ 8, phase 3 step 5 and phase 5 step 3). -- **Perf.** The micro-bench target from the power-functions plan gains a row per kernel; `collect_kpi.py --commit` per phase on the S3 and P4; the desktop tick is the fast alarm. -- **The final gate is the product owner's eyes.** Per phase: the S3 bench board (shiffy, 80×48) for the panel class, the P4 (.139) for the MCU home, the desktop at 128×96 for the ceiling, and a wall run for Trails and Nebula. The criteria are visual: no tearing at the stated fps, no visible steps in a trail's tail, a shader that turns rather than scrolls, and the classic at 32² still smooth. - -## 8. Step-by-step implementation plan - -The steps below are work order, not commit order. Each step is finished on the desktop first, then the S3, then the P4, then the wall where it applies, and the product owner tests as steps land. **When to commit, and how much rides in one commit or PR, is the product owner's call** ([CLAUDE.md § Commit](../../CLAUDE.md#commit)): the pre-commit and pre-merge gates cost real time, so a commit carries several steps and a PR carries several phases. Nothing here implies one PR per phase. Order chosen so the first showcase lands before the one cross-cutting change. - -### Phase 0: measure, then swap the noise (small) -1. Add the kernel rows to the micro-bench target: value noise as it is, per sample, on desktop; record. -2. Implement Perlin improved noise behind `inoise8/16` (2D and 3D; 1D as the 2D with y = 0), integer, same coordinate convention, same output ranges. Delete the "value noise" note in `noise.h`. -3. Run the micro-bench: accept only within 1.3× per sample; else optimize (the 8-gradient 2D form, a 12-entry gradient table) before continuing. -4. Desktop build, `ctest`: the seven noise goldens fail as expected; update them in the same commit with the reason. -5. On the product owner's go-ahead (CLAUDE.md § Build: a board is written to only when they say so), flash S3 and P4; `collect_kpi.py --commit`; they judge Noise, PolarNoise and Tunnel on the panel. Catalog cards: no text change unless the look note in a card mentions value noise. - -### Phase 1: fields and Aurora (medium) -1. `core/oscillators.h` with its unit tests. -2. `light/polar.h`: `PolarLut` with 8-bit default and 16-bit opt-in; unit tests against `atan16`/`dist16`. -3. Migrate `PolarNoiseEffect` to the LUT (golden-pinned at 16-bit; the 8-bit default is a deliberate move judged on the panel) and to the bank for its drift. -4. `AuroraEffect`: the three-layer shader, controls per § 6, card in effects.md, golden. -5. MoonLive: `polar()` and `oscillators()` handles with their builtins; `polarA`/`polarR` read the LUT when declared; `aurora.mle`; the compile-every-script test and a script golden. -6. Scenario: fields (LUT rebuild on resize, memory ladder), Aurora's fps contract per target. -7. Desktop first; then S3 and P4 on the product owner's go-ahead; performance.md rows; their look. - -### Open from phase 1 - -**Aurora hitches on the desktop and not on the S3 (2026-09-04).** The product owner sees a periodic -hitch in the desktop preview; the same firmware on an ESP32-S3 at 64x64 looks smooth, and the S3's -LEDs were not yet judged. Five causes were tested and ruled out with measurements: - -| ruled out | the measurement | -|---|---| -| an integer overflow | every expression stays in range at every control setting, checked by hand | -| the drift sawtooth wrapping | worst single-frame field change at a wrap is 7 of 255, no spike | -| the field moving too fast | per FRAME the desktop moves 15x LESS than the S3 (0.009 against 0.136 noise cells), so it should read smoother | -| the contrast window's per-frame easing | its absolute worst frame is SMALLER at 385 fps (17.6) than at 25 fps (30.2) | -| the preview transport at a large grid | the product owner set the desktop to 64x64, matching the S3, and the hitch stayed | - -What is known: brightness changes are distributed rather than discrete (median 17 per frame, 99th -percentile 101), so there is no single glitching frame; and the desktop renders at ~385 fps against -the S3's 25, which is the one large difference left standing. The next thing to try is a frame-rate -cap on the desktop: if the hitch tracks the render rate rather than the wall clock, something in the -pipeline is per-frame where it should be per-unit-time, and the effect is not the place to look. - -**Aurora's layer crossover (2026-09-04).** With a winner-takes-all palette index, 12.7% of pixels -change which layer owns their color every frame, and 35% on the worst frame; each change moves the -hue a whole palette region. A weighted crossover (cubed weights, so a dominant layer still -dominates) removes the discontinuity and measured 2.7x less frame-to-frame change, for 3.4% more -time per pixel (42.8 against 41.4 ns on the host). Built, measured, then REVERTED on the product -owner's call: it did not address the hitch above, and the cost is real. Revisit when the hitch is -understood, since the two may be related. - -### Phase 1b: three dimensions everywhere (medium) - -Dimension-generic is a stated input of this plan (§ 0), and the library is not there yet. The gap is -uneven rather than absent, and the hot-path worry it raises turns out not to be the blocker. - -**Where it already holds.** `draw::` is `Coord3D` throughout (pixel, line, blendPixel, addPixel, -glyph). `blur` runs a third pass behind `if (z > 1)`, so a 2D layout pays nothing for the capability. -`inoise8` and `inoise16` have real 1D, 2D and 3D forms, each compiling to its own straight-line body -over exactly its corners after the phase 0 swap. - -**Where it does not.** `fbm16`, `turbulence8` and `warp8` stop at 2D; only `fbm8` has a 3D overload. -`PolarLut` stores angle and radius on a plane, with no z. `atan16` and `dist16` are planar by nature. - -**A 3D-capable kernel costs a 2D layout nothing**, and the tree already shows both ways to get that: -the third axis is either a template parameter that compiles away (`if constexpr (Dims > 2)` in the -noise core) or a runtime guard on a value that is 1 (`blur`). Neither is a per-pixel branch. So the -missing overloads are mechanical. - -The one that is not mechanical is **polar in three dimensions**, and the answer is a control rather -than a decision. Spherical (two angles and a radius), cylindrical (an angle, a radius and a height) -and distance-from-center are three different looks, and which one is right is a property of the -FIXTURE: a sphere wants the first, a tube or curtain the second, a cube may want any of them. The -choice costs nothing per pixel because it is made once when the table is built, and the table is the -same size either way. Cylindrical is the default: it reduces to exactly today's behavior when depth -is 1, so no existing 2D fixture changes. - -**What "dimension-generic" does NOT claim.** The KERNELS take any arity; an EFFECT still looks good -only at the arities its design has. Aurora on a strip is one radial line through the field, which -reads as a slow flicker rather than as curtains, and no amount of library work changes that. The -Layer already handles the mismatch (a D2 effect on a 3D layer has its z=0 slice copied across z, a -D1 effect its column copied across x), so an effect that declares less than the fixture is correct -rather than broken. Which effects earn a genuine 1D or 3D form is per-effect judgment, made in the -catalog sweep, not a property this phase delivers. - -1. `fbm16`, `turbulence8` and `warp8` gain their 3D overloads, matching `fbm8`'s existing shape. -2. `PolarLut` gains a z axis and a `mapping` control (cylindrical default, spherical, radial), with - the 2D path bit-identical to today's at depth 1. -3. The effects that are `Dim::D2` only because their kernels were: Aurora, PolarNoise and Tunnel are - the three that a volumetric fixture would show something new, and all three are blocked on step 2 - rather than on their own code. Noise2D needs no polar work at all: it already samples 3D noise - with time on z, so a volumetric form is passing the light's own z instead. -4. ✅ **Noise2D and Noise were the same effect**, and are now one: Noise - already uses the light's real z when depth > 1, and the two differ only in control names and - scale defaults (`bpm`/4 against `speed`/64). Merging them is a catalog decision with a golden and - a card, so it was done here rather than deferred: `motion` chooses drift (the old Noise) or morph - (the old Noise2D), the drift golden is unchanged, and the second effect is deleted. -5. Unit tests per kernel that a 2D call is exactly the 3D call with z held at zero, which is what - makes the extension safe to make everywhere else. ✅ *(shipped: `fbm8`, `fbm16`, `turbulence8` and - `warp8` all have 3D forms, and `warp8`'s 2D form is now literally the 3D one at z = 0, so the two - cannot drift apart.)* - -### Phase 1c: Coord3D in MoonLive (medium) - -A script computes a position as three loose integers today, and flattens it by hand -(`mod(bx+dx, width) + mod(by+dy, height) * width`), which is the buffer layout leaking into every -effect. The engine already passes `Coord3D` everywhere, so the script vocabulary is the odd one out. - -This is **not** general struct support. The MoonLive roadmap settles that: a predefined struct is one -the compiler knows by layout, needing no user-declarable struct machinery -([roadmap § 4b](backlog-moonlive.md), with user structs deferred to § 10 for readability -rather than capability). It depends on the multi-value call ABI (§ 2 there), which is what makes a -coordinate in and a color out expressible at all. - -**`Coord3D` yes; the color struct waited for phase 2, which was reversed (§ 11), so the dependency is void and the struct is its own question.** The roadmap pairs `Coord3D` with a `CRGB`, -and that pairing predates the 16-bit decision. A script-visible color fixed at three `uint8_t` would -be the one place in the pipeline where the channel width stops being runtime data, exactly as phase 2 -makes `draw.h` a template over both widths and has `Correction` quantize once at the driver. So the -color type is specified AFTER phase 2 knows what it is, and it takes our own name rather than -FastLED's (CLAUDE.md: our own code, our own names). A coordinate has no such dependency, which is -why it goes first. - -**How far it goes: as far as every other type.** A predefined struct that only appears in builtin -signatures would be a special case, and the language does not need another one. `Coord3D` is a -class member, a local, a function argument and a return value, the same as `int`, `byte`, `bool` and -`fixed`. Arguments and returns are roadmap § 6, which this step therefore depends on. Two -consequences worth stating: a member costs three of the 16 member records and 6 of the 64 arena bytes -unless the compiler packs it as one record, which is worth deciding when the type is added rather -than after; and a local costs one frame slot per field under today's flat allocator, so a script -holding several coordinates meets the 16-slot ceiling faster (§ 8b there). - -Ordering note: this is the step that makes a 3D script possible, so it follows phase 1b rather than -leading it. A script that can hold a coordinate but calls kernels that ignore z has gained nothing. - -### Phase 2: the 16-bit Layer: BUILT, MEASURED, REVERSED (2026-09-04) - -**Decision: the wide frame buffer is not the way to precision; phases 3 onward are 8-bit with -effect-owned 16-bit planes.** The whole arc, the measurement that reversed it, what replaced it and -what is worth salvaging: [§ 11](#11-the-16-bit-question-what-it-was-both-answers-and-what-is-left). - - -### Phase 3: advection and Trails (medium) - -**Volumetric from the start (PO, 2026-09-04).** What shipped is per-slice: every slice gets its own -flow and they differ, but `advect16`'s rule yields vx and vy only, so light is carried WITHIN a slice -and not between them. The trilinear sampler and the vz below were not built, and 3D transport is -open. TrailsEffect is `Dim::D3`, as Aurora is: the bench -S3 is a 20x20x20 cube, so a flat trail would be the thing on the wall. Per function: `decay` and -`decay16` are already dimension-blind (they walk the buffer flat); `advect` needs a TRILINEAR -sampler for 3D, since `sampleWrap` takes `pos_t x, y` with a whole-pixel z, which is 8 corner reads -instead of 4 plus a `vz` from the rule; `flowWind`, `flowRadial`, `flowSpiral` and `curl16` all have -3D forms (3D curl is a cross product of gradients, not one perpendicular) while `flowRings` stays -planar; `disc` becomes a sphere through `sdSphere`. The effect is written 3D-aware from the start -because `dimensions()` and the plane sizing are decided once, but `advect` ships 2D first and gains -the trilinear path in step 4, so step 3 still reaches the wall early. **Memory is the constraint a -volumetric trail meets first**: a 16-bit RGB plane is 48 KB on a 20-cube and 1.5 MB on a 64-cube -(PSRAM only), against 24 KB for a 64x64 panel. - -1. ✅ `advect`, the velocity rules, `curl16`, `decay`, `disc`, with their unit tests. Two parts of the step did not ship: the SEPARABLE form of `advect` (the 2D and 16-bit forms are what exist), and `lineAA`, which turned out to be already in `draw.h` from 2026-08-07. -2. ✅ `TrailsEffect`: emitters, flows, the bank for breathing, scratch plane in `ScratchBuffer`, card, golden. -3. ✅ MoonLive: the flow, advect, decay and emitter builtins; `trails.mle`. -4. ✅ Scenario: `scenario_Trails_ladder`. -5. Desktop ✅ and the product owner's look ✅. S3, P4 and the wall are open, and performance.md has no Trails row yet. - -### Phase 4: composition, dithering, levers, SIMD (medium) -1. ✅ `quantize` with temporal and ordered dithering; tests. -2. ✅ `fieldScale` and `fieldRate` (`draw::upscale16`, and a frame counter the binding owns). -3. ✅ `NebulaEffect` and `nebula.mle`; card and golden. No Nebula scenario: the Fluid one covers the shared field machinery, so it was not worth its own. -4. The PIE variants of the noise inner loop and the bilinear lerp for S3 and P4 behind `src/platform/`, bit-identical tests against the scalar forms; the FPU `curl16` where `hasFpu`. - - **The P4's PPA is for `upscale`, not for advection (2026-09-04).** The Pixel Processing - Accelerator is a 2D-DMA blitter: it transforms a whole rectangle by ONE affine transform - (scale, rotate, translate, blend) with no CPU in the loop. Advection displaces every pixel by - its OWN velocity, so the interesting rules (`flowNoise`, `curl16`) do not map onto it at all, - and the ones that would (a rigid scroll, `flowRadial`) are the cheap cases anyway. Where it - does fit is `fieldScale`'s bilinear `draw::upscale` above, which is exactly a scaled blit, and - the sprite blit already backlogged under [Sprite follow-ups](backlog-light.md). Both are P4 - only, so PPA can be an acceleration behind an existing signature, never the main path. -5. KPI per target; the product owner's look on the wall. - -### A scripted control's name can disagree with the file that declares it (2026-09-04, open) - -Reproduced on the desktop with `Nebula.mle`, and the three facts contradict each other: - -- the module's `script` control holds `Nebula.mle` (so the UI is NOT lowercasing the selection); -- `GET /api/file?path=/moonlive/Nebula.mle` returns a file whose line 20 is `addControl("rate", ...)`; -- the UI renders that slider as **`rate1`**, a name the file no longer contains anywhere. - -So the compiled program disagrees with its own source. `resolveScript` is exact-match and prefers -the user's copy, which is correct, and the picker sets `option.value` to the exact filename, so -neither is the cause. The `rate1` text is a name the file held EARLIER in the session, which points -at the control list surviving a recompile rather than at the resolver or the UI. - -**Ruled out by measurement, so a future look need not repeat it**: the built-in table has room (67 of -96); `kMaxCtrls` is 8 against 5 declared; the 64-byte arena holds five `byte` members; declaration -ORDER is irrelevant (moving the control first changed nothing); the control NAME is irrelevant -(renaming it changed nothing); `aurora.mle` and `trails.mle` have the identical shape and are fine. - -**Where to start**: `rebuildControls()` after a recompile, and whether a declared control that -vanishes from the script is cleared from `ControlList` or merely left behind. A test that compiles a -script, edits one control's name, recompiles and asserts the OLD name is gone would pin it. - -**A second, smaller thing this exposed**: `/moonlive/Nebula.mle` and `/.moonlive/nebula.mle` are two -files on the device and one on a case-insensitive desktop, and nothing stops a user creating a -capital-N file beside a lowercase catalog entry. The fork mechanism assumes the two names match. - -### Phase 5: fluid (medium; P4 and desktop) -1. ✅ `light/fluid.h` with its unit tests (divergence after `project` within tolerance; a jet moves dye; rest state is stable). -2. ✅ `FluidEffect`; card, golden, and `scenario_Fluid_solver` (host; a P4 run needs a board). `fluid.mle` ships too, but NOT on the solver: the plan's `fluid`/`fluidJet`/`fluidStep` builtins were not built, so the script is jets pouring into a curl flow, which looks like the effect without being it. Exposing the solver to scripts is open. -3. ✅ performance.md carries the desktop rows; the P4 and S3 rows are open, since they need a board. - -**Two bugs the jets taught, both worth keeping.** The first shipped picture was a hollow RING, and -the cause was the forcing rather than the solver: jets pinned to one circle at `w/3`, all sweeping -the same direction, all aimed purely tangentially. Measuring speed by radius showed the energy in a -band (1.35 at r5 against 0.33 at the center) while `|grad v|` was a healthy 0.30 per cell, so the -medium was working and being asked the wrong question. The fix is three-part: the radius breathes -between center and wall, the aim leans either side of the tangent, and alternate jets sweep against -each other so they collide, because colliding jets are what roll up vortex pairs. After it, the peak -sits mid-panel (0.78 at r2) and the outer band halves. - -The second: a purely time-paced pour never fires on the opening tick, because `dt` is deliberately 0 -there, and on a fast device it then takes many frames to owe a whole period. The panel stays black -in the meantime and every resize repeats it. The first pour is now unconditional. The scenario's -`buffer non-zero after render` check is what caught it, at 16×16. - -### After the phases: the catalog sweep (large; its own plan) -Decision 5 mandates that every natural-motion effect runs on the power functions. That sweep is not a phase of this plan: it is sized and sequenced in its own plan once the kernels exist, from the [effects × power functions inventory](effects-power-function-inventory.md), which records for each of the 58 effects what it uses today and what it could use. Two rules carry over: each rewrite is compared on the panel against the effect as it ran and lands only if at least as beautiful, and an effect a showcase supersedes outright is deleted, the way a particle-system effect replaces its non-PS twin. - -## 9. Resource accounting - -- **Flash:** the gradient noise is a 16-entry gradient table (Perlin's twelve cube-edge gradients padded to sixteen so the index is a mask) and NO permutation table: the corner hash is a multiply, which is what made it branch-free on Xtensa; the LFO bank and LUT are small; the 16-bit Layer instantiates the frame-op set twice (estimate low single-digit KB per target ❓, read from the repo-health flash table per batch); the SIMD variants are per-target only. -- **RAM:** all sized buffers are `prepare()`-time, PSRAM-preferred, `dynamicBytes`-reported, zero static (`check_footprint`). As shipped: `PolarLut` 2 B/pixel default; a wide-plane effect carries two 16-bit planes plus a 1-byte dither carry, so 15 B/light on top of the Layer's 3 (5 bytes per channel, three channels) (measured, Trails at 64x64x10: 614,400 B against the Layer's 122,880); the fluid holds two velocity grids sized to the volume plus four working grids sized to ONE SLICE (`int32` throughout). The 16-bit Layer's row is gone with the decision (§ 11). -- **Cycles:** budgets in § 5; the KPI gate per phase; the desktop tick as the fast alarm. -- **Repo:** goldens are hashes; scenarios record their observations; each phase adds ~1 effect, ~1 script, ~1 scenario. -- **Boundary:** `noise`/`oscillators` core; `polar`/`draw`/`fluid` light; SIMD and FPU variants only under `src/platform/`, selected by `platform_config.h` flags, never an `#ifdef` outside. -- **Complete construct, real consumer:** every kernel lands with its showcase and its script in the same PR. - -## 10. Decisions for sign-off - -1. ↩️ **Layer width mechanism: B** (16-bit default, 8-bit as the first cascade step, decided per Layer at prepare from free memory, one template). Agreed 2026-09-03, BUILT, then REVERSED on the measurement (§ 11): the buffer is 8 bits and precision is held in effect-owned planes instead. -10. ⬜ **Quantization lives in `Correction`, once per driver, to the driver's declared wire width**; the pipeline is 16-bit end to end and 16-bit LEDs become driver increments. Agreed 2026-09-03, NOT built: it followed from decision 1, which was reversed. Quantization lives in `draw::blit16`, at the one point a wide plane meets the 8-bit buffer. -2. **Gradient noise swap accepted on the 1.3× per-sample bound**, goldens moved in one commit, judged on S3 and P4 (§ 3.1). -3. **MoonLive kernels operate on the script's Layer; LUT, bank and fluid are handles** (§ 4). -4. **Phase order**: noise, fields and Aurora, the 16-bit Layer, advection and Trails, composition, fluid (§ 8). Alternative: the 16-bit Layer first, if the cross-cutting risk should be retired before any showcase. -5. **Showcase names and targets** (§ 6): the fps-per-fixture numbers become scenario contracts on acceptance. -6. **`PolarLut` 8-bit default** (the earlier decision, restated because Aurora's angular resolution on a 128² wall may want 16-bit as its default) ❓. -7. **Temporal dithering default**: on for 16-bit layers once phase 4 lands, or off with a per-driver switch (the leddriver analysis's per-driver mode) ❓. -8. **Fluid on the S3 and classic**: runs with its card stating the panel size it holds, or hidden behind a capability flag. Recommendation: runs, honestly labeled. -9. **Two documents still state the old stance and need the PO's edit under decision 5**: [power-functions.md § Migrating an effect](../../moonmodules/light/power-functions.md) ("Step 1, the port: behave identically") and the header of `test/unit/light/golden_frame.h` ("pixel-identical by default"). Proposal: "faithful first" stays, and its purpose is stated: it guards against degrading an effect into a quick-and-dirty one (the early Game of Life port), not against improving it. Two sentences follow it: every effect runs on the power functions for whatever they cover, and an effect that does not is rewritten on them, with the effect as it ran as the reference and "at least as beautiful on the panel" as the bar; algorithmic effects (a rule set is the effect) keep their logic. The golden is re-baselined in the same commit with the reason, and the harness comment reads "a golden pins the plumbing; a deliberate re-baseline records an improvement the product owner judged". - -## 11. The 16-bit question: what it was, both answers, and what is left - -The plan opened with a decision to widen the Layer buffer to 16 bits per channel (§ 10, decision 1). -That was built, measured, and reversed, and the precision problem it existed to solve was then solved -a different way. This section is the whole arc, because the reversal is the most useful thing the -work produced and a stash is a poor place to keep a lesson. - -### The problem is real, and it is narrower than it looks - -An 8-bit channel multiplied by slightly less than one, many times a second, has nowhere to go. A -decay to a half-life of two seconds at 60 fps wants to multiply by about 0.994 per frame: at 8 bits -a value of 100 either truncates to 99 and keeps truncating, reaching 0 far too fast, or rounds back -to 100 and never fades at all. The tail either vanishes or freezes, and neither is a tail. - -That is the case that motivated the phase. The important observation, which took building the wrong -thing to see, is WHERE it happens: in a plane that DECAYS, frame after frame. The Layer buffer is -written fresh by every effect on every frame, so its precision is a display question, not an -accumulation one. Banding on a buffer is a dither problem. Banding on a decaying plane is a width -problem. - -### Answer A: widen the Layer (built, measured, reversed) - -Phase 2 made `Buffer`, `Canvas` and every `draw::` primitive width-templated, taught every driver and -both preview paths to read at the layer's width, and kept every golden matching through a narrowed -hash. It worked. Then it was measured: - -| | 8-bit | 16-bit | cost | -|---|---|---|---| -| ESP32-S3, 128x128 | 18.25 ms | 28.58 ms | **+56.5%** (effect +50%, driver read +63%) | -| desktop arm64, 128x128 | 0.48 ms | 0.76 ms | +57.9% | - -Two architectures that usually disagree agreed within 1.5 points, which is what makes the number -trustworthy. **On the S3 the DRIVER READ was the larger half.** Reassembling two bytes into a channel -happens `lights x channels` times per frame whatever the effect does, and an in-order core with no -branch predictor punishes it. The 8-bit dispatch itself cost nothing measurable, so the work was -correct; it was aimed one layer too high. - -### Answer B: widen the plane that accumulates (shipped) - -Trails, Nebula, Fluid and the scripted trail each own a 16-bit plane, advect and decay it at full -width, and narrow ONCE on the way out through `draw::blit16` with temporal dithering. The buffer -stays 8 bits, so the driver read is untouched, which is the entire +56% avoided. - -Measured on the desktop at 64x64x10, the same grid for both: - -| | tick | dynamic bytes | -|---|---:|---:| -| Trails (two 16-bit planes + carry) | 1913 us | 614,400 | -| Noise (8-bit, no state) | 920 us | 0 | -| the Layer buffer itself | | 122,880 | - -**The cost moved to memory, and it is larger per effect than the wide buffer would have been**: 5 -bytes per light (two 16-bit RGB planes plus a 1-byte dither carry) against the 3 the Layer holds, so -2.5x what a 16-bit buffer would have cost. Three properties make that acceptable where the buffer was -not: only effects that need it pay, it is `prepare()`-time and PSRAM-preferred, and it is freed when -the effect is removed. A 64-cube would want 3.75 MB for the set (two 1.5 MB planes plus the carry), which is a PSRAM-class effect and the -cards say so. - -### What to learn from it - -**Measure the whole path, not the kernel.** The effect-side cost of a wide buffer was the obvious -half and the smaller one. Nobody predicted the driver read, and no host benchmark would have found it -either: it is an in-order-core property. - -**Precision is not a pipeline property, it is a per-consumer one.** The plan reasoned "the pipeline -should be 16-bit end to end", which is a tidy sentence and an expensive design. What the code needed -was 16 bits in the four places that accumulate. - -**A reversal is worth more than the feature.** Phase 2 cost a day and produced a number that will -stop this being proposed again for years. That is why the numbers live here rather than in a commit -message. - -**Building it was the only way to measure it.** The estimate before the work was "some overhead"; the -answer was +56%. Neither number could have been argued to. - -### Follow-ups, in the order they are worth doing - -1. **Tag the stash, or lose it.** The phase-2 work is 65 files, +1826/-362, in `stash@{0}` and - NOWHERE else: no branch, no tag. A stash does not survive `git stash drop`, a fresh clone, or a - dead disk. `git tag phase2-16bit-layer stash@{0}` costs nothing and makes "recoverable if the wall - says otherwise" true. Product owner's call, since it is a git operation. -2. **Cherry-pick `Correction`'s dithering** (`src/light/drivers/Correction.h`, +158 in the stash). - Decision 10 put quantization in `Correction`, once per driver, at the driver's declared wire - width. That decision followed from the wide buffer and so was never built, but the DRIVER-side - half stands on its own: an 8-bit buffer dithered at the wire is the cheapest remaining win for - banding on a real panel, and it is the one piece of phase 2 whose cost does not scale with lights - x channels. -3. **The five shared-primitive bugs the phase found are still real** under any wide buffer: `fill`, - Canvas `fade`, `blur`, `Layer::extrude` and `effectSetChannel` all assume one byte per channel. - They are correct at 8 bits, so nothing is broken today, but they are a trap for the next wide - thing. Worth a comment at each site rather than a fix. -4. **16-bit LEDs remain unanswered.** Some drivers can carry more than 8 bits to the wire. With the - buffer at 8 that is now a driver-increment question rather than a pipeline one, which is a smaller - and better-shaped problem than the plan assumed. - -## Out of scope - -16-bit-native LED drivers (HD108, UCS7604): enabled by the per-driver quantization, each its own increment starting with the datasheet; a polar modifier on the LUT (modifier-first path, its own increment); the second-core field producer; 3D variants of the velocity rules and the LUT (the dimension audit's "waits for a consumer" rule); a MoonLive per-pixel inline-op program beyond the LUT loads (the roadmap's § 4c work); GPU acceleration on desktop; supersync's protocol (each stateful kernel declares its resync point, no more). diff --git a/docs/work/future/index.md b/docs/work/future/index.md deleted file mode 100644 index 9906bdd31..000000000 --- a/docs/work/future/index.md +++ /dev/null @@ -1,18 +0,0 @@ -# Future: what does not exist yet - -The to-build list, split along the codebase's own boundary: - -- [backlog-core.md](backlog-core.md): core and infrastructure. -- [backlog-light.md](backlog-light.md): the light domain, drivers, effects, sensors. -- [backlog-mixed.md](backlog-mixed.md): items where a core mechanism meets a light one. -- [backlog-moonlive.md](backlog-moonlive.md): the MoonLive language and its engine, in the order the steps are built. - -The analyses beside them survey a problem before it is planned: read when planning, not otherwise. - -- [code-hosting-comparison.md](code-hosting-comparison.md): GitHub, Codeberg, a self-hosted Forgejo and GitLab.com as MoonLight's home, their rules on agent-written code first. -- [processes-inventory.md](processes-inventory.md): every process MoonLight has, by content, with its copies, its gaps and the two step vocabularies; step 1 of the processes plan. -- [moonwalk.md](moonwalk.md): MoonWalk, the model every process is written in, the journey as a story map, a process shaped after Arazzo, a step as Given, When, Then; step 2 of the processes plan. - -`archive_images/moonlight/` stages images captured in MoonLight: each moves to `docs/assets/light//.` and into its module's `@card` when the module ships, or is deleted, so the folder drains to empty. - -An item leaves here when it becomes a plan in [present](../present/index.md), and a shipped item is deleted rather than marked done. Tags in titles: *(investigation)* needs measurement first, *(deferred)* waits on a prerequisite, *(future)* is directional. diff --git a/docs/work/future/input-mapping-analysis.md b/docs/work/future/input-mapping-analysis.md deleted file mode 100644 index 62d8e3683..000000000 --- a/docs/work/future/input-mapping-analysis.md +++ /dev/null @@ -1,349 +0,0 @@ -# Input mapping: how a physical control reaches a module control - -Analysis, not a decision. Written 2026-09-01 after `ButtonService` shipped with a single button and -a single target, which does not scale to a board with four buttons or a remote with twelve keys. - -## The problem with what shipped - -`ButtonService` maps **one** pin to **one** `Module.control` string, and `IrService` carries a fixed -table of five actions (on/off, brightness up/down, palette next/prev) with a learned code each. -Neither survives contact with real hardware: - -- A QuinLED Dig-Next-2 has three buttons; a Penta has three; a stage rig has a pedalboard. -- A remote has twenty keys, and which five are "the" actions is the firmware's opinion, not the - user's. -- Adding a sixth IR action today means editing `kActions` and reflashing, which is exactly the - configured-at-build-time model the project exists to avoid - ([MoonModule, live reconfiguration](../../explanation/architecture/moonmodule.md#live-reconfiguration-every-change-applies-on-the-next-frame)). - -Both modules have the same shape of defect, so they want the same fix. - -## The two-step interface - -The product owner's framing: **everything goes through ControlModule**. An input service learns to -map its physical events onto the surface (switches, encoders, faders, pads); the surface maps onto -whatever those drive. Two steps, each simple: - -```text -physical event step 1: input service step 2: ControlModule - button on GPIO 0 -> switch1 -> Drivers.on - IR code 0x40BF -> switch1 -> Drivers.on - encoder A turned -> encoder3 -> (whatever encoder3 targets) - OSC /mm/pad/3 -> pad3 -> preset 3 -``` - -**Why two steps rather than the direct `Module.control` that shipped.** A single step is shorter for -one button and wrong for everything else: - -- The surface is the **one place** to see what the device's controls do. With direct targets that - knowledge is scattered across every input service, and two inputs driving the same thing look - unrelated. -- The surface already exists, is already persisted, and is already what OSC drives. A second wiring - model beside it is the split brain - [the OSC plan](../past/plans/Plan-20260829%20-%20OSC%20control%20ingest%20(shipped).md) forbids. -- Feedback needs it. A motorised fader or an LED-ringed encoder has to be *told* the current value; - that lives on the surface, and an input mapped straight to a module control has nowhere to read it - back from. -- It makes inputs interchangeable: a rehearsal driven by IR and a show driven by a pedalboard hit - the same switches, so the mapping behind them is unchanged. - -The cost is one indirection for the simple case, and the direct form should stay available: -`target = "Drivers.on"` and `target = "Control.switch1"` are both just strings, so nothing needs a -special case. The surface is the recommended path, not an enforced one. - -## What the mapping table looks like - -Both services grow **a list of mappings** in place of their single target or fixed action table. The -mechanism exists: `Control::addList` with an editable-row hook (`addListRow`), which -`ControlModule` (presets), `DevicesModule`, `PinsModule` and `TasksModule` already use. - -A row is roughly: - -| field | button service | IR service | -|---|---|---| -| source | pin, and its `activeLow` / pull | the learned code (hex) | -| event | press / release / click / long-press | (a code is one event) | -| target | `Control.switch1`, or any `Module.control` | same | -| mode | latching / momentary | set / toggle / nudge +N | - -**The trigger vocabulary is worth borrowing.** ESPHome models each input as its own entry (not a -central table, which matches the per-service list here) and its useful contribution is the *event* -set: `on_press`, `on_release`, `on_click` with a length window, `on_double_click`, `on_multi_click`. -A long-press and a double-press are what let three physical buttons drive nine actions, which is the -difference between a usable installation panel and a wall of switches. Start with press / release / -long-press; double and multi-click are the same machinery with more timers. - -## Rotary encoders - -Not a button, and worth being explicit: an encoder emits **quadrature** on two pins (A and B), where -the phase relation gives direction and each detent is a step. So it is a `+1 / -1` stream, not a -level, and it maps naturally onto an `encoderN` (a relative nudge), where a button maps onto a -`switchN`. - -Most encoders also carry a **push switch**, which is a plain button on a third pin, so an encoder -row is "two pins for rotation, optionally one for the press". Whether that is one `EncoderService` -with its own list, or an `input type` column in a single service's list, is the open design -question below. - -Reading quadrature reliably needs either an interrupt or a fast poll: the ESP32's PCNT (pulse -counter) peripheral does it in hardware and is the textbook answer, with a 20 ms poll of the count. -That is a new platform seam either way, so an encoder is more work than a button, not less. - -## Foot pedals: not USB - -Worth correcting an assumption recorded earlier in the backlog. Stage pedals are **1/4" jacks**, not -USB, and they come in two kinds that need different handling: - -- A **footswitch** is a switch on a TS jack: electrically identical to a wall button, so - `ButtonService` covers it with no new work. Momentary mode is hold-to-activate; latching is - press-to-toggle. (Some are latching in hardware, which is why the mode has to be configurable - rather than assumed.) -- An **expression pedal** is a **potentiometer** on a TRS jack, giving a continuous 0..100%. That is - an **ADC read**, not a GPIO read, and it maps onto a `faderN`, not a switch. New seam - (`adcRead`), new service, genuinely different from a button. - -So "foot pedal support" is two features: the footswitch is done, and the expression pedal is an -analog-input service that does not exist yet. USB pedals do exist but are a niche (they enumerate as -HID keyboards), and they are the gamepad problem below, not the pedal problem. - -## Two kinds of input, and only one of them is a mapping - -The two-step model above is right for **discrete** inputs and wrong for **continuous** ones, and -conflating them is the mistake to avoid. - -**A discrete input is an event.** A button press, a remote code, a pedal stomp: it happens, it -drives a control, and nothing reads it again until the next one. Its natural rate is human -(a few per second at most), a control write per event costs nothing, and the surface is the right -destination because a person needs to see what it does. This is what the mapping table is for. - -**A continuous input is a stream.** A microphone at 22 kHz, an IMU at 50 Hz, a distance sensor an -effect samples every frame: an effect wants *the current value on the hot path*, not a notification. -Routing that through `setControl` would be wrong twice over. It would put a control write, a name -lookup and a persistence dirty-flag on the render path at frame rate, which the -[hot-path rules](../../explanation/architecture/moonmodule.md#hot-path-discipline) forbid. And it would be lossy: a control is -a setting a person edits, where a stream is data an effect reads. - -**The pattern for a stream already exists and is already domain-neutral.** `AudioService` publishes -an `AudioFrame` and effects reach it through the static `AudioService::latestFrame()`; -`AudioSpectrumEffect`, `GEQEffect`, `SpectrumEffect` and `NoiseMeterEffect` all consume it that way. -[MoonModule, data exchange](../../explanation/architecture/moonmodule.md#data-exchange-between-modules) states it as the -shared-struct pull: a POD struct the producer overwrites in place each tick, a plain-data header -both sides include, a const getter, no allocation and no subscription. It even names this case: -lock-free "is visually harmless for the gyro/sensor data this carries". - -So the rule is: - -| input | rate | how it reaches the light domain | -|---|---|---| -| button, remote code, footswitch, PIR | an event, human rate | mapping table -> `setControl` -> the surface | -| encoder detent | an event, human rate | mapping table -> `setControl` (a relative nudge) | -| microphone | continuous, per tick | `AudioService::latestFrame()`, shipped | -| IMU, distance, light level | continuous, per tick or per 20 ms | a published frame, the same pull | -| expression pedal | either | a fader (a setting) or a frame (a stream), by intent | - -**The expression pedal is the interesting boundary**, and it shows the split is about *use*, not -about the sensor: swelling brightness is a setting a person is adjusting, so a fader; driving an -effect's parameter per frame is a stream. A pedal could reasonably do both, which argues the two -paths should be selectable per input rather than fixed by the device type. - -**What this means for the modules.** A sensor service does both jobs, and they are cheap to combine: -it publishes its frame for effects to pull, *and* it may carry mapping rows for thresholds a person -cares about ("closer than 50 cm" drives a switch). One module, two outputs, matching what the sensor -is: a stream, with events derivable from it. - -### A third shape: a sensor that produces an image - -The VL53L8CX (and its VL53L5CX predecessor) is not a distance sensor with extra decimals. It reports -an **8x8 grid of zones at up to 60 Hz**, over I2C or SPI at 3 MHz: a low-resolution depth image at -render rate. That is a third shape beside event and stream, and it changes three things. - -**It is a frame, not a value.** 64 zones is a *layout* worth of data, and the natural consumers are -an effect that maps zones onto lights (a hand's shape moving across a wall) or a modifier that masks -by depth. A single `distance` control cannot carry it, and neither can a fader. - -**The read cost is the constraint, not the bandwidth.** 64 zones at 2 bytes is 128 B/frame, which is -7.7 kB/s at 60 Hz: nothing. But a blocking I2C read of 128 bytes at 400 kHz takes **~2.9 ms**, and -the render tick on a Dig-2-Go is 289 us. So this sensor must not be read on the render tick at all: -either SPI at 3 MHz (which is why the part offers it), a slower ranging rate matched to what the -effect needs, or the read moved off the render core the way the encode split already is -([MoonModule, parallelism](../../explanation/architecture/moonmodule.md#parallelism)). **This is the first sensor whose -platform seam has to be asynchronous**, and that is worth knowing before the synchronous -`i2cReadRegs` shape is treated as sufficient for everything. - -**It argues the published frame should be sized by the sensor**, not fixed. A `SensorFrame` with one -`distance` field would be wrong here; an 8x8 array is 128 bytes a consumer reads in place, which the -shared-struct pull handles fine (it is a POD overwritten in place), but the header has to declare -the shape. The honest answer is probably a frame type per sensor family, as `AudioFrame` is specific -to audio rather than a general `SignalFrame`. - -**It carries an 84 KB firmware blob**, measured from ST's own driver -([stm32duino/VL53L8CX](https://github.com/stm32duino/VL53L8CX), `vl53l8cx_buffers.h`): - -| | | -|---|---| -| firmware uploaded to the sensor at every boot | **86,017 bytes** (plus 972 B config, 776 B crosstalk) | -| upload time | 1.94 s at I2C 400 kHz, 0.77 s at 1 MHz, **0.23 s at SPI 3 MHz** | -| driver RAM | ~2.3 KB (`VL53L8CX_Configuration`) | -| result struct | ~1 KB per frame | - -**The boot upload is the bigger problem than the per-frame read**, and it was not the number to -worry about first: a blocking 2-second transfer in `setup()` stalls the device. SPI at 0.23 s is the -answer, which gives a second independent reason to prefer SPI for this part. - -**The blob cannot be written ourselves.** It is executable firmware for the sensor's own processor, -not configuration that could be derived: ST publishes no register-level datasheet for this part, -only the ULD API, and the ranging algorithms and SPAD calibration are not documented. Reimplementing -it means reverse-engineering an undocumented DSP. - -**So it is compiled in per firmware, not per build.** 84 KB is about 2% of a 4 MB classic ESP32's -flash, and roughly 4.5% of the ~1.8 MB app PARTITION it actually competes for, which is the number -that matters; it is noise on an S3 or P4. This is the first *peripheral* to need -compile-time inclusion gated by the device catalog, the shape `MM_HLS` and `MM_NO_ETH` already -have. A Dig-2-Go never carries it; a P4 installation does. - -**MoonLive can still USE it, and that is the distinction to keep.** A script cannot upload 84 KB and -should never try, but it does not need to: the compiled module owns the blob and the bus, and the -script calls a builtin (`tofZone(x, y)`) exactly as it calls `beat()` or `paletteRGB()` without -implementing a beat or a palette. The power-function pattern is what makes a heavyweight sensor -scriptable. What a script cannot do is *be* the driver for such a sensor, which is the honest -boundary of the scripted-sensor claim: a script suits a sensor whose init is a handful of register -writes (MPU6050, BH1750, VL53L0X), and a blob sensor is a compiled module by necessity. - -**No prior art to lean on, checked rather than assumed.** `troyhacks/WLED` (the tracked friend repo) -carries `usermods/VL53L0X_gestures` on all three of its P4 branches, but that is upstream WLED's -usermod for the *simple* VL53L0X, present identically in `wled/WLED`, and a code search for -`VL53L8` across both that fork and the MoonModules org returns nothing. So the blob upload, the SPI -path and the zone-frame publishing are ours to work out, with ST's ULD driver as the only reference. - -**Where it lands in the plan:** the only ToF on the bench, so it is both the second I2C device and -the hard case. See step 4b. - -## Bus sensors: I2C and I2S - -A GPIO carries one bit. The sensors an installation actually wants mostly do not: a motion sensor -worth having reports distance or an occupancy zone, not a level, and that arrives over a bus. - -**I2C is the common case, and the seam is already written.** An unmerged commit -(`11f8eb76`, "Add GyroDriver (MPU6050) + generic platform I2C layer") adds exactly the three -functions a sensor module needs, with the register knowledge staying in the module: - -```cpp -bool i2cInit(uint8_t sdaPin, uint8_t sclPin); -bool i2cWriteReg(uint8_t devAddr, uint8_t reg, uint8_t value); -bool i2cReadRegs(uint8_t devAddr, uint8_t reg, uint8_t* buf, size_t len); -``` - -This branch has only `i2cScan` (which addresses ACK, the `i2cdetect` operation) and therefore -cannot read a register at all. So **merging that layer is the prerequisite** for every I2C sensor, -and it is prior art from a contributor rather than something to design. - -What it unlocks, in the order an installation asks for it: a **ToF distance sensor** (VL53L0X: a -hand's distance drives a parameter, the well-behaved version of "motion"), a **light-level sensor** -(BH1750: the piece dims itself to the room), an **IMU** (the MPU6050 the commit already drives), and -a **gesture/proximity sensor** (APDS-9960). All four are the same shape: init the bus, poll a -register set on `tick20ms` or `tick1s`, publish a value. - -**Motion specifically is three different sensors**, and calling them all "motion" is what makes the -category confusing: - -- **PIR** is a digital pin. Not a bus device at all, so it needs nothing beyond the shipped GPIO - seam: presence as a level, which is how MoonLight models it (`pin_PIR`, "HIGH = lights on"). -- **Ultrasonic (HC-SR04)** is a trigger pulse plus an echo whose *width* is the distance. Neither - I2C nor a plain read: it needs a **pulse-timing seam** that does not exist. The textbook answer on - ESP32 is the same PCNT/RMT machinery an encoder wants, so the two features share a dependency. -- **ToF** is I2C, so it rides the layer above. A **mmWave radar** such as the HLK-LD2450 is UART, so it needs the UART seam the [audio-reactive plan](../present/Plan-20261013%20-%20Audio%20reactive%20live%20performance%20%280%20of%207%29.md) names. - -**I2S is audio-shaped, and should stay that way.** The platform's I2S surface is `audioMicInit` / -`audioMicRead`, deliberately named for what it does rather than for the peripheral, and -`AudioService` is its only consumer. A few non-audio sensors do speak I2S (some ToF and radar -modules stream samples that way), but generalising the audio seam to serve them would trade a clear -contract for a vague one. If such a sensor is ever wanted, it gets its own seam named for its own -job, exactly as `audioMic*` is. **No action: I2S is done, for audio.** - -The other reason to be careful: I2S is the mic's bus, and a second I2S consumer on the same board -competes for a peripheral instance. That is a real constraint on a classic ESP32 (two I2S -controllers), not a theoretical one. - -## MoonLive: scripted sensors and mappings - -The direction is that MoonLive gains driver scripts whose hello-world is "read from GPIO, write to -GPIO", with a precompiled sibling always available. Input mapping is where that pays off twice, -because the two halves want different things from a script. - -**Half one: a script AS a sensor.** A sensor nobody has written a module for is exactly what a -script is good at, since the module is small and the value is in the register knowledge, which is -data rather than architecture. Builtins, matching the seams above: - -```c -int gpioRead(int pin); // shipped seam -void gpioWrite(int pin, int on); // shipped seam -int i2cRead(int addr, int reg); // needs 11f8eb76 merged -void i2cWrite(int addr, int reg, int val); -``` - -A VL53L0X script is then a `defineControls` for the address and pins, a `setup` that writes the -init registers, and a `tick20ms` that reads two bytes. That is a page of code, and a user with a -datasheet can write it without a toolchain. **This is the strongest argument for scripted services**: -the long tail of sensors is unbounded, and no firmware can carry all of them, but a script per -sensor costs nothing to ship and nothing to maintain. - -**Half two: a script AS the mapping.** Once a script can call `setControl`, the mapping table itself -becomes optional for anything unusual: "if distance < 50 cm then switch1 on, else off" is three -lines, and expresses a rule no fixed table column could. The table stays the answer for the common -case (a button drives a switch), because a table is inspectable and a script is not. - -**The `setControl` decision is the gate**, and it is bigger here than it looked when first raised. A -script that can write any control on any module can also write a driver's pin list or a layout's -geometry. Options, in increasing order of restriction: expose it plainly and trust the author (a -script is already native code on the device, so this is not a sandbox boundary); restrict it to the -`Control` module, which makes the surface the script's only reach and matches the two-step model -above; or give a scripted service a declared list of targets it may write, which is the most work -and the most inspectable. **The middle option is the one that fits this analysis**: a script drives -the surface, the surface drives everything, and the same two steps hold whether the mapping came -from a table or a script. - -**What it needs beyond builtins:** a `MoonLiveService` host module, the service twin of -`MoonLiveEffect`, carrying a `script` control and the compile/status path. Small, mostly a copy of -the existing binding, and it is what makes a scripted sensor addable from the picker like any other -module. - -## USB game controllers - -Unchanged from the earlier entry, and confirmed as its own project: a HID **report descriptor -parser** is the work (each controller lays its bytes out differently; the descriptor is a nested -tag/value blob that must be parsed to learn which bit is which button), plus USB Host bring-up. -S3/P4 only, since the classic ESP32 has no USB Host peripheral at all. The *mapping* half is free -once reports arrive: buttons to switches or pads, axes to encoders and faders, through the same -list. - -DIY is the alternative worth naming: a gamepad's buttons wired to GPIOs is `ButtonService` with a -longer list, and an analog stick is two ADC channels. For an installation where the enclosure is -custom anyway, that may be the better answer than parsing a commercial controller. - -## What the rules say - -- **[CLAUDE.md, industry standards](../../CLAUDE.md)**: take the textbook construct. Quadrature via - PCNT rather than a hand-rolled edge decoder; a HID descriptor parser rather than a per-model byte - table. -- **[CLAUDE.md, minimalism](../../CLAUDE.md)**: every fact has one home. The surface is that home - for "what does this control do", which is the argument for the two-step model over direct targets. -- **[MoonModule, live reconfiguration](../../explanation/architecture/moonmodule.md#live-reconfiguration-every-change-applies-on-the-next-frame)**: - a mapping must be editable on a running device. A compiled-in action table fails this, which is - the concrete defect in `IrService` today. -- **[MoonCore, services](../../explanation/architecture/index.md)**: "Direction is per-module, not a role: a - service may read (gyro), write (relay), or both." Input services are already the sanctioned shape. - -## Open questions for the product owner - -1. **One service or several?** `InputService` with a `type` column per row (button / encoder / IR / - analog), or `ButtonService`, `EncoderService`, `IrService` each with their own list? One service - is fewer modules and one table to learn; several keep each module's controls honest (an encoder's - two pins do not belong on a button row). The catalog leans toward several, since a board declares - what it has. -2. **Does `IrService` keep its action table** as a convenience default, or become purely - code -> surface mappings? Purely mappings is cleaner and loses the out-of-the-box remote. -3. **What happens to the shipped `ButtonService.target`?** A single row is the same thing as a - one-row list, so the migration is mechanical, but it is a breaking change to a control that has - shipped (in this branch only, so far). -4. **How far into the trigger vocabulary** to go in the first pass: press/release only, or - long-press too? Long-press is what multiplies a small panel's reach. diff --git a/docs/work/future/led-driver-psram-ring-analysis.md b/docs/work/future/led-driver-psram-ring-analysis.md deleted file mode 100644 index 36c50f7de..000000000 --- a/docs/work/future/led-driver-psram-ring-analysis.md +++ /dev/null @@ -1,175 +0,0 @@ -# The classic-ESP32 PSRAM ceiling — the refill-ring memory model - -> **Forward-looking research document — exception to the CLAUDE.md present-tense rule.** One question: **can PSRAM lift the classic-ESP32 LED ceiling MoonLight measured at 2048 lights?** Researched 2026-07 against primary sources — hpwit's driver source read line-by-line, plus Espressif's own docs. Every load-bearing claim is cited in § 4. -> -> **Status: PARKED FINDING — not a queued task.** The answer is **yes, via a refill ring** — but that is a **second classic-ESP32 driver** (it cannot be a flag on the shipped i80 driver), and **nothing currently planned needs it**: the shift-register driver builds on the existing i80/Parlio base, S3/P4-first, where the DMA already reaches PSRAM. **§ 3 holds the verdict and the trigger that would un-park it.** Read § 3 before treating anything here as a plan. - -## 1. Verdict - -**Yes — PSRAM can lift the classic-ESP32 ceiling, and the mechanism is a memory-model change, not a shift-register feature.** Our 2048-light wall is a direct consequence of the *whole-frame* DMA model: `esp_lcd`'s i80 backend materialises every light's WS2812 waveform in one contiguous DMA-capable block before the transfer starts, and on the classic ESP32 that block **cannot be PSRAM** — the IDF source rejects it outright (`esp_lcd_panel_io_i2s.c`: `ESP_RETURN_ON_FALSE((caps & MALLOC_CAP_SPIRAM) == 0, NULL, TAG, "external memory is not supported")`), because the classic chip's I2S DMA physically cannot address the external-RAM window. So the DMA buffer scales linearly with the light count against a ~76 KB internal wall. - -hpwit's driver uses a **streaming refill ring** instead: a handful of tiny DMA buffers (144 bytes each for RGB — *one LED slot across all 16 lanes*), refilled on the fly by the I2S completion ISR, which transposes the next pixel row out of the source color array as it goes. **The DMA never touches the source array — the CPU does, inside the ISR** — so the source array is under no DMA constraint and **can live in PSRAM**. Internal RAM becomes **constant (~1.2 KB at the default `nbDmaBuffer = 6`), independent of the light count**. That is the whole ballgame: the ring **decouples LED count from internal RAM**, for **any** driver built on it, expander or not. - -**The ring must be a second classic driver, not a flag on the existing one.** Our i80 driver goes through IDF's `esp_lcd`, which *owns* the DMA and only does whole-frame. **No IDF API can feed PSRAM into classic-ESP32 I2S DMA** — Espressif's own workaround *is* the ring ("add a buffer in internal memory that is DMA'able, and use memcpy to move data from/to that buffer"). So a ring driver means going under `esp_lcd` to the raw I2S registers, exactly as hpwit does, and it lands **alongside** the i80 driver as a user choice: **i80 = simple, WiFi-underrun-immune, ~2 K lights; ring = PSRAM-fed, many-K lights, needs the `nbDmaBuffer` flicker cushion.** - -**A scoping fact that shapes the work: the ring is not available off the shelf.** This is not a matter of adopting a library. The mainstream ESP32 LED libraries use the same **whole-frame DMA** we do and therefore share our internal-RAM ceiling; the refill-ring model is effectively unique to hpwit's drivers, which is why § 2 studies **his source** rather than surveying alternatives. Two consequences: (1) building it is genuinely new capability for a classic-ESP32 LED controller, not catching up; (2) per [*Industry standards, our own code*](../../CLAUDE.md#principles), we study his implementation hard and then **write our own against our architecture** — carry the idea forward, not the code. - -## 2. The PSRAM question - -### 2.1 Two memory models - -**Whole-frame (MoonLight today, via `esp_lcd` i80).** The DMA buffer *is* the frame: every light's expanded WS2812 waveform — 3 slots per bit, one byte (8-lane) or two (16-lane) per slot — materialised in one contiguous DMA-capable block, handed to the peripheral in a single chained transfer. The CPU is out of the loop for the whole frame, which is why this model is **underrun-immune by construction**: nothing remains to refill, so no ISR storm can starve it. The price is linear scaling: - -```text -internalBytes(whole-frame) = maxLaneLights × channels × 3 slots × slotBytes -``` - -**Refill ring (hpwit, under the raw I2S registers).** The DMA buffer holds only a small rolling window: a ring of `nbDmaBuffer` buffers, each **one pixel-row of slots** wide. On each buffer-EOF the I2S ISR calls `loadAndTranspose()`, which reads the next pixel row from the source array and SWAR-transposes it into the buffer the DMA is about to reach. Internal RAM: - -```text -internalBytes(ring) = (nbDmaBuffer + 2) × (nbComponents × 8 bits × 3 slots × 2 bytes/word) - = (nbDmaBuffer + 2) × 144 B [RGB] -``` - -— **no LED-count term.** That is the decoupling, and it is the entire point. - -**Crucially, "ring buffer" names two different things and only one solves the ceiling.** The distinction is the whole decision, and it is easy to lose: - -| | **(A) Refill ring** | **(B) Ping-pong / double buffer** | -|---|---|---| -| What's in a buffer | one pixel row of slots | a **whole frame** | -| Who fills it, when | the **DMA-completion ISR**, mid-transfer | the CPU, **before** the transfer | -| Source array touched by | **CPU only** → **PSRAM-eligible** | DMA → must be internal | -| Internal RAM vs LED count | **constant** | **scales linearly** | -| Buys you | **lifts the memory ceiling** | hides wire time behind DMA | -| In our tree | *(this proposal)* | **`asyncTransmit`** (already shipped) | - -We already have (B). It is genuinely valuable — it is what took the S3 from ~13 to ~34 fps at 16 K — but **it does not and cannot lift the memory ceiling**, which is exactly why halving the DMA buffer by turning `asyncTransmit` off didn't help the WROVER. - -### 2.2 What hpwit's source actually says - -Read directly from [`src/I2SClocklessLedDriver.h`](https://github.com/hpwit/I2SClocklessLedDriver/blob/main/src/I2SClocklessLedDriver.h) / `.cpp` (branch `main`): - -- **A ring of linked `lldesc_t` descriptors.** `struct I2SClocklessLedDriverDMABuffer { lldesc_t descriptor; uint8_t* buffer; };`. `initDMABuffers()` allocates `nbDmaBuffer + 2` buffers of `nbComponents * 8 * 2 * 3` bytes each (**144 B RGB / 192 B RGBW**) plus one 4×-size terminator. Chained `descriptor.qe.stqe_next = &next->descriptor`, last wrapping to `[0]` — a genuine ring. -- **`uint8_t nbDmaBuffer = 6;`** — default 6, and **runtime-tunable** via `updateDriver(..., uint8_t dmaBuffer, ...)` → `nbDmaBuffer = dmaBuffer;`. (This is why the product owner's StarLight-era setting of **75** was reachable: it's a plain count, not a compile-time constant.) -- **The ISR refills and transposes.** `static void IRAM_ATTR interruptHandler(void*)`: on `I2S_OUT_EOF_INT` it increments `ledToDisplay` and calls `loadAndTranspose()`, which reads `driver->leds + ledToDisplay * nbComponents` per strip, applies brightness/gamma maps, and SWAR-transposes (`transpose16x1Noinline2`) into `dmaBuffersTampon[dmaBufferActive]->buffer`. **One buffer = one LED slot, so the ISR fires per LED (~30 µs at 800 kHz).** -- **Ring buffers are internal; the framebuffer is the caller's.** Ring: `heap_caps_malloc(bytes, MALLOC_CAP_DMA)`. Framebuffer: `initled(uint8_t* leds, ...)` takes a raw pointer the driver only ever reads **with the CPU**. Nothing DMA-touches it → **a PSRAM pointer works by construction.** -- **The ISR is deliberately arranged to be allowed to read PSRAM.** `esp_intr_alloc(interruptSource, ESP_INTR_FLAG_INTRDISABLED | ESP_INTR_FLAG_LEVEL3, ...)` with the in-code comment that `ESP_INTR_FLAG_IRAM` was *"removed to avoid Cache Disabled but Cached Memory Region Accessed"* — i.e. the refill ISR is explicitly permitted to touch cache-mapped (PSRAM) memory. That is the design decision that makes the PSRAM framebuffer legal, stated in hpwit's own source. -- **The whole-frame mode exists too, and is the one that scales.** `FULL_DMA_BUFFER` allocates `numLedPerStrip + 2` buffers — and is precisely the mode behind [issue #11, "DMA mode takes up 60k of ram just for a single 300 led strip"](https://github.com/hpwit/I2SClocklessLedDriver/issues/11). **hpwit's library contains both models, and the scaling complaint lands on the whole-frame one — the same wall we hit.** - -**Honest caveat:** hpwit never writes "put `leds[]` in PSRAM" in so many words *for the classic chip* (the explicit PSRAM-framebuffer statements are in the S3 variants). The classic conclusion is drawn from construction — CPU-only reads from a caller-supplied pointer, in a non-IRAM Level-3 ISR — which is sound but is **inference from source, not a vendor promise.** The bench spike in § 5 is what turns it into a measured fact. - -### 2.3 The memory math, concretely (8 lanes, RGB) - -Whole-frame, `slotBytes = 1` (8-lane bus), 3 channels, 3 slots/bit — our i80 model: - -| Lights/lane | Total (8 lanes) | Whole-frame DMA buffer | Fits ~76 KB internal? | -|---|---:|---:|---| -| 256 | 2,048 | ~18 KB | ✅ **measured working** | -| 512 | 4,096 | ~37 KB | ❌ **measured fail** (`i80 bus init failed`) — the real wall sits below the arithmetic: ~76 KB is the *largest free block*, and `asyncTransmit` wants two, plus IDF descriptor overhead | -| 1024 | 8,192 | ~74 KB | ❌ | -| 2048 | 16,384 | ~147 KB | ❌ | - -Ring, same 8 lanes, RGB (hpwit's own sizing, 144 B/buffer): - -| `nbDmaBuffer` | Ring internal RAM | Lights supported | -|---|---:|---| -| 6 (default) | (6+2) × 144 = **1,152 B** | **any** — 2 K, 16 K, 130 K; the count does not appear in the formula | -| 32 | (32+2) × 144 ≈ **4.9 KB** | **any** | -| 75 (StarLight-era) | (75+2) × 144 ≈ **11 KB** | **any** | - -**Read those against each other.** The whole-frame model needs 147 KB of a ~76 KB budget to reach 16 K lights, so it cannot. The ring reaches 16 K — or 130 K — on **1.6–11 KB**, and even a *very* deep 75-buffer cushion costs ~11 KB. The frame itself moves to PSRAM, where 16 K lights × 3 B = 48 KB is a rounding error against 4 MB. - -### 2.4 What the ring gives the WROVER, and what binds next - -With the internal-RAM wall gone, the binding constraints become, in order: - -1. **Wire time — the hard physical floor.** WS2812 is ~30 µs per light per lane (24 bits × 1.25 µs). At 8 lanes × 2048/lane = 16,384 lights: 2048 × 30 µs = **~61 ms → ~16 fps**, before any CPU cost. A protocol constant no driver escapes; it is why "more LEDs" past a point is a *distribution* problem. -2. **Encode CPU in interrupt context — the ring's true price.** Our whole-frame model transposes once with a SWAR kernel on the main path out of internal RAM (measured **~7.6 µs/light** on classic). The ring does comparable work **inside an ISR, per LED, reading cache-mediated PSRAM**. hpwit fires that ISR once per LED (~30 µs apart) — so the refill must reliably complete within the wire time of one pixel. This is the number to measure first. -3. **PSRAM bandwidth / cache stalls** — the same story from the other side: an ISR that stalls on a cache miss is an ISR that is late, which is item 4. -4. **The underrun cushion** (§ 2.5). - -**Honest bottom line for a 4 MB-PSRAM WROVER with a ring driver:** the **memory ceiling disappears** (the frame is kilobytes against megabytes) and the practical ceiling becomes **wire time and ISR headroom** — comfortably into the **8–16 K** range at single-digit-to-low-teens fps. That is a **4–8× lift over our measured 2048**, and the right framing is *"the ceiling stops being about RAM."* **This is not theory:** a prior firmware built on hpwit's ring (the product owner's own MoonLight / WLED-MM lineage) raises its light boundary to **130 K on PSRAM against 4096 without it** — a ~32× gap between the PSRAM and non-PSRAM configuration of the *same* firmware, which is the empirical shape of ring-vs-whole-frame. - -### 2.5 The cost: underrun, and why `nbDmaBuffer` is tunable - -The ring's weakness is the exact mirror of whole-frame's strength. Every buffer completion needs the ISR to refill *before* the DMA consumes the next one. If a higher-priority ISR (WiFi), or a flash-cache-off window, or a PSRAM stall delays it, the DMA runs off the end of valid data and **the strip glitches** — WS2812 latches on a gap of only ~150 µs. - -`nbDmaBuffer` **is** the cushion: it buys `nbDmaBuffer × (one pixel's wire time)` of slack. hpwit says so directly in the shift-register driver's README — *"Sometimes interrupts can disturb the pixel buffer calculations hence making some artifacts. A solution … is to calculate several buffers in advance. By default we have 2 dma buffers. this can be increased"* — and notes the ceiling case: pushing it to `NUM_LEDS_PER_STRIP` is the full frame, "but it requires too much memory" (i.e. it degenerates back into our model). Mitigations visible in his source: a deeper ring, `_DMA_EXTENSTION` idle padding, **core pinning** (`enableShowPixelsOnCore()`), and **Level-3 interrupt priority** so the refill preempts ordinary WiFi ISRs. - -This is not a niche worry — every ESP32 LED library that streams rather than whole-frames fights it, and they all expose the same remedy: a **tunable count of DMA buffers**, raised when interrupt load (typically WiFi) causes flicker. So the cushion depth is a **recognised, standard tuning parameter, not a bespoke hack** — which matters under [*Common patterns first*](../../CLAUDE.md#principles): if we build the ring, `nbDmaBuffer` is the knob users already expect. - -**The trade, plainly:** our i80 driver's underrun-immunity is a real, defensible property we would be giving up on the ring path — which is exactly why the ring should be an *additional* driver, not a replacement. A 2 K-light install on a WiFi-busy board is *better served* by i80. A 16 K-light install cannot use it at all. - -### 2.6 The shift-register (74HC595) driver — and why the ring is NOT its prerequisite - -**In hpwit's lineage the shift-register driver does inherit the ring's PSRAM property.** It multiplies lanes by shifting each ESP32 pin's byte out through a 74HC595 (one per data pin, a 74HC245 level-shifting LATCH/CLOCK): **"8 strips out of one single pin … for 15 esp32 pins which gives you 8x15=120 strips."** That multiplication happens *downstream on the wire*; upstream it is the **same I2S ring, the same ISR-side transpose, the same caller-supplied framebuffer** (`__NB_DMA_BUFFER`, default 2, buffers of `(NUM_VIRT_PINS+1) * nb_components * 8 * 3 * 2` ≈ 1152 B). - -**But that does not make the ring a prerequisite for *our* shift-register driver — the chip does.** Our backlog sets the shift-register driver's acceptance floor at **48 × 256 = 12,288 lights**. Under the whole-frame model that floor is: - -| Chip | Whole-frame DMA buffer for 48 strands | Reachable today? | -|---|---|---| -| **Classic ESP32** | must be **internal** RAM (I2S DMA cannot address PSRAM) → past the ~76 KB wall | ❌ **needs the ring** | -| **ESP32-S3 / P4** | `esp_lcd` allocates it **from PSRAM** (EDMA reaches external RAM in hardware) | ✅ **no ring needed** | - -So the honest conclusion — and the correction to an earlier draft of this document, which asserted a blanket prerequisite — is: **the whole-frame model cannot meet the shift-register driver's floor on the classic chip, and comfortably meets it on the S3 and P4.** The S3 i80 driver *already* drives 16,384 lights on a PSRAM DMA buffer (measured, SE16). A shift-register driver built on the **existing i80/Parlio base**, targeting **S3/P4 first**, is therefore **not memory-bound** and needs no new memory model. The classic chip's ~2,048-light whole-frame ceiling becomes a **documented limit of that chip**, not a blocker for the feature. - -This also resolves — rather than reopens — the loop in our own backlog. `backlog-light.md` records that the chunk-streaming-ring design was **superseded** by the `esp_lcd` whole-frame path, on the reasoning that whole-frame is underrun-immune and reuses the shared `ParallelLedDriver` base. That decision **stands**: it bought immunity at the price of a *classic-chip* ceiling, and the chips that matter for a 12 K-light virtual install already reach PSRAM. The ring is what would lift the **classic** chip specifically — see § 5 for the trigger. - -## 3. Verdict: a parked finding, not a next step - -**This is a finding, not a plan. Do not build the ring driver yet.** The analysis above stands — the mechanism is real, the memory math is right, and it is genuinely new capability for a classic-ESP32 controller. But three things scope it *out* of the near-term order, and this section exists to say so plainly rather than let a research document read as an approved roadmap item. - -**1. It is a whole second classic driver, and it cuts against what just shipped.** `esp_lcd` **owns the DMA** and only does whole-frame, so the ring **cannot be a flag** on `I80LedDriver` (§ 2.1). It means going under `esp_lcd` to the raw classic I2S registers — a second, register-level, ISR-driven, underrun-*prone* classic path standing next to the i80 driver we just hardened and measured across three chips. Forking the classic path on the strength of an analysis, right after proving the simpler path works, is the wrong trade of risk for capability. - -**2. It is NOT a prerequisite for the shift-register driver — that was this document's own error.** An earlier draft of this section claimed the shift-register driver's 12,288-light floor forces the ring first. That is true **only on the classic ESP32**. On the **S3 and P4** the `esp_lcd`/LCD_CAM backend already draws its DMA buffer **from PSRAM** (hardware EDMA — § 3, and the measured 16,384 lights on the SE16), so a shift-register driver built on the existing i80/Parlio base is **not memory-bound there at all**. The correct scoping is therefore: build the shift-register driver **on the drivers we have, S3/P4-first**, and treat the classic chip's ~2,048-light whole-frame ceiling as **a documented limit of that chip**, not a defect blocking the feature. Step 3's shift-register-driver bullet already says "write our own against the shared slots encoder" — that instinct was right; this document was wrong to contradict it. - -**3. Nothing currently planned requires it.** With the shift-register driver targeting S3/P4, no roadmap item is blocked by the classic memory model. The ring buys the classic chip a 4–8× light-count lift and a competitive win — genuinely valuable, but *optional*, and optional work does not preempt work that is on the critical path. - -**The trigger that would un-park it (name it, so this isn't an indefinite maybe):** **classic-ESP32 above ~2,048 lights becomes a shipping requirement** — a board, a user, or a shift-register install that must run on classic silicon at high light counts. Until then this document is the finished homework, not a queued task: when the trigger fires, § 2's memory math, § 3's competitive position, and § 5's bench spike are ready to go without re-deriving anything. - -**Related but distinct — don't conflate.** Two other levers raise the classic chip's *lane* count without touching the memory model: Step 3's **parallel-I2S driver** (hpwit `I2SClocklessLedDriver` lineage — the classic I2S peripheral can clock well past our current 8 lanes) and the **virtual/shift-register fan-out**. Those are lane-count work on the existing whole-frame model. The ring is *memory-model* work. They are independent; either can ship without the other. - -### 3.1 If it is ever built: the shape, and the one thing to measure first - -**It must be a second driver, alongside i80** — a scoping conclusion, not a preference (see reason 1 above). The two would coexist and the user picks: - -| | `I80LedDriver` (shipped) | ring driver (parked) | -|---|---|---| -| DMA model | whole-frame, `esp_lcd` | **refill ring**, raw I2S | -| Framebuffer | internal only | **PSRAM** | -| Ceiling | ~2 K lights | not RAM-bound (wire/CPU-bound) | -| Underrun | **immune by construction** | needs `nbDmaBuffer` cushion | -| Complexity | thin, shares `ParallelLedDriver` | ISR-side transpose, register-level | - -Keeping i80 is **not** legacy baggage: for a ≤2 K install on a WiFi-busy board, underrun-immunity is a real advantage the ring cannot offer. Two drivers, two honest trade-offs, user picks — and the driver docs should say exactly that, rather than presenting the ring as a strict upgrade. - -**The one caution to pin before the first line of code.** The ring moves the transpose *into an ISR* and its source *into PSRAM*. Our classic encode is ~7.6 µs/light with a SWAR transpose on the main path out of internal RAM; the ring must do comparable work in interrupt context, per LED (~30 µs budget), reading cache-mediated PSRAM. **That ISR cost is the ring's true price and the thing most likely to surprise.** Budget a deep cushion from the start — `nbDmaBuffer` of 32–75 costs only ~5–11 KB (§ 2.3); the cushion is cheap and the flicker is not. Note hpwit's two enabling tricks, both of which we'd want: **Level-3 interrupt priority** (so the refill preempts WiFi) and **omitting `ESP_INTR_FLAG_IRAM`** (so the ISR may legally touch cache-mapped PSRAM). - -**When the trigger fires, the first move is a bench spike — not driver code.** On the WROVER, stand up a minimal raw-I2S ring: `nbDmaBuffer` × 144 B `MALLOC_CAP_DMA` buffers, a `MALLOC_CAP_SPIRAM` source array, an ISR that transposes one pixel row per completion, driving **8 × 1024 = 8192 lights** — 4× past our measured wall — on the existing bench pins. That single spike answers all three open questions at once: - -1. **Does internal RAM stay flat?** (It should read ~1.6–11 KB regardless of light count — the claim of § 2.3.) -2. **What does the ISR transpose actually cost per row out of PSRAM?** (The § 2.4 unknown.) -3. **What `nbDmaBuffer` depth holds a clean strip with WiFi associated and hammering?** (The § 2.5 trade, measured rather than assumed.) - -**If that spike lights 8192 lights cleanly, the design is proven and the rest is integration** behind the existing `ParallelLedDriver` seam. If it flickers at every cushion depth, we learn it on a bench rig instead of inside a driver refactor. Either way the spike, not the argument, settles it — and it also converts § 2.2's one honest inference (PSRAM framebuffer on *classic*, which hpwit documents only for the S3) into a measured fact on our own hardware. - -## 4. Sources - -**hpwit (primary — source read line-by-line):** -- [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) · [`src/I2SClocklessLedDriver.h`](https://github.com/hpwit/I2SClocklessLedDriver/blob/main/src/I2SClocklessLedDriver.h) — `lldesc_t` ring; `nbDmaBuffer = 6` default, runtime-tunable via `updateDriver()`; `initDMABuffers()` → `nbDmaBuffer + 2` buffers of `nbComponents * 8 * 2 * 3` B (144 RGB / 192 RGBW) + 4× terminator; `IRAM_ATTR interruptHandler()` → `loadAndTranspose()` reading `driver->leds`; ring buffers `heap_caps_malloc(..., MALLOC_CAP_DMA)`; `initled(uint8_t* leds, ...)` caller-supplied framebuffer; `esp_intr_alloc(..., ESP_INTR_FLAG_INTRDISABLED | ESP_INTR_FLAG_LEVEL3, ...)` with `ESP_INTR_FLAG_IRAM` "removed to avoid Cache Disabled but Cached Memory Region Accessed"; optional `FULL_DMA_BUFFER` whole-frame mode. -- [issue #11 — "DMA mode takes up 60k of ram just for a single 300 led strip"](https://github.com/hpwit/I2SClocklessLedDriver/issues/11) — the whole-frame mode's scaling complaint. -- [PR #47 — `__NB_DMA_BUFFER` as var & PSRAM-prefer](https://github.com/hpwit/I2SClocklessLedDriver/pull/47). -- [I2SClocklessVirtualLedDriver](https://github.com/hpwit/I2SClocklessVirtualLedDriver) — "8 strips out of one single pin … 8x15=120 strips"; 74HC595 per virtual pin + 74HC245 level shifter; `__NB_DMA_BUFFER` default 2, buffers `(NUM_VIRT_PINS+1) * nb_components * 8 * 3 * 2` ≈ 1152 B; "Artifacts due to interrupts" → "calculate several buffers in advance"; `enableShowPixelsOnCore()`; 12,000-LED worked example; 75 → 129 fps second-core figures. -- [I2SClockLessLedDriveresp32s3](https://github.com/hpwit/I2SClockLessLedDriveresp32s3) — the S3 lineage (PSRAM buffers; S3 EDMA can DMA from PSRAM). - -- [The predecessor MoonLight](https://github.com/ewowi/MoonLight) / [WLED-MM](https://github.com/MoonModules/WLED-MM): a prior firmware riding hpwit's ring on PSRAM: LEDs pre-allocated in PSRAM, light boundary **130 K** (vs **4096** non-PSRAM). The empirical demonstration that the ring's ceiling lift is real. - -**Espressif (the load-bearing hardware constraint):** -- [`esp_lcd/i80/esp_lcd_panel_io_i2s.c`](https://github.com/espressif/esp-idf/blob/master/components/esp_lcd/i80/esp_lcd_panel_io_i2s.c) — `ESP_RETURN_ON_FALSE((caps & MALLOC_CAP_SPIRAM) == 0, NULL, TAG, "external memory is not supported");` -- [Support for External RAM — ESP-IDF (ESP32)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/external-ram.html) — external RAM "cannot be used as a place to store DMA transaction descriptors or as a buffer for a DMA transfer to read from or write into"; prescribed workaround = internal DMA-able buffer + copy. **Confirms: no IDF API feeds PSRAM into classic-ESP32 I2S DMA.** -- [Support for External RAM — ESP-IDF (ESP32-S3)](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-guides/external-ram.html) — the S3 *does* have hardware DMA-to-PSRAM (descriptors still internal): why our S3 i80 reaches 16 K and classic cannot. - -**MoonLight's own measurements:** -- [performance.md § Multi-pin LED driving](../../reference/performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid) — classic i80 2048-light ceiling + `esp_lcd_i80_alloc_draw_buffer` rejecting `MALLOC_CAP_SPIRAM`; S3 16,384 @ ~34 fps; P4 Parlio 4096, 139 fps @ 1024; the `multicore` +44 % table. -- [backlog-light.md](backlog-light.md) — the superseded chunk-streaming-ring decision; the shift-register driver's **48 × 256 = 12,288** acceptance floor. diff --git a/docs/work/future/leddriver-analysis-bottom-up.md b/docs/work/future/leddriver-analysis-bottom-up.md deleted file mode 100644 index b521479d4..000000000 --- a/docs/work/future/leddriver-analysis-bottom-up.md +++ /dev/null @@ -1,631 +0,0 @@ -# LED driver landscape analysis - -> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** This is a Stage-1 landscape survey that grew into an architectural sketch + a recommendation. It maps the field of LED-driver libraries across ESP32, Teensy, Raspberry Pi, and desktop; proposes a two-axis reference architecture (Backend × Multiplex); and recommends a path (build everything ourselves, see "Recommendation — walk Scenario B" near the end). Stage 2 deep-dives validate the architecture's load-bearing assumption (that hpwit's shift-register multiplex can be factored out of the I2S-LCD peripheral code without losing performance). All repos read at **main/master HEAD** on **2026-05-25**, not at last release tag. Source citations use file paths and line numbers where available; inferred claims are marked. - -## TL;DR - -- **What this is.** Stage-1 of a two-stage investigation. Maps the LED-driver landscape, proposes a reference architecture, names which path to walk in Stage 2. -- **Field surveyed.** WLED / WLED-MM (NeoPixelBus under the hood, ESP32-family). hpwit's I2SClocklessLedDriver + I2SClocklessVirtualLedDriver (I2S-LCD parallel; the virtual one adds 74HC595 shift-register multiplex). FastLED master — note: master diverges sharply from the September 2025 release; a modular driver refactor + runtime-driver-switch API landed in May 2026. NightDriverStrip (FastLED legacy + core-pinning patterns). ESP-IDF's `led_strip` (managed component, reference only). Teensy via OctoWS2811 / ObjectFLED. Raspberry Pi via rpi-ws281x (broken on Pi 5 today). Desktop via ESP32-over-USB bridge as the dominant pattern. -- **Reference architecture.** One narrow boundary line: `LedDriver::push(std::span)`. Below the line is **two orthogonal axes**: Backend (RMT, I2S-LCD, LCD-CAM, parlio, FlexIO, RP1 PIO, SPI MOSI, BareMetal) × Multiplex (None vs. ShiftReg). The virtual driver is *not* a sibling backend — it's the ShiftReg multiplex applied on top of any parallel-clocked backend. Composition by member, switchable at runtime; no templates, no CRTP. -- **Identity-mapping fast path preserved.** When `MappingLUT::hasLUT()` is false, drivers receive a span pointing directly at `layer_->buffer()` — zero copy through the wire. This optimisation drove a major part of the architecture's shape. -- **Feasibility on hpwit's code (read at HEAD, 2026-05-25).** Two-target paths interleaved through one file (30 `#ifdef CONFIG_IDF_TARGET_ESP32S3` blocks). The multiplex code isn't cleanly factored out today — refactoring it into a backend-agnostic `ShiftRegMultiplex` layer is real engineering work, not free composition. `Backend × ShiftReg` is high-confidence on classic ESP32 + S3 (hpwit's lib is the reference), medium-confidence on parlio + FlexIO (no reference, plausibly feasible), low-confidence on RP1 PIO (research-grade). -- **What ESP-IDF gives us.** Peripheral plumbing (RMT5, parlio_tx_unit, esp_lcd_panel_io_i80, SPI master DMA, GDMA, GPIO LL) — yes. Anything LED-specific (WS2812 encoder, transposition helpers, multi-protocol abstraction) — no. The `led_strip` component is community, not first-party. Per-backend we'd write ~500-700 lines of new code; ~300-500 lines for the multiplex layer. -- **Recommendation: walk Scenario B (build ourselves).** Reasons in priority order: the multiplex axis is our MoonLight-defining value-add and doesn't compose cleanly onto FastLED; runtime driver switching aligns with our own contract; the identity-mapping fast path needs the driver reading `layer_->buffer()` directly; IDF gives us the plumbing for free; license + binary-size tax shrinks Scenario A's surplus. **Cost accepted:** 3 weeks → 3 months to first working driver, mitigated by spike-ordering. -- **First two Stage-2 spikes (load-bearing).** (1) `LcdCamLedDriver (None)` on ESP32-S3 against the existing `Drivers::loop()` shape — ~1 week, proves the contract. (2) Refactor hpwit's S3 path into `LcdCamLedDriver (ShiftReg)` — ~2-3 weeks, validates that the multiplex can be factored out without losing performance. **If spike 2 fails, fall back to hybrid** (Scenario A for non-multiplex backends + Scenario B for multiplex). The architecture supports both. -- **Out of scope for Stage 1.** Per-driver benchmarking on real hardware; PR-to-FastLED to expose its DMA buffer; final IDF version pin decision; license pick. Stage 2. - -## Why this document exists - -MoonLight's `src/light/drivers/` ships an ArtNet send driver and a WebSocket preview driver. There is **no LED-strip driver yet** — that's the gap this survey serves. The pipeline expects a `DriverBase` (`src/light/drivers/Drivers.h:11`) child that reads from the Drivers container's shared output buffer and pushes bytes to hardware. The requirements (1-10K LEDs must, 10-30K should, 30K+ interesting, 50 FPS, hot-reconfigurable pin/protocol/count, WiFi-coexistent) narrow but don't pick a library. This document characterises the candidates. - -## ESP32 — primary depth - -### WLED — https://github.com/wled/WLED - -- **HEAD**: `0f34973` (2026-05-23), `main` branch, 18,115 stars, EUPL-1.2. -- **Abstraction**: delegates to `NeoPixelBus` (Makuna) for WS281x output via the `PolyBus` wrapper. `BusDigital` (`wled00/bus_manager.cpp:299`, `_driverType` 0=RMT / 1=I2S) selects between RMT and I2S backends at runtime. SPI (APA102/SK9822) goes through NeoPixelBus's two-pin protocol path (`bus_manager.cpp:322`). -- **Platforms**: classic ESP32, S2, S3, C3, C6, H2, P4 — variant-specific paths in `bus_wrapper.h` (e.g. lines 253-258 note that S3 uses NeoPixelBus's LCD parallel `X8` method). RMT5 vs legacy RMT is selected by NeoPixelBus, not WLED directly. -- **Protocols**: WS281x family, SK6812 RGBW, APA102, SK9822, TM1814, WS2801, plus PWM analog, on/off relay, HUB75 (S3 via `BusHub75Matrix`), and network buses (ArtNet, DDP). -- **Parallel pins**: up to 8 parallel channels on S3 via NeoPixelBus's I2S/LCD path; RMT channels divided per variant. `OUTPUT_MAX_PINS` caps it. -- **Threading**: `show()` is synchronous; NeoPixelBus handles DMA/ISR underneath. WLED runs a single-task render loop. -- **Hot reconfig**: `BusManager::add()` (`bus_manager.cpp:1478`) and `removeAll()` (`:1559`) let buses be created/destroyed at runtime, but no per-bus pin/count mutation in place — full reallocation is the pattern. UI "save & reboot" is the common path; on-the-fly is possible but unusual. -- **WiFi coexistence**: nothing explicit beyond what NeoPixelBus provides. Long-standing glitch reports on large installations — historically the reason WLED-MM forked. -- **Maintenance**: very active (commit yesterday, 501 open issues — high but reflects scale). -- **Field notes**: by far the most widely tested LED firmware. Carries baggage as a full firmware, not a library — extracting the bus layer for embedding is non-trivial. - -### WLED-MM — https://github.com/MoonModules/WLED-MM - -- **HEAD**: `e66e478` (2026-05-23), default branch is `mdev`, 395 stars, EUPL-1.2. -- **Delta from upstream**: focused on large installations and HUB75. WLED-MM adds inline-fast-path pixel writes (`unGamma8_bus`, `lastBus`/`laststart`/`lastlen` caching in `bus_manager.cpp` around line 1081), heavy `IRAM_ATTR`/`__attribute__((hot))` annotations, and a substantially extended `BusHub75Matrix` with dirty-bit tracking. Adds board-specific pinout presets for MOONHUB, MatrixPortal S3, HD-WF1/WF2. -- **Notably absent**: WLED-MM does **not** integrate hpwit's I2SClocklessLedDriver or the virtual driver — its HUB75 path uses `ESP32-HUB75-MatrixPanel-DMA` directly; its WS281x path is still NeoPixelBus via PolyBus. The "more pins for big installs" benefit comes from the upstream LCD-parallel I2S, not from a swap-in driver. -- **Channel allocation tweaks**: `bus_wrapper.h:776-786` shifts RMT/I2S channel assignment to accommodate the I2S0 split. Adds `WLEDMM_SLOWPATH` / `WLEDMM_TWOPATH` build options. -- **Maintenance**: active, 102 open issues. Smaller maintainer pool than upstream; product owner is co-maintainer here. -- **Field notes**: this fork's value-add is empirical tuning for installations beyond ~5K LEDs, not a fundamentally different driver architecture. If MoonLight wants the WLED bus layer at all, the question is whether to take it from upstream or from MM. - -### I2SClocklessLedDriver (hpwit) — https://github.com/hpwit/I2SClocklessLedDriver - -- **HEAD**: `a736f5e` (2026-04-06), `main`, 72 stars, MIT. -- **Abstraction**: ESP32 I2S peripheral in parallel-LCD mode (classic ESP32, `i2sInit()` at lines 1093-1167); on S3 it uses **LCD_CAM + GDMA** despite the "I2S" name (`#ifdef CONFIG_IDF_TARGET_ESP32S3`, lines 47-87). One peripheral, many GPIOs. -- **Platforms**: ESP32 and ESP32-S3 only. **No S2, C3, P4** support in the source (no conditional blocks for those). Sweet spot: S3 with PSRAM. -- **Protocols**: WS2812/13/15 and SK6812 (RGB and RGBW). No APA102 / SPI strips. Up to 16 parallel strips per peripheral. -- **Threading**: ISR-driven DMA chain with `nbDmaBuffer + 2` buffers (default 6), `loadAndTranspose()` runs inside the ISR (`:1654`, `:1741`) interleaving transposition with transmission. FreeRTOS semaphores (`sem`, `semSync`, `semDisp`, `waitDisp`) coordinate caller-side waits. `showPixels(NO_WAIT, ...)` is non-blocking. -- **Hot reconfig**: `initled()` is the (re-)init path. Pins and per-strip lengths can be changed by calling `initled()` again; no public API for live pin migration mid-frame. -- **WiFi coexistence**: not explicitly addressed in source; in practice users pin the driver task to core 1 and run WiFi on core 0. The DMA-driven design means the WiFi stack stealing CPU has little effect on bit timing — this is hpwit's core architectural advantage. -- **Field notes**: the standard go-to when RMT runs out of channels. Excellent for fixed installs; less ergonomic for hot reconfiguration. - -### I2SClocklessVirtualLedDriver (hpwit) — https://github.com/hpwit/I2SClocklessVirtualLedDriver - -- **HEAD**: `e7b2f0e` (2024-11-20), `main`, 47 stars, MIT. **Less active than the non-virtual driver** — last release "Titus" Jan 2024 — but stable; the technique doesn't need churn. -- **Abstraction**: same I2S parallel-LCD trick as the non-virtual driver (S3 = LCD_CAM + GDMA; classic = I2S0 LCD mode). Innovation is the **per-physical-pin time-multiplexing** through external 74HC595 shift registers + 74HC245 level shifters. `NUM_VIRT_PINS` (default 7-8) parallel data lines × up to 8 strips per shifted output = **up to ~120 strips total** from one ESP32. Source: project README + `I2SClocklessVirtualLedDriver.h` GDMA setup around line 430. -- **Platforms**: ESP32 + ESP32-S3. Hpwit overclocks the S3 LCD clock up to ~1.125 MHz, giving ~40% bandwidth headroom over baseline 800 kHz WS2812 timing. -- **Protocols**: WS2812/13/15, SK6812. No SPI strips, no DMX. -- **Threading**: optional dedicated display task (`I2SClocklessVirtualLedDriver_dispTaskHandle`), pinnable to core via `enableShowPixelsOnCore(corenum)`. Same semaphore model as the non-virtual driver. WAIT / NO_WAIT / LOOP display modes. -- **Hot reconfig**: init-time pin assignment; the shift-register topology is physical, so "hot pin change" doesn't really apply — the wiring is the wiring. -- **WiFi coexistence**: same story as the non-virtual driver — pin the display task to one core, WiFi on the other. Field-proven on installations approaching 100K LEDs. -- **Field notes**: this is the heavy-iron option for installations beyond 30K LEDs. Requires PCB design / hand-wired shift registers; not plug-and-play. **Fork-and-vendor is a low-risk option** given the product owner's relationship with the author and the technique's stability. - -### FastLED — https://github.com/FastLED/FastLED - -- **HEAD**: `f7d4b0e` (2026-05-24), `master`, 7,399 stars, MIT. -- **Critical: HEAD diverges sharply from the September 2025 release (3.10.3).** Master has a **modular driver refactor** under `src/platforms/esp/32/drivers/` with subdirectories `rmt/`, `rmt_rx/`, `parlio/`, `lcd_cam/`, `lcd_spi/`, `i2s/`, `i2s_spi/`, `spi/`, `uart/`, `gpio_isr_rx/`, `ble/`. Each is an OO peripheral abstraction with "automatic initialization and fallback handling" and "thread-safe operations" per the in-tree README. The pre-overhaul code lived as monolithic `clockless_rmt_esp32.h` / `clockless_i2s_esp32.h`. -- **Runtime driver switching** is the headline new API: `FastLED.add()`, `Channel::create()` (templated, since ~May 2026); non-template overload also lands. Diagnostic: "one-shot affinity-miss" + normalised driver names. Source: commit log on `master` around 2026-05-11 to 2026-05-12. -- **Removed**: `FASTLED_DISABLE_LEGACY_DRIVER_REGISTRY` macro (legacy registry is now opt-out-by-default). -- **Platforms**: classic ESP32, S2, S3, C3, C6, P4 (parlio is the P4 backend). Teensy 4.x via ObjectFLED (default since 3.10.x). ARM M0/M0+ for UCS7604 RGBW (beta). -- **Protocols**: WS281x family, SK6812 RGBW, APA102, SK9822, HD107S, LPD8806, WS2801, UCS7604 (16-bit RGBW, beta), and ~20 others. -- **Parallel pins**: up to 8 via RMT, up to 16 via I80/LCD parallel (S3/P4), up to 50 on Teensy 4 via ObjectFLED. -- **Threading**: per-driver; the LCD/I80 path is DMA + GDMA on S3, zero CPU during transmit. -- **Hot reconfig**: the new runtime-driver API is explicitly designed for it. This is the largest architectural delta from the September release. -- **WiFi coexistence**: better than legacy FastLED because the LCD/parlio paths are DMA-driven. Historical RMT bugs (`taskYIELD()` during WiFi storms) are mitigated in the new path. -- **Field notes**: master is in flux; the API surface above is fresh as of May 2026 and could shift before the next tagged release. Anyone building on it should pin a commit SHA, not the version. - -### NightDriverStrip — https://github.com/PlummersSoftwareLLC/NightDriverStrip - -- **HEAD**: `53edb35` (2026-05-16), `main`, 1,551 stars, GPL-3.0. -- **Abstraction**: built on FastLED (the legacy monolithic ESP32 RMT/I2S path, not the master refactor — release-version-pinned in `platformio.ini`). -- **Platforms**: ESP32 (classic, S2, S3) on a variety of dev boards (M5Stick, Heltec, LilyGo, plain DevKit). HUB75 via custom integration. -- **Threading**: explicit multi-task design — render task pinned to core 1, networking and effects on core 0. The README's "core affinity" discipline is what makes the project recognisable. Documentation here is thin; the patterns live in the source. -- **WiFi coexistence**: addressed by core pinning and explicit FreeRTOS task priorities, not by disabling WiFi. -- **GPL-3.0** is the key gotcha — copyleft constraints make code reuse in MoonLight (under whatever final licence) a deal-breaker for direct copy. Read it for **patterns**, not lines. -- **Field notes**: the canonical example of "how to keep a multi-thousand-LED ESP32 install stable while WiFi is on". Worth studying, not vendoring. - -### ESP-IDF `led_strip` — https://github.com/espressif/idf-extra-components/tree/master/led_strip - -- **Abstraction**: official Espressif component. Two backends — RMT (RMT5 in IDF 5.x) and SPI clockless (single-data-pin via SPI MOSI rate-matching). -- **Platforms**: every IDF-supported ESP32 variant. -- **Protocols**: WS2812/SK6812 generic single-wire. -- **Hot reconfig**: handle-based, init/deinit per strip. Multi-strip via multiple handles; no built-in parallel multiplexing. -- **License**: Apache-2.0. -- **Field notes**: the lowest-common-denominator reference. Worth knowing for the SPI-MOSI trick alone (one-pin WS2812 with zero CPU). Not a contender for large installs. - -### ESP32 driver summary - -| Driver | Abstraction | Platforms | Protocols | Max LEDs envelope | Hot reconfig | Licence | Maintenance | One-line | -|---|---|---|---|---|---|---|---|---| -| WLED bus layer | NeoPixelBus (RMT + I2S) | ESP32 / S2 / S3 / C3 / C6 / P4 | WS281x, APA102, SK9822, TM1814, DMX, HUB75 | ~10K typical, more via I2S-parallel | add/removeAll, full re-init | EUPL-1.2 | very active | full firmware, hard to extract | -| WLED-MM bus layer | same + hot-path tuning + HUB75 | as upstream | as upstream | ~16K demonstrated on Olimex per WLED-MM users | as upstream | EUPL-1.2 | active | hardened upstream, no virtual driver | -| I2SClocklessLedDriver | ESP32 I2S-LCD / S3 LCD_CAM+GDMA | ESP32, S3 | WS281x, SK6812 | 16 pins × strip length; 8K-30K | init-time pin change | MIT | active (2026-04) | DMA, ISR, 16 parallel pins, no WiFi issues | -| I2SClocklessVirtualLedDriver | same + 74HC595 shift-reg | ESP32, S3 | WS281x, SK6812 | up to ~120 strips, 50K-100K+ field-proven | init-time | MIT | stable (2024-11) | the heavy-iron option | -| FastLED master | modular per-peripheral drivers | ESP32 / S2 / S3 / C3 / C6 / P4 | WS281x + ~20 others | 50 pins on Teensy; 16 via LCD on S3 | **runtime driver-switch API new** | MIT | flux on master, stable releases | major overhaul — read HEAD not Sep release | -| NightDriverStrip | FastLED (legacy path) | ESP32 family | WS281x, HUB75 | demos with several thousand | restart | GPL-3.0 | active | study the task-pinning patterns | -| ESP-IDF led_strip | RMT5 / SPI clockless | every ESP32 variant | WS281x | per-handle, modest | per-handle | Apache-2.0 | active | reference, not a contender | - -## Teensy - -Three options for Teensy 4.x (NXP i.MX RT1062). License notes per project; all permissive. - -- **OctoWS2811** — https://github.com/PaulStoffregen/OctoWS2811 — Paul Stoffregen's reference. 8 parallel WS281x outputs via DMA on Teensy 3.x; on Teensy 4.x uses the `_imxrt.cpp` variant which exploits the i.MX RT's DMA + FlexIO. Stable but slow release cadence (last release March 2022). The historical Teensy reference. -- **ObjectFLED** — https://github.com/zackees/ObjectFLED — up to **40 parallel pins on Teensy 4.0, 55 on Teensy 4.1**. Uses OctoWS2811's DMA core underneath, layered to add per-object independent timing. Non-blocking show — returns in ~6% of transmission time. Teensy 4.x only; FastLED-compatible. This is FastLED's default Teensy backend since 3.10.x. -- **FastLED on Teensy** — same library, ObjectFLED backend by default. The master-branch refactor (above) consolidates Teensy paths but doesn't reshape them as drastically as the ESP32 layer. - -Teensy is a strong fit when the LED count exceeds what an ESP32 can drive cleanly and Ethernet (built-in on 4.1) supplants WiFi entirely. No WiFi-coexistence problem, period. - -## Raspberry Pi - -Three patterns, all known but each with caveats in 2026. - -- **rpi-ws281x** — https://github.com/jgarff/rpi_ws281x — BSD-2. The historical reference. Supports Pi 1-4 via PWM (2 channels), PCM (1 channel), or SPI MOSI (1 channel). Up to ~5,400 LEDs via PCM, ~2,700 per string via PWM. **Pi 5 (RP1 chip) is broken** — issues #554 (2025-04, build fails when `PAGE_SIZE != 16384`), #555 (2025-05, "HW revision not supported"), #564 (2025-09, 2 GB variant). Last release v1.0.0 in March 2023. Community-maintained; no active Pi 5 PR landed at HEAD read time. -- **Pi-via-SPI bit-bang** — using the Pi's SPI MOSI as a one-pin WS281x driver at ~3.2 MHz, encoding each WS281x bit as 3-4 SPI bits. Works on Pi 5 because SPI is still exposed via RP1. Single-pin, but reliable. -- **OPC server pattern** — Pi as a render node feeding LEDs via Open Pixel Control over USB to a downstream ESP32/Teensy, or via E1.31/ArtNet over Ethernet. Doesn't address the Pi's own GPIO, but is the most-deployed Pi pattern in 2026 for large installs. -- **Userspace GPIO libraries** (2026-10-11): WiringPi, now a community fork after its author stopped, and the kernel's GPIO character device through libgpiod make pins portable across Pi-compatible boards. Both go through a call per pin change, far too slow and jittery for WS2812's ±150 ns or HUB75's refresh, so LED output on any Pi takes a peripheral with DMA (PWM, PCM, SPI), PIO, or direct register writes. -- **HUB75 on a Pi** (2026-10-11): hzeller's rpi-rgb-led-matrix (https://github.com/hzeller/rpi-rgb-led-matrix) drives panels on a Pi 1 to 4 by writing the GPIO registers directly. Its options are a tested catalog of the panel variety a HUB75 backend has to cover: multiplexing schemes (`--led-multiplexing`), row-address types (`--led-row-addr-type`), driver-chip init sequences such as FM6126A (`--led-panel-type`), chained and parallel panels, PWM depth, a pixel mapper. MoonLight writes its own drivers, so it is study material, implemented from the chip datasheets with credit. -- **Pi 5 / RP1** (updated 2026-10-11): feasible through RP1's PIO. Raspberry Pi publishes piolib (https://www.raspberrypi.com/news/piolib-a-userspace-library-for-pio-control/, BSD-3) over a kernel driver: the PIO registers are not reachable over RP1's PCIe link, only the state machines' FIFOs through DMA, so userspace goes through that driver. RP1 has one PIO block, 4 state machines and 32 instruction slots. HUB75 on the Pi 5 is proven by Adafruit's PioMatter (https://docs.circuitpython.org/projects/piomatter/en/latest/adafruit_raspberry_pi5_piomatter.html), alpha, up to 3 panels, written because the register writes of earlier Pis stopped working. No worked WS2812-over-PIO example on RP1 was found; the technique is the RP2040's, and parallel lanes would feed pre-encoded slots through a FIFO by DMA, as the Teensy driver feeds its port. WS2812 on a Pi 5 today goes over SPI, one lane on MOSI (the rpi5-ws2812 package). - -## Desktop - -Product owner's question: "Is there any way to do something with GPIO pins? E.g. connect an ESP32 via USB, high-speed access via USB to its pins?" Honest answer: - -- **USB-CDC to ESP32 bridge** — by far the most common pattern. ESP32 acts as a smart pixel pusher; the desktop sends RGB data over USB serial. ESP32-S3's native USB-Serial-JTAG bus sustains ~12 Mbps (1.5 MB/s); UART-bridge boards (CP210x, CH340) cap at ~1 Mbps. At 12 Mbps, 30K RGB LEDs at 50 FPS needs 4.5 MB/s — over the wire's limit, so this caps somewhere around 10-15K LEDs. **The ESP32 is the driver; the desktop is the renderer.** -- **OPC over USB-CDC** — same idea with a defined protocol on top. Open Pixel Control (Zestyping/openpixelcontrol) is widely supported by visualisers (Processing, TouchDesigner). Maintenance dormant — superseded by E1.31/DDP in professional lighting — but still works. -- **FT232H bit-bang** — FTDI's MPSSE mode runs at up to 30 MHz. Libraries (pyftdi, libFTDI) can produce WS281x-timed pulses directly from the desktop, no microcontroller. Realistic for ~500-2000 LEDs; not for the project's targets. Niche but it does exist. -- **USB-to-SPI dongles** — Bus Pirate / FT2232H / Cypress CY7C65211. Same envelope as FT232H, marginally easier API. Same scaling ceiling. -- **Native desktop GPIO** — parallel-port WS281x bit-bang was a thing in 2015. Effectively extinct in 2026. - -**Direct answer to the question**: yes, "ESP32 over USB" is real, well-trodden, and the right model for desktop-driven installations up to ~10K LEDs. Beyond that, the desktop should output E1.31/DDP/ArtNet over Ethernet to one or more ESP32/Teensy nodes — the network protocols are designed for exactly this scale. - -## Architectural primitives observed - -**Output paths.** Five clusters across the field: - -- *RMT* (ESP32 legacy + RMT5) — per-bit timing precision, but channel count is 4-8 depending on variant, and the IDF 5 RMT5 API is structurally different from RMT4. Good for ≤8 strips. -- *I2S parallel* (classic ESP32) — one peripheral, 8-16 GPIOs in lockstep via DMA. Pixel data must be *transposed* (interleaved across strips bit-by-bit) before DMA reads it — this is the work hpwit's driver does in its ISR. -- *LCD-CAM / I80* (ESP32-S3) — successor to I2S parallel; faster, cleaner DMA via GDMA. FastLED master's `lcd_cam` driver is here; hpwit's S3 path is also here. -- *parlio* (ESP32-P4) — new parallel I/O peripheral, ESP-IDF 5.3+ only. FastLED master has a backend; ecosystem otherwise sparse. -- *SPI MOSI* — encode each WS281x bit as 3-4 SPI bits at ~3 MHz. One pin, but DMA-driven and zero CPU. The Pi and ESP-IDF `led_strip` both use this. -- *FlexIO/DMA* (Teensy 4.x) — OctoWS2811's territory. Functionally equivalent to ESP32 I2S-parallel. - -**Buffer strategies.** Single-buffer + busy-wait (small installs, RMT4) gives way to double/N-buffer + DMA chain at scale. hpwit uses `nbDmaBuffer + 2` (default 6) circular descriptors with the ISR doing on-the-fly transposition — this is the most sophisticated pattern in the field. NeoPixelBus and ESP-IDF `led_strip` use simpler double-buffering. Per-strip vs mega-buffer trade-off: per-strip is more flexible (independent strip lengths), one mega-buffer is faster to DMA. hpwit supports both via overloaded `initled()` with per-strip `sizes[]`. - -**WiFi-coexistence patterns.** Three approaches: - -1. *Core pinning* — driver on core 1, WiFi on core 0. NightDriverStrip is the canonical example; hpwit's drivers also support this via `enableShowPixelsOnCore()`. -2. *DMA-driven output* — once data is in DMA, WiFi can't steal it. This is the real reason I2S/LCD-CAM drivers don't flicker: it's not core affinity, it's that the CPU isn't in the timing-critical path at all. -3. *Disable WiFi during transmit* — never used in serious projects; defeats the point of a networked controller. Mentioned only to dismiss. - -Most libraries (NeoPixelBus, FastLED legacy RMT, Adafruit_NeoPixel) don't address this and rely on the CPU staying responsive — which is exactly when WiFi storms cause visible glitches. - -**Hot reconfiguration.** A genuine differentiator. WLED's `BusManager::add()`/`removeAll()` supports runtime bus mutation but at the cost of full reallocation. hpwit's drivers require an `initled()` re-init for pin/count change. **FastLED master's runtime-driver-switch API** (May 2026) is the only library where switching the entire driver (RMT ↔ LCD) at runtime is a first-class supported operation. For MoonLight, the requirement is GPIO/protocol/count via UI controls — that's "re-init on change" pattern, achievable with all of these but ergonomically cleanest with FastLED's new API. - -## Research question — can the virtual driver be reused on Teensy / Pi? - -The product owner's explicit question. Honest answer from reading the source: - -The technique has **two components**: (a) parallel-clocked GPIO output via a peripheral that can drive N pins in lockstep from DMA-fed memory, and (b) external 74HC595 shift registers that demultiplex one clocked output into 8 strip signals via a clock/latch protocol. (b) is hardware — protocol-agnostic, reusable anywhere. (a) is platform-specific. - -**Teensy 4.x (NXP i.MX RT1062)**: built (2026-10-11) as GPIO-DMA rather than FlexIO: a QTimer paces one eDMA channel writing pre-encoded slots into a GPIO port, up to 16 lanes, because no 8 contiguous FlexIO pins stay free beside a W5500. The original FlexIO assessment follows. It has **FlexIO**, a programmable parallel I/O peripheral with DMA support. FlexIO can do parallel-clocked output of 8-32 GPIOs with timer-driven clocking. The architecture matches what ESP32's I2S-LCD provides. **Conceptually portable**, but the rewrite is non-trivial: FlexIO's programming model (shifters + timers + pin muxing) doesn't map 1:1 to I2S DMA descriptors. The `loadAndTranspose()` ISR pattern needs adapting to FlexIO's interrupt model. **Estimate: 1-2 weeks of focused work**; existing OctoWS2811 i.mx_rt code is the reference for FlexIO + DMA, but it doesn't do the shift-register multiplexing. **Needs Stage-2 verification** to confirm FlexIO clock can reach the 800 kHz × 8-slot = 6.4 MHz effective rate needed. - -**Raspberry Pi 4 (BCM2711)** — has PWM and SMI (Secondary Memory Interface) for parallel output, plus DMA. Less programmable than FlexIO; the timing precision is the question. **Likely portable but with effort**; not a clean fit. - -**Raspberry Pi 5 (RP1)**: RP1 contains PIO blocks (the same architecture as RP2040). PIO is a near-perfect fit for the virtual driver pattern: programmable state machines with deterministic timing, DMA-fed FIFO, parallel pin output up to 32 GPIOs. **Conceptually the best target outside ESP32.** The userspace access path is answered (2026-10-11): the kernel's PIO driver, which piolib wraps, and Adafruit's PioMatter drives HUB75 through it. See the Raspberry Pi section above. - -**Summary**: the technique generalises. ESP32 I2S-LCD and RP1 PIO are the natural fits; Teensy FlexIO is workable; BCM2711 is the weakest. The shift-register hardware is reusable across all platforms. A cross-platform virtual driver is a real engineering target, but each port is 1-3 weeks, not a weekend. - -## Stage-2 candidates - -For the product owner to pick 2-3 from: - -1. **FastLED master — modular driver layer** — what we'd learn: whether the new runtime driver-switching API and per-peripheral driver subdirectories are mature enough to use directly, or if vendoring a snapshot makes more sense. Estimated cost: 1.5 days. -2. **I2SClocklessVirtualLedDriver — fork-and-vendor scope** — what we'd learn: minimal API surface to wrap as a MoonLight driver MoonModule; what config controls (pin map, brightness, gamma, virtual-pin count) need to be hot-reconfigurable; whether the init-time-only constraint is acceptable. Estimated cost: 1 day. -3. **WLED-MM bus layer extraction** — what we'd learn: whether the bus layer can be lifted out of WLED-MM and embedded in MoonLight cleanly (or if it's too entangled with WLED's segment/effect plumbing). The EUPL-1.2 licence interaction with MoonLight's licence is part of this. Estimated cost: 1 day. -4. **NeoPixelBus directly** — what we'd learn: bypass WLED entirely, use the underlying library as MoonLight's WS281x backend. NeoPixelBus is what every WLED bus class wraps anyway. Estimated cost: 0.5 day. -5. **Hybrid: hpwit non-virtual + FastLED for non-WS281x** — what we'd learn: covers the 1-30K WS281x case with hpwit's I2S-LCD driver (no shift registers needed) and falls back to FastLED for APA102/SK9822/HD107S. Estimated cost: 1 day. -6. **ObjectFLED + FastLED Teensy path** — what we'd learn: feasibility of Teensy 4.1 as a parallel target alongside ESP32, especially for installations where WiFi is replaced by 4.1's built-in Ethernet. Estimated cost: 1 day. - -## Answers — product-owner direction (2026-05-25) - -These were the open questions; the answers now drive the architecture below. - -1. **Teensy 4.1 — near-term target.** The architecture must host a Teensy backend from day one, not as "later work". ObjectFLED (FlexIO + DMA) is the reference; FastLED-on-Teensy is the implementation path for the "use libraries" scenario. -2. **Licence — GPL-3.0 default; MIT under consideration.** Either way the WLED-MM bus-layer extraction is licence-compatible (EUPL-1.2 is compatible with GPL-3.0; MIT is compatible with both). Neither rules out any Stage-2 candidate. Decision deferred but stops being a blocker. -3. **Custom PCBs are acceptable.** The virtual-driver shape (74HC595 shift-register multiplex) is in-house already; the architecture must natively support `pinCount × stripsPerPin` topology, not bolt it on as a special case. -4. **Runtime driver switching is a hard requirement.** Confirmed: switch RMT ↔ LCD-CAM ↔ I2S ↔ virtual from the UI. FastLED master's new `Channel::create()` proves the field is converging here. This is the architectural axis everything else hangs off. -5. **Raspberry Pi 5 — stretch goal.** RP1 PIO stays in the architecture's future-target list (the abstraction must not assume Xtensa or NXP). Doesn't drive Stage-1 sketch choices, but the boundary line should respect it. - -## Reference architecture - -The architecture splits at one place: **what the LED driver receives** vs **what the LED driver does**. Above the line is platform-independent; below the line is platform-dependent. Both Stage-2 scenarios (build vs. borrow) sit on the **same** above-the-line shape — they differ only in who provides the below-the-line implementations. - -Below the line is **two orthogonal axes**, not a flat list of backends: - -- **Backend axis** — which parallel-clocked peripheral feeds the wire (RMT, I2S-LCD, LCD-CAM, parlio, FlexIO, RP1 PIO, …). -- **Multiplex axis** — `None` (one peripheral pin = one strip) or `ShiftReg` (one peripheral pin → N strips via 74HC595 cascade; the "virtual driver" technique). - -The virtual-driver technique is *not* a sibling backend alongside LCD-CAM — it's a **modifier on top of** any parallel-clocked backend. The shift-register protocol on the wire is identical regardless of which peripheral is driving the parallel pins underneath. hpwit's library uses I2S-LCD on classic ESP32 and LCD-CAM+GDMA on the S3, but the technique generalises: anywhere there's a parallel-clocked DMA-fed pin block (parlio on P4, FlexIO on Teensy, RP1 PIO on Pi 5), the multiplex can hang off it. - -```text - hardware-independent (above the line) -+--------------------------------------------------------------------+ -| Layouts → Layers → BlendMap → Drivers (container) → Layer buffer | -| OR | -| outputBuffer_ | -| ↓ | -| std::span + metadata | -| ↓ | -| LedDriver::push(span) | -+----------------------------------|---------------------------------+ - hardware-dependent (below the line) - ↓ -+--------------------------------------------------------------------+ -| LedDriver = composition of a Backend + a Multiplex | -| | -| Multiplex axis → | -| None (direct) ShiftReg (virtual) | -| ┌────────────────────┬────────────────────────────┐ | -| Backend Rmt│ RmtLedDriver │ (RMT can't drive shift-reg │ | -| axis │ (ESP32 family, │ cleanly: no parallel-clock │ | -| ↓ │ ≤8 strips/chip) │ pin block — N/A) │ | -| ├────────────────────┼────────────────────────────┤ | -| I2sLcd│ I2sLcdLedDriver │ I2sLcdVirtualLedDriver │ | -| │ (classic ESP32, │ (classic ESP32, hpwit's │ | -| │ 16 parallel pins) │ current real-world deploy)│ | -| ├────────────────────┼────────────────────────────┤ | -| LcdCam│ LcdCamLedDriver │ LcdCamVirtualLedDriver │ | -| │ (S3, 16 pins, │ (S3, hpwit's modern path; │ | -| │ GDMA) │ S3-overclocked for headroom)│ | -| ├────────────────────┼────────────────────────────┤ | -| Parlio│ ParlioLedDriver │ ParlioVirtualLedDriver │ | -| │ (P4, 16 pins, new) │ (P4 future port — needs │ | -| │ │ Stage-2 verification) │ | -| ├────────────────────┼────────────────────────────┤ | -| FlexIo│ FlexIoLedDriver │ FlexIoVirtualLedDriver │ | -| │ (Teensy 4.x via │ (Teensy future port — │ | -| │ ObjectFLED-like) │ needs Stage-2 verification)│ | -| ├────────────────────┼────────────────────────────┤ | -| Rp1Pio│ Rp1PioLedDriver │ Rp1PioVirtualLedDriver │ | -| │ (Pi 5 stretch) │ (Pi 5 stretch + virtual) │ | -| ├────────────────────┼────────────────────────────┤ | -| SpiMosi│ SpiMosiLedDriver │ (single pin: no multiplex │ | -| │ (one pin, any chip,│ benefit — N/A) │ | -| │ small installs) │ │ | -| └────────────────────┴────────────────────────────┘ | -| | -| BareMetalBackend — escape hatch for exotic chips; same two-axis | -| shape, just owns its register pokes instead of using a HAL. | -| | -| Each LedDriver instance owns: peripheral handle, DMA descriptors, | -| transposition buffer, optional ShiftRegMultiplex member, ISR. | -| None of this leaks above the line. | -+--------------------------------------------------------------------+ -``` - -The multiplex axis is **composition, not inheritance**: a `LcdCamLedDriver` instance has a `Multiplex multiplex_` member (defaulting to `Multiplex::none()`); setting it to `Multiplex::shiftReg(virtualPins, cascadeDepth)` enables the virtual driver mode at runtime. The peripheral backend stays the same — only the inner-loop transposition function changes, and only at topology-change time, not per-frame. This preserves the runtime driver-switch requirement (axis 1) *and* the runtime virtual-on-or-off switch (axis 2) without templates. - -### The line itself — `LedDriver::push(std::span)` - -The boundary is a single virtual call per frame per driver. Inside `push()`, the driver uses its own typed peripheral handle with no further virtual dispatch — hot-path-clean. The line is intentionally narrow: just bytes and metadata, no buffer ownership crossing it. The driver does not store the span beyond the call's scope (the caller's buffer may be reused for the next frame). - -Metadata carried by the driver as bound MoonModule controls, **not** through `push()`: - -- `pinMap[]` — which GPIO each strip lives on (or, for virtual driver: which shift-register output). -- `stripLengths[]` — per-strip light counts, sums to `bufferBytes / channelsPerLight`. -- `protocol` — WS2812 / WS2815 / SK6812 / APA102 / SK9822 / HD107S / etc. -- `colorOrder` — GRB / RGB / GRBW / etc. -- `gamma` (1.0-3.0). -- `globalBrightness` (0-255 uint8). -- *Backend-specific*: each backend may expose its own controls (RMT clock divider, LCD overclock factor, FlexIO timer divider). These live on the backend, not the base. - -Per-frame: just the span + an implicit "size matches the bound metadata". - -> **Superseded: `colorOrder` / `gamma` / `globalBrightness` shipped differently.** These three are now the **output correction** (`src/light/drivers/Correction.h`): the `Drivers` container holds the shared global `brightness`, and each driver owns its own `Correction`, built from that brightness and the driver's own `fixture` (channel order *and* RGBW), rather than binding its own `colorOrder`/`gamma`/`brightness`. One brightness across all physical drivers, one wiring per driver; ArtNet already uses it. The future LED driver consumes the same `Correction`: see [leddriver-analysis-top-down.md § 4.6](leddriver-analysis-top-down.md) and [MoonLight, drivers](../../explanation/architecture/moonlight.md#drivers). Gamma is not implemented yet (the LUT is brightness-only; gamma folds in later as a per-channel R/G/B split). - -### Identity-mapping fast path preserved - -The existing optimisation at `Drivers.h:90` — when `layer_->lut().hasLUT() == false`, drivers point at `layer_->buffer()` directly, no copy — survives unchanged. The architecture sketches the `outputBuffer_` path because it's the common case, but the identity case is the *fast* case: - -```text -Identity case (no MappingLUT): - Layer.buffer ────────► LedDriver::push(span) (zero-copy) - -Composed case (one or more Layers with non-identity LUT): - Layer1.buffer ─┐ - Layer2.buffer ─┼─► BlendMap → Drivers.outputBuffer_ ─► LedDriver::push(span) - ... ─┘ -``` - -The driver doesn't know which path produced the span. That's the point. - -### Scenario A — Use existing libraries - -Below the line is **FastLED master** (the modular driver subdirectories `src/platforms/esp/32/drivers/` and FastLED's Teensy ObjectFLED integration) for the backend axis, **plus a vendored fork of hpwit's library for the multiplex axis only**. - -```text -+--------------------------------+--------------------------------------+ -| MoonLight LedDriver (backend×Multiplex)| External implementation | -+--------------------------------+--------------------------------------+ -| RmtLedDriver (None) → FastLED::add | -| LcdCamLedDriver (None) → FastLED::add | -| FlexIoLedDriver (None) (Teensy)→ FastLED::add | -| ParlioLedDriver (None) (P4) → FastLED parlio backend | -| | -| LcdCamLedDriver (ShiftReg) → FastLED lcd_cam DMA path + our | -| ShiftRegMultiplex transposition. | -| hpwit's lib provides the transposition | -| algorithm reference; we vendor that | -| function only, not the peripheral init | -| (FastLED owns that). | -| I2sLcdLedDriver (ShiftReg) → same shape on classic ESP32 (FastLED's | -| legacy I2S backend hosts our multiplex)| -+--------------------------------+--------------------------------------+ -``` - -Pros: ride FastLED's runtime driver-switch API and per-backend maturity (matches requirement 4). FastLED owns the peripheral churn (RMT4 → RMT5 → parlio API revisions); we own the multiplex transposition and the MoonLight-side wiring. Teensy support comes essentially free via ObjectFLED. The multiplex code we vendor is small (~200 lines from hpwit's lib — just the transposition + cascade-shift protocol, not the peripheral init). ~2-3 weeks to a working ESP32 + Teensy hybrid; +1 week for the multiplex layer on top of FastLED lcd_cam. - -Cons: FastLED master is in flux (May 2026 surface could shift), so we pin a commit SHA, not a version. The multiplex-on-top-of-FastLED-lcd_cam path needs FastLED to expose its DMA buffer for us to write into — its current API doesn't do that cleanly, so we either bypass FastLED for that one specific backend (effectively becoming Scenario B for it) or upstream a PR to FastLED adding the hook. **This is the brittle seam of Scenario A.** - -### Scenario B — Build everything ourselves - -Below the line is **our own peripheral backends + our own multiplex layer**, all talking to ESP-IDF / NXP HALs (the floor is HAL-level, not register-level: `esp_lcd_panel_io`, `parlio_tx_unit`, `rmt_tx_channel`; NXP FlexIO HAL on Teensy; not register pokes — that's the `BareMetalBackend` escape hatch). - -```text -+--------------------------------------+--------------------------------+ -| MoonLight LedDriver (backend×Multiplex)| ESP-IDF / NXP HAL | -+--------------------------------------+--------------------------------+ -| RmtLedDriver (None) → rmt_tx_channel + encoder | -| I2sLcdLedDriver (None) → legacy I2S in LCD mode + DMA | -| I2sLcdLedDriver (ShiftReg) → same + our ShiftRegMultiplex | -| LcdCamLedDriver (None) → esp_lcd_panel_io_i80 + GDMA | -| LcdCamLedDriver (ShiftReg) → same + our ShiftRegMultiplex | -| ParlioLedDriver (None) → parlio_tx_unit (IDF 5.3+) | -| ParlioLedDriver (ShiftReg) (future) → same + our ShiftRegMultiplex | -| FlexIoLedDriver (None) (Teensy) → NXP FlexIO HAL + eDMA | -| FlexIoLedDriver (ShiftReg) (future) → same + our ShiftRegMultiplex | -| (Rp1PioLedDriver — stretch) → /dev/pio + ioctl | -| SpiMosiLedDriver (None) (single pin) → SPI MOSI bit-rate encoder | -| BareMetalBackend (exotic) → register pokes for one-offs | -+--------------------------------------+--------------------------------+ -``` - -Notice that **the ShiftReg row is the same class** across all the parallel-clocked backends. `ShiftRegMultiplex` is one ~300-line class parameterised by virtual-pin count and cascade depth, sharing the transposition algorithm. The peripheral backend doesn't know it's being multiplexed — its `pinCount` is the physical pin count; the multiplex layer expands that to `physicalPins × stripsPerPin` upstream of `push()`. - -Pros: everything under our roof, no upstream churn surprises. The two-axis composition is *natural* in our tree because there's no library boundary to fight: the multiplex layer cleanly sees the backend's DMA buffer. Virtual driver as a first-class deployment target everywhere (S3 today, P4 / Teensy / Pi5 when those backends land). Hot-reconfigure semantics are exactly what we want. No upstream API stability dependency. - -Cons: 2-4 weeks per backend (HAL-floor; longer for parlio because of its newness). 5 backends × ~3 weeks + multiplex layer + integration = ~4-5 months before parity with what FastLED + vendored multiplex would give us in 3-4 weeks. Carries ongoing maintenance cost for every IDF API churn. Teensy backend needs separate test hardware. **The cost is mostly in the backend axis, not the multiplex axis** — the multiplex layer is small and stable. - -### Feasibility check — is the two-axis split actually reachable? - -This section is **hope-and-gotchas**, written after one pass through hpwit's `I2SClocklessVirtualLedDriver/src/I2SClocklessVirtualLedDriver.h` (HEAD, ~3820 lines, 2026-05-25). The architectural picture above is the goal; what follows is what stands between us and it. - -**What I found in hpwit's source:** - -- **Two distinct code paths interleaved through one file.** Classic ESP32 uses I2S0 in LCD mode (`soc/i2s_reg.h`, direct register pokes into `(&I2S0)->lc_conf.val`). S3 uses LCD-CAM + GDMA (`soc/lcd_cam_struct.h`, `hal/gdma_types.h`, `esp_private/gdma.h`). 30 `#ifdef CONFIG_IDF_TARGET_ESP32S3` blocks across the file; the two paths share the `loadAndTranspose()` inner-loop algorithm but the peripheral init, DMA setup, and ISR are entirely separate per target. -- **The multiplex code is not factored out.** `NUM_VIRT_PINS` and `NBIS2SERIALPINS` are macros baked at compile time. The transposition function (`loadAndTranspose` at lines 3533 + 3713 — itself duplicated under different `I2S_MAPPING_MODE` paths) intermixes the shift-register cascade math with the peripheral's DMA buffer layout (`DMABuffersTampon[dmaBufferActive]->buffer`). The shift-register protocol and the peripheral's DMA descriptor format are entangled in the same memory layout calculations. -- **No P4 / parlio code.** Zero references to `parlio`, `P4`, or `esp32p4` in the source tree. The S3 path was added relatively recently (the GDMA+LCD_CAM code is the newest large addition); P4 is a similar amount of work, not yet done. - -**What that means for the two-axis split:** - -- **The classic-ESP32 ↔ S3 split as two backends** — already exists *de facto* in hpwit's code via the `#ifdef`, just not factored as separate classes. Extracting `I2sLcdBackend` (classic) and `LcdCamBackend` (S3) as siblings is a real refactor (a few days of careful surgery) but it's reading what's there, not inventing it. **Feasible.** -- **The shift-register multiplex as a reusable layer** — the harder one. The transposition function `loadAndTranspose` knows the DMA buffer's pixel-encoding layout (per-bit interleaving × WS2812 timing × the cascade-shift signalling). Pulling that into a backend-agnostic `ShiftRegMultiplex` class requires the backend to expose its DMA buffer's *encoding contract* (where bit N for output O lives in memory). Different peripherals encode parallel-clocked output differently — I2S-LCD has a specific FIFO layout; LCD-CAM's GDMA descriptors are different again; parlio uses a different bit-packing scheme (per its `parlio_bit_pack_order_t` enum). **The multiplex isn't peripheral-agnostic in the way I wrote earlier; it needs per-backend specialisation of its bit-layout math.** What stays shared is the algorithm shape (cascade-shift signal generation, latch protocol, transposition strategy); what changes per backend is the memory-write pattern. -- **ESP32-P4 + parlio support for the multiplex** — ESP-IDF's `parlio_tx_unit` exposes a 16-pin parallel-clocked DMA-fed output (`SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH = 16` per `esp32p4/include/soc/soc_caps.h:462`). Same architectural shape as LCD-CAM. **The technique conceptually fits**; the work is writing the parlio-specific transposition function that matches parlio's bit-packing layout. Estimated 1-2 weeks of focused implementation + bench-verification on real hardware. **Feasible but unproven.** No one has shipped this; we'd be first. Stage-2 verification required. - -**Net honest read on the two-axis split:** - -The composition I drew is the *architectural target* and is achievable. The reality is: - -1. **`Backend × None`** (RMT, LCD-CAM direct, parlio direct, FlexIO direct) — all feasible; each is a standard peripheral wrapper. -2. **`I2sLcd × ShiftReg`** (classic ESP32 virtual) — hpwit's working code is the reference; lifting it into our architecture is a refactor not a port. **High confidence.** -3. **`LcdCam × ShiftReg`** (S3 virtual) — same, the S3 path in hpwit's lib is the reference. **High confidence.** -4. **`Parlio × ShiftReg`** (P4 virtual) — no existing reference; needs original engineering. The parlio API supports it in principle (DMA-fed parallel-clocked output); the multiplex transposition has to be re-derived for parlio's bit-pack layout. **Medium confidence, Stage-2 verification needed.** -5. **`FlexIo × ShiftReg`** (Teensy virtual) — likewise no existing reference; OctoWS2811 doesn't multiplex. Each port is a multi-week engineering project, not free composition. **Medium confidence.** -6. **`Rp1Pio × ShiftReg`** (Pi 5 virtual) — most speculative. RP1 PIO has the right shape but no LED-driving reference exists. **Low confidence, true research.** - -So the architecture's promise — "the same `ShiftRegMultiplex` class works across backends" — is **aspirational**. What's realistic is "the same `ShiftRegMultiplex` *protocol* (cascade-shift signalling, transposition strategy) generalises, but each `Backend × ShiftReg` pair needs its own per-backend transposition implementation matching that backend's DMA layout". The class hierarchy can express it; the inner-loop code is per-pair work. - -**Possible stoppers that Stage 2 needs to verify:** - -- Does hpwit's algorithm survive being refactored out of one file into a Backend + Multiplex pair without losing performance? The current code's tight coupling is partly a performance optimisation (the transposition writes directly to known DMA-buffer slots). Splitting it adds an abstraction layer that we'd need to prove is zero-cost. -- Does parlio's `parlio_tx_unit_transmit` API support the cascade-shift signalling pattern? The hpwit S3 trick uses LCD-CAM in a slightly unusual way; whether parlio is flexible enough in the same direction is unproven. -- Does the bit-overlap technique (using one peripheral's clock edges to clock shift registers *and* deliver pixel data on other pins) work on parlio? The S3 LCD-CAM lets you commandeer the LCD output clock for this; parlio's clock generation is structurally similar but the pin-routing model differs. - -These are the "hope vs. confirmed" gaps. Stage 2 deep-dive into option #2 (virtual driver vendor) is where they get resolved — and resolves them on real hardware, not by reading source. - -### What does ESP-IDF give us if we build everything ourselves? - -Short answer: **a lot of low-level peripheral plumbing, almost nothing LED-specific.** ESP-IDF treats LED driving as a community concern, not a first-party concern. - -What IDF provides natively (relevant to LED driving): - -- **`driver/rmt_tx.h`** (`esp_driver_rmt`) — the RMT TX peripheral. Generic per-bit pulse-train generator with timing precision. **Useful** for WS281x single-pin output up to 8 channels; programmable encoder support for any clockless protocol. RMT5 API (IDF 5.x) is what we'd target. -- **`driver/parlio_tx.h`** (`esp_driver_parlio`) — parallel I/O TX. Up to 16 GPIOs in lockstep, DMA-fed. **Useful** as the host for our `ParlioBackend` (None and ShiftReg). New (IDF 5.3+), P4-only today, API still stabilising. -- **`esp_lcd_panel_io_i80.h`** (`esp_lcd`) — i80 LCD interface, ESP32-S3. 16-pin parallel data + clock, DMA-fed via GDMA. **Useful** as the host for our `LcdCamBackend`. This is what hpwit's S3 code uses underneath, just exposed via the public IDF API rather than direct register pokes. -- **`driver/spi_master.h`** — DMA-fed SPI MOSI at MHz rates. **Useful** for the `SpiMosiBackend` (one-pin WS281x via bit-rate encoding, the same trick the official `led_strip` component uses for its non-RMT mode). -- **`hal/gpio_ll.h`** — low-level GPIO ops, IRAM-safe. **Useful** for the latch-pin signalling in our shift-register multiplex. -- **GDMA / `esp_private/gdma.h`** — the underlying DMA engine on S3/C-series/P4 chips. **Useful** for advanced backends that need to chain DMA descriptors manually (the way hpwit does on S3). - -What IDF does **not** provide: - -- **No LED-strip driver in core IDF.** No `esp_ws2812.h`, no built-in WS281x bit encoder, no APA102 helper. The `led_strip` community component (`espressif/idf-extra-components`, Apache-2.0) gives a basic single-strip handle on top of RMT or SPI MOSI, but it's a managed-component dependency we'd pull in, not built into IDF. -- **No transposition helpers.** If you want to drive 16 parallel WS281x strips through I2S-LCD or LCD-CAM, you write the bit-interleaving yourself. That's the work hpwit's lib does. -- **No multi-protocol abstraction.** "Drive WS2812 here, APA102 there, DMX over there" is your problem; IDF gives you the peripherals, not the protocol mapping. -- **No color-order / gamma / brightness layer.** Up to us. -- **No hot-reconfigure pattern.** Each peripheral driver's API is "create_unit → enable → transmit → delete"; toggling pin maps at runtime means delete + recreate, and the discipline of doing that without dropping a frame is on us. - -**What that means for Scenario B effort estimate:** - -Per backend, we're writing: - -- ~200 lines of peripheral setup (config struct, handle ownership, GPIO routing) -- ~50-100 lines of DMA buffer management (descriptor chain, double-buffering) -- ~100-300 lines of encoding (WS2812 timing-bit pattern, APA102 SPI clocking, etc.) -- ~50-100 lines of ISR / completion-callback handling -- ~100 lines of hot-reconfigure logic -- ~50 lines of MoonModule control surface - -Per backend: ~500-700 lines of new code. For ESP32 (RMT) + S3 (LCD-CAM) + P4 (parlio) + Teensy (FlexIO) that's 2000-2800 lines just for backend basics. The shift-reg multiplex layer is another ~300-500 lines on top, with per-backend transposition specialisations. - -**IDF helps a lot at the peripheral layer; nothing at the LED-protocol layer.** That's the honest weight of Scenario B. The 2-4-weeks-per-backend estimate stands; it's not naïve, but it does require that the IDF peripheral APIs *don't have surprises*. RMT5 has had three breaking changes between IDF 5.0 and 5.4; parlio is still pre-stabilisation. Expect to pin an IDF version and re-validate on every bump. - -### What we gain by building everything ourselves - -Importing a library means you get a lot that wasn't built for your case. Specifically, what we'd give up by adopting FastLED master under the line: - -- **The pixel buffer layout is FastLED's, not ours.** Their CRGB struct, their channel ordering decisions, their gamma/brightness application points. We bridge — every `push()` call has to translate from MoonLight's flat `std::span` (the existing `Buffer::data()` shape) into whatever FastLED's bus class expects. That bridge is a per-frame copy or a per-frame view rewrite; in either case it's hot-path overhead that disappears in Scenario B because we control both sides. -- **Hot-reconfigure is FastLED-paced.** Their new `Channel::create()` API is the closest the field has to runtime driver-switching, but it's young (May 2026), the API surface is in flux, and "switch RMT to LCD-CAM mid-flight without dropping a frame" is something we'd have to verify works the way we need — not assume. In Scenario B the contract is ours; if a frame drop on driver switch is unacceptable, we engineer around it. -- **No leverage on inner-loop optimisations specific to MoonLight's MappingLUT / identity-mapping fast path.** FastLED knows nothing about our identity-mapping optimisation (`!hasLUT()` → direct Layer-buffer push). To use FastLED's bus path we either pre-compose into FastLED's CRGB buffer (defeating the identity path) or write a thin wrapper that exposes our raw buffer (possible but adds a coupling point we don't control). Scenario B keeps the identity path zero-copy through to the wire. -- **The multiplex seam is brittle on FastLED.** This is the strongest finding from the feasibility check above. FastLED has no shift-register multiplex backend. To use FastLED's lcd_cam DMA path *with* our shift-register multiplex, we need FastLED to hand us its DMA buffer for direct writes — its current API doesn't, and either (a) we bypass FastLED for that backend (effectively Scenario B for the most MoonLight-defining backend), (b) we upstream a PR (slow, library-author dependent), or (c) we accept a per-frame copy from FastLED's buffer into our transposed buffer (hot-path cost). -- **No control over IDF version pinning.** FastLED master targets whatever IDF FastLED master targets. If our MoonLight IDF version pin disagrees with FastLED's, we either bump them both in lockstep or carry a divergent fork. In Scenario B we own the IDF compatibility matrix. -- **Carrying FastLED is binary size we don't fully use.** FastLED master ships drivers for ~20 protocols, ~6 ESP32 variants, Teensy, ARM M0/M0+, and AVR; gamma tables, palette helpers, FX layer, blur kernels. We use the bus driver and the bus driver only. Dead code elimination at link time helps, but FastLED's runtime driver-registry is the opposite of dead-code-eliminable (the whole point is that drivers are reachable from a string lookup). Realistic estimate: 80-150 KB of binary we'd carry without using. -- **Profile-guided tuning is harder.** MoonLight's hot path is unusual — most LED firmwares don't have a Layer/MappingLUT composition layer above the driver. Tuning the inner loop for MoonLight's specific access patterns is straightforward if we own the inner loop; in Scenario A it's a sequence of conversations with the library maintainer. - -What we **lose by building everything ourselves**: speed-to-first-working-driver. Scenario A puts an ESP32 + Teensy hybrid in your hands in ~3 weeks. Scenario B is multi-month before parity, and during those months we own every IDF API surprise. That cost is real. - -What we **gain by building everything ourselves**: every byte of the hot path is ours, the multiplex axis is symmetric across backends without library-author cooperation, the binary stays focused on MoonLight's actual needs, and the architecture stops being a negotiation between MoonLight's design and FastLED's. **The architecture becomes a contract we own**, not a translation layer between two contracts. - -### Recommendation — walk Scenario B - -Reading the whole document honestly: **build everything ourselves.** Reasons, in priority order: - -1. **The multiplex axis is the MoonLight-defining value-add for the >30K-LED case, and it doesn't compose cleanly onto FastLED.** Scenario A's "ride FastLED + vendor hpwit for the multiplex" framing was the first thing I wrote, and the feasibility check made it visibly worse. The cleanest path for the virtual driver is Scenario B; that's our heaviest single dependency on Scenario A, and it's the spot where Scenario A is structurally weakest. -2. **Runtime driver switching as a first-class requirement aligns naturally with our own contract.** FastLED's API is moving toward this in May 2026 master, but it's young and we'd be downstream of their choices. Owning the contract means owning the semantics of "switch backend without dropping a frame" — the user-facing requirement. -3. **The identity-mapping fast path needs the driver to read directly from `layer_->buffer()`.** That's a MoonLight-specific optimisation that the rest of the architecture is built around (Drivers.h:90 already implements it for ArtNetSend / Preview). FastLED has no incentive to support it; we'd lose the optimisation or carry a coupling layer. -4. **ESP-IDF gives us the peripheral plumbing for free.** As the previous section showed, `parlio_tx_unit`, `esp_lcd_panel_io_i80`, `rmt_tx_channel` are all production-ready. The LED-specific layer on top is small (300-700 lines per backend); we're not writing a peripheral driver from scratch. -5. **License + binary-size tax shrink the surplus from Scenario A.** EUPL-1.2 / MIT / GPL-3.0 mixing is solvable but adds review burden; 80-150 KB of unused FastLED code is real on a 4 MB partition table. - -**Costs accepted by this recommendation:** - -- **Time to first working driver shifts from ~3 weeks to ~3 months.** This is the biggest concrete cost. Mitigated by ordering: ship `LcdCamLedDriver (None)` on ESP32-S3 first (highest-value single backend), then `LcdCamLedDriver (ShiftReg)` on the same chip (validates the multiplex layer against an in-house reference), then `RmtLedDriver (None)` for classic ESP32 fallback, then `FlexIoLedDriver (None)` for Teensy, then expand. By month 4 we have parity with what Scenario A would give in month 1; by month 6+ we have everything Scenario A could never give us (parlio multiplex, RP1 PIO if Pi 5 is ready, custom inner-loop optimisations). -- **Per-backend IDF API surface changes are our problem.** Mitigated by IDF-version pinning + a documented compatibility matrix. Plan-17 already established the v6.1-dev pin discipline; this extends it. -- **Teensy backend needs separate test hardware.** Mitigated by the fact that Teensy 4.1 was named as a near-term target; the hardware purchase is in scope regardless of which scenario. - -**Suggested order of Stage-2 work** (informed by the recommendation): - -1. **Spike: `LcdCamLedDriver (None)` on ESP32-S3 against the existing `Drivers::loop()` shape.** ~1 week. Goal: prove the `LedDriver::push(span)` contract works end-to-end on real hardware (16 parallel pins, 8 KB strip each, 50 FPS). If this spike fails, the rest of the recommendation fails too — so do it first, fastest signal. -2. **Refactor hpwit's S3 path into `LcdCamLedDriver (ShiftReg)` against the same contract.** ~2-3 weeks. This is the highest-information-density spike: validates the two-axis split, validates the multiplex transposition refactor, gives us a working virtual driver on our own architecture. **If this spike fails, the recommendation needs revising — the multiplex-can-be-factored-out claim is the single load-bearing architectural assumption.** -3. **`RmtLedDriver (None)`** for classic ESP32 small-install support. ~1 week (RMT5 is the simplest backend). -4. **Continue per the architecture diagram**, prioritising by user-visible value: FlexIo (Teensy) > Parlio (P4) > Rp1Pio (Pi 5 stretch). - -**When to revisit this recommendation:** - -If the spike at step 2 fails — i.e. the multiplex refactor turns out to be infeasible without ruining performance, or hpwit's S3 path turns out to be too register-poke-specific to extract cleanly — **we fall back to a hybrid**: Scenario A for the non-multiplex backends (RMT, LCD-CAM direct, FlexIO direct), Scenario B for the multiplex layer specifically, accepting that the multiplex bypass-FastLED-DMA path is the brittle seam. The architecture supports this; the diagram remains accurate; the recommendation is just "we shipped less of Scenario B than we wanted." - -## Concrete DriverBase API sketch - -The header that Stage 2 starts from (illustrative, not final). Lives at `src/light/drivers/LedDriver.h`: - -```cpp -#pragma once - -#include "light/drivers/DriverBase.h" -#include -#include - -namespace mm { - -enum class LedProtocol : uint8_t { - WS2812, WS2815, SK6812, SK6812RGBW, - APA102, SK9822, HD107S, TM1814, P9813, - Custom, // backend-specific timing in its own controls -}; - -enum class ColorOrder : uint8_t { - RGB, RBG, GRB, GBR, BRG, BGR, - RGBW, GRBW, // … -}; - -// Per-strip descriptor — one of these per physical strip on the driver's -// peripheral. Stored as a bound MoonModule control array. -struct StripSpec { - uint16_t lightCount; // logical lights on this strip - uint8_t pin; // GPIO or shift-register output index - LedProtocol protocol; - ColorOrder order; -}; - -// LedDriver — abstract base for hardware-specific LED-strip output. -// -// Inherits the existing DriverBase (src/light/drivers/Drivers.h) shape, so -// it composes with ArtNetSendDriver and PreviewDriver in the Drivers -// container without changes to the container. Lifecycle (setup / loop / -// teardown) is MoonModule's; the LED-specific contract is push() + -// onTopologyChange(). -// -// Hot path: push() is called once per render frame per driver instance. -// Inside push(), the implementation uses its own typed members with no -// further virtual dispatch. push() must: -// - not block longer than the per-driver budget (see performance section) -// - not allocate -// - not call any platform API that holds the WiFi stack -class LedDriver : public DriverBase { -public: - // Per-frame entry point. `pixels` is the source buffer (either the - // shared outputBuffer_ or a Layer's own buffer in the identity-mapping - // fast path); the driver does NOT store this span past push() return. - // Span length matches strips_ × channelsPerLight at all times — caller - // (Drivers container) guarantees this; driver may assert in debug. - virtual void push(std::span pixels) = 0; - - // Called when strip count, lengths, pin map, or protocol changes via - // UI. Backends use this to (re-)allocate DMA buffers, re-bind GPIOs, - // and recompute transposition tables. May block (it's not the render - // loop). Returns false if the new topology can't be honoured (e.g. - // RMT channel exhaustion, too many parallel pins for the peripheral). - virtual bool onTopologyChange(std::span strips) = 0; - - // Diagnostic: name of the backend ("rmt", "lcd_cam", "virtual_i2s", - // "flexio", …). Used by the UI's driver-switch dropdown. - virtual const char* backendName() const = 0; - - // Optional: maximum strips/lights this backend can hold given the - // current chip. Used to grey-out impossible UI configurations. - virtual uint16_t maxStrips() const { return 16; } - virtual uint16_t maxLightsPerStrip() const { return UINT16_MAX; } -}; - -} // namespace mm -``` - -Notes on the shape: - -- `std::span` for `pixels` — caller-owned, zero-copy, lifetime tied to the call. ESP-IDF allows `std::span` (C++20 is the project baseline). -- No `color_order` / `gamma` / `brightness` argument to `push()` — those are controls on the driver and applied during push by the implementation. Keeps the boundary narrow. -- `StripSpec.pin` is a uint8 because the virtual driver indexes shift-register outputs (0..63), not real GPIOs. Real-GPIO backends just use it as a GPIO number. -- `onTopologyChange()` returns bool so the UI can surface "this combination isn't possible on this chip" instead of silently truncating. -- No CRTP, no templates — straightforward virtual dispatch, one call per frame. Runtime driver switch is `delete oldDriver; oldDriver = makeDriverByName(...)` — trivially supported. - -## Performance budget (16K LEDs × 50 FPS, ESP32-S3 + PSRAM) - -| Component | Memory | Time per frame | Notes | -|---|---|---|---| -| Frame budget total | — | **20 ms** | 50 FPS = 20 ms wall clock per tick | -| Effect compute (per Layer) | n/a | 8-12 ms | Empirical: Metaballs at 16K = ~12 ms today | -| Layer buffer (one Layer, RGB) | 48 KB | n/a | 16384 × 3 | -| MappingLUT (identity case) | 0 KB | 0 ms | Direct hand-off to driver, the *fast* path | -| MappingLUT (sparse) | 32-64 KB | n/a | (lightCount+1) × sizeof(idx) + dest count × sizeof(idx) | -| BlendMap (composed) | n/a | 1-3 ms | One memcpy in identity case; per-light loop in LUT case | -| Driver `push()` budget | n/a | **3-5 ms** | What's left after effect + blend | -| DMA transmission (background) | per-backend | 16-20 ms | WS2812 timing × strips; runs *after* push returns | -| Output buffer (RGB → wire bytes) | 48-96 KB | n/a | Pre-encoded vs runtime-encoded | -| Free heap headroom on no-PSRAM ESP32 | ≥ 40 KB | n/a | Otherwise WiFi / Ethernet can't start | -| Free heap headroom on PSRAM S3 | ≥ 100 KB | n/a | More breathing room; multi-driver becomes viable | - -Two real numbers worth noting: - -- **DMA transmission is asynchronous to `push()`.** Wire-level WS2812 transmission at 800 kHz takes ~30 µs per LED × 16K = ~500 ms in serial; on parallel hardware (16 pins) it's ~30 ms. Both of these are larger than the frame budget — which is why double-buffering + DMA chains exist. The driver's `push()` returns immediately after handing the buffer to DMA; the next frame's `push()` blocks (or yields) until DMA is free. -- **`push()` budget shrinks fast at higher counts.** At 30K LEDs the effect compute alone is 20+ ms (linear in light count for most effects); the frame budget either grows (drop to 30 FPS) or moves to multi-core (effects on core 1, network on core 0). MoonLight does not pin tasks today — the render runs on whichever core the scheduler task lands on. - -## Hot-path do-and-don't checklist - -Specific to the LED-driver hot path (inside `push()` and anything it calls per-frame). General-purpose render-loop rules from `architecture.md` § Hot path discipline apply too. - -### Don't -- `std::function`, `std::bind`, `std::variant`'s `std::visit` with non-trivial visitor — all heap-allocate or have hidden vtable surface. -- `new`, `malloc`, `push_back`, `std::string`, `std::vector` growth — anything that can hit the heap. -- Virtual calls inside per-light or per-byte inner loops. One virtual call per `push()` for `LedDriver::push()` itself is fine; per-byte virtual dispatch in transposition is not. -- `mutex.lock()`, `semaphore.take()` with non-zero timeout — block the render loop. `try_lock` returning fast is OK. -- `printf` / `ESP_LOGI` — they take a global lock and write to UART. Use `ESP_DRAM_LOGE` if absolutely needed; better, log from `loop1s()` not from `push()`. -- PSRAM for the *output staging buffer* that DMA reads — PSRAM is ~12 MB/s sequential vs SRAM's 80+ MB/s; DMA from PSRAM works but is slow. -- Float math per-light if integer suffices. The existing code is already integer-first; preserve that in any new backend. - -### Do -- Bound the inner loop count statically where possible (`uint16_t` for strip lengths up to 65 535). -- Place ISR helpers in IRAM via `IRAM_ATTR` — IDF-level discipline. -- For DMA buffers on ESP32, prefer `heap_caps_malloc(size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)` to ensure SRAM, not PSRAM. -- Document each `IRAM_ATTR` placement; the iram budget is tight on the classic ESP32 (~70 KB total). -- Use `std::span` over pointer+size pairs where C++20 syntax allows — keeps the hot path readable without abandoning zero-cost. (Note: this is the opposite of what `platform.h` does today across the broader platform API — there it's pointer+size for consistency with C-style ESP-IDF APIs. Inside the LED driver layer where everything is C++, `std::span` is the right call.) -- For runtime driver switching: free the old backend (DMA stopped, peripheral released, ISR detached) before allocating the new one. Avoid the heap-fragmentation pattern that bit us in plan-18 (Drivers null-deref). - -## Risks and unknowns - -Specific to the LED-driver architecture; ranked by potential blast radius. - -1. **RMT5 API churn.** ESP-IDF's RMT v5 (`driver/rmt_tx.h`) landed in IDF 5.0 and has had non-trivial revisions in 5.1, 5.2, 5.3, and 5.4 — encoder API, callback signatures, queue semantics. Our backend has to pin to one IDF version and re-validate on every IDF bump. FastLED's master takes the same pinning approach; we'd inherit the same risk in scenario A. -2. **parlio is too new.** `parlio_tx_unit` is IDF 5.3+ only, ESP32-P4 only, and the API surface has rough edges (especially around chained DMA and idle-state behaviour). Worth treating as "Stage-2 verification needed" rather than "ready to use". -3. **Virtual driver portability beyond ESP32.** The `loadAndTranspose()` ISR pattern hpwit uses is conceptually portable to FlexIO and RP1 PIO but neither has a published reference implementation. Each port is its own multi-week engineering project; cross-platform "free" doesn't exist here. -4. **LCD overclock non-portability.** hpwit overclocks the S3 LCD clock to ~1.125 MHz for WS2812 timing headroom. This is undocumented behaviour; Espressif could change it without warning. The non-overclocked path works; "overclock for headroom" is performance-only and falls back cleanly if removed. -5. **Hot-reconfigure during DMA in-flight.** What happens if the user changes `pinMap` via the UI while the *current* frame's DMA is mid-transmission? The architecture must define this: most likely "topology changes are taken into account on the next frame's `push()`; the in-flight frame completes on the old topology". `onTopologyChange()` blocks until DMA-quiescent before re-allocating peripheral resources. -6. **WiFi-coexistence empirically variable.** Different routers, different switches, different cable conditions all interact with the WiFi stack's CPU-burst patterns. Our two tracks (DMA-driven + core pinning) cover the known modes; there's an unknown unknown around very-large installs (50K+) that no public library has fully solved. -7. **Identity-mapping path subtlety.** The fast path requires the Layer's buffer layout to *exactly* match the wire layout (no gamma, no brightness, no color-order swap on the way out). If the driver wants to apply gamma/brightness in `push()`, the identity path is gone — we re-introduce the copy. The control-binding shape should make this trade-off explicit per backend. **Resolved for clockless LEDs:** a WS2812-class driver must encode RGB into a pulse pattern anyway, so it already allocates a separate DMA buffer — the `Correction` (brightness/reorder/white) fuses into that encode pass at no extra buffer cost. The true zero-copy-to-wire identity path only ever applied to byte-identical protocols (ArtNet/DMX, APA102-SPI), where there's no encode and correction is the only reason to copy. So "identity → no copy" is a property of the *protocol*, not the mapping. -8. **Build vs borrow lock-in.** Whichever scenario Stage 2 picks, switching later is expensive (months, not weeks). The hybrid escape hatch (Scenario A for some backends, Scenario B for others) reduces this but doesn't eliminate it. - -## Stage-2 candidates (revised) - -The original list still stands; the answers above narrow it: - -1. **FastLED master — modular driver layer** (Scenario A backbone). Confirmed in scope. -2. **I2SClocklessVirtualLedDriver — fork-and-vendor** for the virtual-driver shape. Confirmed in scope (custom PCBs OK). -3. **WLED-MM bus layer extraction** (Scenario A alternative for ESP32-only). Confirmed: license compatible either way. -4. **NeoPixelBus directly** — superseded by 1+3; deprioritise unless 1 and 3 both fall through. -5. **Hybrid hpwit non-virtual + FastLED** — covers the 1-30K WS281x case without shift registers. Still a strong middle option. -6. **ObjectFLED + FastLED Teensy path** — confirmed in scope (Teensy 4.1 is near-term). - -Pick 2-3 for Stage 2. **See the "Recommendation — walk Scenario B" section above** for which to pick under that recommendation (the answer is the two spikes at the head of "Suggested order of Stage-2 work" — `LcdCamLedDriver (None)` on S3 first as a contract-shape spike, then `LcdCamLedDriver (ShiftReg)` to validate the multiplex refactor against hpwit's existing S3 implementation as the reference). - -If you reject the Scenario-B recommendation and want to evaluate Scenario A or a hybrid instead: **#1 (FastLED master) + #2 (virtual driver vendor) + #6 (Teensy via ObjectFLED)** is the trio that exposes the most surface for Stage-2 to characterise. Be aware that this is the trio that produces *Scenario A + a vendored multiplex bypass for the virtual driver*, which is the hybrid path the recommendation explicitly names as the fallback if the Scenario-B multiplex spike fails. diff --git a/docs/work/future/leddriver-analysis-top-down.md b/docs/work/future/leddriver-analysis-top-down.md deleted file mode 100644 index 68a31bfe9..000000000 --- a/docs/work/future/leddriver-analysis-top-down.md +++ /dev/null @@ -1,543 +0,0 @@ -# LED driver — top-down analysis - -A second-attempt design study, deliberately inverted: instead of cataloguing the LED-driver libraries that exist and choosing among them, start from the end goal — *driving WS2812-class LEDs from a GPIO pin* — and reason about what each supported MCU can actually do, how to defend the timing against the rest of the system (WiFi, the OS, the cache), and how to *prove* the output is correct without trusting the code. - -The output of this study is the next-iteration plan: a generic driver architecture, the per-platform implementation that wires into it, the testing strategy that verifies it, and a "hello world" path that starts with WS2812 on ESP32 classic and walks out to Teensy 4.1 and Raspberry Pi 5. - -The earlier study at [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) is intentionally not consulted while writing this one; a short reconciliation section at the bottom compares the two after the fact. - -## TL;DR - -- WS2812 is a strict-timing 1-wire NRZ protocol at 800 kHz. Every working driver does the same thing at the bottom: a DMA engine clocks out a pre-encoded bit pattern with sub-150 ns edge accuracy, while the CPU stays out of the timing loop. -- The peripheral that hosts that DMA engine is what changes per device: RMT on ESP32, I2S/LCD_CAM/PARLIO on the newer ESPs, FlexIO on Teensy 4.x, PIO (via RP1) on Raspberry Pi 5. -- The WiFi-glitch problem is, almost without exception, an ISR-latency problem: a refill ISR that misses a deadline by > ~5 µs corrupts one bit and shifts every downstream pixel. The standard fix is to take the CPU out of the refill entirely (DMA-only ping-pong) and pin everything else to the other core. -- Architecture: one tiny abstract `LedDriver` interface; per-platform implementations live in `src/platform//` and are picked by `if constexpr (platform::…)` switches at compile time. No `#ifdef` outside `src/platform/`. -- Testing: on-board GPIO loopback is the cheap CI workhorse (RMT-RX on ESP32, FlexPWM-capture on Teensy). A $12 fx2lafw clone (the project uses a NanoDLA v1.3) + sigrok-cli adds an independent-clock cross-check. A $149 DSLogic Plus is the right "later" upgrade when timing-margin tests start to matter. -- Hello-world order: ESP32 classic → ESP32-S3 → ESP32-P4 → Teensy 4.x → Raspberry Pi 5 (via PIO/RP1 — and seriously consider just bridging to an MCU on the Pi 5). - -## 1. The protocol, in detail - -WS2812 (and the SK6812 RGBW variant) is a 1-wire NRZ protocol clocked at 800 kHz: every bit is a 1.25 µs cell with a high-then-low pulse whose duty cycle encodes 0 or 1. There is no clock line. Receivers latch on idle-low > 50 µs (original WS2812) or > 280 µs (current WS2812B silicon). - -### 1.1 Datasheet numbers and real-silicon tolerances - -| Chip | T0H | T0L | T1H | T1L | Period | Reset (current silicon) | -| ---------- | ------ | ------ | ------ | ------ | ------- | ----------------------- | -| WS2812B | 0.40 µs | 0.85 µs | 0.80 µs | 0.45 µs | 1.25 µs | > 280 µs | -| WS2812 (orig.) | 0.35 µs | 0.80 µs | 0.70 µs | 0.60 µs | 1.25 µs | > 50 µs | -| SK6812 RGB | 0.30 µs | 0.90 µs | 0.60 µs | 0.60 µs | 1.20 µs | > 80 µs | -| SK6812 RGBW | 0.30 µs | 0.90 µs | 0.60 µs | 0.60 µs | 1.20 µs | > 80 µs | - -Sources: [WS2812B datasheet](https://cdn-shop.adafruit.com/datasheets/WS2812B.pdf), [WS2812 datasheet](https://cdn-shop.adafruit.com/datasheets/WS2812.pdf), [SK6812 datasheet](https://cdn-shop.adafruit.com/product-files/1138/SK6812+LED+datasheet+.pdf), [SK6812 RGBW datasheet](https://cdn-shop.adafruit.com/product-files/2757/p2757_SK6812RGBW_REV01.pdf). - -Tim "cpldcpu" Böscke's [reverse engineering of the real WS2812 silicon](https://cpldcpu.com/2014/01/14/light_ws2812-library-v2-0-part-i-understanding-the-ws2812/) shows that the actual decode threshold sits around 600 ns of high time — far more forgiving than the datasheet implies. That is why every library you can name uses slightly different numbers and they all work. The numbers *we* use should be: - -- **Bit timing**: target T0H = 350 ns, T1H = 700 ns, period = 1250 ns, with a ±150 ns budget. These satisfy WS2812, WS2812B, and SK6812 simultaneously. -- **Reset / latch**: ≥ 300 µs idle low. This covers WS2812B current silicon, WS2813, and SK6812. The 50 µs value from the older datasheet is dead — [WorldSemi silently changed it ca. 2017–2018](https://tomverbeure.github.io/2019/10/13/WS2812B_Reset_Old_and_New.html) and code that assumed 50 µs randomly fails on new strips. - -### 1.2 Bit order, byte order, frame order - -- **Pixel order**: GRB (G7..G0, R7..R0, B7..B0), MSB first within each byte. SK6812 RGBW: GRBW, 32 bits, MSB first. Every other chip-specific quirk (BGR, RBG) is a transposition the driver applies on the *logical* color before encoding. -- **Frame order**: the first 24/32 bits go to the *first* pixel (the one closest to the controller). Subsequent pixels read the data after a per-pixel shift register inside the previous chip — there is no addressing. - -### 1.3 Refresh-rate math - -Per-pixel time = bits/pixel × 1.25 µs. Frame time = N × per-pixel + reset. - -| N pixels (WS2812B) | Frame (µs) | Max FPS | -| ------------------ | --------------- | ------- | -| 100 | 3000 + 300 = 3300 | 303 | -| 300 | 9000 + 300 = 9300 | 107 | -| 1000 | 30000 + 300 = 30300 | 33 | -| 4096 | 122880 + 300 | 8.1 | - -Driving *K* strands in parallel from one peripheral does not multiply wall-clock time — the per-frame ceiling is set by the longest strand. The DMA *buffer* scales with K. SK6812 RGBW costs ~33% more time per frame than WS2812 at equal pixel counts because of the extra white byte. - -This refresh ceiling is a property of the LED protocol, not of any MCU. No driver, however clever, breaks 33 FPS at 1000 pixels per strand — that is *the* number that drives every parallel-strand design decision below. - -## 2. Per-platform peripheral inventory - -### 2.1 ESP32 classic (Xtensa LX6, dual-core, with WiFi/BT) - -| Peripheral | Status | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| RMT v1 / v2 | 8 channels, 64 symbols per memory block, no DMA. Each channel timing-independent. The historical FastLED `clockless_rmt_esp32` driver (Sam Guyer's) does manual ping-pong refill at the half-buffer interrupt — that explicit double-buffering is what makes RMT v1 *more* WiFi-resilient than v2 ([FastLED #2082](https://github.com/FastLED/FastLED/issues/2082)). | -| I2S0 in LCD mode | Up to 16 parallel chains via the LCD sub-mode of I2S0; this is what hpwit's [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) uses. DMA-driven, ping-pong buffers, CPU out of the loop. The hard cost is WiFi coexistence — both peripherals contend for the SPI/DMA bus arbiter. Mitigations covered below. | -| SPI | Right peripheral for APA102 / SK9822 (clocked, two-wire). Not for WS2812 unless you tolerate 4× bit-stuffing (NeoPixelBus does this as a fallback). | -| LCD_CAM, PARLIO | Absent. | -| MCPWM, PCNT | Never used in production for LEDs. Ignore. | - -**Recommendation**: RMT (v1 API where possible) for 1–8 chains, mixed chipsets, with WiFi on. I2S parallel for 9–16 uniform-timing chains when you can tame WiFi. - -### 2.2 ESP32-S3 (Xtensa LX7, vector instructions, USB-OTG) - -| Peripheral | Status | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| RMT v2 | 4 TX + 4 RX channels, 48 symbols/block, **DMA optional** (`with_dma = true`). On a typical board with USB-CDC console, you have room for one DMA channel plus one non-DMA channel. | -| I2S | Still works for parallel LED output (16 lanes), but the S3's I2S no longer contains the LCD sub-module — that's been promoted to its own peripheral. The legacy I2S-parallel drivers still compile and run. | -| LCD_CAM | **Present, and this is the right peripheral for parallel LEDs on S3.** Originally a HUB75 / 8-bit-parallel-display peripheral; hpwit and Adafruit (Phil Burgess) [discovered](https://blog.adafruit.com/2022/06/14/esp32uesday-hacking-the-esp32-s3-lcd-peripheral/) it can be repurposed for 8- or 16-lane WS2812 output. It has its own GDMA channel and does not share the legacy DMA arbiter with WiFi. | -| SPI | Same role as classic ESP32 — APA102 / SK9822. | - -**Origin of the "LCD_CAM discovery" the brief refers to**: the Adafruit "ESP32uesday" posts in June 2022 ([1](https://blog.adafruit.com/2022/06/14/esp32uesday-hacking-the-esp32-s3-lcd-peripheral/), [2](https://blog.adafruit.com/2022/06/21/esp32uesday-more-s3-lcd-peripheral-hacking-with-code/)) introduced the technique for HUB75 RGB matrices. hpwit ([I2SClockLessLedDriveresp32s3](https://github.com/hpwit/I2SClockLessLedDriveresp32s3)) carried it across to addressable WS2812-class chains shortly after. FastLED's WIP `clockless_lcd_*_esp32.h` is the public-library equivalent ([#1779](https://github.com/FastLED/FastLED/issues/1779), [#2113](https://github.com/FastLED/FastLED/issues/2113)). - -**Recommendation**: LCD_CAM (I80 or RGB mode) for ≥ 4 parallel chains, especially with WiFi active. RMT v2 with DMA for ≤ 4 chains and mixed chipsets. - -### 2.3 ESP32-P4 (RISC-V, no built-in WiFi) - -| Peripheral | Status | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| RMT v2 | 4 TX + 4 RX, 48 sym/block, DMA standard. Without WiFi contention, RMT5 finally behaves as advertised. | -| PARLIO TX | **The headline P4 peripheral for LEDs.** Dedicated parallel TX block, 8 or 16 bits wide, hardware bit timing, GDMA. FastLED ships an alpha PARLIO driver ([#2095](https://github.com/FastLED/FastLED/issues/2095), [#2178](https://github.com/FastLED/FastLED/issues/2178)). Auto-detection is the current sore spot; force the driver via build flag for now. | -| LCD_CAM | Also present on P4. FastLED's LCD driver targets P4 alongside S3 ([#2113](https://github.com/FastLED/FastLED/issues/2113)) but community testing is the bottleneck because P4 hardware is still scarce. | -| SPI | Adds Octal SPI (8 data lines) — useful for big APA102 banks. Not for WS2812. | - -**WiFi coexistence**: a non-issue on P4. If WiFi is needed it's via an ESP32-C6 over SDIO, fully decoupled. - -**Recommendation**: PARLIO for any parallel layout, RMT5+DMA for ≤ 4 mixed-chipset chains. - -### 2.4 Teensy 4.x (i.MX RT1062, ARM Cortex-M7, no WiFi) - -The "trivially great" platform. The relevant peripheral is **FlexIO** — a configurable shifter/timer/state-machine fabric (essentially a smaller PIO before PIO was cool), DMA-fed, runs at the i.MX bus clock. - -| Library | Use | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [OctoWS2811](https://www.pjrc.com/teensy/td_libs_OctoWS2811.html) | Paul Stoffregen's reference, FlexIO + DMA. The Teensy-3 "8 channel" limit is gone on Teensy 4 — supports an arbitrary number of digital pins, zero CPU cost during refresh. | -| [TriantaduoWS2811](https://github.com/wramsdell/TriantaduoWS2811) | 32 channels from 3 Teensy pins via FlexIO clocking external 74HC595 shift registers, still zero CPU cost. Headline number: ~1.065 M LED updates/second across 32 channels. | -| WS2812Serial | UART-based, blocking-free, single pin. "Best for <600 LEDs." The fallback when you genuinely just want one strip and don't care about parallelism. | - -Teensy 4.1 has **1 MB tightly-coupled RAM** and supports up to **16 MB external PSRAM** via under-board solder pads, accessed via the `EXTMEM` attribute. PSRAM is the natural home for large frame buffers; DMA can read from it. No WiFi means no peripheral-vs-radio contention. - -**Recommendation**: OctoWS2811 for ≤ 8 strands; Triantaduo/Duotrigesimal for 16–32 parallel; WS2812Serial for one-strip toys. PSRAM via `EXTMEM` for anything past a few hundred KB of frame buffer. - -### 2.5 Raspberry Pi 5 (BCM2712 + RP1 southbridge) - -Pi 5 deliberately moved GPIO, PWM, DMA, SPI, I2C off the SoC onto the in-house **RP1** chip. That broke every WS2812 driver that worked by mapping BCM283x PWM+DMA registers directly from userspace — [jgarff/rpi_ws281x #528](https://github.com/jgarff/rpi_ws281x/issues/528) tracks the fallout. - -State of the art on Pi 5 in 2026: - -| Path | Status | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [rpi_ws281x Pi 5 branch](https://github.com/jgarff/rpi_ws281x/wiki/Raspberry-Pi-5-Support) (jgarff) | Experimental: custom kernel module `rp1_ws281x_pwm.ko` + device-tree overlay. Single-channel works, concurrent channels not yet. | -| [PIOLib](https://www.raspberrypi.com/news/piolib-a-userspace-library-for-pio-control/) + [Adafruit_Blinka_Raspberry_Pi5_Neopixel](https://github.com/adafruit/Adafruit_Blinka_Raspberry_Pi5_Neopixel) | **The forward-looking path.** RP1 contains PIO blocks like the RP2040; PIOLib is a userspace ioctl wrapper over an `rp1-pio` kernel driver. WS2812 is the canonical PIO demo and lives at [`utils/piolib/examples/ws2812.c`](https://github.com/raspberrypi/utils/blob/master/piolib/examples/ws2812.c). | -| [niklasr22/rpi5-ws2812](https://github.com/niklasr22/rpi5-ws2812) | Bit-bang WS2812 timing through SPI MOSI at 2.4 MHz. Single channel, no kernel module needed, well-trodden hack. | -| Bridge to an MCU | Use the Pi 5 as the brains (HTTP, preview, scenes) and hand the strand timing to an ESP32 / Teensy / Pico over USB or SPI. **Cleanest path today.** | - -**Recommendation**: in MoonLight, treat the Pi 5 as a desktop-class platform that delegates strand timing to an attached MCU. If single-strand-from-Pi-5 ever becomes a requirement, wrap Adafruit's PIO library behind a `WS2812Rp1Driver` and accept the single-strand limit. - -## 3. WiFi vs the driver — the failure that defines ESP32 driver design - -The defining failure mode on ESP32 is: it works perfectly at boot, then it flickers — wrong colors, shifted pixels — the moment WiFi associates. This is not RF interference. Measured root cause ([FastLED #2082](https://github.com/FastLED/FastLED/issues/2082)): an ISR that should fire every ~35 µs is delayed **40–50 µs under light WiFi load** — i.e. the deadline is missed outright. One missed deadline corrupts one bit, which shifts every downstream pixel of that frame, until the next reset. - -Contributing factors, ranked: - -1. **WiFi task priority on core 0.** WiFi stack runs at a priority above user tasks and cannot be preempted. If the LED render task is also on core 0, it gets starved. -2. **Flash-cache stalls.** Any ISR or hot function not marked `IRAM_ATTR` stalls during cache-disabled windows (NVS writes, OTA, WiFi calibration). The interrupt must also be registered with `ESP_INTR_FLAG_IRAM`. -3. **Dynamic frequency scaling.** If the CPU drops from 160 MHz to 40 MHz, the APB divider feeding RMT/I2S can't keep up. Force `pm_config.min_freq_mhz = 80`. -4. **RF coupling** — the *inverse* problem, where I2S parallel clock lines next to the WiFi front-end degrade WiFi RX. Rare, board-layout-dependent. -5. **DMA bus contention** — rarely the dominant factor. The AHB matrix arbitrates per-peripheral. - -**Mitigations checklist**, in priority order — these are what every production driver does: - -- Pin the LED render task to **core 1**, leave WiFi tasks on core 0 (`xTaskCreatePinnedToCore(..., 1)`). -- `WiFi.setSleep(false)` — modem sleep introduces periodic ~100 ms blackouts. -- Mark every ISR and hot function `IRAM_ATTR`; register interrupts with `ESP_INTR_FLAG_IRAM`; constants in `DRAM_ATTR`. -- Raise interrupt priority (level 3 via `esp_intr_alloc`). -- Set `pm_config.min_freq_mhz` ≥ 80. -- Prefer DMA-fed peripherals (RMT-with-DMA, I2S, LCD_CAM, PARLIO) so the CPU is out of the timing loop. - -Diagnostic signature for WiFi-induced corruption (vs power or signal-integrity faults): first frames after boot look perfect; symptoms appear only after WiFi associates; pixels are shifted/stuck *from a position outward* (one bit slipped, everything downstream inherits it); color flashes correlate with traffic bursts. Compare against power-fault signature: brownout, whole-strip dim, first-pixel corruption. - -For MoonLight, this means: **the LED driver task lives on core 1** (the quiet core, away from the WiFi stack and its interrupts) and **the effects / network path lives on core 0** (it can tolerate latency spikes — a late effect frame is invisible, a late driver bit is a corrupted pixel). This is the inverse of the WLED render-on-core-1 pattern, applied to a different observation about which task has the harder deadline; see § 7.2 for the full rationale and the MoonLight-specific per-module core-affinity story. - -## 4. Generic driver architecture - -A driver does five things, in order: - -1. **Configure** peripheral timing for a chipset variant (WS2812B vs SK6812 RGBW vs APA102) at setup. -2. **Correct + encode** the logical `Buffer` into a peripheral-native bit pattern. The source buffer is linear RGB; the driver applies the shared **output correction** (brightness, channel reorder, RGBW white — see § 4.6) per light, then encodes the corrected, wire-ordered bytes into the bit pattern. Correction and encode fuse into one pass. -3. **Start** the DMA / peripheral, point it at the encoded buffer. -4. **Wait** for completion (or signal completion via callback / semaphore). -5. **Enforce** the inter-frame reset gap (≥ 300 µs). - -That's it. The five steps are the same on every MCU; what changes is *who* does step 2 (CPU vs DMA encoder) and what the peripheral in step 3 looks like. - -### 4.1 The interface - -```cpp -// src/light/drivers/LedDriverBase.h -namespace mm { - -struct LedFrame { - std::span bytes; // logical RGB (3 ch); driver applies Correction (brightness, - // reorder to GRB/…, RGBW white) per light during encode - uint16_t lightCount; // pixels (not bytes) - uint8_t channelsPerLight; // source channels (3 = RGB today); output channels come - // from the Correction's fixture-profile (3 or 4) -}; - -struct LedDriverConfig { - uint32_t t0h_ns = 350; - uint32_t t1h_ns = 700; - uint32_t period_ns = 1250; - uint32_t reset_us = 300; - uint8_t invert = 0; // for level-shifters that swap polarity -}; - -class LedDriverBase { -public: - virtual ~LedDriverBase() = default; - virtual bool begin(const LedDriverConfig&) = 0; - virtual void show(const LedFrame&) = 0; // synchronous: returns after DMA done + reset - virtual void end() = 0; -}; - -} -``` - -`show()` is synchronous because the per-frame budget is small enough that there is nothing useful to do on the same core while a frame is in flight. The caller decides whether to call `show()` from the render task (simple) or from a dedicated task pinned to a different core than the network stack (the WiFi mitigation pattern). The driver itself doesn't pin tasks — that's a wiring decision in `main.cpp`. - -### 4.2 Per-platform implementations - -``` -src/platform/esp32/ Esp32RmtWs2812Driver // 1-8 chains, mixed chipsets, WiFi-safe -src/platform/esp32/ Esp32I2sParallelDriver // 9-16 chains, uniform timing -src/platform/esp32s3/ Esp32S3LcdCamDriver // 8/16 chains, DMA, post-WiFi-clean -src/platform/esp32p4/ Esp32P4ParlioDriver // 8/16 chains, hardware-clocked, no WiFi -src/platform/teensy/ TeensyFlexIoDriver // OctoWS2811-equivalent on FlexIO -src/platform/rpi5/ Rpi5PioDriver // PIOLib via Adafruit Pi5 NeoPixel -src/platform/desktop/ DesktopNullDriver // writes frames to a file/socket for preview -``` - -Each platform header is gated by `if constexpr (platform::isEsp32)` etc. at the call site — no `#ifdef` outside `src/platform/`. The selection logic lives in one `main.cpp` factory that returns a `std::unique_ptr` chosen by `platform_config.h` flags. - -### 4.3 The encoding step (step 2 above) - -For RMT, the encoded form is an array of `(level, duration)` symbols — one per bit, with the duration set from the chipset's T0H/T0L/T1H/T1L. RMT v2 also supports a *callback encoder* that produces symbols on-the-fly, but for a fixed chipset the symbol cost is small (~2 KB for 1000 WS2812 pixels with one strand) and pre-encoding is simpler. - -For I2S parallel / LCD_CAM / PARLIO, the encoded form is a *transposed* frame: the buffer is bit-sliced so that one DMA "tick" emits one bit-of-the-same-pixel across all K lanes. Concretely, for K=16 lanes: - -``` -DMA word at sample N = ((lane0_bitN) << 0) | ((lane1_bitN) << 1) | ... | ((lane15_bitN) << 15) -``` - -Each WS2812 bit becomes 3 sub-bits at 2.4 MHz (high → high-or-low depending on 1/0 → low), producing the per-bit pulse pattern. This is the same encoding hpwit's I2S driver and `jgarff/rpi_ws281x` use. - -The transpose costs O(N×24) per frame on the CPU. On ESP32 classic that's ~50 µs for 1000 pixels — well inside the frame budget. On S3/P4 it's cheaper (vector instructions on LX7 / hardware barrel shifter on RISC-V). Doing the transpose on the *render side* (writing logical pixels into a pre-transposed shape) is faster but couples the buffer layout to the driver — keep it in the driver until profiling proves otherwise. - -### 4.4 What goes in shared code vs platform code - -Shared, in `src/light/`: - -- `LedDriverBase` interface. -- `Buffer` (already exists), `MappingLUT` (already exists). -- Chipset descriptors (WS2812B, SK6812 RGB, SK6812 RGBW, APA102) — pure data: GRB order, channels, default timing. -- Output correction — already exists: `Correction` (`src/light/drivers/Correction.h`), owned by the `Drivers` container, applies brightness / channel reorder / RGBW white per light. The LED driver applies it in step 2 (§ 4.6), the same `const Correction*` ArtNet already uses. Gamma / dither fold into the same LUT later. - -Platform-specific, in `src/platform//`: - -- Peripheral setup (clock, DMA channel, pins, interrupt registration). -- Encoder for that peripheral's bit format. -- ISR / DMA callback. - -### 4.5 Shift-register expanders - -Putting 74HC595 (or similar) on the data lines is **not** a new driver layer — it's a hardware multiplexer that lets one peripheral-controlled clock + data drive N strands. The driver still emits the same bit pattern; the shift register fans it out in space rather than time. The Teensy TriantaduoWS2811 design is the canonical example: 3 Teensy pins (clock, data, latch) drive 32 strands via external '595s. - -In the architecture above, an expander variant is a configuration of an existing driver (e.g. `TeensyFlexIoDriver` with `expanderChains = 32`), not a new class. The transpose step gets a wider stride; everything else is unchanged. - -For MoonLight this is **third-priority** behind WS2812 and SK6812: design the interface so it could host it (the K-lane abstraction is already general enough), but don't ship it day one. - -### 4.6 Output correction (already shipped) - -The brightness / channel-reorder / RGBW-white stage is **not** specific to LED drivers and is **already implemented** — ArtNet uses it today. It is a per-driver stage shared by every *physical* driver, with the shared state owned by the `Drivers` container: - -- `Correction` (`src/light/drivers/Correction.h`) holds a 256-entry brightness LUT, a channel-order table (the `fixture`: RGB / GRB / … / RGBW / GRBW), an output-channel count (3 or 4), and a derive-white flag. `Correction::apply(src, out)` transforms one logical RGB light into the wire form: brightness via LUT, then reorder, then (for RGBW presets) `W = min(r,g,b)` of the brightness-scaled channels. -- `Drivers` exposes the shared `brightness`, and each driver exposes its own `fixture`; a change to either rebuilds only the affected corrections (`Drivers::rebuildAllCorrections`, `DriverBase::rebuildCorrection`) without a pipeline rebuild, so the slider stays fluent. Each driver owns its `Correction`; Preview ignores it (shows the raw logical buffer). -- An LED driver consumes the *same* `const Correction*`. In the fused single-pass case (§ 4.3, identity / shuffle, no blend) it calls `correction_->apply(...)` per light and encodes the result straight into the DMA buffer — F/G/H and the WS2812 encode are one pass, never a second sweep over encoded bytes. - -So the LED driver does **not** re-invent brightness/reorder/white — it reuses the shipped `Correction`. Gamma and white-balance fold into the same LUT later as a per-channel R/G/B split (the field is `briLut`, not `gammaLut`, so that's a fill change, not a rename). See [MoonLight, drivers](../../explanation/architecture/moonlight.md#drivers) for the cross-driver picture. - -## 5. Testing architecture - -Goal: prove the driver produced the right bits at the right times, without trusting the code that produced them. The methods, ranked by what to actually do: - -### 5.1 Software unit tests of the encoding pipeline (table stakes) - -In `test/test_led_encoder.cpp`: feed a known logical `Buffer` into the encoder, assert the resulting bit pattern (GRB order, MSB first, T0H/T1H pulse widths in *symbol counts*). This catches color-order bugs, gamma bugs, off-by-one strip length, RGBW-vs-RGB confusion. Runs in CI on every push, zero hardware. - -### 5.2 On-board GPIO loopback (the cheap CI workhorse — pick this) - -The ESP32 RMT peripheral is a *transceiver*: the RX side captures pulse durations into 15-bit `(level, duration)` symbols at up to 12.5 ns resolution. Wire `GPIO_TX → GPIO_RX` with a jumper, run the driver, decode the RX symbol stream back into bytes. - -- Cost: $0 (a jumper). -- Resolution: 12.5 ns native, 100 ns is plenty for WS2812. -- Coverage: bit-level + per-edge timing margin. -- Headless / CI: runs entirely on-device, host harness reads pass/fail over USB. -- Setup: write the RX-symbol → byte decoder. ESP-IDF ships an [RMTLoopback example](https://github.com/espressif/arduino-esp32/blob/master/libraries/ESP32/examples/RMT/RMTLoopback/RMTLoopback.ino) to fork. - -On Teensy 4, FlexPWM dual-input capture gives 6.7 ns resolution; Paul Stoffregen's [WS2812Capture](https://github.com/PaulStoffregen/WS2812Capture) library is purpose-built for exactly this — it captures pulse widths via DMA and decodes bytes per byte. Use it directly. - -This is the **primary CI signal-verification method** for the project. It runs on the device under test, on every push, with zero added hardware. - -### 5.3 fx2lafw + sigrok-cli (the $12 independent cross-check) - -A $10–15 fx2lafw clone gives 24 MS/s on 8 channels, sigrok-compatible. The project's reference device is the **NanoDLA v1.3** (CY7C68013A-based, 24 MHz / 8 channels, fx2lafw firmware), but any board the sigrok `fx2lafw` driver enumerates works the same way. The libsigrokdecode tree has a WS2812 protocol decoder ([logic-ws2812](https://github.com/dustin/logic-ws2812)) that emits RGB triples; [sigrok-cli](https://sigrok.org/wiki/Sigrok-cli) runs it headless: - -```bash -sigrok-cli -d fx2lafw -c samplerate=24m --samples 100000 \ - -P ws2812:data=D0 -A ws2812=rgb -``` - -Why bother when 5.2 exists: the loopback method trusts the *MCU's own clock*. An independent analyzer with its own clock crosses the verification — if loopback and analyzer disagree, you have a real bug. 24 MS/s = 41 ns/sample, enough to *correctness*-check WS2812 (350 vs 700 ns) but marginal for *timing-margin* tests (320 vs 380 ns). - -#### 5.3.1 Wiring the NanoDLA v1.3 to an ESP32 and capturing one frame - -**Wiring** (any fx2lafw clone follows the same pattern; only the labels differ): - -- **Common ground.** Tie the analyzer's `GND` pin to an ESP32 `GND` pin. Without this the analyzer reads garbage — every other concern is downstream of this one. Use the shortest jumper you have. -- **Signal.** Tie analyzer channel `D0` (NanoDLA labels them `CH0..CH7`) to the ESP32 GPIO that drives WS2812 data. For the on-board loopback rig from §5.2 that's the `GPIO_TX` side of the loopback jumper — keep the loopback in place and tap the same line with a second jumper into `D0`. The analyzer is high-impedance; it doesn't load the line. -- **Do NOT power the ESP32 from the analyzer's 5 V / 3.3 V rail.** The CY7C68013A's regulator can't carry an ESP32 under WiFi load. Power the ESP32 over its own USB cable; the analyzer's USB is data-only as far as the ESP32 is concerned. -- **Strand power.** If a real WS2812 strand is on the line too, give it its own 5 V supply with the supply ground also tied to the common ground above. The analyzer taps the signal; it does not drive the strand. - -**Voltage levels.** ESP32 GPIO is 3.3 V CMOS. fx2lafw boards specify 3.3 V logic (5 V tolerant inputs). 3.3 V → 3.3 V is a direct connection — no level shifter, no series resistor. Skip every "is it safe?" worry; this is the well-understood case. - -**Sample-rate vs strand length.** At 24 MS/s, 8 channels (the NanoDLA's max), the host-side buffer fills at ~24 MB/s. A 30-LED strand is one full frame ≈ 0.9 ms wire-time + reset — well inside one sigrok-cli `--samples` capture window. For larger strands either bump `--samples` proportionally or trigger on the first edge so you only capture the active wire-time. - -**Capture + decode (single frame, one strand):** - -```bash -# 1. Plug in: NanoDLA via USB, ESP32 via its own USB cable, jumpers as above. -# 2. Confirm sigrok sees the device. Should print one fx2lafw entry. -sigrok-cli --scan - -# 3. Capture ~4 ms at 24 MS/s — enough for any reasonable WS2812 frame -# plus the 50–280 µs idle that latches it. -sigrok-cli -d fx2lafw -c samplerate=24m --samples 100000 \ - -P ws2812:data=D0 -A ws2812=rgb - -# 4. The output is one line per pixel, with RGB triples in transmit -# order. Compare against the byte stream the firmware claims to have -# sent: that's the cross-check the doc above describes. -``` - -**Common gotchas, in the order you'll hit them:** - -1. **No common ground** — capture looks like random toggling. Re-seat the GND jumper. -2. **Wrong channel** — `-P ws2812:data=D0` vs the channel you actually wired. Re-check the NanoDLA label; CH0 = D0 in the sigrok driver. -3. **Sample rate too low** — at 8 MS/s a 350 ns "0" pulse becomes 3 samples and the decoder will start mistaking 0s for 1s. Always pass `samplerate=24m` explicitly; the driver's default is lower. -4. **PulseView for debugging.** When `sigrok-cli` says "no frames decoded," open the same capture in [PulseView](https://sigrok.org/wiki/PulseView) and look at the raw waveform — usually it's an idle-low gap missing or a stuck bit, both obvious by eye. - -### 5.4 DSLogic Plus ($149) — the "later" upgrade - -[DSLogic Plus](https://www.dreamsourcelab.com/shop/logic-analyzer/dslogic-plus/): 16 channels @ 100 MS/s (10 ns/sample). sigrok-compatible — the CI scripts from 5.3 reuse without change. 16 channels means you can capture multiple parallel strands at once. The right purchase when a timing-margin bug appears that 24 MS/s can't see. - -### 5.5 Saleae, oscilloscope, camera, photodiode - -Skip for CI: - -- **Saleae Logic 8 / Pro 8** — gold standard, but the [Logic 2 Automation gRPC API](https://saleae.github.io/logic2-automation/) is the only feature that justifies the price over DSLogic for a CI rig, and that only at high test volume. -- **Oscilloscope** (Rigol DS1054Z and similar) — useful for development, fragile for CI (screen-scraping or vendor-specific scripting). -- **Webcam + OpenCV** — end-to-end ("a real LED lit up"), but rolling shutter, exposure noise, and refresh-vs-frame-rate beat make it low-SNR. -- **Photodiode array** — niche; interesting only when a *driver → LED* (vs driver-output) bug is suspected. - -### 5.6 Recommended layered test suite - -| Layer | Method | Cost | What it catches | When | -| ----- | ------ | ---- | --------------- | ---- | -| Unit | Encoder tests, host-side | $0 | Encoding bugs, color order, RGBW, off-by-one | Every push, CI | -| Module | Loopback (RMT-RX / FlexPWM-capture) | $0 | Wrong bits emitted, wrong timing on real silicon | Every device-test push | -| Scenario | NanoDLA v1.3 (or any fx2lafw clone) + sigrok-cli | $12 | Cross-check vs MCU clock; reset gap; bus traffic | Per release | -| Margin | DSLogic Plus + sigrok-cli | $149 | Sub-50 ns timing drift, jitter under WiFi load | When a specific failure motivates it | - -Layers 1–2 ship day one. Layer 3 ships when the first non-trivial driver lands. Layer 4 is reactive — buy it when you have a bug that 24 MS/s can't see. - -## 6. Hello-world plan - -The goal of hello-world is: end-to-end, one solid-color frame on a real WS2812 strip, with the test rig confirming it was correct, on each platform in turn. - -### 6.1 ESP32 classic — first - -1. Promote `src/platform/esp32/Esp32RmtWs2812Driver.{h,cpp}` (RMT v1 path; one channel; one strand of 100 WS2812B; pin 4). -2. Encoder: `LedFrame.bytes` → `rmt_item32_t[]` with `T0H/T0L/T1H/T1L` from `LedDriverConfig`. -3. `show()`: configure RMT TX, point at item buffer, start, wait for done, sleep ≥ 300 µs for reset. -4. Wiring in `main.cpp`: pick `Esp32RmtWs2812Driver` via `if constexpr (platform::isEsp32 && !platform::isEsp32S3 && !platform::isEsp32P4)`. -5. Pin the render task to core 1, WiFi/HTTP to core 0. -6. Verification: jumper GPIO 4 → GPIO 5, run RMT-RX decoder, assert decoded byte stream matches what was sent. Plus fx2lafw on the wire as cross-check. - -This is the first commit. Everything that follows builds on this. - -### 6.2 ESP32-S3 — second - -Add `Esp32S3LcdCamDriver` with 8 parallel chains via LCD_CAM (I80 mode). Reuse the encoder logic; the difference is the transposed bit-slice and the GDMA setup. Verification: same loopback (RMT-RX still exists on S3) plus fx2lafw on lane 0. - -### 6.3 ESP32-P4 — third - -Add `Esp32P4ParlioDriver`. Sane API surface (Espressif provides PARLIO TX driver in ESP-IDF), no WiFi to defeat. The encoded form is the same transposed bit-slice as LCD_CAM, but PARLIO is a cleaner peripheral and the GDMA story is mature. Watch for [#2178](https://github.com/FastLED/FastLED/issues/2178) — auto-detection regression in FastLED; we're not using FastLED, so we don't inherit it, but it's a marker that the IDF API has been moving. - -### 6.4 Teensy 4.1 — fourth - -Add `TeensyFlexIoDriver`. Easiest of the lot because OctoWS2811 is mature and well-documented. Either link OctoWS2811 directly or re-implement the FlexIO setup; given that the project is C++20 / CMake and OctoWS2811 is Arduino-flavoured, a thin re-implementation is probably cleaner. Verification: `WS2812Capture` library handles loopback directly. - -### 6.5 Raspberry Pi 5 — last, and probably as a bridge - -Treat the Pi 5 as a desktop platform. Two paths: - -- **Bridge** (recommended day one): connect a Pico or ESP32 over USB-serial; the Pi 5 sends frames; the MCU drives strands. Re-uses any of the drivers above. The Pi 5 implementation is just a serial protocol on top of `DesktopNullDriver`. -- **Direct** (only if needed): wrap Adafruit's PIO library behind `Rpi5PioDriver`, accept single-strand limit. - -### 6.6 Acceptance bar for each step - -Each platform reaches hello-world when: - -1. A 100-pixel solid-color test frame renders on a real strip. -2. The on-board loopback test (5.2) passes on real hardware. -3. The fx2lafw cross-check (5.3) passes — same bytes, same reset gap. -4. The fps/jitter KPI line (per [collect_kpi.py](../../../moondeck/check/collect_kpi.py)) is captured and stored. -5. (ESP32 only) the test still passes with WiFi associated and a packet flood running. - -## 7. Product-owner decisions - -These were the open questions raised by §1–§6; each is now either settled or explicitly deferred with the shape it will take when revisited. - -### 7.1 Network protocol — settled - -Live LED data over **DDP / ArtNet / E1.31 / WebSocket**. No other protocols in scope for now. - -### 7.2 Core affinity — settled, with a generalisation - -Default direction is **inverted from §3 of this doc**: the LED driver task runs on **core 1**, the effects / network task runs on **core 0**. The reasoning is the same as §3 (WiFi-stack-and-radio interrupts land on core 0), but applied to a different observation about which side suffers more from disruption: - -- If the **driver task is interrupted**, the LED signal corrupts → visible flicker. The driver must be on the quiet core. -- If the **effects task is interrupted**, the next frame is a few ms late → invisible at 30+ FPS. Effects can tolerate core 0. - -That is the inverse of what §3 of this doc proposes (which followed the WLED pattern of network-on-core-0, render-on-core-1 — same conclusion, applied to the *render* loop rather than the *driver* loop). In MoonLight the driver and the render-of-effects are separate tasks, so the choice is about which one sits on the quiet core. The driver wins because its deadline is per-bit, not per-frame. - -This is also how MoonLight (v2 → MoonLight's predecessor) is wired today: it works, the precedent matters. - -**Generalisation**: a single fixed default is ESP-classic-and-S3 sensible. P4 has no built-in WiFi so the choice is moot there. **Because MoonLight already supports per-module core affinity** (the wider plan is to expose this as a control), the driver and effects task affinities become MoonModule-level controls with the defaults above. Other devices (Teensy, Pi 5) ignore the control. - -Practical wiring rule for hello-world: in `main.cpp`, pin the LED driver task to core 1 and the effects/render task to core 0 on classic ESP32 + S3; leave it unset on P4 / Teensy / Pi 5. Make the values overridable via the existing core-affinity control. - -**Single-purpose-device assumption**: in practice a device runs *either* live LEDs (network-driven art frames) *or* GPIO LEDs (effects engine driving local strands), not both heavy duty at once. The architecture must let either side own the quiet core; for hello-world we pick GPIO-LED-driver-on-core-1 as the default because the LED-driver-task path is what carries hard-realtime requirements regardless of which side is generating the frames. - -### 7.3 Color depth at the wire — flexible-from-day-one, default 8-bit - -No single answer; the design must be flexible enough to add the other paths later. The architecture supports three modes, with **default 8-bit** for the first commit: - -| Mode | Layer buffers | BlendMap | Driver input | Wire | Notes | -| ---- | ------------- | -------- | ------------ | ---- | ----- | -| 8-bit (default) | 8-bit | 8-bit | 8-bit | 8-bit | Simplest, smallest RAM, WS2812 native. The starting point. | -| 8-bit + driver-side dither | 8-bit | 8-bit | 8-bit | 8-bit with temporal dither applied in the driver | Cheaper than full 16-bit; gradient quality between modes 1 and 3. WLED-style. | -| 16-bit pipeline (incl. dither) | 16-bit | 16-bit | 16-bit | 8-bit (driver downsamples + dithers) | Doubles RAM; best gradient quality; required for 16-bit-native LEDs (UCS7604, HD108) when those are added. | - -Per-driver configurable; each driver instance declares which mode it operates in via a bound control. The shared upstream pipeline (effects, BlendMap) reads the driver's declared input width and produces buffers accordingly — i.e. switching the driver to 16-bit mode rebuilds the Layer buffers as 16-bit, not just the driver's encoding step. - -For the first commit: 8-bit only. Mode 2 and Mode 3 are stubbed in the interface (e.g. `LedDriverBase::inputBitsPerChannel()` returning 8, with the override hook for later modes) but not implemented. This is the "stub code now, full implementation later" pattern from §7.5. - -### 7.4 Frame buffering policy / DMA buffers — auto-from-heap, with override - -Both single-buffer (write-then-show) and double/N-buffer (render-while-show) need to work; the framework decides per driver at setup based on available DMA-capable RAM. Concretely: - -- The driver declares its per-buffer DMA size at setup (computed from strip count × pixels × encoding overhead). -- At `begin()`, the driver allocates `N` buffers where `N = clamp(min_for_target_fps, max(2, available_dma_ram / per_buffer_size), platform_ceiling)`. -- The auto-computed `N` is **exposed as a read-only diagnostic field on the UI**, and **can be overridden** by a bound control (default = `auto`, otherwise = explicit integer). - -Per-platform expected ranges (the numbers you cited from MoonLight observation): - -- Classic ESP32, no PSRAM: ~7 buffers. -- ESP32-S3, no PSRAM: ~30 buffers. -- ESP32-S3 + PSRAM: ~75 buffers. - -These are the *anti-flicker* values — fewer buffers and the refill ISR can't tolerate a WiFi-induced latency spike. The auto-derive logic uses these as upper bounds; the override is the safety valve when the auto-derive is wrong for a given install. - -### 7.5 Shift-register expander — yes, with stubs now, full implementation later - -Confirmed in scope. The architecture must accommodate `pinCount × stripsPerPin` topology from day one. Concretely: - -- `LedDriverBase` exposes `maxStrips()` / `maxLightsPerStrip()` (per the bottom-up doc's API sketch). -- A `ShiftRegMultiplex` configuration object is part of `LedDriverConfig` from the first commit (defaulting to `none()`), even when no driver actually implements it yet. -- Each parallel-clocked driver (`I2sLcdLedDriver`, `Esp32S3LcdCamDriver`, `Esp32P4ParlioDriver`, `TeensyFlexIoDriver`) has stub code that *rejects* a non-`none()` multiplex config with a clear "not implemented yet" return — not a crash, not silent acceptance. -- Full implementation of the multiplex layer is its own later plan, scheduled after the first non-multiplex driver lands and passes the testing layers (§5). - -This is the pattern the bottom-up doc calls "Backend × Multiplex" — confirmed as the architectural target. - -### 7.6 Pi 5 — accept "Pi 5 + attached MCU", defer firmware-shape decision - -Confirmed: MoonLight does **not** commit to driving WS2812 directly from the Pi 5's own GPIO pins. Bridge to an MCU. - -The shape of the firmware that runs on the bridged MCU is **deferred** until Pi 5 work actually starts. Two paths are on the table, both viable, both with trade-offs worth weighing at decision time rather than now: - -#### Path A — Stripped MoonLight on the bridge MCU - -The bridge MCU runs a thin MoonLight build: no UI, no effects engine, no full network listener — just the `Drivers` + `LedDriver` pipeline plus a USB-serial input parser that pushes incoming RGB byte frames into the driver. Estimate: 50–150 KB build target. - -Pros: -- Code reuse with the main MoonLight tree. Driver fixes flow into the bridge automatically. -- A bridge MCU is a regular MoonLight build with three modules disabled — no second codebase. -- The bridge can locally expose status/heartbeat/diagnostics via the existing MoonModule control surface. - -Cons: -- Larger image. Slower to boot. More to flash. -- Couples the bridge to MoonLight's release cadence and IDF version pin — bridge upgrades become MoonLight upgrades. - -#### Path B — Dedicated minimal firmware - -Standalone firmware, no MoonLight code reuse: `read N RGB bytes from USB serial → push to a vendored LedDriver implementation → repeat`. Estimate: under 30 KB. - -Pros: -- Tiny, fast to boot, easy to flash from any laptop. -- Independent release cadence; the bridge stays stable while MoonLight iterates. -- No build-system overhead — single-file possibility. - -Cons: -- Driver code diverges from MoonLight's tree over time unless we vendor + sync deliberately. -- No diagnostic surface beyond what the protocol carries. -- Two codebases to maintain in the longer run. - -**Decision deferred** until the Pi 5 bridge becomes a real task. When it does, the deciding factor is likely: how often will the bridge's driver be touched? If rarely, Path B. If it tracks MoonLight's main-line driver work, Path A. - -For *this* analysis it's enough to note: the `LedDriver` interface designed in §4 is the same in both paths — both reuse it. The choice between A and B is a *build target* decision, not an *interface* decision. - -### 7.7 Carry-over from this doc's TL;DR - -These were not in the original "open questions" list but are decisions that need to stay visible: - -- **Hello-world platform order remains ESP32 classic → S3 → P4 → Teensy → Pi 5 bridge.** -- **CI signal-verification stack remains layered**: software unit tests, RMT-RX (or equivalent) loopback, fx2lafw cross-check, DSLogic Plus when timing-margin testing becomes necessary. - -## 8. Reconciliation with the bottom-up analysis - -Read [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) at the end, after this analysis was complete. Below are the points of strong agreement, where the two analyses diverge, and where the bottom-up document is meaningfully deeper than this one. - -### 8.1 Strong agreement - -- **Peripherals per chip.** Same list, same rankings. Classic ESP32 → RMT + I2S-LCD; S3 → LCD_CAM + GDMA; P4 → PARLIO + LCD_CAM; Teensy → FlexIO; Pi 5 → RP1 PIO. Both analyses identify the same "what to drive WS2812 from" answer per platform. -- **WiFi-glitch root cause and fix.** Both converge on the same triple: ISR latency / cache stalls / CPU starvation, mitigated by core pinning + DMA-only data path + IRAM discipline. The bottom-up doc states "the real reason I2S/LCD-CAM drivers don't flicker is that the CPU isn't in the timing-critical path at all" — exactly the same finding as this doc's WiFi section. -- **Pi 5 strategy.** Both reach the same conclusion: bridge to an MCU; PIO-via-RP1 exists but is research-grade today. The bottom-up doc adds the SPI MOSI bit-bang option ([rpi5-ws2812](https://github.com/niklasr22/rpi5-ws2812)) which works through RP1 because SPI is still exposed there. -- **Shift-register expanders as a value-add, not a separate backend.** The bottom-up doc names this explicitly as the "Multiplex axis" — a modifier on top of any parallel-clocked backend, not a sibling of LCD_CAM. This doc reached the same shape ("an expander variant is a configuration of an existing driver, not a new class") but the bottom-up doc has the cleaner vocabulary. -- **Hot path discipline.** Both arrive at the same do/don't list: no allocations, `IRAM_ATTR`, integer math, DMA-from-SRAM-not-PSRAM, no `printf` in `push()`. -- **WS2812 protocol fundamentals.** Same 800 kHz, same per-pixel time math, same ≥ 300 µs reset. - -### 8.2 Genuine divergences - -- **Build-vs-borrow recommendation.** The bottom-up doc recommends **Scenario B — build everything ourselves**, with five reasons in priority order (multiplex axis, runtime driver switching, identity-mapping fast path, IDF gives plumbing, license/binary-size tax). **This top-down doc does not make a build-vs-borrow recommendation.** It implicitly assumes we write our own drivers on top of ESP-IDF / NXP HALs, never even considering FastLED-as-dependency, because the top-down approach starts from "what is the simplest direct path from buffer to wire" and library overhead drops out. - Reconciled: the bottom-up doc's recommendation stands. This top-down doc is consistent with Scenario B — same direction, more concrete implementation detail, less analysis of the choice itself. - -- **Recommended Stage-2 spike order.** The bottom-up doc starts with **LCD_CAM on S3** (highest-value single backend). This doc proposes starting with **RMT on classic ESP32** (smallest, cheapest, most-likely-to-work first contract). Reconciled: starting with classic-ESP32-RMT is the right "hello world" — the goal there is to prove the `LedDriver` interface and the loopback test harness end-to-end on the simplest peripheral. Once that exists, the LCD_CAM-on-S3 spike from the bottom-up doc becomes the *second* commit and the contract is already known to fit. - -- **Buffering policy.** This doc originally treated double-buffering as an open question. The bottom-up doc had already done the math (§ "Performance budget") and concluded: double-buffer is required at any non-trivial light count because DMA transmission dominates the frame budget. **Now resolved** in § 7.4 — buffer count is auto-derived from heap budget with explicit override, expected ranges 7 (classic ESP32) / 30 (S3 no PSRAM) / 75 (S3 + PSRAM). Both docs land in the same place. - -- **Identity-mapping fast path.** The bottom-up doc treats this as a load-bearing architectural constraint (see `Drivers.h:90`, "drivers point at `layer_->buffer()` directly, no copy"). This top-down doc mentions zero-copy in passing but doesn't carry it as a constraint. Reconciled: it *is* a constraint. The `LedDriverBase::show(LedFrame&)` interface above already supports it — `LedFrame::bytes` is a `std::span` — but the wiring in `main.cpp` has to be explicit about handing the Layer's buffer pointer to the driver in the identity case rather than copying through `outputBuffer_`. - -### 8.3 Where the bottom-up doc goes meaningfully deeper - -- **API surface for the driver.** The bottom-up doc's `LedDriver` interface in § "Concrete DriverBase API sketch" (§ 477-549) is fully specified: `push(span)` + `onTopologyChange(strips)` + `backendName()` + `maxStrips()` / `maxLightsPerStrip()` for UI greying. This is more concrete than my `LedDriverBase::begin/show/end` and should be the starting point for the actual header. -- **Performance budget table.** The 16K × 50 FPS budget breakdown (§ 559-578) is the kind of math this top-down doc gestures at but does not actually do. It shows `push()` has 3-5 ms; DMA transmission runs 16-20 ms in the background; effects are 8-12 ms; the frame budget is 20 ms total. That table should be carried forward into the actual implementation plan. -- **Library landscape characterisation.** WLED, WLED-MM, NightDriverStrip, FastLED master vs release, NeoPixelBus, ESP-IDF `led_strip` — the bottom-up doc has read each one at a specific HEAD commit and characterised the abstractions, license, maintenance, and gotchas. Even if we walk Scenario B, that landscape map is the documentation we point at when someone asks "why didn't you just use WLED?" -- **Risks and unknowns.** The eight-item risk list (§ 601-612) names specific failure modes — RMT5 API churn, parlio newness, shift-register driver portability, LCD overclock non-portability, hot-reconfigure-during-DMA, WiFi-coexistence empirical variability, identity-mapping path subtlety, build-vs-borrow lock-in. These are real and we will hit them. This top-down doc's § 7 "Open questions" is shallower. -- **Hpwit's source read.** The bottom-up doc has actually read `I2SClocklessVirtualLedDriver.h` (3820 lines, HEAD 2026-05-25) and located the specific entanglements (30 `#ifdef CONFIG_IDF_TARGET_ESP32S3` blocks, duplicated `loadAndTranspose` under different `I2S_MAPPING_MODE` paths, multiplex math intertwined with DMA buffer layout). This is the kind of detail that only comes from reading the source, and it changes the cost estimate for the multiplex-as-reusable-layer claim. Both docs accept the same conclusion ("multiplex code needs per-backend specialisation, not free composition"), but the bottom-up doc has the receipts. - -### 8.4 Net effect on the next plan - -The two analyses are compatible — neither contradicts the other on any load-bearing decision. The right next plan reads the top-down doc for the architectural shape, the protocol math, the platform-by-platform peripheral choice, and the testing strategy; and reads the bottom-up doc for the build-vs-borrow recommendation, the concrete `LedDriver` interface, the performance budget, and the library landscape map. Hello-world is RMT-on-classic-ESP32 → LCD_CAM-on-S3 → PARLIO-on-P4 → FlexIO-on-Teensy → Pi-5-via-bridge. Both docs agree. - diff --git a/docs/work/future/livescripts-analysis-bottom-up.md b/docs/work/future/livescripts-analysis-bottom-up.md deleted file mode 100644 index 9c60bb1f5..000000000 --- a/docs/work/future/livescripts-analysis-bottom-up.md +++ /dev/null @@ -1,237 +0,0 @@ -# MoonLive — live-script engine landscape analysis - -> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** This is a Stage-1 bottom-up survey of *live scripting* for MoonLight: running user-authored scripts (LED effects, layouts, modifiers, drivers, sensor logic) on a running device without a recompile-and-flash cycle. It deep-reads one reference implementation — the [ewowi/ESPLiveScript `fix-warnings` fork](https://github.com/ewowi/ESPLiveScript/tree/fix-warnings) of [hpwit/ESPLiveScript](https://github.com/hpwit/ESPLiveScript) — at HEAD on **2026-06-25**, surveys the comparable field (WLED ARTI-FX, embedded VMs, WASM), and extracts the architectural primitives a clean MoonLight redesign must decide. Companion to the monthly digest [friend-repos/hpwit-ESPLiveScript.md](../../friend-repos/hpwit-ESPLiveScript.md) (credits + activity log). The **top-down** redesign document ([livescripts-analysis-top-down.md](livescripts-analysis-top-down.md)) expands the decisions recorded here into the build spec. Source citations use `file:line` against the cloned fork; inferred claims are marked *(inferred)*. Modelled on [leddriver-analysis-bottom-up.md](../../work/future/leddriver-analysis-bottom-up.md). - -## TL;DR - -- **What live scripting is, and why MoonLight wants it.** The same itch that produced WLED ARTI-FX and ESPLiveScript: author an effect (or layout, modifier, driver, sensor rule) *as text*, push it to a running device, see it run **on the next tick** — no toolchain, no flash, no reboot. It turns MoonLight from "the effects we compiled in" into "any effect a user can write," and it's the natural home for a web-based pattern editor. -- **The design space has three corners, set by *how* a script becomes execution.** (1) **AST-walking interpreter** — parse to a tree, walk it every frame (WLED **ARTI-FX**: everything stored as `double`, flexible, slow). (2) **Bytecode VM** — compile to a compact opcode stream, run a dispatch loop (**PixelBlaze**, most embedded Lua/JS). (3) **Native JIT** — emit real machine code, call it as a function (**ESPLiveScript**). Speed climbs (1)→(3); portability and simplicity fall (1)→(3). MoonLight's "blazingly fast like ESPLiveScript" requirement points at (3), but (3) is exactly where portability dies — see below. -- **ESPLiveScript is a from-scratch C-like compiler that JIT-emits native Xtensa machine code** (confirmed, not bytecode): `tokenizer.h` → `asm_parser.h` (AST of `NodeToken`) → visitor methods emit Xtensa assembly *strings* → `asm_parser_LMbin.h` encodes them to 32-bit opcodes → the binary is copied to executable RAM (`heap_caps_malloc(..., MALLOC_CAP_EXEC)`) and **called as a function pointer** via inline `callx8` (`execute_asm.h:386-399`). That direct-call, no-dispatch-loop design is the entire "85 fps C++ vs 10 fps Gravity vs 3 fps Lua" speed story from its README. -- **The portability finding that reshapes everything: ESPLiveScript is Xtensa-only.** The agent deep-read confirmed **no RISC-V backend** — all codegen is Tensilica Xtensa LX (`asm_parser_LMbin.h`, the inline-asm executor). This matters enormously for MoonLight's target order: classic ESP32 + S3 are Xtensa (✅ ESPLiveScript runs), but **ESP32-P4 is RISC-V** (❌), as are Teensy 4.x (ARM Cortex-M7) and desktop (x86-64 / ARM64). So ESPLiveScript as-is covers exactly the *first two* targets on MoonLight's list and **none** of the rest. A native-codegen engine needs **one backend per ISA** (Xtensa, RISC-V, ARM Thumb-2, x86-64, ARM64) — that's the real cost of "blazingly fast everywhere." -- **The front-end is portable; the back-end is not.** Tokenizer + parser + AST (`NodeToken`) are CPU-agnostic; only the *visitor → opcode* tier and the *load-and-execute* tier are ISA-bound. But today they're **deeply interleaved** — visitor methods emit Xtensa strings inline, there is **no intermediate representation (IR)** between AST and machine code. A clean redesign's load-bearing decision is whether to introduce that IR seam so one front-end feeds many back-ends (the LLVM shape, scaled down). -- **The "compatible with MoonModule" requirement is the MoonLight-specific value-add.** ESPLiveScript binds to the host via `addExternalFunction(name, ret, sig, fnptr)` / `addExternalVariable(name, type, _, ptr)` (`asm_external.h`) — a flat C-pointer registry. MoonLight needs scripts to read/write **controls**, consume the **producer/consumer data structures** (a `Buffer`, an `AudioFrame`), and slot into the **module tree** as a scripted effect/layout/modifier/driver/peripheral. That binding layer — script ⇄ MoonModule — is ours to design; no surveyed engine has it. -- **Memory + sync are already partly modelled in ESPLiveScript** and align with MoonLight's constraints: compiled code lands in IRAM/PSRAM by target (`execute.h:10-15` gates PSRAM stack on S3/P4), a **save/load compiled-binary path** exists (`savebinary`/`executebinary` examples → compile once, ship the binary, skip re-compile on boot), and a `sync()` primitive coordinates concurrent script tasks. These are the right *ideas*; the redesign carries them forward against our `platform::` seam and `Scheduler`. -- **⚠️ Superseded upstream (noted 2026-08-06): hpwit has rewritten it as [ESPLiveScript2](https://github.com/hpwit/new-parser).** A from-scratch C++ reimplementation whose stated goal is precisely the gap this analysis identified below — a compiler you can *verify*: the whole toolchain builds and runs as a host program, and its tests execute the actual compiled Xtensa bytes under QEMU against v1's own example corpus. The rewrite landed in the first days of August 2026 (the repo was dormant May 2025 → August 2026), so this document's reading of v1 stands as written but is no longer a reading of hpwit's *current* work. **Before Stage 2 acts on any v1 conclusion, re-read v2** — the portability finding in particular (is codegen still Xtensa-only, or did the rewrite introduce the IR seam we concluded was missing?). Digest: [friend-repos/hpwit-new-parser.md](../../friend-repos/hpwit-new-parser.md). -- **Code-quality reality (for the redesign).** Header-only, ~18K lines across 11 headers, **pervasive global state** (`string signature; Token __t;` and dozens of file-scope compiler counters), no IR, no unit tests, a 4,100-line `Parser` and a 5,824-line `NodeToken`. It works and it's fast, but it is **not** a base to extend in place — it's the reference to learn from and rewrite against our architecture (exactly the *Industry standards, our own code* method we used for LED drivers). -- **Recommendation: build our own native engine, Xtensa-first, behind an IR seam — start small, start beautiful, no dead-ends.** Take the ESPLiveScript *approach* (native machine-code execution, near-100% speed — the standout, never-done-before-in-this-space when bound to a module system) and add the one thing our multi-target goal needs that a single-ISA engine doesn't: put an **IR seam** between a platform-independent front-end (tokenizer→parser→AST) and the code generator. **Ship one backend first — Xtensa (classic ESP32 + S3)** — exactly where ESPLiveScript already proves native speed; that's the small, beautiful, blazingly-fast first deliverable. The IR seam is the **no-dead-end guarantee**: adding RISC-V (P4), ARM (Teensy), or x86/ARM64 (desktop) later is "write another backend behind the same IR," never "go back to the drawing board." ESPLiveScript's real dead-end isn't *Xtensa-first* — it's *Xtensa-welded-in, no IR*; we start at the same fast place but with the seam it lacks. **WASM/WAMR is the named fallback, per target**: a target without a native backend yet can run the portable path through the same IR, so we're never blocked — but the *flagship* experience is native. (Detail + why-this-over-WASM-wholesale in § Recommendation.) -- **Safety the same way — climb the tiers, don't pay upfront.** A user-facing script editor means a bad script must degrade, not brick. Start with the **cheap** safety (array **bounds-checking** = a compare-branch per indexed access, low single-digit %, and removable in a trusted/fast mode; **watchdog / instruction budget** to kill a runaway loop = near-free, the task WDT already does most of it) — these catch the common bad-script cases at low cost (the kind the `fix-warnings` null-deref was). The **expensive** tier — a true memory sandbox where a script *cannot* touch memory outside its arena — is exactly what WASM gives for free and native can't cheaply; leave it as a tier we *can* climb via the IR→WASM fallback if field experience demands it, not a wall we hit. So safety is staged, not a foregone full-sandbox cost. -- **Ruled out (with reasons, so the top-down doesn't relitigate).** **FastLED's WASM** is browser *preview* (Emscripten-compiled FastLED in a Web Worker), not on-device scripting — adjacent, not it. **MicroPython/Python** is the right *edit* ergonomics but far too slow for the hot path (the Lua/Gravity wall that birthed ESPLiveScript). **Rust/TinyGo** are near-native but **AOT-compiled-and-flashed** — they remove no edit loop. **Adopting WASM/WAMR *wholesale* as the only engine** is the considered alternative, not a dead end — it wins portability + free sandbox but tops out at ~50% native (WAMR-AOT) and carries a 200KB+ runtime; we keep it as the per-target fallback rather than the flagship, because *native speed is the differentiator we're chasing*. -- **Out of scope for Stage 1.** Final VM-vs-JIT decision; IR design; the web editor; benchmarking on real hardware; the language grammar spec. All Stage 2 (top-down). - -## Why this document exists - -MoonLight compiles its effects, layouts, modifiers, and drivers into the firmware. Adding one means writing C++, building, and flashing. **Live scripting removes that loop**: a user writes an effect as text in a browser, the device compiles/loads it, and it runs as a first-class module on the next tick — the same leap WLED took with ARTI-FX. - -The product owner's requirements (verbatim intent): - -- **General in core, specific in light.** Scripts must work for domain-neutral core jobs (e.g. read/transform sensor data) *and* the light domain: a scripted **layout** (coordinate iterator), **effect** (writes the buffer), **modifier** (remaps positions), **driver** (consumes the buffer). One engine, many module roles. -- **Target order.** ESP32 classic + S3 **first**; then P4 and other ESP32 flavours; then Teensy; then macOS / Linux / Windows. -- **Blazingly fast, like ESPLiveScript** — near-native per-pixel throughput, because a script runs in the render hot path at up to 16K+ lights × 50 FPS. -- **Smart memory management** — IRAM/PSRAM-aware, no hot-path allocation, compile-once/run-many. -- **Infinitely scalable** — run *as many* live scripts concurrently as memory allows, exploiting PSRAM (each script is an independent compiled unit; the only ceiling is free heap, not a fixed slot count). Many small scripted modules — several effects across layers, a scripted modifier, a couple of core sensor rules — coexist; the device hosts what fits and degrades gracefully when it doesn't, the same way the light pipeline already scales to available memory. -- **Sync with the rest of the system** — live reconfig (no reboot), `Scheduler`-driven, robust to add/delete/replace in any order, controls and producer/consumer data wired in. -- **Compatible with the MoonModule class** — a scripted module is a MoonModule: it has controls, a `loop()`, a role, and renders generically in the UI with zero per-script UI code. - -This document characterises the one reference that already hits "blazingly fast" (ESPLiveScript), maps it against the field, and surfaces every decision the redesign must make. It does **not** pick the design — that's the top-down doc. - -## ESP32 — primary depth: ESPLiveScript (the reference) - -Read at `ewowi/ESPLiveScript@fix-warnings`, cloned 2026-06-25. ~18,358 lines across 11 header-only files in `src/`. - -### What it is, in one sentence - -A hand-written **C-like language with a from-scratch tokenizer → parser → AST → native-Xtensa code generator**, where the compiled script is loaded into executable RAM and **called directly as a function** — a JIT compiler, not an interpreter. Yves Bazin (hpwit) built it because Lua (3 fps) and Gravity (10 fps) couldn't drive his 12,288-LED panel where hand-written C++ hit 85 fps (README intro). - -### The pipeline (the load-bearing structure) - -Source text flows through five stages; the data structure between them is `Token` → `NodeToken` (the AST node) → assembly text → binary: - -1. **Tokenize** — `src/tokenizer.h` (2,394 lines). Lexes source into `Token`s with a `tokenType` enum. Also owns the user-defined-type registry (`_userDefinedTypes`, global). -2. **Parse → AST** — `src/asm_parser.h` (1,929 lines) builds a tree of `NodeToken` (`src/NodeToken.h`, 5,824 lines). `NodeToken`'s `nodeType` enum has ~47 kinds (`binOpNode`, `defFunctionNode`, `forNode`, `ifNode`, `callFunctionNode`, `returnNode`, `defAsmFunctionNode`, …). A for-loop becomes a `forNode` with init/cond/incr/body children. -3. **Generate code (visitor)** — `NodeToken::visitNode()` (`NodeToken.h:818-1001`) dispatches to `_visitbinOpNode()`, `_visitcallFunctionNode()`, etc., each of which **emits Xtensa assembly strings** into output buffers (e.g. `NodeToken.h:1858` emits `movi a%d,%d`). There is **no IR** — visitors know Xtensa directly. -4. **Encode to binary** — `src/asm_parser_LMbin.h` (592 lines) turns assembly text into 32-bit Xtensa opcodes (`bin_add`, `bin_l32i`, `bin_movi`; e.g. `bin_add` at `:97-100` emits `0x800000 | …`). -5. **Load + execute** — `src/execute_asm.h` (876 lines). `_createExcutablefromBinary()` (`:224-275`) copies the binary into executable RAM with `heap_caps_malloc(size, MALLOC_CAP_EXEC)` (`:232`), patches external references (relocation, `:44-223`), and `executeBinaryAsm()` (`:386-399`) runs it via inline asm: `l32i a15,%0,0 ; callx8 a15` — **a direct indirect call to the generated code.** No dispatch loop. That is the speed. - -### Why it's blazingly fast (confirmed) - -It is **native machine code called as a function** — the CPU fetches the script's own instructions from IRAM, exactly like a compiled C function. There is no per-opcode interpreter overhead (the cost a bytecode VM or AST-walker pays every operation). The README's benchmark (85 fps C++ ≈ ESPLiveScript ≫ 10 fps Gravity ≫ 3 fps Lua) is the direct consequence. Scripts can even drop to **inline Xtensa** for the hottest paths (`__ASM__ uint32_t millis() { "entry a1,32" … "retw.n" }`, `sc_examples/animwle.sc`). - -### What ties it to ESP32 — the portability barrier - -The agent's deep-read is unambiguous: **Xtensa-only, ESP-IDF-coupled.** - -- **Codegen is 100% Xtensa LX** (`asm_parser_LMbin.h`, all visitor emission). **No RISC-V, no ARM, no x86 backend exists.** Only ~1 arch `#ifdef` in the codegen — and it's a *memory-caps* choice (S3/P4 PSRAM stack vs classic internal, `execute.h:10-15`), not a second ISA. (Note the irony: that one `#ifdef` *mentions* ESP32-P4, but only for stack allocation — P4 is RISC-V, so the **generated code wouldn't run on it.** *(inferred from "Xtensa-only codegen" + P4 being RISC-V)*.) -- **Execution is ESP-IDF-specific**: `MALLOC_CAP_EXEC` IRAM allocation, inline `callx8`, `rsr a14,234` cycle-counter reads, `xTaskCreatePinnedToCoreWithCaps` (`execute.h:590`). -- **Front-end is portable, back-end is not**: tokenizer + parser + AST are CPU-agnostic; tiers 2-4 (visit→opcode, encode, load-execute) are ISA/platform-bound and **interleaved** with no seam between them. - -So on MoonLight's target list, ESPLiveScript as-is runs on **classic ESP32 + S3** and stops there. P4 (RISC-V), Teensy (ARM), and desktop (x86-64/ARM64) each need a *new code generator* — 2-3K lines per ISA *(inferred, agent estimate)* — or a different execution strategy entirely. - -### Host integration — the binding model - -A host C program drives it through `class Parser` (`ESPLiveScript.h:79`): - -- **Compile + run**: `parseScript(&str)` → `Executable`; `Executable::execute("fn", args)` runs a function; `executeAsTask("fn", core, args)` runs it pinned to a FreeRTOS core; `suspend()/restart()/kill()/free()` manage its lifetime (`execute.h:352+`). -- **Expose a C function to scripts**: `addExternalFunction("calc","float","int",(void*)calcul)` → script calls `float h = calc(52);` (`asm_external.h`, README example). -- **Expose a C variable**: `addExternalVariable("value","int","",(void*)&v)` and `("array","int *","",(void*)arr)` → script does `value = value + 2; array[i] = 10;`. -- **Arguments**: `int` and `float` only (`ESPLivescriptRuntime.h:150-176`). -- **JSON path** (`__JSON__OPTION__`): scripts exchange JSON with the host (`execute_asm.h:400-471`) — the `enjoy json` feature from the digest; the bridge a web editor would lean on. -- **Precompiled binaries**: `parseScriptBinary()` → `saveBinary()/loadBinary()` → `createExecutableFromBinary()` (`execute_asm.h:276-384`). Compile once (on a desktop or web service), persist the `ESPLiveScript1.0.1`-format binary, load it on the device — but **external pointers are not serialized**, they re-bind at load (README). This is the seed of a smart compile-once memory strategy. - -This binding is a **flat C-pointer registry** — exactly what MoonLight must *replace* with a MoonModule-aware layer (controls, producer/consumer structs, the module tree). - -### Memory model - -- Generated code → executable RAM via `MALLOC_CAP_EXEC` (IRAM on classic; PSRAM-capable on S3/P4 via `__LS_STACK_CAPS`, `execute.h:10-15`). -- Script globals → a malloc'd `data` buffer; locals → the Xtensa ABI stack frame (`entry a1, size`); params → registers a2-a7. -- Precompiled-binary persistence (above) = compile-once. -- `Executable::free()` releases both code and data. -- No PSRAM is *forced* for the data section; that's a knob the redesign would make policy. - -### The language (what a user writes) - -C-like, LED-oriented. From `sc_examples/*.sc` + README: - -- **Types**: `int`/`s_int` (32/16-bit), `uint8_t..uint32_t`, `float`, `char`, `bool`, plus **`CRGB`/`CRGBW`** (LED color) as first-class; user `struct`s with fields, methods, constructors; multi-dimensional arrays (`int g[z][y][x]`). -- **Control flow**: `if/else`, ternary, `while`, C-style `for`, `break`/`continue`, `return`; recursion (`sc_examples/fibonacci.sc`). -- **Built-ins**: `printf`/`printfln` (int only), `millis()`, `rand`/`copy`/`memset`/`fill` (inline-asm in `functionlib.h`), `hsv()` and FastLED math when `USE_FASTLED` is set. -- **Escape hatch**: `__ASM__ … @` blocks for hand-written Xtensa. -- **Preprocessor**: `#define TOKEN value` (substitution only). -- **Flavour example** — `sc_examples/animwle.sc` is a **Mandelbrot effect ported from an existing pattern** (`#define width 128`, float `cR/cI`, nested grid loops, inline-asm `millis()`), which tells you the target audience: people porting effects from other LED-scripting environments. - -### What the `fix-warnings` fork changed - -Despite the branch name, the fork's substantive change is **one commit (`4871509`, 2026-04-02): a null-pointer crash fix**, not a `-Wall` cleanup. `findMaxArgumentSize()` in `NodeToken.h` dereferenced `getChildAtPos(1)`/`(2)` unconditionally; a **nested external-function argument** (`setRGB(random16(NUM_LEDS), CRGB(0,0,255))`) produces a node without those children → `LoadProhibited` crash on device. The fix adds null guards (return 0 — a scalar needs no pre-call stack spill). *Relevance to MoonLight*: this is precisely the class of bug a from-scratch hand-written parser breeds (no test harness caught it), and a data point for "rewrite with tests" over "extend in place." - -### Structural observations (what a multi-target rewrite must account for — not a verdict on the original) - -These are the differences between ESPLiveScript's design (one author, one ISA, maximum speed) and what MoonLight's *different* goals (multi-target, module-bound, tested) need. They are reasons to write our own against our architecture, not faults — ESPLiveScript meets its own goals well. - -- **Header-only**, ~18K lines, 11 files; the two biggest (`NodeToken.h` 5,824, `ESPLiveScript.h` 4,100) carry several jobs each (tree + metadata + 47 visitors + asm emission). Fine for a single-include library; we'd split for testability. -- **File-scope state**: `string signature; Token __t;` (`ESPLiveScript.h:29-30`), plus global register-allocation stacks, output buffers, compiler counters. A consequence: one compilation at a time. Acceptable on a device that compiles one script; we'd encapsulate it. -- **No IR** between AST and Xtensa — the one structural thing that makes multi-target hard, and the single highest-leverage change our redesign makes. (ESPLiveScript didn't need it — it targets one ISA.) -- **`.ino` integration examples, no unit suite** — natural for an Arduino library; we'd add unit + scenario tests because robustness is pinned by tests here. - -The lesson is the LED-driver lesson: **study it hard, credit it (see § Prior art & credits), write our own against our architecture and goals.** - -## The comparable field (what else to learn from) - -MoonLight's "industry standards" rule says: name the prior art, take the textbook approach. The live-scripting field has three established design points; ESPLiveScript is the extreme of one. - -### WLED ARTI-FX — the AST-walking interpreter (our sibling project) - -[ARTI-FX](https://mm.kno.wled.ge/moonmodules/arti-fx/) (MoonModules, by ewowi — the author of this analysis — built on the **ARTI** runtime, a PEG-grammar-driven interpreter) parses a script and **walks the AST every frame**. Every value is stored as a **`double`**, converted to int when needed; scripts define `renderFrame` (per-frame) + `renderLed` (per-LED) callbacks and call `setPixelColor`/`setRange`/`fill`. Host binding is `arti_external_function` / `arti_set/get_external_variable` (the same flat-registry shape as ESPLiveScript). **What it contributes:** it proved live scripting works end-to-end in this ecosystem, and its design is maximally flexible and portable — pure C++ tree-walking, runs anywhere unchanged. The `double`-everything per-LED walk trades per-frame speed for that flexibility, which is the trade MoonLight's 16K hot path can't take (and exactly the gap ESPLiveScript's native path closes). So the two are complementary baselines: ARTI-FX is the **reach** baseline (runs everywhere, the product shape proven), ESPLiveScript is the **speed** baseline (native, Xtensa) — and MoonLight wants both, which is why neither alone is the answer. - -### WASM on ESP32 — the strongest off-the-shelf portable-runtime candidate (answering "is WASM what we want?") - -WebAssembly is a portable **bytecode standard** with mature small runtimes that **already run on the ESP32**, which makes it the most serious "don't build the engine, adopt one" option for the multi-target problem ESPLiveScript can't solve. Two runtimes matter: - -- **wasm3** — an ultra-light **interpreter** in C (~64 KB code, ~10 KB RAM), runs Arduino-class MCUs upward. Pure interpretation, so *slower* (the per-opcode dispatch cost, same class as a bytecode VM) — fine for control logic, questionable for a 16K-pixel inner loop. -- **WAMR (WebAssembly Micro Runtime)** — supports interpreter **and AOT/JIT**; in **AOT mode WAMR reaches ~50% of native speed**, "quite acceptable" for embedded use, at a larger footprint. Rule of thumb from the field: **RAM < 256 KB → wasm3, > 256 KB → WAMR**; the classic ESP32's 320 KB+ puts it in WAMR's range. ([arXiv survey](https://arxiv.org/html/2512.00035v1), [WAMR-ESP32](https://registry.platformio.org/libraries/mlaass/WAMR-ESP32)) - -**Why this is genuinely relevant to MoonLight**, and arguably *the* answer to "runs everywhere": - -- **One artifact, every target.** A script compiled to `.wasm` runs on classic/S3/P4/Teensy/desktop through the same runtime — no per-ISA backend, which is exactly ESPLiveScript's missing piece. WASM **is** the portable IR + VM, off the shelf. -- **WAMR-AOT is the "blazingly fast" bridge.** AOT-compiling the `.wasm` to native on the device (or on a host) gets ~50% of native — between a naive bytecode VM and ESPLiveScript's near-100%. That's the same "portable baseline + native acceleration" shape the bottom-up proposes, but **already built and multi-ISA** (WAMR's AOT backends cover Xtensa, RISC-V, ARM, x86). -- **Sandboxed by design.** WASM is memory-safe and bounds-checked — a runaway/bad script traps instead of bricking the device (the safety story ESPLiveScript's native code lacks; recall the `fix-warnings` null-deref). -- **Mature toolchain + editor path.** Any language that targets WASM (C/C++, Rust, AssemblyScript ≈ TypeScript) becomes a script source; the browser already runs WASM natively, so a web editor could compile *and preview* the exact artifact the device runs. - -**The costs / open questions** (the top-down must weigh): the runtime is **heavier** than a hand-rolled VM (WAMR is 200 KB+; wasm3 is light but interpreter-only); the **host-binding** for WASM (imports/exports, linear-memory marshalling of a `Buffer`/`AudioFrame`) is more ceremony than ESPLiveScript's flat pointer registry and must be designed against the MoonModule data model; the **toolchain** (a WASM compiler in the editor path) is a real dependency; and **whether WAMR-AOT actually holds 16K×50FPS** on an S3 is the load-bearing benchmark. But as a way to get *every target on day one with sandboxing for free*, WASM is the candidate to beat — the top-down should evaluate "WAMR as the engine" head-to-head against "our own VM+IR." - -### FastLED's WASM — adjacent but **not** what we want - -FastLED's `master` WASM support (`src/platforms/wasm/`) compiles **FastLED itself to WebAssembly via Emscripten to run in a *browser*** — a **simulation/preview** of effects, not a runtime that runs user scripts *on the ESP32*. It runs FastLED in a Web Worker (`PROXY_TO_PTHREAD`), bridges C++↔JS via `EMSCRIPTEN_KEEPALIVE` exports (`js_bindings.cpp`), and exports frame/strip/UI data as JSON for the page to draw. FastLED's `FxEngine` is likewise a *compiled* effect manager (switch/transition between C++ effects), not an on-device scripting language. So FastLED gives MoonLight **two adjacent ideas, neither the live-script engine**: (1) "compile your effect library to WASM to **preview it in the browser**" — a preview technique that sits next to MoonLight's own 3D WebGL preview, not the scripting engine; (2) precedent that the WASM toolchain is production-ready. It does **not** answer the on-device live-scripting need. ([FastLED wasm platform](https://github.com/FastLED/FastLED/tree/master/src/platforms/wasm)) - -### Compiled languages (Rust / TinyGo) and interpreted Python — why neither is the answer (answering the Rust/Python question) - -- **Python / MicroPython is too slow for the hot path** — the benchmark literature is consistent: MicroPython is *"many times slower"* than C/Rust/TinyGo on ESP32 ([MDPI study](https://www.mdpi.com/2079-9292/12/1/143)). It's the same wall hpwit hit (Lua 3 fps, Gravity 10 fps) that *caused* ESPLiveScript. So "Python is an interpreter, so live-editing is easy" is true for the *edit loop* but fails the *speed* requirement at 16K×50FPS. Its real value is the **REPL/editor UX** reference, not the engine. (For non-hot-path *core* scripts — a slow sensor rule at 1 Hz — an interpreter's speed is irrelevant and Python-class ergonomics would be fine; this argues again for a **tiered** answer: cheap interpreter acceptable off the hot path, fast path needs VM/native.) -- **Rust / TinyGo are fast but *compiled*, not interpreted** — Rust-on-ESP32 (`esp-hal`, `no_std`) and TinyGo land near C speed, but they are **AOT-compiled and flashed** — they need a toolchain and a reflash, which is *exactly the loop live scripting exists to remove*. They give no live-edit story on their own. (Rust *does* become relevant via WASM: Rust → `.wasm` → WAMR is a legitimate script-authoring path, but then the engine is WASM, not Rust-on-device.) -- **Net:** interpreted-Python solves edit-speed but not run-speed; compiled-Rust solves run-speed but not edit-speed. The only options that give **both** live edit *and* hot-path speed are (a) a custom VM/native engine (ESPLiveScript's path, our redesign) or (b) **WASM+WAMR-AOT** (portable, sandboxed, ~50% native). Those two are the real finalists. - -### Other embedded VMs / JIT libraries (textbook back-ends) - -- **Lua / eLua / Luau** — the canonical embeddable scripting VM (register-based bytecode); the reference for a clean host C API and a GC'd value model. Too slow per-pixel raw (hpwit measured 3 fps), but the *architecture* (compile → bytecode → register VM) is the textbook. -- **Espruino** — JS on MCUs; full-language, GC-paused; an editor/REPL UX reference, the "too much" end for a per-pixel hot path. -- **LLVM / MIR / GNU lightning** — real JIT libraries with multi-ISA back-ends. LLVM is far too big for an MCU; **MIR** and **GNU lightning** are lightweight JITs that *do* target multiple ISAs and are the closest prior art to "one front-end, many native back-ends" if we go the custom-native-multi-target route (the alternative to adopting WASM). - -### The design-space map - -| Approach | Example | Speed (per-pixel) | Portability | Runtime size | Sandbox | Editor-friendliness | -|---|---|---|---|---|---|---| -| AST-walk interpreter | WLED **ARTI-FX** | Low (double-everything) | **Highest** (pure C++) | Small | Easy | High | -| Bytecode VM | Lua, AssemblyScript | Medium-High | **High** (one VM, any CPU) | Small-Medium | Easy | High | -| **WASM interpreter** | **wasm3** | Medium | **Highest** (standard) | ~64 KB | **Built-in** | High (any→wasm) | -| **WASM AOT/JIT** | **WAMR** | **~50% native** | **Highest** (standard, multi-ISA) | 200 KB+ | **Built-in** | High (any→wasm) | -| Native JIT (custom) | **ESPLiveScript** | **Highest** (native) | **Lowest** (one backend/ISA) | Medium | None (can crash) | Medium | - -MoonLight wants ESPLiveScript's **speed** *and* ARTI-FX's **reach** — no single *custom* corner gives both, which is why one redesign path is a **layered custom** engine (portable VM baseline + optional native back-end behind a shared IR). But **WASM+WAMR-AOT collapses that table into one row**: portable to every target *and* ~50% native *and* sandboxed, off the shelf. The two real finalists for the top-down are therefore **(A) build our own VM+IR (+ optional native backend)** vs **(B) adopt WASM/WAMR as the engine** — weighed on hot-path speed (does WAMR-AOT hold 16K×50FPS?), runtime footprint, and how cleanly each binds to the MoonModule data model. - -## Architectural primitives observed (the decisions the redesign must make) - -Distilled across all four references, these are the load-bearing choices a clean engine faces — the *questions* the survey raises. Each is **decided and designed in the top-down**; listed here so the survey names what's at stake. - -1. **Execution strategy** — AST-walk vs bytecode-VM vs native-JIT (or a tier ladder). *The* decision; everything follows. ([top-down §2](livescripts-analysis-top-down.md#2-why-native-and-why-our-own-expanding-decision-1)) -2. **The IR seam** — ESPLiveScript emits Xtensa directly (right for one ISA); a multi-target redesign adds a representation between AST and execution so one front-end feeds many backends. The highest-leverage structural change. ([§3.2](livescripts-analysis-top-down.md#32-the-three-tiers-where-the-ir-seam-lives)) -3. **Host-binding model** — all four references use a flat name→pointer registry (`addExternalFunction`/`arti_*`); MoonLight's value-add is a MoonModule binding (controls, producer/consumer structs, module role) with no prior art to copy. (§3.4) -4. **Per-frame contract** — script writes its own `loop()` vs an engine-called `renderLed()`; determines the hot-loop shape and where per-pixel dispatch lands. (§3.4) -5. **Compile-once / persist** — a saved artifact skips device-side recompile; portable (one artifact) with a VM/WASM, per-ISA with native. (§3.7) -6. **Memory placement** — code IRAM/PSRAM, data internal/PSRAM, per-target; routed through `platform::` as one policy. (§3.7) -7. **Concurrency + sync** — in-tick vs a pinned task; the threading contract against the scheduler + no-blocking-hot-path rule. (§3.8) -8. **Live reconfig + robustness** — a re-pushed script swaps in tick-atomically, old freed, no reboot, no mid-render crash. (§3.6) -9. **Safety / sandboxing** — native can crash, a VM can bound; a user-facing editor raises the stakes. Coupled to the execution-strategy choice. (§4) - -## Mapping to MoonLight's requirements - -| Requirement | ESPLiveScript today | What the redesign must add | -|---|---|---| -| Blazingly fast | ✅ native Xtensa | Keep native speed *where the ISA has a backend*; VM elsewhere | -| Core (sensor data) + light (layout/effect/modifier/driver) | ⚠️ generic funcs only; no module roles | The MoonModule binding + per-role entry-point contracts | -| ESP32 classic + S3 first | ✅ (both Xtensa) | Carry forward (native backend) | -| P4 + other ESP32 | ❌ P4 is RISC-V | RISC-V backend *or* VM fallback | -| Teensy | ❌ ARM | ARM backend *or* VM | -| macOS/Linux/Windows | ❌ x86/ARM + no IRAM | Desktop backend *or* VM (VM is the obvious win here) | -| Smart memory | ⚠️ one `#ifdef`, IRAM/PSRAM | Route through `platform::alloc`; compile-once artifact | -| Infinitely scalable (N scripts) | ❌ examples run one script | Independent `MoonLive` per module; code+data arenas PSRAM-first; ceiling = free heap, not a fixed count | -| Sync with system | ⚠️ FreeRTOS tasks + `sync()` | `Scheduler` tick contract; tick-atomic hot-swap; live reconfig | -| MoonModule-compatible | ❌ flat C registry | Scripted module = MoonModule (controls, loop, role, generic UI) | - -The pattern is clear: **ESPLiveScript nails speed on two chips and nothing else on this list.** Every other requirement is new work, and the multi-target + MoonModule-binding pieces are the bulk of it. - -## Recommendation — native engine, Xtensa-first, behind an IR seam - -The survey lands on a clear direction (mirroring the LED-driver bottom-up's "walk Scenario B" call, not an open fork): **build our own native-codegen engine, ship the Xtensa backend first, put an IR seam between the front-end and the code generator from day one — start small, start beautiful, extend with no dead-ends.** Native speed (near-100%) is the differentiator; the IR seam is the no-dead-end guarantee that makes "Xtensa-first" safe (RISC-V/ARM/desktop are each a new backend behind the unchanged IR); WASM/WAMR is the per-target fallback, never the rival; safety is staged (cheap bounds+watchdog first, true sandbox deferred to the WASM fallback). - -The full reasoning — why native over WASM-wholesale, why Xtensa-first isn't a corner, the cost accepted, the load-bearing spike — **is expanded in the top-down** ([§2 Why native](livescripts-analysis-top-down.md#2-why-native-and-why-our-own-expanding-decision-1), §9 Staged plan), and the decisions it rests on are recorded verbatim in § Answers — product-owner direction below. This section is the survey's conclusion; the build spec is the top-down. - -## Prior art & credits - -Per *Industry standards, our own code*: study the prior art, credit it by name, write our own. This redesign rests on work others did first. - -- **ESPLiveScript — Yves Bazin (hpwit).** The native-codegen approach the recommendation builds on is his. A from-scratch C-like compiler — tokenizer, parser, register allocator, Xtensa code generator, save/load compiled-binary path, header-only — that runs a script at near hand-written-C++ speed on an ESP32 (85 fps on a 12,288-LED panel where Lua managed 3 and Gravity 10). That is the result that makes "go native, not interpreted" the right call, and the reason this document exists. The structural notes below (global state, no IR, large files) are what a *rewrite toward a different goal* — multi-target, module-bound — has to account for; they are not a verdict on the original, which does its job and does it fast. We carry the idea forward and add the IR seam + the MoonModule binding. -- **ARTI-FX / ARTI — ewowi.** The prior MoonLight-family answer to the same problem, written by this analysis's author: the interpreted-effects runtime in WLED MoonModules, on the PEG-grammar ARTI interpreter. It proved the live-scripting idea works end-to-end in this ecosystem (load a script, run it live), and it is the source of lessons carried straight into this design — the `renderFrame`/`renderLed` split, the host-binding shape, and, by being the AST-walking design, the clearest demonstration of *why* a 16K hot path wants native or VM execution over tree-walking. The redesign trades its interpreter for native speed; it keeps its product shape and its lessons. -- **MoonLight, the predecessor** (the [effects tutorial](https://ewowi.github.io/MoonLight/moonlight/effects-tutorial/), the staging spine and the host-binding surface model). See the per-engine sections above for what each contributes. - -Activity + credits also in the digest [friend-repos/hpwit-ESPLiveScript.md](../../friend-repos/hpwit-ESPLiveScript.md). - -## Risks and unknowns - -The open questions the survey surfaced are **resolved in the top-down**, each in its own section: the load-bearing speed unknown — can native-with-safety hold 16K×50FPS, else fall back to WASM — is the first spike ([top-down §9.2](livescripts-analysis-top-down.md#92-the-ladder)); IR design ([§3.2](livescripts-analysis-top-down.md#32-the-three-tiers-where-the-ir-seam-lives)); the per-pixel-vs-per-frame contract (§3.4); the MoonModule-binding mechanics ([§3.3–3.4](livescripts-analysis-top-down.md#33-a-scripted-module-is-a-moonmodule-decision-7)); safety depth (§4); editor + persistence ([§9.2](livescripts-analysis-top-down.md#92-the-ladder), stage 10). The single load-bearing one to flag here: **whether native codegen holds the frame budget on a real S3** — if it doesn't, the IR seam makes the WASM fallback a backend swap, not a restart. - -## Answers — product-owner direction (2026-06-25) - -Decisions from the design discussion that produced this survey. These are *direction*, terse on purpose; the top-down expands each into full reasoning, an API/architecture, and a staged plan. (Mirrors the LED-driver bottom-up's product-owner-direction section.) - -1. **Execution = native, the standout.** Build our own **native-codegen** engine (ESPLiveScript-class speed, near-100%) — the differentiator; MoonLight should stand out with something not done before (a native live-compiler bound to a real module system). *Not* a slow interpreter, *not* WASM-as-flagship. -2. **No dead-ends, start small + beautiful (the LED-driver method).** Ship **one ISA backend first — Xtensa (classic ESP32 + S3)** — as a complete, blazing first increment, then grow. The **IR seam** (front-end → typed IR → per-ISA backend) is the no-dead-end guarantee: RISC-V (P4), ARM (Teensy), x86/ARM64 (desktop) each become a *new backend behind the unchanged IR*, never a rewrite. WASM/WAMR is the **per-target fallback** so no target is ever blocked. -3. **The IR must NOT cost speed (hard constraint).** It is a *compile-time* representation that lowers to the *same* native instructions ESPLiveScript hand-emits — **zero per-pixel runtime overhead**, no interpreted layer. Equally fast as hpwit on Xtensa is non-negotiable; prove it by diffing generated instructions for a hot loop against hand-written Xtensa, and keep an `__asm__` escape hatch for the very hottest paths (as ESPLiveScript has). -4. **Source language = a C-subset, "as close as possible" to the precompiled effect, with pragmatic simplifications.** The effect *body* (types like `uint8_t`/`uint32_t`/`lengthType`, nested `for`, integer + 64-bit math, `static_cast`, `RGB`, `hsvToRgb`, buffer writes) ports **near-verbatim** from a file like `RipplesEffect.h` (our reference effect — it exercises the hard cases: `float` trig `std::sqrt`/`std::sin`/`std::floor`, `std::memset`, 3D with `depth()`, two controls). The C++ *file/class ceremony* that buys nothing in a script (`#pragma`/`#include`/`namespace`, and — accepted as a pragmatic simplification — `class : public EffectBase`/`override`/the `controls_.addControl` host-object dance) is **dropped or lightened**: the engine supplies the module scaffolding around the script. Target: porting an existing effect is the loop body verbatim + a handful of lines changed, *not* a rewrite, and *not* implementing a full C++ object model (inheritance/vtables/host-method binding) in the engine. **Not** a JS-subset (the ARTI-FX surface): JS's double-everything number model is the slow path *and* further from our C++ codebase, so it's worse on both speed and portability of existing effects. -5. **Controls = minimal ceremony.** A scripted control is a near-plain top-level variable (e.g. `uint8_t speed = 60;` with a range annotation); the engine derives the MoonModule control + UI + persistence. Lighter than today's explicit `controls_.addControl(...)`, copy-paste-friendly. (Exact annotation syntax is the top-down's call.) -6. **Safety = staged, climb the tiers, don't pay upfront.** Ship the **cheap** tier first — array **bounds-checking** (a compare-branch per indexed access, low single-digit %, removable in a trusted/fast mode) + **watchdog / instruction budget** (kill a runaway loop, near-free). The **expensive** true-memory-sandbox tier (a script physically can't touch memory outside its arena — what WASM gives free, native can't cheaply) is **deferred**, reachable via the IR→WASM fallback only if a public script editor in the field shows the cheap tier isn't enough. Decided this way because the price of full sandboxing upfront isn't worth paying before evidence demands it. -7. **MoonModule-first.** A scripted module **is** a MoonModule (role, controls, `loop()`, generic UI, lifecycle, robustness, live-reconfig). The script ⇄ MoonModule binding (reach the `Buffer`/`AudioFrame`/LUT via the producer/consumer pull pattern, no copy) is the MoonLight value-add to design — no prior art copies cleanly. -8. **General in core + specific in light.** One engine serves a domain-neutral core script (e.g. transform sensor data) *and* a scripted layout / effect / modifier / driver. **Effect is the first role.** `RipplesEffect.h` is the *reference* effect for the language design (it stresses float trig + 3D + memset), but it is **too complex for the hello-world spike**: the first running script must be trivial (e.g. fill the buffer one color, or a single moving dot), proving the engine end-to-end before any real effect. Ripples is the *graduation* target, not the spike. For how an effect is structured for a newcomer, the [MoonLight effects tutorial](https://ewowi.github.io/MoonLight/moonlight/effects-tutorial/) is a good read (a sibling project's step-by-step). The simple→Ripples progression is itself the start-small-grow staging applied to the demo. -9. **Infinitely scalable.** Run *as many* live scripts concurrently as memory allows, exploiting PSRAM — each script is an independent compiled unit, the ceiling is free heap, not a fixed slot count. Many small scripted modules coexist; the device hosts what fits and degrades gracefully when it doesn't (the same scaling-to-available-memory contract the light pipeline already honours). -10. **Inline execution by default; task is the exception.** A scripted effect/layout/modifier/driver runs *inline in the `Scheduler` tick*, called exactly like a compiled effect's `loop()` — one mental model, no cross-thread sync to reach the buffer/`AudioFrame`, and it runs on the render task's *internal-RAM* stack (fast). Task-per-script isn't blocked on memory (a task stack can live in PSRAM), but it pays two costs inline doesn't: per-task **scheduling overhead** (a context switch per task per frame — hundreds of tasks thrash the scheduler, a ceiling independent of memory), and a **PSRAM-backed stack is hot-path-slow** (PSRAM latency on every per-pixel local access, ~12 vs ~80 MB/s). So inline keeps the per-script stack fast and free, and PSRAM is spent on script *code + data* (decision 9) rather than per-script stacks. A pinned task is the narrow, documented opt-in *only* for a long/blocking *core* script (e.g. slow sensor I/O) that must not share the render tick — never the default, never for a pipeline script. -11. **Sequencing: hybrid (depth-first to hello-world, then prove the seam on a 2nd ISA early).** Build the full vertical slice on Xtensa just far enough to run hello-world native (classic/S3), then *immediately* prove a minimal second-ISA backend (P4/RISC-V, or desktop x86-64) on that same slice — before deepening to controls/math/2D/3D. This retires the project's biggest risk (does the IR seam genuinely decouple front-end from backend?) at hello-world cost, when fixing it is cheap, rather than discovering a leak after six stages. Then deepen, primarily on Xtensa; the full second backend follows later. -12. **Domain-neutral engine core, thin binding.** The engine (front-end + IR + backends) is domain-neutral core — it never depends on MoonLight; the binding (`MoonLiveHost`/`MoonLiveEffect`) depends on the engine, one-directionally, through the engine's public API + a tiny injectable platform seam (`allocExec`/`alloc`). This clean layering is adopted *because it is what makes MoonLight-as-a-library optimal* (the [*Domain-neutral core*](../../CLAUDE.md) principle applied), and is **never compromised** for any separability — if a separation would cost MoonLight's speed/simplicity/hot-path/principles, it isn't done. -## Out of scope for Stage 1 - -Final VM-vs-JIT decision; the IR design; the language grammar spec; the web editor; per-engine benchmarking on real hardware; the MoonModule-binding mechanics; the sandboxing depth. All belong to the top-down document the prompt above generates. diff --git a/docs/work/future/livescripts-analysis-top-down.md b/docs/work/future/livescripts-analysis-top-down.md deleted file mode 100644 index dd94ea511..000000000 --- a/docs/work/future/livescripts-analysis-top-down.md +++ /dev/null @@ -1,445 +0,0 @@ -# MoonLive — live-script engine, top-down redesign - -> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** **MoonLive** is MoonLight's live-script engine (the Moon family: MoonLight, MoonDeck, MoonLive — author an effect as text, see it live). Stage-2 companion to [livescripts-analysis-bottom-up.md](livescripts-analysis-bottom-up.md) (read first: it deep-reads the ESPLiveScript fork, surveys WLED ARTI-FX, the embedded-VM field, and a portable WASM fallback, and ends with the product-owner-direction decisions this document expands). It reasons from MoonLight's end goal — *author a script as text, run it on a running device on the next tick* — down to a reference architecture, a concrete API, a performance budget, and a staged spike plan. Modelled on [leddriver-analysis-top-down.md](../../work/future/leddriver-analysis-top-down.md). This expands the eight decisions already made; it does not re-open them. All design is written fresh against MoonLight's architecture — prior art (ESPLiveScript, ARTI-FX, MoonLight) is credited, not traced. - -## TL;DR - -- **MoonLive is our native-codegen engine** — a real compiler (lex → parse → AST → **IR** → native machine code), executed by direct function-pointer call, so a scripted effect runs at **near-100% native speed** in the render hot path. That speed, bound to a real module system, is MoonLight's standout. -- **One narrow boundary, three tiers.** The seam is `MoonLive::run()` (the analog of `LedDriver::push()`). Above it: a **platform-independent front-end** (tokenizer → parser → typed AST). Below it: a **typed IR** (the seam that lets one front-end feed many backends) → a **per-ISA backend** (Xtensa first). The IR is compile-time only — **zero per-pixel runtime cost**; it lowers to the same native instructions a hand-written backend would emit. -- **Xtensa first, no dead-ends.** Ship the Xtensa backend (classic ESP32 + S3 — MoonLight's first targets, the bench hardware) as a complete, blazing first increment. RISC-V (P4), ARM (Teensy), x86-64/ARM64 (desktop) are each *a new backend behind the unchanged IR* later — additive, never a rewrite. **WASM/WAMR is the per-target fallback** (IR→WASM is one more backend), so no target is ever blocked and a true sandbox stays reachable. -- **Source language: a C-subset, "as close as possible" to a precompiled effect, with pragmatic simplifications.** The effect *body* ports near-verbatim from a `.h` (types, `for`, integer + float math, `static_cast`, `RGB`, `hsvToRgb`, buffer writes). The C++ *file/class ceremony* that buys nothing in a script (`#pragma`/`#include`/`namespace`; lightened: `class : public EffectBase`/`override`/the `controls_`-dance) is supplied by the engine. **Not** JS (doubles = slow + further from our code); **not** full C++ (object model = build + hot-path cost for zero-value boilerplate). -- **Minimal-ceremony controls.** A control is a near-plain top-level variable with a range annotation; the engine derives the MoonModule control + UI + persistence. -- **MoonModule-first.** A scripted module **is** a `MoonModule` (role, controls, `loop()`, generic UI, lifecycle, robustness, live-reconfig). The script ⇄ host binding reaches the `Buffer` / `AudioFrame` / LUT through the producer/consumer pull pattern — no copy. This is the MoonLight value-add with no prior art to trace. -- **Safety staged.** Ship cheap first — array **bounds-checking** (a compare-branch per indexed access, low single-digit %, switchable off) + a **watchdog / instruction budget** (kill a runaway loop, near-free). The expensive **true memory sandbox** (WASM gives it free; native can't cheaply) is deferred, reachable via the IR→WASM fallback only if a public editor in the field demands it. -- **Staging spine = the [MoonLight effects tutorial](https://ewowi.github.io/MoonLight/moonlight/effects-tutorial/) ladder.** Each tutorial rung (random pixel → control → trails → oscillators → 2D → 3D → audio) is one engine-capability spike with a concrete acceptance bar. **RipplesEffect.h is the language-fidelity *graduation test*** (does the C-subset handle float trig + 3D + `memset` near-verbatim?), the hard case, not the hello-world. -- **Load-bearing spike:** a minimal native Xtensa engine running the tutorial's hello-world (`setRGB(random16(N), blue)`) live on an ESP32-S3, hitting the frame budget *and* surviving a deliberately-bad script via cheap safety. If native-with-cheap-safety can't hold 16K×50FPS, the fallback is demote-to-WASM/WAMR — a backend swap behind the IR, not a restart. -- **Cost, eyes open.** A real compiler is more work than adopting an off-the-shelf VM — weeks to the first beautiful Xtensa increment, each ISA backend its own increment later. The deliberate trade for native speed + a differentiator, mitigated exactly as the LED drivers were: spike-ordered, one complete increment at a time, the hard multi-target part deferred behind a seam that keeps it reachable. - -## 1. The goal, in detail - -A user writes a script — an effect, layout, modifier, driver, or a domain-neutral core rule (transform sensor data) — in a text box in the browser. The device compiles it and runs it as a first-class `MoonModule` **on the next tick**: no toolchain, no flash, no reboot. The same leap WLED took with ARTI-FX. The hard requirements (from the bottom-up): - -- **Blazingly fast** — the script runs in the render hot path at up to 16K+ lights × 50 FPS, so a slow per-pixel path is fatal. This is *the* constraint that picks native over interpreted. -- **General in core + specific in light** — one engine, many `MoonModule` roles. -- **Target order** — ESP32 classic + S3, then P4 + other ESP32, then Teensy, then desktop. -- **Smart memory** — IRAM/PSRAM-aware via `platform::alloc`, no hot-path allocation, compile-once. -- **Infinitely scalable** — run *as many* live scripts concurrently as memory allows, exploiting PSRAM. No fixed slot count; each script is an independent compiled unit and the only ceiling is free heap. A device hosts as many scripted modules (effects across layers, modifiers, core sensor rules) as fit, and degrades gracefully when it doesn't — the same scaling-to-available-memory contract the light pipeline already honours. -- **Synced** — `Scheduler`-tick, live reconfig (no reboot), tick-atomic hot-swap, robust to add/delete/replace in any order. -- **MoonModule-compatible** — controls, `loop()`, role, generic UI with zero per-script UI code. - -## 2. Why native, and why our own (expanding decision 1) - -The design space runs from interpreted to native, and the choice is driven by the hot-path requirement: - -- **AST-walk** (ARTI-FX): stores values as `double` and walks the tree per frame — which buys maximum flexibility and runs on any platform unchanged, at the cost of per-frame speed. That speed cost is what rules it out *for the 16K×50FPS hot path specifically* — not a flaw, a different trade than MoonLight needs here. (For a slow core script off the hot path, that trade would be fine.) -- **A bytecode VM is the middle ground** — a compact opcode stream run by a dispatch loop, far faster than tree-walking but paying a per-opcode dispatch tax every operation; at 16K×50FPS = 800K px/s that tax is the open question, not a given. -- **Native JIT is the only thing that reaches ~100%** (ESPLiveScript's 85 fps ≈ hand-written C++ — hpwit's result). The differentiator we're after is the *combination*: native speed **and** multi-target **and** bound to a real module system. Each prior engine has part of it — ESPLiveScript has the native speed (Xtensa); ARTI-FX has the live-scripting product shape and runs anywhere (interpreted). Neither combines all three; that combination is the open space. -- **WASM/WAMR** gets portability + free sandbox but tops at ~50% native (WAMR-AOT) with a 200KB+ runtime — kept as the fallback, not the flagship, because native speed is what we're chasing. - -**Why our own, not "adopt WAMR":** *Industry standards, our own code.* We take the textbook compiler *shape* (lex → parse → AST → IR → native — the LLVM structure scaled to an MCU) and textbook *names*, written fresh against our architecture. An off-the-shelf runtime would make the engine someone else's and cap us at half speed; building means the language, the MoonModule binding, and the hot path are ours to make beautiful. The cost (a real compiler) is accepted and staged. - -**What would flip it to WASM-wholesale:** if the load-bearing spike shows native-with-cheap-safety *cannot* hold 16K×50FPS on an S3 (native machine code makes this unlikely), or if a public script editor proves to demand a true sandbox we can't afford natively. The IR seam makes either flip a backend swap, not a restart. - -## 3. Reference architecture - -### 3.1 The one narrow boundary - -Everything hangs off a single tiny seam — the analog of the LED doc's `LedDriver::push(std::span<...>)`: - -```cpp -// A compiled script, ready to run. The host calls run() once per tick. -class MoonLive { -public: - bool ok() const; // compiled cleanly - const char* error() const; // human-readable compile/runtime error, "" if none - void run(); // execute the script's loop() — the hot path - void bind(MoonLiveHost& host); // wire controls + host data (§3.4) - // lifecycle: free() releases code + data; recompile swaps tick-atomically (§3.6) -}; -``` - -Above the line is the portable front-end; below it the IR and the per-ISA backend. The host (a `MoonLiveModule`, §3.3) owns a `MoonLive` and calls `run()` from its `loop()`. - -### 3.2 The three tiers (where the IR seam lives) - -``` - source text - │ ┌─────────────── platform-independent (one implementation, all targets) - tokenizer (lexer) - │ - parser → typed AST - │ - ┌────┴──────────── IR SEAM ──────────────┐ ← one front-end, many backends - │ - typed IR (SSA-ish, register/temp model, types resolved, bounds-check nodes inserted) - │ ┌─────────────── platform-bound (one backend per ISA) - backend: lower IR → native instructions - │ Xtensa first; RISC-V / ARM / x86-64 / ARM64 later; WASM as the fallback backend - encode → executable memory (platform::allocExec) → call as function pointer -``` - -- **Front-end (portable):** tokenizer + parser + AST live in `src/core/moonlive/` (domain-neutral). They know the *language*, never a CPU. One implementation serves every target. -- **IR (the seam):** a small typed intermediate representation — the AST lowered to a flat list of typed operations with explicit temporaries, types resolved, and **bounds-check / safety nodes inserted here** (so every backend inherits safety for free). This is a *compile-time data structure*; it does not exist at run time. -- **Backend (per-ISA):** lowers the IR to native instructions for one ISA. `src/platform//moonlive_backend_*.{h,cpp}` — the only place CPU-specific codegen lives, behind the platform boundary. A WASM backend (lower IR → `.wasm`, run by WAMR) is one such backend, the portable fallback. - -**Critical: the IR costs nothing at run time (decision 3).** It is consumed during compilation and discarded. The CPU executes only the final native instructions — identical in kind to hand-written assembly. *Matching native speed on Xtensa is non-negotiable.* The Xtensa backend must lower a hot loop to instructions that match hand-written Xtensa; the spike's acceptance bar **diffs generated instructions for the Ripples inner loop against a hand-emitted reference**, and an `__asm__` escape hatch covers the very hottest paths. If the IR ever costs hot-path speed, that's a backend bug to fix, not a tax to accept. - -### 3.3 A scripted module IS a MoonModule (decision 7) - -The one deliberate class hierarchy in the codebase is the module tree; a scripted module joins it like any other. `MoonLiveEffect` is a normal `EffectBase` whose `loop()` delegates to the compiled `MoonLive`: - -```cpp -// src/light/moonlive/MoonLiveEffect.h — a scripted effect is a first-class EffectBase -class MoonLiveEffect : public EffectBase { -public: - ModuleRole role() const override { return ModuleRole::Effect; } - const char* tags() const override { return "📝"; } // scripted - Dim dimensions() const override { return engine_.declaredDim(); } // from the script - - void setup() override { // acquire the engine - engine_.bind(host_); // wire the host data (§3.4) - } - - void onBuildControls() override { // DYNAMIC controls — re-runs when the script changes - controls_.addText("source", source_, kMaxSource); // the script text (persisted, editable) - // The engine declares its controls as NEUTRAL data; the binding translates to controls_. - for (auto& c : engine_.declaredControls()) // {name,type,min,max,default} — no MoonLight type - controls_.add(c.name, c.type, c.min, c.max); // binding maps neutral → MoonLight control (§3.5) - } - - // onBuildState is the rebuild sweep: it fires on a source edit (recompile) AND on a - // grid/size change (the engine re-sizes its script buffers for the new dimensions). - // MoonLight has no separate onSizeChanged — resize routes through onBuildState, so the - // dynamic-memory re-allocation rides the same hook every config change already uses. - void onBuildState() override { - engine_.compile(source_); // recompile if source changed - if (!engine_.ok()) { setStatus(engine_.error(), Severity::Error); return; } - engine_.allocForSize(width(), height(), depth()); // (re)alloc script data for the current size - } - - void onUpdate(const char* name) override { // cheap per-control reaction, no full rebuild - engine_.onControlChanged(name); // poke a running script's bound control - } - - void loop() override { - if (engine_.ok()) engine_.run(); // the hot path: native code over our buffer - } - - void teardown() override { // release: free compiled code + script data - engine_.free(); // the "destructor" role — arenas returned to the heap - } -}; -``` - -The same shape gives `MoonLiveLayout` (role `Layout`, emits coordinates), `MoonLiveModifier` (role `Modifier`, remaps positions), `MoonLiveDriver` (role `Driver`, consumes the buffer), and a core `MoonLiveModule` (domain-neutral, e.g. a sensor rule). One engine, many roles — each a thin `MoonModule` subclass whose `loop()` is `engine_.run()`. The UI renders them generically (the `source` text control + the script-declared controls) with **zero per-script UI code** — exactly the module-tree payoff. - -**A scripted module implements the whole `MoonModule` lifecycle, not just `loop()`** — that's what makes it a first-class module and what answers dynamic controls / dynamic memory / cleanup: - -- **`onBuildControls()` — dynamic controls.** Re-runs whenever the module rebuilds, so a script that declares different controls (a new `@control` var) gets a different control set in the UI + persistence, live. The controls are *the script's*, not a fixed list. -- **`onBuildState()` — dynamic memory on size change.** MoonLight routes a grid/size change through `onBuildState` (the same rebuild sweep that applies every config change without a reboot), so MoonLive re-allocates its per-size script buffers here (`allocForSize`, PSRAM-first per §3.7). There is no bespoke `onSizeChanged` — using the existing hook means a scripted module resizes exactly like a compiled one, and inherits the no-reboot + robustness contracts for free. -- **`setup()` / `teardown()` — acquire / release (the destructor role).** `teardown` frees the compiled code block + the script's data arena back to the heap, so deleting a scripted module returns all its memory — the lifecycle that makes "as many scripts as memory allows" (§3.7) safe to add *and remove* in any order. -- **`onUpdate(name)` — cheap per-control reaction.** A control edit pokes the running script's bound variable without a full recompile (the fast path for a slider drag); only a *source* edit triggers the heavier `onBuildState` recompile. - -So the binding overrides the same hooks any compiled module does; the only difference is that each one delegates to the compiled `MoonLive` instead of hand-written C++. - -**Crucially, all of these lifecycle methods live in the *binding* (`MoonLiveEffect`, `src/light/moonlive/`), not in the engine.** `onBuildControls`/`onBuildState`/`onUpdate`/`teardown`, `EffectBase`, `ModuleRole`, `controls_` — every MoonLight type — sit on the binding side of the §3.9 seam. The engine (`MoonLive`, `src/core/moonlive/`) sees none of them; the binding reaches it only through a **neutral public API**: `compile(source)`, `run()`, `free()`, `declaredControls()` → a plain list of `{name, type, min, max, default}` structs the engine owns, and `allocForSize(w, h, d)` → plain ints. The binding *translates* — it reads the engine's neutral `declaredControls()` and calls MoonLight's `controls_.addControl(...)`; it maps a grid resize to `allocForSize`. **The engine never takes a `ControlList`, a `Buffer`, or any MoonLight type** — so the rich MoonModule lifecycle is entirely a property of the binding, and the engine stays the domain-neutral core §3.9 describes. (This is the seam working as intended: a different host writes its own binding with its own lifecycle against the same neutral engine API.) - -### 3.4 The host binding — script ⇄ MoonModule (decision 7, the value-add) - -Rather than a flat name→pointer registry (the host-binding shape surveyed engines share), MoonLight uses a **MoonModule-aware `MoonLiveHost`** that exposes the producer/consumer data the script needs, by reference, no copy — the same pull pattern effects already use (`EffectBase::buffer()/width()/elapsed()`, [Layer.h:499-504](../../moonmodules/light/supporting.md#layer)): - -- **Buffer + geometry** — `width()`, `height()`, `depth()`, `channelsPerLight()`, `nrOfLights()`, `elapsed()`, and pixel writers `setRGB(i,c)` / `setRGBXY(x,y,c)` / `setRGBXYZ(x,y,z,c)` (the MoonLight tutorial's exact surface). These compile to direct loads/stores against `layer_->buffer()` — the **identity-mapping fast path** preserved (the script writes the real buffer, no intermediate copy). -- **Controls** — the script's declared variables (§3.5) bind by reference so a UI control edit updates the running script live. -- **Producer structs** — a core or audio script reads `AudioFrame` (level + 16-band spectrum) / sensor structs through the same `const`-pointer pull the C++ effects use (`AudioService::latestFrame()`), so add/remove in any order returns a live or silent-default frame, never null (robustness). -- **Built-ins** — `hsvToRgb`/`hsv`, `random16`, `sin`/`cos`/`sqrt`/`floor` (the trig Ripples needs), `millis`/`elapsed`, `fill`/`memset`. A small, fixed, recognizable library (FastLED-flavoured — the vocabulary effect authors already know), implemented once in the host and callable from any backend. - -The binding is generated *around* the script body — the script never writes `#include`, never reaches a raw pointer it shouldn't, and the host decides what's in scope per role (an effect sees the buffer; a core sensor script sees the sensor struct, not the LED buffer). - -### 3.5 Controls — minimal ceremony (decision 5) - -A control is a near-plain top-level variable with a range annotation; the engine derives the `MoonModule` control + UI + persistence: - -```c -byte speed = 50; // @control 0..99 → controls_.addControl("speed", …, 0, 99) -uint8_t interval = 128; // @control 1..254 -``` - -The front-end collects annotated top-level vars during parsing and the engine exposes them as a neutral `declaredControls()` list (`{name, type, min, max, default}` — no MoonLight type); the *binding* reads that list and calls the normal `controls_.add(...)` the rest of MoonLight uses (§3.3) — so a scripted control is indistinguishable from a compiled one in the UI, persistence, and the live-reconfig sweep, while the engine stays MoonLight-agnostic. Lighter than today's explicit `onBuildControls` + `addControl` (the engine writes that for you), and copy-paste-friendly: the `uint8_t speed = 50;` line is *already* how RipplesEffect.h declares it. (Exact annotation syntax — `@control`, a trailing comment convention, or a `slider(0,99)` initializer — is settled in the spike; the principle is "declare the var, get the control".) - -### 3.6 Live reconfig + tick-atomic hot-swap (decision: sync) - -A re-pushed script must swap in on the next tick with the old one freed, no reboot, no crash mid-render — the no-reboot + robustness principles applied to *code*. The mechanism rides the existing `onBuildState()` rebuild sweep (the same one that makes every config change live): a `source` edit marks the module dirty; the next `onBuildState()` compiles the new source into a *second* `MoonLive`, and only on success swaps the active pointer (the old `MoonLive` freed after the swap), so a failed compile leaves the running effect untouched and surfaces the error in the module's status. `run()` reads the active pointer once per tick — the swap is a single pointer store between ticks, never mid-`loop()`. - -### 3.7 Memory placement + infinite scalability, routed through `platform::` (decisions: smart memory, infinitely scalable) - -Memory placement routes through the existing `platform::` seam, so it's one policy, not scattered per-target branches: - -- **Compiled code** → `platform::allocExec(size)` (a new seam: `MALLOC_CAP_EXEC` IRAM on ESP32; `mmap(PROT_EXEC)` on desktop; the platform decides, the engine doesn't know). PSRAM-capable on S3/P4 where the chip allows executable PSRAM. -- **Script data** (globals, stack arena) → `platform::alloc` (PSRAM-first with internal fallback — already the project's policy). -- **Compile-once** → a portable compiled-artifact format persisted to LittleFS, so a known-good script skips device-side recompile on boot. The native artifact is per-ISA; the portable fallback artifact is one file for all targets. - -**Infinite scalability — as many scripts as memory allows.** Each `MoonLive` (§3.1) is a **self-contained compiled unit**: its own code block (from `allocExec`) and its own data arena (from `alloc`), owned by the `MoonLiveModule` that holds it, freed when that module is deleted. Nothing is shared or fixed-slot — so running N scripts is just N independent `MoonLive`s, and **the only ceiling is free heap**, not an arbitrary limit. This falls out of the architecture for free: - -- **The module tree already hosts N modules.** A scripted module is a `MoonModule` (§3.3); the tree puts no cap on how many effects a Layer holds or how many peripherals System hosts. Ten scripted effects across layers + a scripted modifier + two core sensor rules are just twelve modules — the UI, persistence, and `Scheduler` handle them like any other. -- **PSRAM is where it scales.** On an S3/P4 (8 MB PSRAM) the compiled code + data arenas live in PSRAM (`alloc` is PSRAM-first), so the device holds *far* more scripts than internal RAM alone would allow — exploiting PSRAM is exactly what lifts the ceiling from "a handful" to "as many as the script sizes sum to under PSRAM." A non-PSRAM classic ESP32 holds fewer (internal heap only) — correct and honest, the same internal-vs-PSRAM split the rest of the system has. -- **Graceful degradation when full.** When the next script won't fit, the device does what the light pipeline already does at the memory edge ([architecture.md § scaling to available memory](../../explanation/architecture/moonlight.md#scaling-to-available-memory)): the compile/bind fails cleanly, the module reports a "not enough memory" status, and everything already running keeps running — no crash, no reboot (the robustness + no-reboot principles). The cap is reached by *degrading*, never by bricking. -- **The hot-path cost is per-*running* script, not per-*loaded* script.** Memory scales with how many scripts are loaded; tick time scales with how many are *enabled and rendering*. A device can hold a large library of scripts in PSRAM and run only the active ones, so "infinitely scalable in memory" doesn't mean "infinitely slow" — a disabled scripted module costs RAM but no tick time (and the disable-releases-resources backlog item, when it lands, lets it cost neither). - -**Memory-scaling status + the one refactor ahead (watch-item as the language grows).** A memory review at the 3-builtin stage (`setRGB`/`fill`/`random16`) pinned where the per-effect cost lives and what scales with language richness — recorded here so the optimisation is planned, not discovered: - -- **Already in place (don't redo):** the exec block is allocated at the *emitted length* (word-rounded), not a worst-case cap — a `fill` is ~50–70 B of native code, a four-call `setRGB` ~400 B, so per-effect heap scales with the script, not a flat reservation. And the effect reports `setDynamicBytes(codeLen())`, so the UI card's "+ dynamic" reflects the JIT'd program (0 when a compile fails and frees it). These two are the right shape for everything below. -- **Flat regardless of language size:** the engine's at-rest members (~48 B/instance) and each `Builtin` descriptor (32 B). A full math/color library is ~30–50 builtins → a ~1.5 KB table that should become a **`static const` built once** (today `lightBuiltins()` returns a ~520 B table by value per `onBuildState` — fine at 3 entries, wasteful at 50; make it static when the library grows). -- **Scales with script complexity (the exec block, heap, per effect):** today's one-liners are 50–400 B; a Ripples-class effect (per-pixel `sqrt`/`sin`, 3D, a fade loop, two controls) is a few hundred instructions → an estimated **1.5–4 KB of native code**, comparable to the equivalent compiled effect (the point of native). PSRAM-first `allocExec` (§ above) absorbs this. -- **The one architectural change ahead — transient compile buffers must move stack → reusable heap arena.** Today `compile()` puts the staging buffer (`kCodeCap`), the `IrProgram` (`kMaxIrOps=64` ops × 32 B ≈ 2 KB), and the assembler buffer on the **stack** (~4 KB peak, off the hot path, in `onBuildState`). This is fine for the current grammar but **will not hold once scripts have loops + real expressions**: a Ripples body easily exceeds 64 IR ops, and growing `kMaxIrOps` to 256–512 makes the IR alone 8–16 KB — too large for an MCU task stack. The planned fix (matches "code+data arenas" above): a **single heap arena the compiler borrows during `onBuildState` and releases**, sized once and reused across recompiles, instead of stack arrays. Introduce it **when the op count first outgrows the stack-safe range — likely Stage 3 (math + loops)**, not before; it's a contained change (the compiler's buffers, not the IR or front-end) and the `allocExec(len)` direction already set the precedent. -- **New at full language — a per-script *data* arena.** Scripts are stateless pure functions today (no per-instance data beyond the source text). Once a script declares controls (`uint8_t speed = 50;`) or state (`static float lut[256];`), each effect needs a **data arena** (`platform::alloc`, PSRAM-first) alongside its code block — a few hundred bytes to a few KB per effect, sized per script. This is the second arena §3.7 already names; it lands with Stage 1 (controls) and grows through the oscillator/LUT stages. -- **Backlog — grow-on-demand source buffer (small *and* big scripts).** The `source` text control today is a fixed member buffer (`MoonLiveEffect::source_[512]`), and the textarea control API now carries a `uint16_t` buffer size (was capped at 255), which covers Stage-1 multi-control scripts. But it's still a *fixed* size on every effect (a 512 B member even for `fill(0,0,255);`), and Ripples-class scripts in later stages will need KBs. The cap is that the generic control write path binds a fixed `(char* ptr, bufSize)` and `parseString` truncates the incoming value to it — so a buffer can't *grow* mid-write. The future design: a **dynamically-sized source buffer** (PSRAM-first, allocated to the script's actual length and freed on teardown) so a small script costs little and a big script is allowed — which means the control write path (or a MoonLive-specific source-set hook) must re-allocate-then-rebind *before* the write lands, instead of truncating. Land it when scripts genuinely outgrow a sensible fixed cap (Stage 6 / Ripples), together with the data + compile arenas above. - -Net: no resource cliff — at-rest per-effect cost stays PSRAM-friendly (code + data arenas, a few KB each) — but **one deliberate refactor is queued** (stack compile-buffers → shared reused arena), best landed exactly when a real script body first needs it. - -### 3.8 Execution model — inline by default, task as the exception (decision: sync) - -**A script runs inline in the `Scheduler` tick by default — not in its own task.** A scripted effect's `loop()` is called exactly like a compiled effect's `loop()`, on the render task, each tick. The task-per-script model some engines use fits when a script *is* the top-level loop and owns the device; in MoonLight a scripted module is one `MoonModule` among many, called from the same single-threaded render loop as every compiled module, so inline is the consistent shape. Three reasons make inline the default, not just a choice: - -- **Consistency.** A scripted effect behaves identically to a compiled one — same call site, same hot-path rules, same `Scheduler`. One mental model, and the UI/persistence/lifecycle treat it like any other module. -- **It sidesteps two costs task-per-script can't.** Task stacks *can* live in PSRAM (`xTaskCreateWithCaps`, `MALLOC_CAP_SPIRAM`), so task-per-script is not blocked on *internal* RAM — but it pays two costs inline doesn't: (a) **scheduling overhead** — each task is a TCB + scheduler bookkeeping + a context switch; hundreds of tasks all wanting to run each frame thrash the scheduler instead of rendering, a ceiling that has nothing to do with memory; and (b) **a PSRAM-backed task stack is hot-path-slow** — a per-pixel inner loop touching locals on a PSRAM stack pays PSRAM latency (~12 MB/s vs internal ~80 MB/s) every access, exactly what a 16K×50FPS loop can't afford. An inline script runs on the render task's *internal-RAM* stack, fast, with no per-task scheduler cost. So PSRAM scales the script's *code + data* (§3.7), and inline keeps the per-script *stack* fast and free — the two pull together, where task-per-script would put them in tension. -- **No cross-thread sync.** An inline script reads `buffer()` / `elapsed()` / `AudioFrame` from the thread that owns them — no locks, no race, no memory barriers. A task touching the shared buffer while the render task reads it is exactly the data race the single-threaded hot path avoids by design. - -An inline script obeys the no-blocking-hot-path rule (it can't `delay`); a runaway loop is caught by the instruction-budget watchdog (§4), so a bad inline script degrades, it doesn't wedge the tick. - -**The exception — a pinned task — is narrow and opt-in.** A *core* script that genuinely blocks or runs at its own cadence and must **not** share the render tick (e.g. a slow I²C sensor transaction, or a rule that ticks at 1 Hz independent of render) may opt into its own task, pinned opposite the render core. This is a per-module, documented exception for off-hot-path core work — never the default, and never for a script in the render pipeline (effect/layout/modifier/driver), which is always inline. Two execution paths exist, but the inline one is the default and the task one earns its place case by case. - -### 3.9 Layering — a domain-neutral engine core, a thin binding (decision: domain-neutral core) - -The tiers above already separate cleanly along MoonLight's own *Domain-neutral core* principle, and the layering is held to it deliberately: - -- **The engine core (MoonLive) is domain-neutral.** "MoonLive" is the engine's *name*, not a coupling — the front-end (`src/core/moonlive/`) and the IR + backends (`src/platform//moonlive_backend_*`) know the *language* and the *ISA*, never `Buffer`, `EffectBase`, the module tree, or anything light- or MoonLight-specific. The core's only outward contract is a tiny injectable platform seam (`platform::allocExec` / `alloc` / `millis`) — a handful of functions, not a reach into MoonLight's full platform layer. -- **The binding is the only MoonLight-coupled layer**, and it is *thin*. `MoonLiveHost` + `MoonLiveEffect`/`MoonLiveLayout`/… (`src/light/moonlive/`, with a core `MoonLiveModule` for sensor rules) sit **on top of** the engine's public API and consume it; they never reach into engine internals. -- **Dependency direction is one-way:** the binding depends on the engine; the engine never depends on the binding (or on MoonLight). The engine does not `#include` MoonLight; MoonLight `#include`s the engine. - -Why this matters concretely: **it is what makes MoonLight-as-a-library optimal.** A clean library needs exactly this — a domain-neutral core with a one-directional dependency and a thin, replaceable binding, so the whole stack composes without circular dependencies or hidden coupling. So this layering is not extra structure for its own sake; it is the *Domain-neutral core* + *Complexity lives in core, domain modules stay simple* principles applied, and it is the same boundary MoonLight needs to be a well-formed library. - -A true property of that boundary, worth stating: because the core (MoonLive) knows only the *language* and the *ISA* — never LEDs, buffers, or MoonLight — the same front-end + IR + backends would serve a **wholly different host**: a different output device, or a different application entirely (a script that drives a display, reads a keypad, computes a result). Such a host writes its own thin binding against the same public API + platform seam; nothing in the core changes. The IR seam is what makes that portable, too — the host targets whatever chip it likes by writing one backend behind the unchanged IR. This is a *consequence* of building the core domain-neutral for MoonLight, not a goal we design toward — but it is real, and it is the mark of a well-factored core: it doesn't care what you point it at. - -**Hard constraint: the layering is justified entirely by MoonLight's optimality — never compromised for it.** The clean engine/binding split is adopted only because it makes MoonLight architecturally sound, fast, and CLAUDE.md/architecture.md-compliant (domain-neutral core, data-over-objects on the hot path, the platform boundary). If any separability would cost MoonLight's optimality — a slower hot path, a heavier binding, an abstraction the engine doesn't need — it is **not** done. The binding stays thin and the core stays neutral *because that is the optimal MoonLight design*, full stop; nothing in the layering is bent toward a use beyond MoonLight. - -## 4. Safety — staged (decision 6) - -A user-facing editor means a bad script must degrade, not brick. There are two distinct failure surfaces, and they need different defenses: - -- **Compile-time (parser / analysis).** A malformed script must be *rejected cleanly at parse*, never crash the compiler. The `fix-warnings` fork's null-deref is exactly this class: a nested external-call argument produced an AST node missing its children, and the compiler dereferenced them unconditionally. The defense is **AST/analysis validation** — the parser checks node shape (children present, types resolved) and emits a diagnostic instead of dereferencing a null, backed by a **regression test** per malformed input (the `fix-warnings` case is the first such test). This is a correctness property of the front-end, not a runtime cost. -- **Runtime — cheap, ship first.** Once a script *compiles*, a bad *value* at run time must degrade, not brick: (a) **Array bounds-checking** — the IR inserts a compare-branch before each indexed access (a clamp or skip on out-of-range), e.g. a `setRGB(i, …)` with `i` past the buffer. Low single-digit % overhead, inserted at the IR so every backend gets it, and **switchable off** in a trusted/fast mode for vetted built-in scripts. (b) **Watchdog / instruction budget** — a per-tick instruction or time budget that aborts a runaway `while(1)` (near-free; the task WDT already does most of it). Together these catch the common *runtime* failures — out-of-range index, infinite loop — at low cost. -- **Expensive, deferred.** A *true* memory sandbox (the script physically cannot touch memory outside its arena) is what native can't cheaply provide. Don't build it first — it's reachable via the IR→WASM fallback (suspect scripts compiled to the sandboxed backend) only if a public editor in the field shows the cheap tier isn't enough. Safety is a ladder climbed on evidence, not a wall built before any script runs. - -## 5. The language — a C-subset as close as possible (decision 4) - -### 5.1 The model - -A C-subset, not full C++, not JS. The type model is exactly what real effects use: `uint8_t`/`uint16_t`/`uint32_t`/`int`/`lengthType`/`nrOfLightsType` integers (with 64-bit where overflow matters — Rainbow's `uint64_t` phase), `float` (Ripples' trig), `bool`, `char`, a `RGB`/`CRGB` struct, and arrays (incl. multi-dim). Control flow: `if/else`, ternary, `while`, C-style `for`, `break`/`continue`, `return`; user functions; `static_cast` (or C casts). Grammar: **hand-written recursive-descent** — the recognizable textbook default, what most embedded script languages use; a PEG is the alternative but recursive-descent is simpler to make fast and to emit good errors from. Built-ins: the fixed host library (§3.4). - -### 5.2 What's dropped vs lightened (the pragmatic simplifications) - -- **Dropped** (file ceremony, zero value in a script): `#pragma once`, `#include`, `namespace`. The engine supplies the surrounding module. -- **Lightened** (the C++ object model): no `class : public EffectBase`, no `override`, no `controls_.addControl(...)` host-object dance. The engine synthesizes the `MoonLiveEffect` wrapper (§3.3) around the script body; the role/`dimensions`/controls come from light annotations (§3.5) and the script's `loop()`. -- **Kept verbatim** (the part you iterate on): types, the `loop()` body, all the math, `static_cast`, `RGB c = hsvToRgb(...)`, the loops. - -**Why not full C++:** supporting `class`/inheritance/`override`/host-method-binding means implementing a C++ object model (vtables, member-reference binding) in the engine — build cost up front, and the object machinery is the very "object graph in the hot path" the architecture forbids. The wrapper has no runtime value; let the engine write it. - -**Why not JS** (the ARTI-FX surface): JS's number model is doubles-everywhere — the per-pixel cost that makes ARTI-FX flexible-but-not-fast — *and* it's further from our C++ effects, so porting an existing effect is harder, not easier. A C-subset is both faster and closer to the source. - -### 5.3 RipplesEffect.h → scripted form (the language-fidelity test) - -`RipplesEffect.h` is the **graduation test** for the language (float trig + 3D + `memset` + two controls — the hard case, deliberately not the hello-world). The body must port near-verbatim: - -**Today (`src/light/effects/RipplesEffect.h`, the C++):** - -```cpp -class RipplesEffect : public EffectBase { // ← dropped (engine supplies) - const char* tags() const override { return "💫🟦🦅"; } // ← lightened → annotation - Dim dimensions() const override { return Dim::D3; } // ← lightened → annotation - uint8_t speed = 50; // ← kept (becomes a control) - uint8_t interval = 128; - void onBuildControls() override { // ← dropped (derived from the vars) - controls_.addControl("speed", speed, 0, 99); - controls_.addControl("interval", interval, 1, 254); - } - void loop() override { // ← KEPT VERBATIM (the body) - uint8_t* buf = buffer(); … std::memset(buf, 0, nrOfLights()*cpl); - const float rippleInterval = 1.3f * … * std::sqrt((float)h); - for (lengthType z=0; z=h) continue; - uint8_t hue = elapsed()/50 + x*3 + z*7; - RGB c = hsvToRgb(hue,255,255); - setRGBXYZ(x, y, z, c); // replaces manual idx + px[0..2] - } -} -``` - -The diff is exactly the ceremony: gone are `class`/`override`/`onBuildControls`/`#include`/`namespace`/`std::`-qualification and the manual buffer-index arithmetic (→ `setRGBXYZ`). The math, the trig, the loops, the types — verbatim. **If the engine can compile this at native speed, the C-subset decision is proven.** (Float-trig native codegen is the part to validate — see the perf budget.) - -## 6. Testing — the engine's biggest structural advantage - -A live-script engine is one of the **most testable things MoonLight can build**, and MoonLight's two-tier test framework ([testing.md](../../reference/testing.md): doctest unit tests + JSON scenarios, each run in-process *and* live) maps onto it almost perfectly. This is a genuine edge: the bottom-up's structural note that ESPLiveScript ships `.ino` examples and no unit suite isn't a knock on it — it's the gap our framework closes. **Every live-script feature gets pinned by a test, back to back**, because two properties make a compiler exceptionally test-friendly: - -1. **Every compiler stage is a pure input→output function.** Lex, parse, IR-lower, codegen each take a known input and produce a deterministic output — the easiest thing in the world to unit-test, with no hardware and no flakiness. -2. **A script's *result* is deterministic and exactly assertable.** A known script over a known grid at a known `elapsed()` produces an exact buffer — byte-for-byte checkable. There is no "looks about right"; there is a golden buffer. - -### 6.1 Unit tests (`test/unit/core/unit_moonlive_*.cpp`, doctest) - -The compiler front-end and IR are domain-neutral core (§3.9), so they unit-test on the desktop with zero hardware: - -- **Tokenizer / parser** — source string → expected token list / AST shape; every language construct (types, `for`/`if`/ternary, functions, arrays, `static_cast`, struct access) and every *error* (unterminated string, type mismatch, undeclared var) pinned to an expected diagnostic. The fuzz-class bug the `fix-warnings` fork fixed (a nested external-call arg) is exactly a parser unit test — the regression that would have caught it for free. -- **IR** — AST → expected IR for representative snippets; the bounds-check / safety nodes (§4) asserted present where the IR should insert them. -- **Codegen (host backend)** — the **desktop/x86-64 backend** is itself a test asset: it runs in-process, so a compiled script *executes during a unit test* and its output buffer is asserted directly. A script that fills blue → assert every pixel is `(0,0,255)`; Ripples at a fixed `elapsed()` → assert the exact lit-column pattern. No device needed to test *the language*; the device tests only *the native ISA backend*. -- **Determinism harness** — the existing `setTestNowMs` clock-override seam (the same one scenarios use) lets a time-dependent script be tested at a fixed tick, so an animated effect is a deterministic assertion, not a guess. - -### 6.2 Scenario tests (`test/scenarios/{core,light}/scenario_moonlive_*.json`) - -Scenarios exercise a scripted module *as a wired `MoonModule`* — the integration layer unit tests don't reach: - -- **The MoonModule binding** — `add_module MoonLiveEffect`, `set_control source=