diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7290f8da..c9e33d18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -234,12 +234,40 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + # Full history: compute_version.py counts commits since the last v* tag. + fetch-depth: 0 # CMakeLists.txt calls find_program(UV_EXECUTABLE … REQUIRED) so the # build-host Python (gzip / build_info.h) is reached through uv. The # runners don't ship uv by default — install it before package_desktop. - uses: astral-sh/setup-uv@v3 + # Same tag + version resolution as build-esp32 (see the comments there): the computed + # semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare + # core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash + # keeps the three jobs' copies of this block identical; without it the Windows copy would + # run under PowerShell and fail on the bash syntax. + - name: Resolve release tag + id: tag + 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 + - 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" - name: Build + package macOS arm64 - run: uv run moondeck/ci/package_desktop.py + run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" - uses: actions/upload-artifact@v4 with: name: desktop-macos @@ -252,10 +280,37 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + fetch-depth: 0 # Same uv prerequisite as build-macos, see the comment there. - uses: astral-sh/setup-uv@v3 + # Same tag + version resolution as build-esp32 (see the comments there): the computed + # semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare + # core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash + # keeps the three jobs' copies of this block identical; without it the Windows copy would + # run under PowerShell and fail on the bash syntax. + - name: Resolve release tag + id: tag + 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 + - 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" - name: Build + package Windows x64 - run: uv run moondeck/ci/package_desktop.py + run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" - uses: actions/upload-artifact@v4 with: name: desktop-windows @@ -268,14 +323,41 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + fetch-depth: 0 # Same uv prerequisite as build-macos, see the comment there. - uses: astral-sh/setup-uv@v3 # Builds with the runner's default GCC (13 on ubuntu-24.04). Its four false-positive # warnings are handled in CMakeLists.txt, which demotes them to non-fatal on GCC below # 16 rather than pinning a compiler here: a from-source build on Debian or Raspberry Pi # OS meets the same GCC and deserves the same fix. + # Same tag + version resolution as build-esp32 (see the comments there): the computed + # semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare + # core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash + # keeps the three jobs' copies of this block identical; without it the Windows copy would + # run under PowerShell and fail on the bash syntax. + - name: Resolve release tag + id: tag + 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 + - 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" - name: Build + package Linux x64 - run: uv run moondeck/ci/package_desktop.py + run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}" - uses: actions/upload-artifact@v4 with: name: desktop-linux diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a72fadd..ab8f51a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,16 @@ target_include_directories(mm_core PUBLIC src/) target_link_libraries(mm_core PUBLIC mm_platform) # `add_dependencies(mm_core ui_embed)` is below, after the ui_embed target is defined. +# The computed semver (compute_version.py): the core for a stable tag, -dev. for a +# moving latest build. Only the release pipeline sets it (package_desktop.py --version); a local +# build falls through to build_info.h's #ifndef default (library.json). The same override the +# ESP32 build has (esp32/main/CMakeLists.txt), so a desktop binary reports the same precise +# version its release assets are named with, which is what lets the UI's update badge compare +# a running desktop against the `latest` channel at all. +if(MM_VERSION) + add_compile_definitions(MM_VERSION=${MM_VERSION}) +endif() + # Platform library (desktop). moonlive_emit.cpp is the desktop MoonLive backend (host-ISA # codegen) — it lives here because emitted machine code is platform/ISA-specific. add_library(mm_platform diff --git a/README.md b/README.md index 9983b44e..0864afe5 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The numbers above are observations. The **contracts** projectMM commits to, what **Desktop: download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases): -- **macOS arm64:** `projectMM-macos-arm64-vX.Y.Z.dmg`: open it and drag projectMM to Applications, then launch it like any app. A Terminal window opens showing what it is doing, your browser opens the UI, and closing that window stops it. (`projectMM-macos-arm64-vX.Y.Z.tar.gz` is the same binary without the wrapper, for scripting.) x86-64 macOS is supported and tested, but only the arm64 build is packaged: build from source for an Intel Mac. The binary is ad-hoc signed rather than notarized, so Gatekeeper says it cannot verify the developer; right-click → Open and confirm, or clear the flag with `xattr -dr com.apple.quarantine ./projectMM`. +- **macOS arm64:** `projectMM-macos-arm64-vX.Y.Z.dmg`: open it and drag projectMM to Applications, then launch it like any app. A Terminal window opens showing what it is doing, your browser opens the UI, and closing that window stops it. (`projectMM-macos-arm64-vX.Y.Z.tar.gz` is the same binary without the wrapper, for scripting.) x86-64 macOS is supported and tested, but only the arm64 build is packaged: build from source for an Intel Mac. The app is ad-hoc signed rather than notarized, so the first launch is refused with "Apple could not verify projectMM is free of malware". On macOS 15 and later that dialog offers no way through, so clear the download flag once in Terminal and open it again: `xattr -dr com.apple.quarantine /Applications/projectMM.app` (or `./projectMM` for the tarball). - **Windows x64:** `projectMM-windows-x64-vX.Y.Z.zip`: unzip, double-click `projectMM.exe`. SmartScreen may warn on first run because the binary is unsigned (More info → Run anyway). - **Linux x64:** `projectMM-linux-x64-vX.Y.Z.tar.gz`, or `projectmm_X.Y.Z_amd64.deb` on Debian, Ubuntu and Raspberry Pi OS (`sudo apt install ./projectmm_X.Y.Z_amd64.deb` puts it on your PATH). diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 2e87cf31..9ab3eb9e 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -16,7 +16,6 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co - **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 `projectMM.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. - **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. -- **Desktop builds report a bare version, so the dev-channel badge cannot fire** — `package_desktop.py` never passes `-DMM_VERSION`, so a desktop binary reports `library.json`'s `3.0.0` while the `latest` release is named `3.0.0-dev.N`. Semver ranks a prerelease *below* its release, so `isNewer("3.0.0-dev.N", "3.0.0")` is false and the desktop dev badge never lights; the `onPrerelease` gate is false for a bare `3.0.0` for the same reason. The stable badge works, so a desktop user is told about real releases and only misses `latest` builds. Fix at the root by stamping the computed `-dev.N` version into the desktop build the way the ESP32 job does, not by special-casing the comparison. - **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 projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. diff --git a/docs/backlog/moonlive-language-roadmap.md b/docs/backlog/moonlive-language-roadmap.md index b425451c..13453d5e 100644 --- a/docs/backlog/moonlive-language-roadmap.md +++ b/docs/backlog/moonlive-language-roadmap.md @@ -35,7 +35,7 @@ Five hard limits, all found by hitting them: | script state | **64 bytes** shared by all members | `kCtrlBytes`, `MoonLiveBuiltins.h:132` | | distinct members | **8** | `kMaxCtrls`, same file | | branch labels | **16** (an `if` or `for` takes up to 2) | `kIrLabels`, `MoonLiveIr.h:201` | -| numeric types | `uint8_t`, `uint16_t` | no float, no signed | +| numeric types | `uint8_t`, `uint16_t`, `int16_t` | no float | | ~~builtin table~~ | ~~16, and 16 used~~ → **64** ✅ | `BuiltinTable::kMax` — raised, with an overflow assert | The branch budget was binary-searched with generated scripts: **6 `if`/`else` + 2 `for` compiles, @@ -50,7 +50,6 @@ Each row is a compromise the balls effect makes, and the language feature that w |---|---|---| | 4 objects, not 25 | 64-byte arena, 8 members | a bigger arena, or a pool handle (shipped for particles) | | whole-pixel motion | no fractional type | fixed-point or float | -| a direction bit per axis | unsigned only | signed values | | one flat colour | no `hsv()` builtin | `hsv()` | | one array per field | no structs | structs | | the helper reads a member for its index | functions take no arguments | arguments | @@ -129,6 +128,15 @@ Doing #1 and #2 together is what actually opens the library; either alone leaves 64 bytes across 8 members is why an effect holds four objects rather than twenty-five. +**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 are `DeclaredControl[8]` at 24 B each, so 8 -> 12 costs 96 B per engine and +roughly 600 B per device across three engines, against `sizeof(MoonLive)` at 864 B today. + **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 @@ -279,12 +287,35 @@ does not foreclose float. 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. -### 7. Signed values — *moderate, and it removes a whole class of workaround* - -Unsigned-only forces a sign bit alongside every value that can go negative — a velocity, a delta, -an offset from a centre. It also makes ordinary expressions dangerous: `a - b` wraps instead of -going negative, so scripts guard every subtraction. Comes naturally with fixed-point (#3) if that -type is signed, which argues for doing them together. +### 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 — *probably a constant, worth measuring first* diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 551e406f..f0f22729 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,10 +1,10 @@ { - "commit": "9eea71d6", + "commit": "ab508402", "flash": { "esp32": 1764416, "esp32p4rev1-eth": 1653696, "esp32p4rev1-eth-wifi": 1933472, - "esp32s3-n16r8": 1801632, + "esp32s3-n16r8": 1813456, "esp32s3-n8r8": 1753232, "esp32s31": 2079904, "esp32-16mb": 1714608, @@ -12,12 +12,12 @@ "esp32-wrover": 1765504, "qemu": 1318160, "esp32p4rev3-eth": 1643760, - "desktop": 1105920 + "desktop": 1230168 }, "perf": { "desktop": { - "tick_us": 437, - "fps": 2288 + "tick_us": 182, + "fps": 5494 }, "esp32": { "tick_us": 2151, @@ -25,54 +25,54 @@ } }, "loc": { - "core": 19591, - "light": 25712, - "platform": 14786, - "ui": 6859, - "test": 45620, - "moondeck": 21359 + "core": 19684, + "light": 25824, + "platform": 14891, + "ui": 7028, + "test": 45902, + "moondeck": 21626 }, "comments": { "core": { - "lines": 7689, - "ratio": 0.425 + "lines": 7746, + "ratio": 0.426 }, "light": { - "lines": 10161, - "ratio": 0.436 + "lines": 10237, + "ratio": 0.438 }, "platform": { - "lines": 5263, + "lines": 5303, "ratio": 0.391 }, "ui": { - "lines": 1803, - "ratio": 0.279 + "lines": 1861, + "ratio": 0.281 }, "test": { - "lines": 8355, - "ratio": 0.21 + "lines": 8438, + "ratio": 0.211 }, "moondeck": { - "lines": 3479, - "ratio": 0.186 + "lines": 3504, + "ratio": 0.185 } }, "tests": { - "cases": 1489, + "cases": 1511, "scenarios": 23 }, "docs": { "md_files": 189, - "md_lines": 27471, + "md_lines": 27631, "plans_files": 96, - "backlog_lines": 4310, + "backlog_lines": 4341, "lessons_lines": 576, "claude_md_lines": 136 }, "complexity": { - "functions": 2690, - "over_threshold": 164, + "functions": 2716, + "over_threshold": 165, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 2ebb3aec..14cdf918 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `9eea71d6`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `ab508402`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,7 +8,7 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,080 KB (+2 KB) ⚠ | +| desktop | 1,201 KB | | esp32 | 1,723 KB | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | @@ -16,7 +16,7 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | esp32p4rev1-eth | 1,615 KB | | esp32p4rev1-eth-wifi | 1,888 KB | | esp32p4rev3-eth | 1,605 KB | -| esp32s3-n16r8 | 1,759 KB | +| esp32s3-n16r8 | 1,771 KB (+0 KB) ⚠ | | esp32s3-n8r8 | 1,712 KB | | esp32s31 | 2,031 KB | | qemu | 1,287 KB | @@ -25,43 +25,43 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 437 µs (+72 µs) ⚠ | 2,288 (−451) ⚠ | +| desktop | 182 µs (−309 µs) ✓ | 5,494 (+3,458) ✓ | | esp32 | 2,151 µs | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 19,591 | 7,689 | 42.5 % | -| light | 25,712 (+22) ⚠ | 10,161 | 43.6 % | -| platform | 14,786 (+58) ⚠ | 5,263 | 39.1 % (−0.1 %) ✓ | -| ui | 6,859 | 1,803 | 27.9 % | -| test | 45,620 (+19) ⚠ | 8,355 | 21.0 % | -| moondeck | 21,359 | 3,479 | 18.6 % | +| core | 19,684 | 7,746 | 42.6 % | +| light | 25,824 (+9) ⚠ | 10,237 | 43.8 % (+0.1 %) ⚠ | +| platform | 14,891 | 5,303 | 39.1 % | +| ui | 7,028 | 1,861 | 28.1 % | +| test | 45,902 (+123) ⚠ | 8,438 | 21.1 % | +| moondeck | 21,626 (+8) ⚠ | 3,504 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,489 (+1) ✓ | +| unit cases | 1,511 (+7) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,690 | -| over threshold | 164 | +| functions | 2,716 | +| over threshold | 165 | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 189 (+1) ⚠ | -| markdown lines | 27,471 (+92) ⚠ | -| plan files | 96 (+1) ⚠ | -| backlog lines | 4,310 | +| markdown files | 189 | +| markdown lines | 27,631 (+1) ⚠ | +| plan files | 96 | +| backlog lines | 4,341 (−1) ✓ | | lessons lines | 576 | | CLAUDE.md lines | 136 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 9a5883b2..01dc0a31 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -64,7 +64,9 @@ The functions are **not built into the compiler** — `setRGB`, `fill`, `random1 `defineControls()` runs once after a successful compile, the way the Scheduler runs a compiled module's. Editing a control's slider does **not** recompile: the value lands in the engine's control-values arena and the next render tick reads it (the live-edit guarantee, the *no-reboot* principle). Saving the script and re-naming it recompiles and re-derives the control set; a control kept across the edit keeps its slider value, a removed control's saved value drops. - **The call has to match the member's width**: `addUint8` binds a `uint8_t` and `addUint16` a `uint16_t`. A mismatch is a compile error naming the call to use instead, because the alternative is silent: `addUint8` on a wide member would drive only its low byte, leaving the high half holding whatever it had, so the number the script reads is one nobody chose. A control binds a single member, never an array. + **The call has to match the member's width**: `addUint8` binds a `uint8_t` and `addUint16` a `uint16_t`. A mismatch is a compile error naming what the call takes, because the alternative is silent: `addUint8` on a wide member would drive only its low byte, leaving the high half holding whatever it had, so the number the script reads is one nobody chose. A control binds a single member, never an array. + + **`int16_t` is the third member type**, for a value that goes below zero: a velocity, a delta, a coordinate from `uvX`/`uvY`. It is two arena bytes read back sign-extended, its initializer may be negative and is range-checked (`int16_t d = 60000;` is a compile error naming `-32768..32767`), and it is script-internal state only: no `addInt16` exists, so an `int16_t` member cannot be a control. `int16_t` arrays are refused with a diagnostic. There is deliberately no `int8_t`: the Xtensa has no signed byte load, and a small signed value declares `int16_t`. ### System variables — what the engine hands a script @@ -101,7 +103,7 @@ Registered by the light domain, not built into the compiler (the core owns only | `beatsin(bpm, t, high)` | a sine `0..high` at `bpm` | | `noise(x, y, z)` | `0..255` value noise at that point — the field behind fire, clouds and plasma | | `scale(value, n)` | a `0..65535` value onto `0..n-1` — lands a wave on an axis | -| `sin(angle)`, `cos(angle)` | the circle; one turn is `0..65535`, result biased to `1..65535` centred at 32768 | +| `sin(angle)`, `cos(angle)` | the circle; one turn is `0..65535`, result biased to `1..65535` centered at 32768 | | `turn(n)` | one revolution split `n` ways — the angle step for placing `n` points on a circle | | `print(v)` | log a value and return it ([what it costs](writing-scripts.md#debugging-print)) | | `a / b`, `a % b` | divide and remainder. Both are host calls: cheap on a cold path, deliberate per light | @@ -110,6 +112,7 @@ Registered by the light domain, not built into the compiler (the core owns only | `smin(a, b, k)` | the smooth minimum of two distances, so shapes melt into one surface rather than overlapping | | `fade(amt)` | dim every light toward black, FastLED's `fadeToBlackBy`. The trail primitive | | `polarA(dx, dy)`, `polarR(dx, dy)` | angle and distance from a center, for a radial effect | +| `escape(cx, cy, jx, jy, iters)` | the Mandelbrot/Julia escape count, `0..255`, `0` inside the set. Zero seed = Mandelbrot; coordinates are uv's own fixed point (8192 = 1.0). The one loop a script cannot write: it squares signed values in 64 bits | | `setPaletteColor(x, y, index, bri)` | one light from the ACTIVE palette, in one call | | `paletteR(i, bri)`, `paletteG`, `paletteB` | one palette channel, when a script needs the value rather than a pixel | | `pool(n)` | size this script's particle pool, from `defineControls()`. Returns what it got | @@ -130,7 +133,9 @@ dozen particles pile convincingly, a few hundred cost more than the rest of the vocabulary follows the [WLED Particle System](https://github.com/wled/WLED) by Damian Schneider ([@DedeHai](https://github.com/DedeHai)); the fixed-point kernel and this binding are ours. -`sin`/`cos` return an **unsigned** wave, so a coordinate comes from scaling by the full span and not by half of it: `scale(cos(a), radius * 2 + 1)` sweeps a whole axis, where scaling by `radius` alone would only ever reach one side of centre. +`sin`/`cos` return an **unsigned** wave centered on 32768, so a coordinate comes from scaling by the full span and not by half of it: `scale(cos(a), radius * 2 + 1)` sweeps a whole axis, where scaling by `radius` alone would only ever reach one side of center. Subtract 32768 for a signed wave when you want one. + +`uvX`/`uvY` are the other way round, and the difference is deliberate: they return a **signed** coordinate with the center of the grid at 0 and the left half negative. A coordinate has an origin, so a script uses the number it is given rather than re-centering it; a wave does not, which is why the two conventions differ. Hold a uv value in an `int16_t` member, not a `uint16_t`. `noise(x, y, z)` takes **16.8 fixed-point** coordinates: the high byte selects the noise cell and the low byte interpolates within it. So `x * zoom` sets how much of the field the fixture spans, and the time axis must be **monotonic** — feeding it a `beat()` sawtooth walks one cell and then snaps back to its start, which reads as a hiccup once per beat. Scaling `t` keeps walking into new cells. 2D is the same call with `z` held constant. diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index eb65ccc4..682053fc 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -98,7 +98,7 @@ Streams the buffer to **LED panel cards** as raw Ethernet frames, compatible wit The board renders and sends: effects, layers and MoonLive run on the device, so one board replaces a host PC driving the same panels. Add a Network Receive effect to take Art-Net in as well. - `format`: the card's wire format (ColorLight 5A-75). -- `firmware`: the card's firmware generation, `v13 and newer` (default) or `v12 and older`. v13 and newer act on the *second* copy of the brightness and sync frames, so both are sent twice; v12 and older act on the first, and take a second sync as another latch. Set to `v13 and newer` on a downgraded card, the wall updates once every few seconds. Reading and changing a card's version: [the tutorial](../../tutorials/panel-cards.md#5-card-firmware-and-the-flicker). +- `firmware`: the card's firmware generation, `v12 and older` (default) or `v13 and newer`. v13 and newer act on the *second* copy of the brightness and sync frames, so both are sent twice; v12 and older act on the first, and take a second sync as another latch. Set to `v13 and newer` on a downgraded card, the wall updates once every few seconds. Reading and changing a card's version: [the tutorial](../../tutorials/panel-cards.md#7-card-firmware-and-the-flicker). - **No geometry controls**: the wall comes from the [Layout](layouts.md). A `PanelsLayout` already states how many panels there are, their size, wiring order and snaking; this driver reads the finished picture and cuts it into card rows. A row wider than 497 pixels goes out as several packets. - `interface`: which NIC to send from on desktop/Raspberry Pi. The kernel name on Linux/macOS (`eth0`, `en0`), or any distinctive part of the adapter description on Windows (`Realtek USB`). **Ignored on ESP32**, which has one MAC. Raw sending is privileged: root or `CAP_NET_RAW` on Linux, BPF access on macOS, and [Npcap](https://npcap.com/) or WinPcap on Windows; without it the driver records frames instead and says so. Step-by-step per OS: [Driving LED panels with a receiving card](../../tutorials/panel-cards.md). - `fps`: frame-rate limit (default 40, 1 to 120). diff --git a/docs/tutorials/panel-cards.md b/docs/tutorials/panel-cards.md index 4ad9d5f9..9c36193d 100644 --- a/docs/tutorials/panel-cards.md +++ b/docs/tutorials/panel-cards.md @@ -1,6 +1,6 @@ # Driving LED panels with a receiving card -You bought a ColorLight receiving card. This page takes you from a box of parts to a lit wall: first on an ESP32, then from a desktop. +You bought a panel receiving card, most likely a **ColorLight** one, which is the family projectMM supports today. This page takes you from a box of parts to a lit wall, on an ESP32 or from a desktop. > New here? Start with **[Install & first light](../gettingstarted.md)**, then **[How projectMM works](how-projectmm-works.md)**. This page assumes you can find a card and change a control. @@ -24,9 +24,11 @@ Currently supported: **ColorLight 5A-75** (5A-75B and 5A-75E use the same wire f |---|---| | ColorLight 5A-75 receiving card | The one this guide is about | | HUB75 panels | Any size; you tell projectMM the geometry later | -| 5 V power supply | Sized for the panels: a 64×64 panel at full white can pull 20 A+ | +| 5 V power supply | Sized from the panels' own rating: see the note on power in [§5.3](#53-wire-it) | | Cat5e or Cat6 cable | Controller → the card's **input** port | -| A controller | An ESP32 board (Part 3) or a desktop/Pi (Part 4) | +| A controller | An ESP32 board ([§5](#5-on-an-esp32)) or a desktop/Pi ([§6](#6-from-a-desktop)) | +| A gigabit switch | Only if your controller is a **P4 or an S3**, whose Ethernet is 100 Mbit: the switch lets the card negotiate a gigabit link on its own side and re-times the frames toward it. It does **not** make the controller faster: the 100 Mbit leg and its wire time ([§2](#the-one-hardware-fact-that-decides-everything)) remain, so a large wall still wants an S31. An **S31 is gigabit already** and connects straight to the card. | +| A USB gigabit Ethernet dongle | Recommended on Windows. Not for projectMM, which drives these cards fine from a built-in port, but for **LEDUpgrade**: if it cannot find the card through your built-in adapter, a dongle is the known way through. See [§7](#7-card-firmware-and-the-flicker). | ### The one hardware fact that decides everything @@ -36,57 +38,160 @@ The cards have no buffering and no flow control. They latch the image when the s The failure mode is the confusing part: **nothing errors**. The link is up, frames go out, and the panels tear, show wrong rows, or never latch. That is why projectMM reads the *negotiated* speed and warns you, rather than letting a slow link look like a format bug. It still sends, since a small wall on 100 Mbit is often fine, but if your picture is unstable, check this first. -The cards can also be picky about detecting a gigabit link. A gigabit switch between the controller and the card is reported to help. +The cards can also be picky about negotiating a gigabit link with a 100 Mbit controller. A gigabit +switch in between is the remedy: the card negotiates gigabit with the switch, the switch buffers, and +the controller's slower link stops being the card's problem. This applies to the **P4 and the S3**. +An **S31 is gigabit on its own** and connects directly. --- -## 3. Part one: on an ESP32 +## 3. What you will find here -Do this first even if a desktop is your goal. It is fewer moving parts: no drivers to install, no permissions, one MAC and therefore nothing to choose. +| Section | What it covers | +|---|---| +| [4. Set the panels up in LED Vision](#4-set-the-panels-up-in-led-vision) | Telling the card what panels it drives, before anything renders | +| [5. On an ESP32](#5-on-an-esp32) | Board, flash, wiring, layout, driver | +| [6. From a desktop](#6-from-a-desktop) | The same, on Windows, Linux, a Pi or a Mac, plus the raw-Ethernet permission each one needs | +| [7. Card firmware, and the flicker](#7-card-firmware-and-the-flicker) | The v13 defect, and the LED Upgrade 4.0 downgrade that clears it | +| [8. When it does not light up](#8-when-it-does-not-light-up) | Symptom to cause | + +The two halves are independent: an ESP32 and a desktop drive the same card the same way, and neither +is a prerequisite for the other. Pick whichever hardware you have. + +--- + +## 4. Set the panels up in LED Vision + +The receiving card has to know what it is driving before projectMM sends it anything: how big each +panel is, how many there are, and which driver IC they use. That configuration lives **on the card**, +written once with ColorLight's own **[LEDVision](https://en.colorlightinside.com/product/download/380)**, +and it is why projectMM itself needs no panel wiring settings at all (see +[§5.4](#54-describe-the-wall)). + +**Which version.** An **8.x** build is what people running these cards in this scene actually use: +this project's own wall is set up with **8.8**, and the walkthrough linked below uses **8.5**. Newer +releases exist, and whether they are equally suitable here has not been established, so the safe +advice is to take an 8.x build and only move if you have a reason to. + +> **Worth watching first:** [Setting up a Colorlight Card with FPP v6.3 and LED Vision 8.5](https://www.youtube.com/watch?v=L4lHbwUszAs) +> walks through the whole card-and-panel setup on video. It drives the card from FPP rather than +> projectMM, but everything up to the sender is the same job, and seeing it done is worth more than +> any written step list. The steps below cover the same ground in short form. + +> The steps below are written from how LEDVision generally works, not from a verified run on this +> project's bench. Labels and menu paths move between versions; treat the shape as right and the +> exact wording as approximate. + +1. Connect the card to the machine's Ethernet port, and power the panels and the card. +2. Open LEDVision and go to the **receiving-card** setup (usually `Settings` then a receiving-card + or `Screen` panel; some versions ask for a password, commonly `168`). +3. Load the panel definition. Either pick your panel from the built-in module list, or load the + `.rcfgx` / `.rcvx` file the panel supplier provided, which is the reliable route for a panel that + is not a well-known model. +4. Set the **cabinet** size: how many pixels one card drives, across and down. +5. Set the **panel arrangement**: how the HUB75 ribbons chain, and which physical panel is first. + This is the step that makes the card, not projectMM, responsible for panel order. +6. **Send to the receiving card**, then **Save** so the configuration survives a power cycle. Saving + is a separate action from sending in most versions, and skipping it is the usual reason a wall + comes back wrong after being unplugged. + +When this is right, a test pattern from LEDVision fills the wall correctly. Get to that point before +introducing projectMM: it separates "the panels are wired and configured" from "the sender works". + +--- + +## 5. On an ESP32 -### 3.1 Pick a board +### 5.1 Pick a board | Board | Link | Verdict | |---|---|---| | **ESP32-S31** (Function-CoreBoard-1) | **1 Gbit** (YT8531) | The right board. On-chip EMAC at gigabit is exactly what these cards want. | -| **ESP32-P4** (Waveshare P4-NANO) | 100 Mbit (IP101) | Works for a small wall. A gigabit switch in between is reported to help. | +| **ESP32-P4** (Waveshare P4-NANO) | 100 Mbit (IP101) | Works for a small wall. A gigabit switch in between is reported to help. Use the **`-eth-wifi`** firmware: the Ethernet port is carrying panel data, so WiFi is how you reach the UI. | +| **ESP32-S3** (DevKitC-1, N16R8 or N8R8) | 100 Mbit over SPI (W5500 module) | The board most people already own, and a real way to try a small panel before buying an S31. The slowest of the three: budget for a gigabit switch in between. | -Other ESP32 variants do not ship panel-card support: their Ethernet is 100 Mbit at best, and most reach it over an SPI module that is slower still. +Other ESP32 variants do not ship panel-card support: their Ethernet is 100 Mbit at best, and most have none at all. -### 3.2 Flash it +The S3 needs a **W5500 Ethernet module** wired to its SPI pins, which the S3 firmwares already +support; its pins come from the board entry in `deviceModels.json`. Because W5500 is 100 Mbit and +sits behind SPI, it is the configuration most likely to need the gigabit switch described in +[§2](#the-one-hardware-fact-that-decides-everything). + +### 5.2 Flash it Use the [web installer](https://moonmodules.org/projectMM/install/), or from a checkout: ```sh -uv run moondeck/build/flash_esp32.py --firmware esp32s31 --port +uv run moondeck/build/flash_esp32.py --firmware --port ``` Panel-card support is compiled in per firmware, and it is already on for the boards in the table above. -### 3.3 Wire it +### 5.3 Wire it 1. **Controller → card.** Ethernet cable from the board to the card's *input* (some cards have two RJ45 ports; the second is for daisy-chaining to the next cabinet). 2. **Card → panels.** HUB75 ribbons from the card's outputs to the panels, in the order you intend to address them. -3. **Power.** 5 V to the panels *and* to the card. Do not power panels from the board. +3. **Power.** 5 V to the panels *and* to the card. + +**On powering panels from the same supply as the board.** This works, and plenty of small setups run +that way. What it costs you is headroom. + +HUB75 panels draw far less than a naive count suggests, and the reason is **multiplexing**: the panel +lights one group of scan rows at a time, cycling fast enough to look continuous, so at 1/16 or 1/32 +scan only a fraction of the LEDs are on at any instant. This is why these panels have a reputation +for modest consumption. Size the supply from the rating on your panel's own datasheet rather than +from pixels multiplied by LED current. + +The failure when you do run short is not a clean one: the 5 V rail sags, and a sagging rail shows up +as flicker, color shifts, or the controller resetting mid-frame. None of those look like a power +problem, which is why they cost an evening. A wall that is stable at 30% brightness and misbehaves at +100% is telling you this is the problem, not the network. -Nothing needs an IP address. If your board also has WiFi, leave it configured as normal; it is unrelated to the panel link. +Nothing needs an IP address: the panel link is below IP entirely. -### 3.4 Describe the wall +**That is also how you reach the UI.** The board's Ethernet port is now carrying panel data, so +WiFi is what serves the web interface. Leave WiFi configured as normal; it is unrelated to the panel +link and the two do not interfere. On the P4 this decides which firmware to flash, because the +`-eth` variant has no WiFi compiled in at all: use `-eth-wifi`. The S31 and S3 firmwares carry both +already. + +### 5.4 Describe the wall The driver has **no geometry controls**. The wall's shape lives in the Layout, once, so that everything else (effects, modifiers, the preview) sees the same picture. -Add a **Panels** layout and set: +**A plain Grid is usually all you need.** Two 128x64 panels stacked is a 128x128 grid, and that is +the whole configuration. The reason it is that simple is worth knowing: the driver reads only the +wall's width and height and sends the image row by row. Which physical panel a row lands on, and in +what order the HUB75 ribbons chain, was already settled on the card in +[§4](#4-set-the-panels-up-in-led-vision). The card owns panel arrangement; projectMM owns the +picture. + +That is also why this needs none of the physical detail you may have filled in elsewhere. An output +page that asks for scan rate, address lines and chain order is describing panels driven *directly*, +where the software has to generate the HUB75 timing itself. Through a receiving card, none of that is +the sender's business: the card generates the timing, and the sender hands it an image. That holds +for any sender, [FPP](https://github.com/FalconChristmas/fpp) included, which reaches these cards +over Ethernet exactly as projectMM does. + +**When you need the Panels layout instead.** It exists for walls where projectMM, not a card, owns +the ordering: addressable panels wired as one long pixel strip, where the strip snakes from panel to +panel and the layout has to undo that. Its controls are about **wiring order**, which a HUB75 ribbon +does not have. | Control | Meaning | |---|---| -| `horizontalPanels` / `verticalPanels` | How many panels across and down (1–32 each) | -| `panelWidth` / `panelHeight` | One panel's pixels (default 16×16; a common HUB75 panel is 64×64) | +| `horizontalPanels` / `verticalPanels` | How many panels across and down (1-32 each) | +| `panelWidth` / `panelHeight` | One panel's pixels (default 16x16) | | `wiringOrder`, `X++`, `Y++`, `snake` | How pixels run *within* a panel | | `wiringOrderP`, `X++P`, `Y++P`, `snakeP` | How the panels themselves are ordered | -The snake settings are what to reach for when the image is right but every other row or column is reversed. +**Do HUB75 panels snake?** Not in the sense these controls mean. A HUB75 panel is addressed by row +and column over the ribbon, so its internal pixel order is fixed by the panel's own driver ICs and +is not something a layout re-maps. A *chain* of panels can be arranged in any order, including a +serpentine one, but that is configured on the card, not here. So if you are driving panels through a +receiving card and every other row looks reversed, the setting to revisit is in LEDVision. -### 3.5 Add the driver +### 5.5 Add the driver Add a **Panel Card** driver under Drivers, and set: @@ -97,32 +202,32 @@ Add a **Panel Card** driver under Drivers, and set: | `fps` | 40 is a good start (1–120) | | `start` / `count` | Leave at defaults unless you are splitting one picture across several controllers | -### 3.6 What you should see +### 5.6 What you should see -The driver's status line tells you what the wire is doing, and the three states are worth recognising: +The driver's status line tells you what the wire is doing, and the three states are worth recognizing: | Status | Meaning | |---|---| -| `1000 Mbit - 5280 packets/s` | Working. The rate is (rows + 4) × fps: one packet per row, plus two brightness and two sync packets per frame. A row wider than 497 pixels splits into several packets, so a wide wall sends more than one per row. | -| `100 Mbit - …` (warning) | Sending, but see §2: fine for a small wall, tearing on a large one. | +| `1000 Mbit - 5200 packets/s` | Working. At the default `v12 and older` the rate is (rows + 2) × fps: one packet per row plus one brightness and one sync per frame, so 128 rows at 40 fps is 5,200. On `v13 and newer` both extras go out twice, (rows + 4) × fps = 5,280. A row wider than 497 pixels splits into several packets, so a wide wall sends more than one per row. | +| `100 Mbit - …` (warning) | Sending, but see [§2](#the-one-hardware-fact-that-decides-everything): fine for a small wall, tearing on a large one. | | `no ethernet link` | Cable, card power, or the wrong port on the card. | -If the status is healthy and the panels are dark, jump to §5. +If the status is healthy and the panels are dark, jump to [§7](#7-card-firmware-and-the-flicker). --- -## 4. Part two: from a desktop +## 6. From a desktop -A PC, a Mac or a Raspberry Pi can drive the same card. Reasons to want this: far more compute for heavy effects, and a machine you already own. This is the setup [FPP](https://github.com/FalconChristmas/fpp) popularised on the Pi. +A PC, a Mac or a Raspberry Pi can drive the same card. Reasons to want this: far more compute for heavy effects, and a machine you already own. -The steps are the same as Part 3, same Layout and same driver, with **two differences**: +The steps are the same as [§5](#5-on-an-esp32), same Layout and same driver, with **two differences**: - **`interface` matters.** A desktop has several NICs and the frames must leave the right one. Which spelling to use is per-OS, below. - **Raw Ethernet needs permission.** Sending below IP is privileged on every desktop OS. Without it, projectMM does not fail silently: the driver warns and *records* frames instead of sending them, which is also how the tests run with no hardware. Pick your OS. -### 4.1 Windows: needs Npcap +### 6.1 Windows: needs Npcap Windows has **no** built-in way for an application to put a raw Ethernet frame on the wire. That is an OS restriction, not a projectMM limitation, and it is why Wireshark bundles a driver and why ColorLight's own LEDVision needs one. @@ -134,7 +239,7 @@ For `interface`, type **any distinctive part of the adapter's name**, case-insen > If binding fails with Npcap installed, re-run its installer and check whether *"Restrict Npcap driver's access to Administrators only"* was selected. If so, run projectMM as Administrator. -### 4.2 Linux, including Raspberry Pi +### 6.2 Linux, including Raspberry Pi Raw frames go out over `AF_PACKET`, which needs `CAP_NET_RAW`. @@ -146,7 +251,7 @@ sudo setcap cap_net_raw+ep ./projectMM For `interface`, use the kernel's name exactly: `eth0`, `enp3s0`. `ip link` lists them. -### 4.3 macOS +### 6.3 macOS Raw frames go out over BPF (`/dev/bpf*`), which is root-only by default. @@ -156,13 +261,13 @@ For `interface`, use the BSD name exactly: `en0`, `en7`. `ifconfig` lists them. > On an Apple Silicon or Intel Mac the built-in port is usually gigabit; a USB-C dongle may not be. Check the driver's status line rather than assuming. -### 4.4 Then, on any desktop +### 6.4 Then, on any desktop -Set up the **Panels** layout and the **Panel Card** driver exactly as in §3.4 and §3.5, with `interface` filled in per your OS. The status line means the same things. +Set up the layout and the **Panel Card** driver exactly as in [§5.4](#54-describe-the-wall) and [§5.5](#55-add-the-driver), with `interface` filled in per your OS. The status line means the same things. --- -## 5. Card firmware, and the flicker +## 7. Card firmware, and the flicker A card's firmware has a version of its own, separate from the hardware revision printed on the board. It matters twice: once because one generation is defective, and once because projectMM has to know which generation it is talking to. @@ -183,9 +288,24 @@ The second row is worth taking seriously, because projectMM sends a full frame e ### Reading and changing the version -**[LEDUpgrade](https://en.colorlightinside.com/product/download/383)** reads and writes card firmware. Use **version 4.0**. Version 5.0 ships no pre-v12 firmware at all, so it cannot do this downgrade from its preset list however long you fight it. +**Use [LEDUpgrade](https://en.colorlightinside.com/product/download/383) 4.0 and firmware 11.09.** +That is the proven combination, and the easiest one, because 11.09 ships inside LEDUpgrade 4.0: it is +in the preset list, so there is no firmware file to find. Treat any other pairing as a detour. + +**Why not 5.0.** Version 5.0 ships no pre-v12 firmware at all, so it cannot do this downgrade from +its preset list however long you fight it. -**Why 11.09 rather than something older.** Anything before v12 clears the flicker, but older is not automatically safer: cards on 11.08 were reported strobing white, which 11.09 fixes. 11.09 is the newest build on the safe side of the defect, so it carries the most fixes while carrying none of the flicker. +**Why 11.09 rather than something older.** Anything before v12 clears the flicker, but older is not +automatically safer: cards on 11.08 were reported strobing white, which 11.09 fixes. 11.09 is the +newest build on the safe side of the defect, so it carries the most fixes while carrying none of the +flicker. + +> **On Windows, if LEDUpgrade cannot find the card.** A built-in Ethernet port can be held by +> something else in the stack, and Hyper-V's virtual switch is the usual culprit: it binds the +> adapter, so a tool that needs raw layer-2 access reaches nothing even though the port looks +> ordinary and your normal networking works. The reliable way through is a **USB gigabit Ethernet +> dongle**, which Hyper-V is not bridging. This is about the card-flashing step: projectMM's own +> sending works from a built-in port. 1. Connect the card **directly** to the machine, no switch in between. 2. **Close everything else that talks to the card**, projectMM included. A card being streamed at will not answer, and two ColorLight tools at once (LEDVision and LEDUpgrade) interfere. @@ -201,31 +321,35 @@ Set the driver's `firmware` control to match the card: | Setting | For | |---|---| -| `v13 and newer` | A stock card. The brightness and sync frames go out twice, which is the copy this firmware acts on. | -| `v12 and older` | A downgraded card. Both go out once. | +| `v12 and older` | **The default**, and a downgraded card. Brightness and sync go out once. | +| `v13 and newer` | A stock card. Both go out twice, which is the copy this firmware acts on. | + +The default is the downgraded generation on purpose: this page's own advice is to move a v13 card +off it, so the setting is already right when you finish rather than being one last unexplained step. The mismatch is not subtle in one direction: leave a downgraded card on `v13 and newer` and it receives a second sync, treats it as another latch, aborts the refresh already running, and the wall updates once every few seconds. --- -## 6. When it does not light up +## 8. When it does not light up | Symptom | Look at | |---|---| | `no ethernet link` | On a desktop, check `interface` first: a string that matches no adapter fails the bind and reports exactly this, with nothing to distinguish it from an unplugged cable. Then cable seated, card powered, and plugged into the card's **input** port. On a 100 Mbit controller, try a gigabit switch in between. | | Status healthy, panels dark | The Layout, not the driver. If the wall is 0 lights, or the driver's window (`start`/`count`) selects none, there is nothing to send. | -| Image tears or rolls | Link speed (§2). Check the status line says 1000 Mbit. | -| A new frame only every few seconds | `firmware` (§5) is set to `v13 and newer` on a card running v12 or older. The duplicate sync latches twice and aborts the refresh. | -| Flicker in time with network activity | The card's own v13 firmware defect (§5), not the sender. Downgrade the card. | -| Every other row or column mirrored | `snake` for within-panel, `snakeP` for panel order. | -| Panels in the wrong places | `wiringOrderP`, `X++P`, `Y++P`: the panel-grid ordering. | -| Right image, wrong colours | `lightPreset` on the driver, which is where channel order and RGBW synthesis live for every driver. There is no separate colour-order control here. | -| Works on ESP32, not on desktop | Permission (§4). The driver will be showing a warning that says so. | +| Image tears or rolls | Link speed ([§2](#the-one-hardware-fact-that-decides-everything)). Check the status line says 1000 Mbit. | +| A new frame only every few seconds | `firmware` ([§7](#7-card-firmware-and-the-flicker)) is set to `v13 and newer` on a card running v12 or older. The duplicate sync latches twice and aborts the refresh. | +| Flicker in time with network activity | The card's own v13 firmware defect ([§7](#7-card-firmware-and-the-flicker)), not the sender. Downgrade the card. | +| Every other row or column mirrored | With a receiving card, this is the card's own panel configuration: revisit it in LEDVision ([§4](#4-set-the-panels-up-in-led-vision)). The layout's `snake` / `snakeP` apply only to walls wired as a pixel strip. | +| Panels in the wrong places | Same: the chain order is set on the card. | +| Right image, wrong colors | `lightPreset` on the driver, which is where channel order and RGBW synthesis live for every driver. There is no separate color-order control here. | +| Works on ESP32, not on desktop | Permission ([§6](#6-from-a-desktop)). The driver will be showing a warning that says so. | --- ## Where to go next +- **[Setting up a Colorlight Card with FPP v6.3 and LED Vision 8.5](https://www.youtube.com/watch?v=L4lHbwUszAs)**: a video walkthrough of the card and panel side. A different sender, the same cards and the same LEDVision work. - **[Drivers](../moonmodules/light/drivers.md#panelcard)**: the Panel Card control reference. - **[Layouts](../moonmodules/light/layouts.md#panels)**: the Panels layout in full. - **[Effects](../moonmodules/light/effects.md)** and **[live scripting](../moonmodules/light/MoonLiveEffect.md)**: a wall is a big canvas, and scripted effects are the fastest way to fill it. diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index dc470d60..e3806a2e 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -197,6 +197,11 @@ def check_idf_pin(idf_path: Path) -> None: "description": "ESP32-S3 DevKitC-1 (N16R8: 16 MB flash, 8 MB octal PSRAM) — WiFi + " "W5500 SPI Ethernet (external module, pins per board in deviceModels.json)", "ships": True, + # W5500 over SPI is 100 Mbit, well under the gigabit these cards want, so a wall of + # any size needs a gigabit switch between the S3 and the card to negotiate the link. + # Enabled anyway: the S3 is the board most people already own, and a small panel is a + # real way to try this before buying an S31. + "panel_cards": True, }, "esp32s3-n8r8": { "chip": "esp32s3", @@ -207,6 +212,11 @@ def check_idf_pin(idf_path: Path) -> None: "Ethernet. Half the flash of N16R8; the N16R8 binary overruns an " "8 MB board, so N8R8 boards (LightCrafter etc.) need this variant.", "ships": True, + # W5500 over SPI is 100 Mbit, well under the gigabit these cards want, so a wall of + # any size needs a gigabit switch between the S3 and the card to negotiate the link. + # Enabled anyway: the S3 is the board most people already own, and a small panel is a + # real way to try this before buying an S31. + "panel_cards": True, }, "esp32p4rev1-eth": { "chip": "esp32p4", diff --git a/moondeck/ci/package_desktop.py b/moondeck/ci/package_desktop.py index 91644394..f818b649 100644 --- a/moondeck/ci/package_desktop.py +++ b/moondeck/ci/package_desktop.py @@ -9,11 +9,12 @@ Each archive carries the executable + a short README.txt with run instructions. -The macOS build is ad-hoc signed, which turns Gatekeeper's outright refusal into -the "unidentified developer" prompt a user can accept. Windows is unsigned, so -SmartScreen warns on first run. Documented in the README and each README.txt. +The macOS build is ad-hoc signed, which gets it as far as Gatekeeper's "could not +verify" dialog; clearing the quarantine flag is still required to open it. Windows +is unsigned, so SmartScreen warns. Documented in the README and each README.txt. """ +import argparse import json import os import platform @@ -47,14 +48,22 @@ def run(cmd: list[str]) -> None: sys.exit(r.returncode) -def configure_and_build_macos() -> Path: +def version_args(version: str) -> list[str]: + """The -DMM_VERSION override, or nothing: the exact contract build_esp32.py has. Empty means + a local/dev build and build_info.h's library.json default; the release pipeline passes the + computed semver so the binary, the asset names and the update badge all carry one version. + The inner quotes make the macro a string literal, same as the ESP32 build.""" + return [f'-DMM_VERSION="{version}"'] if version else [] + + +def configure_and_build_macos(version: str = "") -> Path: """Configure + build for macOS arm64. Returns the built binary path.""" bdir = str(BUILD_DIR_MACOS.relative_to(ROOT)) run([ "cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_OSX_ARCHITECTURES=arm64", - ]) + ] + version_args(version)) run(["cmake", "--build", bdir, "--config", "Release", "-j"]) binary = BUILD_DIR_MACOS / "projectMM" if not binary.exists(): @@ -63,10 +72,10 @@ def configure_and_build_macos() -> Path: return binary -def configure_and_build_linux() -> Path: +def configure_and_build_linux(version: str = "") -> Path: """Configure + build for Linux x86-64. Returns the built binary path.""" bdir = str(BUILD_DIR_LINUX.relative_to(ROOT)) - run(["cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release"]) + run(["cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release"] + version_args(version)) run(["cmake", "--build", bdir, "--config", "Release", "-j"]) binary = BUILD_DIR_LINUX / "projectMM" if not binary.exists(): @@ -116,7 +125,11 @@ def package_deb(binary: Path, version: str) -> Path | None: sys.exit("package_desktop: no dpkg-deb on this CI runner, cannot build the .deb") print("package_desktop: no dpkg-deb on this host, skipping the .deb") return None - # A Debian version cannot carry a leading 'v' and must start with a digit. + # A Debian version cannot carry a leading 'v' and must start with a digit. A hyphen is also + # out: dpkg reads it as the upstream/revision separator, so the computed 3.0.0-dev.N becomes + # 3.0.0~dev.N here. Deliberately a tilde: dpkg sorts ~ BEFORE the bare version, so a dev build + # upgrades to the 3.0.0 release exactly as semver intends the prerelease to. + version = version.replace("-", "~") stage = DIST_DIR / f"deb-{version}" shutil.rmtree(stage, ignore_errors=True) (stage / "DEBIAN").mkdir(parents=True) @@ -159,7 +172,7 @@ def package_deb(binary: Path, version: str) -> Path | None: return out -def configure_and_build_windows() -> Path: +def configure_and_build_windows(version: str = "") -> Path: """Configure + build for Windows x64. Returns the built binary path.""" bdir = str(BUILD_DIR_WIN.relative_to(ROOT)) # No -G: let CMake auto-detect the installed Visual Studio. Pinning a @@ -168,7 +181,7 @@ def configure_and_build_windows() -> Path: run([ "cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release", - ]) + ] + version_args(version)) run(["cmake", "--build", bdir, "--config", "Release"]) # MSVC multi-config places binaries under /Release/. binary = BUILD_DIR_WIN / "Release" / "projectMM.exe" @@ -184,15 +197,19 @@ def configure_and_build_windows() -> Path: def readme_text(version: str, platform_label: str) -> str: return ( - f"projectMM v{version} — {platform_label}\n" + f"projectMM v{version} ({platform_label})\n" f"\n" f"Run: ./projectMM (macOS) or projectMM.exe (Windows)\n" f"Open: http://localhost:8080/\n" f"\n" - f"macOS first run: the binary is ad-hoc signed, not notarized, so\n" - f"Gatekeeper says it cannot verify the developer. Right-click → Open\n" - f"and confirm, or clear the flag with\n" - f"'xattr -dr com.apple.quarantine ./projectMM'.\n" + f"macOS first run: the app is ad-hoc signed, not notarized, so macOS\n" + f"refuses it with 'Apple could not verify projectMM is free of malware'.\n" + f"That dialog has no way through on macOS 15 and later, so clear the\n" + f"download flag in Terminal and open it again:\n" + f"\n" + f" xattr -dr com.apple.quarantine /Applications/projectMM.app\n" + f"\n" + f"(for the tarball, point it at ./projectMM instead). One time only.\n" f"\n" f"Source: https://github.com/MoonModules/projectMM\n" ) @@ -201,10 +218,14 @@ def readme_text(version: str, platform_label: str) -> str: def adhoc_sign(binary: Path) -> None: """Sign the macOS binary with an ad-hoc signature. Free, and it changes what a user sees. - An UNSIGNED binary is refused outright by recent macOS with no obvious way through. Ad-hoc - signed, the same download gets the familiar "cannot verify the developer, open anyway?" dialog - and a working right-click -> Open. Neither is as good as notarization, which needs a paid - Developer ID; this is the free half of the distance. + An UNSIGNED binary is refused by recent macOS before it even reaches Gatekeeper's usual + prompt. Ad-hoc signing gets it as far as the standard "could not verify" dialog, which is the + free half of the distance to notarization (that needs a paid Developer ID). + + It is NOT enough to make the app openable: macOS 15 dropped the right-click -> Open bypass for + ad-hoc signed apps, so that dialog now has only "Move to Trash" and "Done". The user clears the + quarantine flag instead, which every README this script writes explains. Verified on macOS + 26.6: the app launches normally once the flag is gone. Best effort: a failure prints and continues, because an unsigned build is still shippable and a release that stops for this would be worse than one that warns. @@ -327,9 +348,9 @@ def package_macos(binary: Path, version: str) -> Path: DIST_DIR.mkdir(exist_ok=True) out = DIST_DIR / f"projectMM-macos-arm64-v{version}.tar.gz" readme = DIST_DIR / "_README.txt" - # encoding="utf-8" — the README contains "→" and "—"; Windows' default - # write_text encoding is cp1252 and rejects them. Explicit utf-8 matches - # what tar/zip readers expect today. + # encoding="utf-8" explicitly: Windows' default write_text encoding is cp1252, so a + # non-ASCII character added to readme_text later would raise there and nowhere else. + # The text is plain ASCII today; naming the encoding keeps that from being load-bearing. readme.write_text(readme_text(version, "macOS arm64"), encoding="utf-8") try: with tarfile.open(out, "w:gz") as tar: @@ -358,7 +379,13 @@ def package_windows(binary: Path, version: str) -> Path: def main() -> int: - version = read_version() + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--version", default="", + help="Override the library.json version with the pipeline-computed semver " + "(compute_version.py), so the binary, the asset names and the update " + "badge all carry the same 3.0.0-dev.N. Empty = a local/dev build.") + args = ap.parse_args() + version = args.version or read_version() system = platform.system() machine = platform.machine().lower() @@ -374,12 +401,12 @@ def main() -> int: print(f"package_desktop: unsupported macOS arch '{machine}'. " f"projectMM 1.0 ships macOS arm64 only.") return 2 - binary = configure_and_build_macos() + binary = configure_and_build_macos(args.version) package_macos(binary, version) return 0 if system == "Windows": - binary = configure_and_build_windows() + binary = configure_and_build_windows(args.version) package_windows(binary, version) return 0 @@ -388,7 +415,7 @@ def main() -> int: print(f"package_desktop: unsupported Linux arch '{machine}'. " f"Only x86-64 is packaged; other arches build from source.") return 2 - binary = configure_and_build_linux() + binary = configure_and_build_linux(args.version) package_linux(binary, version) return 0 diff --git a/moondeck/moonlive/emit_isa.cpp b/moondeck/moonlive/emit_isa.cpp index 3b5c5b97..ec325a12 100644 --- a/moondeck/moonlive/emit_isa.cpp +++ b/moondeck/moonlive/emit_isa.cpp @@ -24,7 +24,7 @@ #elif defined(MM_EMIT_ARM64) #define __aarch64__ 1 #include "platform/desktop/moonlive_asm_host.h" -#include "platform/desktop/moonlive_asm_host.cpp" +#include "platform/desktop/moonlive_asm_arm64.cpp" #elif defined(MM_EMIT_X86_64) // No macro to define: the desktop backend's x86-64 branch is selected by the HOST's own // `__x86_64__` / `_M_X64`, so this ISA reads what the machine already compiles. That also means @@ -41,7 +41,7 @@ #error "MM_EMIT_X86_64 requires an x86-64 host; run --isa x86_64 on an x86-64 machine" #endif #include "platform/desktop/moonlive_asm_host.h" -#include "platform/desktop/moonlive_asm_host.cpp" +#include "platform/desktop/moonlive_asm_x86_64.cpp" #else #error "define MM_EMIT_XTENSA, MM_EMIT_RISCV, MM_EMIT_ARM64 or MM_EMIT_X86_64" #endif @@ -54,17 +54,15 @@ // backend could drift from the backend's own. #include "core/moonlive/moonlive_emit.h" +// Each backend now carries its own lowerToBytes, INSIDE its arch guard and beside the assembler +// it names, so including the asm file above already brought it in. There is nothing more to +// include here; only the arch macros this file forced still have to come back off. #if defined(MM_EMIT_XTENSA) -#include "platform/esp32/moonlive_lower_xtensa.cpp" #undef __XTENSA__ #elif defined(MM_EMIT_RISCV) -#include "platform/esp32/moonlive_lower_riscv.cpp" #undef __riscv #elif defined(MM_EMIT_ARM64) -#include "platform/desktop/moonlive_lower_host.cpp" #undef __aarch64__ -#elif defined(MM_EMIT_X86_64) -#include "platform/desktop/moonlive_lower_host.cpp" #endif #include "core/moonlive/MoonLiveCompiler.h" diff --git a/moonlive/effects/fractal.mle b/moonlive/effects/fractal.mle new file mode 100644 index 00000000..5dc57c0e --- /dev/null +++ b/moonlive/effects/fractal.mle @@ -0,0 +1,38 @@ +// Fractal: the Mandelbrot and Julia sets, escape-time rendered. +// seed 0 is the still Mandelbrot set; any other value walks a Julia seed along the cardioid. + +class FractalEffect { + uint8_t bpm = 6; + uint8_t iters = 40; + uint8_t zoom = 34; + uint8_t seed = 128; + + int16_t cx = 0; + uint16_t n = 0; + + defineControls() { + addUint8("bpm", bpm, 0, 30); + addUint8("iters", iters, 8, 64); + addUint8("zoom", zoom, 12, 40); + addUint8("seed", seed, 0, 128); + } + + tick() { + for (y = 0; y < height; y = y + 1) { + for (x = 0; x < width; x = x + 1) { + cx = div(uvX(x, width, height) * zoom, 40); + if (seed == 0) { cx = cx - 4500; } + + n = escape(cx, div(uvY(y, width, height) * zoom, 40), + div((cos(beat(bpm, t)) - 32768) * seed, 1024) + - div((cos(beat(bpm, t) * 2) - 32768) * seed, 2048), + div((sin(beat(bpm, t)) - 32768) * seed, 1024) + - div((sin(beat(bpm, t) * 2) - 32768) * seed, 2048), + iters); + + // 0 = inside the set: stays black, the silhouette is the shape. + setPaletteColor(x, y, n, n * 255); + } + } + } +} diff --git a/moonlive/effects/metal.mle b/moonlive/effects/metal.mle index 82ee62a5..67d5c5ad 100644 --- a/moonlive/effects/metal.mle +++ b/moonlive/effects/metal.mle @@ -1,17 +1,14 @@ // Metal: blobs of liquid mercury that MELT into each other instead of overlapping. -// A shader: every light is a function of where it is and what time it is, nothing is stored. -// -// smin() is the trick: a plain minimum of two distances draws two circles with a seam between -// them, smin() draws one surface that flows. Turn blend down to 0 to see the difference. +// smin() is the trick: a plain minimum draws two circles with a seam, smin() one flowing surface. class MetalEffect { uint8_t bpm = 14; uint8_t blend = 40; uint8_t glow = 30; - uint16_t ux = 0; - uint16_t uy = 0; - uint16_t d = 0; + int16_t ux = 0; + int16_t uy = 0; + int16_t d = 0; defineControls() { addUint8("bpm", bpm, 1, 60); @@ -22,19 +19,19 @@ class MetalEffect { tick() { for (y = 0; y < height; y = y + 1) { for (x = 0; x < width; x = x + 1) { - // Shader space: normalized on the short side, so a blob is round on any panel. - ux = uvX(x, width, height) - 32768; - uy = uvY(y, width, height) - 32768; + ux = uvX(x, width, height); + uy = uvY(y, width, height); - // Each blob is a distance: how far this light is from a center that drifts on the clock. + // Each blob is a distance to a center that drifts on the clock. d = polarR(ux - beatsin(bpm, t, 30000) + 15000, uy - beatsin(bpm + 5, t, 30000) + 15000) - 4200; d = smin(d, polarR(ux - beatsin(bpm + 3, t, 30000) + 15000, uy) - 3600, blend * 32); d = smin(d, polarR(ux, uy - beatsin(bpm + 7, t, 30000) + 15000) - 3600, blend * 32); - // One distance, one surface: brightest at the surface, the palette running through it. - setPaletteColor(x, y, scale(d * 8, 256), - scale(smoothstep(0, glow * 100, glow * 100 - d), 256)); + // d < 0 = inside the surface: the start of the palette, full bright. + if (d < 0) { setPaletteColor(x, y, 0, 255); } + else { setPaletteColor(x, y, scale(d * 8, 256), + scale(smoothstep(0, glow * 100, glow * 100 - d), 256)); } } } } diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 4fcc9e5d..26854866 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -156,6 +156,12 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { } void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { + // Before the switch: every branch below returns, and a declared default belongs to the + // control whatever its type is. Emitted only when one was set, so the wire format and every + // module that relies on the type-level defaults in /api/types are untouched. + if (c.def != ControlDescriptor::kNoDefault) { + sink.appendf(",\"default\":%d", static_cast(c.def)); + } switch (c.type) { case ControlType::Uint8: case ControlType::Uint16: diff --git a/src/core/Control.h b/src/core/Control.h index 92d7521a..90402884 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -284,6 +284,14 @@ struct ControlDescriptor { // here; Text/Password/ReadOnly reuse max as the buffer size (min unused). int32_t min = 0; int32_t max = 255; + // The value the control was BORN with, for the UI's reset-to-default affordance. Normally the + // UI reads defaults per module TYPE from /api/types, which probes a fresh instance: correct + // while a type's controls are fixed, and empty for a module whose controls come from data. + // A scripted module is exactly that (a MoonLive script declares its own), so the default has + // to travel with the control instance. INT32_MIN means "none declared", so a control that + // never sets one costs nothing on the wire and the type-level route is unchanged. + static constexpr int32_t kNoDefault = INT32_MIN; + int32_t def = kNoDefault; bool hidden = false; // UI visibility flag. Set via ControlList::setHidden() after addX(). // Persistence ignores this — hidden controls are still saved/loaded // so toggling visibility doesn't lose state. @@ -538,6 +546,13 @@ class ControlList { if (i < count_) controls_[i].hidden = hidden; } + // Record what a previously-added control was born with, so the UI can offer a reset for a + // control whose default cannot be probed from the module TYPE. Used by the scripted modules, + // whose controls are declared by the running script rather than by the C++ type. + void setDefault(uint8_t i, int32_t def) { + if (i < count_) controls_[i].def = def; + } + // Flip the readonly flag on a previously-added control. Typical use: call addText() // then setReadOnly(count() - 1, true) for a value that's persisted via the standard // path but pushed by tooling rather than user-edited (e.g. SystemModule.deviceModel). diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 110bc635..ced5a4eb 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -25,9 +25,15 @@ namespace mm::moonlive { // The width of a script member, and how many arena bytes one element of it occupies. Here rather // than with the IR because a builtin descriptor names the width its by-reference argument takes. -enum class CtrlType : uint8_t { Uint8, Uint16 }; +// Int16 is the SIGNED sibling of Uint16, and there is no Int8 on purpose: Xtensa has no signed +// byte load, so an int8_t member would need a sign-extend sequence the other three ISAs do not, +// for a width no script has asked for. A script wanting a small signed value declares int16_t. +enum class CtrlType : uint8_t { Uint8, Uint16, Int16 }; -constexpr uint8_t ctrlWidth(CtrlType t) { return t == CtrlType::Uint16 ? 2 : 1; } +constexpr uint8_t ctrlWidth(CtrlType t) { + return (t == CtrlType::Uint16 || t == CtrlType::Int16) ? 2 : 1; +} +constexpr bool ctrlIsSigned(CtrlType t) { return t == CtrlType::Int16; } // Neutral inline opcodes — "store shapes a backend can emit", not "LED operations". A host maps diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 2f80e61b..671d8c46 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -396,8 +396,12 @@ struct Parser { } if (members[mi].count > 1) { fail("an array needs an index: write name[i]"); return 0; } VReg v = alloc(); - emit({members[mi].type == CtrlType::Uint16 ? IrOp::LoadCtrl16 : IrOp::LoadCtrl, - v, 0,0,0,0, members[mi].offset, nullptr, {}}); + // Three member types, three loads: the signed one sign-extends, which is the + // whole point of declaring int16_t rather than uint16_t. + const IrOp loadOp = members[mi].type == CtrlType::Int16 ? IrOp::LoadCtrl16S + : members[mi].type == CtrlType::Uint16 ? IrOp::LoadCtrl16 + : IrOp::LoadCtrl; + emit({loadOp, v, 0,0,0,0, members[mi].offset, nullptr, {}}); return v; } VReg out = 0; @@ -513,8 +517,12 @@ struct Parser { // would move element 0 and leave the rest, with nothing on screen saying so. if (members[mi].type != fn->refType) { fail(fn->refType == CtrlType::Uint16 - ? "addUint16 binds a uint16_t member (use addUint8)" - : "addUint8 binds a uint8_t member (use addUint16)"); return; } + ? "addUint16 binds a uint16_t member" + : "addUint8 binds a uint8_t member"); return; } + // No addInt16 exists: an int16_t member is script-internal scratch, not a + // control. The two messages above therefore name only what each call takes, + // rather than recommending the sibling call, which for an int16_t member + // would fail just the same. if (members[mi].count > 1) { fail("a control binds a single member, not an array"); return; } v = alloc(); @@ -605,6 +613,11 @@ struct Parser { // size depend on a value the UI changes while the program runs. uint8_t count = 1; if (lex.kind == Tok::LBracket) { + // int16_t arrays are refused, not mis-read: element access lowers through the UNSIGNED + // indexed load on every backend, so a negative element would silently read as a large + // positive where a scalar member of the same type reads correctly. Add the signed + // indexed load to all four backends before lifting this. + if (type == CtrlType::Int16) { fail("int16_t arrays are not supported"); return; } lex.advance(); if (lex.kind != Tok::Number) { fail("expected an array length (a number)"); return; } if (lex.number < 1 || lex.number > kCtrlBytes) { fail("array length out of range"); return; } @@ -629,14 +642,26 @@ struct Parser { return; } if (!expect(Tok::Assign, "expected '=' in a member declaration")) return; + // A leading minus, here and nowhere else in the grammar: an int16_t member's whole purpose + // is to hold a negative, and `int16_t v = -100;` is how every language spells that. In an + // EXPRESSION the same minus is the subtraction operator, which is why this is read at the + // one point where a number is the only thing that can follow. + bool negated = false; + if (lex.kind == Tok::Minus) { negated = true; lex.advance(); } if (lex.kind != Tok::Number) { fail("expected a default value (a number)"); return; } - // The initializer is range-checked against the DECLARED type, so a uint8_t member cannot be - // given a value it silently truncates. A uint16_t's own default is bounded below, once its - // arena slot is known: the DeclaredControl record carries a byte, so a wide default is the - // seeding path's concern rather than the parser's. - const long defMax = type == CtrlType::Uint16 ? 65535 : 255; - if (lex.number < 0 || lex.number > defMax) { - fail(type == CtrlType::Uint16 ? "uint16_t default out of range (0..65535)" + if (negated) lex.number = -lex.number; + // The initializer is range-checked against the DECLARED type, so a member cannot be given + // a value it silently truncates. + // Checked against the DECLARED type, so `int16_t d = 60000;` is refused here rather than + // silently becoming -5536 at run time. That value was a real bug: a script used a large + // number as a "start big" sentinel, a builtin read it through a signed window, and every + // light rendered black with nothing reporting an error. + const long defMin = type == CtrlType::Int16 ? -32768 : 0; + const long defMax = type == CtrlType::Int16 ? 32767 + : type == CtrlType::Uint16 ? 65535 : 255; + if (lex.number < defMin || lex.number > defMax) { + fail(type == CtrlType::Int16 ? "int16_t default out of range (-32768..32767)" + : type == CtrlType::Uint16 ? "uint16_t default out of range (0..65535)" : "uint8_t default out of range (0..255)"); return; } @@ -680,9 +705,15 @@ struct Parser { return lex.kind == Tok::Ident && lex.identLen == len && std::strncmp(lex.identBeg, kw, len) == 0; } // Is the current Ident the `uint8_t` type keyword (the only declared type in Stage 1)? - bool atTypeKeyword() const { return atKeyword("uint8_t", 7) || atKeyword("uint16_t", 8); } + bool atTypeKeyword() const { + return atKeyword("uint8_t", 7) || atKeyword("uint16_t", 8) || atKeyword("int16_t", 7); + } /// The type the current keyword names. Only called when atTypeKeyword() is true. - CtrlType currentType() const { return atKeyword("uint16_t", 8) ? CtrlType::Uint16 : CtrlType::Uint8; } + CtrlType currentType() const { + if (atKeyword("uint16_t", 8)) return CtrlType::Uint16; + if (atKeyword("int16_t", 7)) return CtrlType::Int16; + return CtrlType::Uint8; + } // program := { decl } { stmt }. Declarations (control vars) come first, then one-or-more // call statements. (Multi-statement now: a script has decl lines AND a statement line.) @@ -903,7 +934,11 @@ struct Parser { VReg v = parseExpr(); if (failed) return false; if (li >= 0) emit({IrOp::Spill, 0, v, 0,0,0, locals[li].slot, nullptr, {}}); - else emit({members[mi].type == CtrlType::Uint16 ? IrOp::StoreCtrl16 : IrOp::StoreCtrl, + // Selected by WIDTH, not by naming one type: a store truncates, so signedness does not + // matter on the way in, but a 1-byte store into a 2-byte member writes half of it and the + // sign-extending load then reads a stale high byte. That bug shipped: int16_t members + // assigned in tick() collapsed to 0..255 and a whole shader went one flat color. + else emit({ctrlWidth(members[mi].type) == 2 ? IrOp::StoreCtrl16 : IrOp::StoreCtrl, 0, v, 0,0,0, members[mi].offset, nullptr, {}}); freeTemp(v); return expect(Tok::Semicolon, "expected ';' after an assignment"); @@ -912,8 +947,12 @@ struct Parser { /// `if (a OP b) { … }` with an optional `else { … }`. /// /// The six comparisons lower onto the TWO branch ops the loops already use. The emitted branch - /// skips the then-block, so each one emits the NEGATION of what the script wrote. `BranchGe` is - /// unsigned `a >= b` and `BranchNe` is `a != b`, which is all a byte language needs: + /// skips the then-block, so each one emits the NEGATION of what the script wrote. `BranchGeS` + /// is SIGNED `a >= b` and `BranchNe` is `a != b`, which is all this needs: + /// + /// SIGNED because this is the script's own comparison. The unsigned `BranchGe` still carries + /// the constructs that COUNT rather than compare: a for loop's entry guard and back edge, the + /// array-index clamp, and the `z >= z` unconditional-jump idiom below. See IrOp::BranchGe. /// /// written skip when emitted /// a < b a >= b BranchGe a, b @@ -951,13 +990,15 @@ struct Parser { // Emit the branch that SKIPS the then-block, which is the NEGATION of the written test. switch (cmp) { - // !(a < b) is a >= b. - case Tok::Less: emit({IrOp::BranchGe, 0, a, b, 0,0, lElse, nullptr, {}}); break; + // !(a < b) is a >= b. SIGNED, here and below: this is the script's own comparison, + // and `a - b` produces a two's-complement negative the moment b exceeds a. Compared + // unsigned that negative is a huge value and `if (a - b < 0)` never fires. + case Tok::Less: emit({IrOp::BranchGeS, 0, a, b, 0,0, lElse, nullptr, {}}); break; // !(a >= b) is a < b, which is b > a: BranchGe with the operands swapped tests b >= a, // so the strict form needs the pair below. a >= b skips when a < b == b > a. case Tok::GreaterEq: emitStrictLess(a, b, lElse); break; // !(a > b) is a <= b, i.e. b >= a. - case Tok::Greater: emit({IrOp::BranchGe, 0, b, a, 0,0, lElse, nullptr, {}}); break; + case Tok::Greater: emit({IrOp::BranchGeS, 0, b, a, 0,0, lElse, nullptr, {}}); break; // !(a <= b) is a > b, i.e. b < a. case Tok::LessEq: emitStrictLess(b, a, lElse); break; case Tok::EqEq: emit({IrOp::BranchNe, 0, a, b, 0,0, lElse, nullptr, {}}); break; @@ -1006,15 +1047,18 @@ struct Parser { return true; } - /// Branch to `label` when `a < b`, STRICTLY. BranchGe gives `>=` only, so the strict form is + /// Branch to `label` when `a < b`, STRICTLY. BranchGeS gives `>=` only, so the strict form is /// "not (a >= b)": branch over an unconditional jump. Two branches for the two comparisons a - /// single unsigned `>=` cannot express, rather than a third branch op every backend must grow. + /// single `>=` cannot express, rather than a fourth branch op every backend must grow. void emitStrictLess(VReg a, VReg b, uint8_t label) { if (nextLabel >= kIrLabels) { fail("too many branches in one script"); return; } const uint8_t lSkip = nextLabel++; - emit({IrOp::BranchGe, 0, a, b, 0,0, lSkip, nullptr, {}}); // a >= b: do NOT take the skip + emit({IrOp::BranchGeS, 0, a, b, 0,0, lSkip, nullptr, {}}); // a >= b: do NOT take the skip VReg z = alloc(); emit({IrOp::Const, z, 0,0,0,0, 0, nullptr, {}}); + // Unsigned on purpose: `z >= z` is the unconditional-jump IDIOM, not a comparison, and it + // is true either way. Keeping it on BranchGe leaves the signed op meaning exactly one + // thing, which is what a reader checking "is this comparison signed?" needs. emit({IrOp::BranchGe, 0, z, z, 0,0, label, nullptr, {}}); // always taken freeTemp(z); emit({IrOp::Label, 0, 0,0,0,0, lSkip, nullptr, {}}); diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 4c934a73..aa936dbf 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -93,6 +93,10 @@ enum class IrOp : uint8_t { // op hands the emitted code a pointer that outlives it. Inline, // a host-registered inline op (inlineOp tag); operands a/b/c/d (op-specific) LoadCtrl, // dst = ((const uint8_t*)kArg4)[imm] — read a control value byte at offset imm + LoadCtrl16S, // dst = *(int16_t*)((const uint8_t*)kArg4 + imm): read a wide member SIGN-EXTENDED, + // which is what an int16_t member means. Separate from LoadCtrl16 for the reason + // the note below gives: a width or sign FIELD a backend ignored would silently + // zero-extend a negative member, and the script would read 65436 for -100. LoadCtrl16, // dst = *(uint16_t*)((const uint8_t*)kArg4 + imm): read a WIDE member. // Separate ops rather than a width field on LoadCtrl/StoreCtrl: every backend // switch is exhaustive over IrOp, so a new op makes a backend that forgot the @@ -124,6 +128,17 @@ enum class IrOp : uint8_t { Label, // a branch target; `imm` is the label id. Emits no instruction. BranchGe, // if (a >= b) goto label `imm` — UNSIGNED. The loop's ENTRY guard: skip a loop // whose range is empty, which is also what makes `for (i = 0; i < 0; …)` correct. + // + // STAYS unsigned, and BranchGeS is a separate op rather than a replacement, because + // three of its users need unsigned and would break: the array-index clamp + // (moonlive_lower.h) reads a negative index as a huge value so ONE branch catches + // both ends of the range, the element-store bounds check does the same, and the + // recursion-depth guard counts a byte. A loop counter is a count, so parseFor uses + // this one too. Only a script's own comparison is signed. + BranchGeS, // if (a >= b) goto label `imm`, SIGNED: the comparison a script writes. Separate + // from BranchGe per the note above; every backend switch is exhaustive over IrOp, + // so a backend that forgets it fails to COMPILE rather than silently comparing the + // wrong way, which is the same guarantee LoadCtrl16 documents below. BranchNe, // if (a != b) goto label `imm` — the BACKWARD edge that closes the loop. Spill, // frame slot `imm` = a — a value the register file could not hold, parked Reload, // dst = frame slot `imm` — the same value brought back for one use diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index d21f4fc9..10ce55fb 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -67,7 +67,8 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { // time and needs no live interval here. case IrOp::StoreCtrl: case IrOp::StoreCtrl16: out[0] = in.a; return 1; - case IrOp::LoadCtrl16: out[0] = kArg4; return 1; // reads the arena pointer + case IrOp::LoadCtrl16: + case IrOp::LoadCtrl16S: out[0] = kArg4; return 1; // reads the arena pointer // An indexed access reads its INDEX (and, for a store, the value). The arena pointer is // deliberately NOT reported: the rewriter below writes sources back POSITIONALLY (src[0] // into in.a, src[1] into in.b), so listing kArg4 first would shift every real operand one @@ -79,6 +80,7 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { case IrOp::Add: case IrOp::Mul: case IrOp::BranchGe: + case IrOp::BranchGeS: case IrOp::BranchNe: out[0] = in.a; out[1] = in.b; return 2; // A Call reads NO registers. Its arguments were staged into consecutive frame slots by the // parser, so `imm` is their base and `b` is how MANY there are — a literal count, not a @@ -104,7 +106,7 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { // buffer pointer) a spurious live range that the allocator would then try to manage. bool writesDst(IrOp op) { switch (op) { - case IrOp::Label: case IrOp::BranchGe: case IrOp::BranchNe: + case IrOp::Label: case IrOp::BranchGe: case IrOp::BranchGeS: case IrOp::BranchNe: // A member store writes MEMORY, not a register: its `a` is the value and `imm` the arena // offset, so reading its dst as a definition would give vreg 0 a spurious live range. case IrOp::StoreCtrl: diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index 0cac463f..f80fe4af 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -249,11 +249,16 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee if (op.imm >= 0 && op.imm < kIrLabels) a.branchGeU(reg(op.a), reg(op.b), labelFor(op.imm)); break; + case IrOp::BranchGeS: + if (op.imm >= 0 && op.imm < kIrLabels) + a.branchGeS(reg(op.a), reg(op.b), labelFor(op.imm)); + break; case IrOp::BranchNe: if (op.imm >= 0 && op.imm < kIrLabels) a.branchNe(reg(op.a), reg(op.b), labelFor(op.imm)); break; case IrOp::LoadCtrl: a.load8(reg(op.dst), host(kArg4), op.imm); break; // dst = ctrls[imm] + case IrOp::LoadCtrl16S: a.load16S(reg(op.dst), host(kArg4), op.imm); break; // signed wide member case IrOp::LoadCtrl16: a.load16(reg(op.dst), host(kArg4), op.imm); break; // dst = *(u16*)(ctrls+imm) // An ARRAY element. `imm` is the array's base, op.c the element width and op.d the // element count, so both the scaling and the bound come from the IR rather than from a diff --git a/src/light/drivers/PanelCardDriver.h b/src/light/drivers/PanelCardDriver.h index 2b08cd38..c9b8520f 100644 --- a/src/light/drivers/PanelCardDriver.h +++ b/src/light/drivers/PanelCardDriver.h @@ -132,11 +132,15 @@ class PanelCardDriver : public DriverBase { /// FPP auto-detects it and how ColorLight's own LEDUpgrade reports a card as "5A 13.17". Probing /// needs a receive seam beside platform::ethSendRaw, which does not exist yet. FPP keeps the /// manual setting regardless, as its FIRST source, falling back to discovery only when unset. - static constexpr const char* kFirmwareOptions[] = {"v13 and newer", "v12 and older"}; + /// v12-and-older FIRST, and the default. A stock card ships on v13, but v13 on v8.x hardware + /// has a flicker defect with no sending-side workaround, so the documented path is to downgrade + /// the card (tutorials/panel-cards.md). Defaulting to the generation the guide leaves you on + /// means the setting is already right when you finish, rather than being the last unexplained + /// step between a downgraded card and a wall that updates once every few seconds. + static constexpr const char* kFirmwareOptions[] = {"v12 and older", "v13 and newer"}; static constexpr uint8_t kFirmwareCount = 2; - /// Card firmware generation (index into kFirmwareOptions). Defaults to v13+, which is what a - /// card ships with today. + /// Card firmware generation (index into kFirmwareOptions): 0 is v12-and-older, 1 is v13+. uint8_t firmware = 0; /// Host NIC to send from ("eth0", "en0"). Ignored on ESP32, which has one MAC. Blank on a host /// means capture-only: nothing reaches the wire and the status says so. @@ -267,7 +271,8 @@ class PanelCardDriver : public DriverBase { // How many copies of the brightness and sync frames this card wants: see `firmware`. Sending // two to a card that acts on the first is not harmless, which is why this is a choice and // not a constant. - const int frameCopies = (firmware == 0) ? 2 : 1; + // Index 1 is v13-and-newer, which acts on the SECOND copy, so it needs both sent. + const int frameCopies = (firmware == 1) ? 2 : 1; // Brightness first, ahead of the rows — the order the cards expect. Advisory: older card // firmware ignores it, and the driver never depends on it having landed (our own Correction diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index db37229a..e7373745 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -35,6 +35,37 @@ namespace mm::moonlive { // division or shift to do that with. Three calls is the shape that works today, and // `setRGB(idx, paletteR(i), paletteG(i), paletteB(i))` reads clearly. // +// A value a builtin takes as SIGNED: the script's own 32-bit two's complement, read as itself. +// +// This used to fold through a 16-BIT window (`v > 32767 ? v - 65536 : v`), because a script had no +// way to hold a negative and the convention was that the top half of the 16-bit range meant one. +// That window was the inverse of uint16_t member truncation, it was written down in neither place, +// and it is what made `d = 60000` read as -5536: the script author thought in the member's range +// and the builtin thought in the window's. int16_t members hold a negative directly now, so the +// window has nothing left to undo and the value passes through. +inline int32_t signedArg(uintptr_t a) { + return static_cast(uint32_t(a)); +} + +// A value a builtin takes as a BYTE: clamped to 0..255, not truncated to its low eight bits. +// +// `static_cast` was the obvious spelling and it is the wrong one. A script computing a +// brightness as `n * 255` means "full", and truncation turns that into an arbitrary walk: n=50 +// gives 206, n=128 gives 128, so the brightness draws its own pattern across the picture while +// every part of the expression looks right. Saturating is what the author meant in every case, +// and it is what a hardware byte channel does. +// +// Signed on the way in, so a value that went below zero clamps to 0 rather than to whatever its +// low byte holds. +// +// The boundary: this covers the CALL builtins (setPaletteColor, paletteR/G/B). setRGB and fill +// are inline stores whose channel bytes truncate in the emitted code itself, where a clamp would +// cost three compares per channel per light on the hottest path there is. +inline uint8_t byteArg(uintptr_t a) { + const int32_t v = signedArg(a); // one home for the signed reinterpretation of the ABI word + return v < 0 ? 0 : (v > 255 ? 255 : static_cast(v)); +} + // The ACTIVE palette, so a script follows the device's palette control exactly as a compiled // effect does — which is the whole point: before this, a script could only hard-code colour. // @@ -43,16 +74,13 @@ namespace mm::moonlive { // palette; the exceptions are effects where colour carries meaning, like the axis-identifying // red/green/blue in LinesEffect). Giving scripts hsv() would reintroduce it as the easy default. extern "C" inline uint32_t mm_light_paletteR(const uintptr_t* args, uint32_t, const uint8_t*) { - return colorFromPalette(*Palettes::active(), static_cast(args[0]), - static_cast(args[1])).r; + return colorFromPalette(*Palettes::active(), byteArg(args[0]), byteArg(args[1])).r; } extern "C" inline uint32_t mm_light_paletteG(const uintptr_t* args, uint32_t, const uint8_t*) { - return colorFromPalette(*Palettes::active(), static_cast(args[0]), - static_cast(args[1])).g; + return colorFromPalette(*Palettes::active(), byteArg(args[0]), byteArg(args[1])).g; } extern "C" inline uint32_t mm_light_paletteB(const uintptr_t* args, uint32_t, const uint8_t*) { - return colorFromPalette(*Palettes::active(), static_cast(args[0]), - static_cast(args[1])).b; + return colorFromPalette(*Palettes::active(), byteArg(args[0]), byteArg(args[1])).b; } extern "C" inline uint32_t mm_light_random16(const uintptr_t* args, uint32_t, const uint8_t*) { @@ -71,18 +99,6 @@ extern "C" inline uint32_t mm_light_random16(const uintptr_t* args, uint32_t, co return n ? (next >> 16) % n : 0u; } -// A script's arithmetic is UNSIGNED, so `x - cx` for x < cx arrives as a huge value rather than a -// negative one. Anything above half the range is that wrap, and subtracting the range recovers the -// signed offset the maths needs. -// -// One home rather than a copy per builtin: this is a language-wide ABI fact, not a detail of any -// one function, and a new signed-taking builtin that forgets it renders INVERTED rather than -// failing (the outside of a shape reads as fully inside), which looks like a working effect until -// the shape moves. Every builtin below that takes a signed argument calls this. -inline int32_t signedArg(uintptr_t a) { - const int32_t v = static_cast(uint32_t(a)); - return v > 32767 ? v - 65536 : v; -} // mod(a, b) → a % b, the wrap a cyclic animation needs. `t` grows without bound, so every effect // that repeats has to fold it back into a range: `mod(t * speed, width)` is a sweep that returns to @@ -92,18 +108,38 @@ inline int32_t signedArg(uintptr_t a) { // all, and emitting a division routine inline would cost more code than the whole script. One host // function, called like any other builtin, keeps the emitted code small and the three backends // identical. b == 0 returns 0 rather than trapping: a script must degrade, never fault. +// SIGNED, like `%` in every language a script author already knows. A coordinate is signed now, so +// an unsigned remainder here would be a bespoke rule with nothing on the page to signpost it: the +// exact shape of the bugs this whole change set exists to remove. +// +// `t` is unsigned time and passes 2^31 after about 25 days, at which point `mod(t, n)` reads it as +// negative. That is a real edge, and it is not the reason to keep this unsigned: `t` breaks at 2^32 +// regardless, so signedness moves WHEN rather than WHETHER. A wrapping clock needs its own answer, +// not a modulo that hides it. No shipped script uses mod(t, ...). extern "C" inline uint32_t mm_light_mod(const uintptr_t* args, uint32_t, const uint8_t*) { - const uint32_t a = uint32_t(args[0]), b = uint32_t(args[1]); - return b ? a % b : 0u; + const int32_t a = static_cast(uint32_t(args[0])); + const int32_t b = static_cast(uint32_t(args[1])); + // INT32_MIN % -1 is UB and traps on x86-64 (the other three ISAs quietly wrap, which is why a + // bench never shows it). Same stance as b == 0: a script degrades, never faults. + if (b == 0 || (a == INT32_MIN && b == -1)) return 0; + return static_cast(a % b); } // div(a, b) → a / b, and what the '/' OPERATOR lowers to. Registered under a name for the same // reason mod is: the parser resolves both operators through the builtin table, so core stays // domain-neutral and a divide is one host call rather than an instruction no ISA here has. // b == 0 returns 0, matching mod, a script degrades, never faults. +// SIGNED, for the reason given at mod above: `/` means what it means everywhere else. Scaling a +// coordinate is the common case and coordinates go negative, so an unsigned divide turned +// `uvX(...) * zoom / 40` on the left half of a grid into 107361151 rather than -13030. extern "C" inline uint32_t mm_light_div(const uintptr_t* args, uint32_t, const uint8_t*) { - const uint32_t a = uint32_t(args[0]), b = uint32_t(args[1]); - return b ? a / b : 0u; + const int32_t a = static_cast(uint32_t(args[0])); + const int32_t b = static_cast(uint32_t(args[1])); + // INT32_MIN / -1 overflows: UB, and a SIGFPE on x86-64. Returns the saturated value a script + // would expect from negating INT32_MIN, rather than 0, which would read as "division broke". + if (b == 0) return 0; + if (a == INT32_MIN && b == -1) return static_cast(INT32_MAX); + return static_cast(a / b); } // smoothstep(edge0, edge1, v) → a soft 0..65535 ramp between the edges, GLSL's own and the @@ -158,7 +194,13 @@ extern "C" inline uint32_t mm_light_uvAxis(const uintptr_t* args, bool wantY) { const int64_t extent = wantY ? sh : sw; const int64_t v = ((px * 2 - extent + 1) * 8192) / s; const int64_t c = v < -32768 ? -32768 : (v > 32767 ? 32767 : v); - return static_cast(c + 32768); + // SIGNED, with no +32768 bias. A coordinate has an origin: the center of the grid is 0, the + // left half is negative, and a script uses the number it is given. The bias this used to add + // made every consumer write `uvX(...) - 32768`, and that subtraction is exactly what unsigned + // arithmetic broke: on the left half it wrapped to about 4.29 billion and tore the plane into + // blocks. sin/cos KEEP their bias, deliberately, because a wave has no origin and + // `scale(sin(a), width)` sweeping a full axis is the idiom 14 shipped call sites rely on. + return static_cast(static_cast(c)); } extern "C" inline uint32_t mm_light_uvX(const uintptr_t* args, uint32_t, const uint8_t*) { return mm_light_uvAxis(args, false); @@ -167,6 +209,64 @@ extern "C" inline uint32_t mm_light_uvY(const uintptr_t* args, uint32_t, const u return mm_light_uvAxis(args, true); } +// escape(cx, cy, jx, jy, iters) → how many steps z = z*z + c survives before it runs away, +// scaled to 0..255. The Mandelbrot set when the seed is zero, a Julia set when it is not. +// +// A BUILTIN rather than script arithmetic, and this is the one case where that is not a +// judgement call. The iteration squares a SIGNED fixed-point value, and a script's arithmetic is +// unsigned 32-bit: `x * x` where x holds the wrapped form of -1 computes 65535 * 65535, not 1. +// There is no spelling of this loop in the language, at any cost, until signed values land +// (moonlive-language-roadmap #7). Everything else here stays expressible in script on purpose. +// +// Q13 fixed point: 1.0 is 8192, matching uvX/uvY's 8192-per-unit. That is what puts the whole +// set inside the signed 16-bit window a script can pass: x spans -2.5..1.0 (-20480..8192) and +// y spans -1.25..1.25, so a script hands over uv coordinates directly with no rescaling. +// +// The products are int64. z*z at the escape radius reaches 4.0 in Q13, and the intermediate +// before the shift is that squared again: an int32 overflows there and the point reads as +// escaped when it has not, which draws holes in the middle of the set. +// +// `iters` is the detail dial and the cost: the loop is bounded by it, so a script trades +// definition against frame time directly. Capped at 64, which is where the returned byte stops +// gaining visible bands on a panel, and it bounds the per-pixel cost no matter what a slider says. +extern "C" inline uint32_t mm_light_escape(const uintptr_t* args, uint32_t, const uint8_t*) { + // Inputs clamped to |8.0| in Q13. A coordinate that far out is already deep outside the + // escape radius (2.0) and iterates identically after clamping; without the clamp, a script + // passing an extreme value (a full int32) makes zx * zx reach 2^62 and the escape test's + // SUM overflow int64, which is UB. The clamp is what makes every product below safely wide. + const auto q13 = [](uintptr_t a) { + const int32_t v = signedArg(a); + return v < -65536 ? -65536 : (v > 65536 ? 65536 : v); + }; + const int32_t cx = q13(args[0]), cy = q13(args[1]); + const int32_t jx = q13(args[2]), jy = q13(args[3]); + uint32_t iters = uint32_t(args[4]); + if (iters > 64) iters = 64; + if (iters == 0) return 0; + + // Julia iterates z from the pixel with a FIXED c; Mandelbrot iterates z from zero with c + // taken from the pixel. One loop serves both: a zero seed selects Mandelbrot, which is why + // the seed is not a separate builtin. + const bool julia = (jx != 0 || jy != 0); + int64_t zx = julia ? cx : 0, zy = julia ? cy : 0; + const int64_t ax = julia ? jx : cx, ay = julia ? jy : cy; + + constexpr int kShift = 13; + constexpr int64_t kEscape = int64_t(4) << (kShift * 2); // |z|^2 > 4.0, in Q26 + + uint32_t n = 0; + for (; n < iters; ++n) { + const int64_t xx = zx * zx, yy = zy * zy; + if (xx + yy > kEscape) break; + const int64_t nx = ((xx - yy) >> kShift) + ax; + zy = ((2 * zx * zy) >> kShift) + ay; + zx = nx; + } + // Inside the set returns 0, so a script can test for it. Outside, the count spreads over the + // full byte whatever `iters` is, which keeps the palette mapping independent of the dial. + return (n >= iters) ? 0u : (n * 255u) / iters; +} + // smin(a, b, k) → the smooth minimum of two distances: two shapes FLOW into one another instead of // merely overlapping (Quilez). `k` is the blend radius, 0 a plain min. Wraps draw::smin, so a // script and a compiled effect melt shapes identically. @@ -608,9 +708,7 @@ extern "C" inline uint32_t mm_light_setPaletteColor(const uintptr_t* args, uint3 const uint32_t x = uint32_t(args[0]), y = uint32_t(args[1]); if (x >= uint32_t(cv.dims.x) || y >= uint32_t(cv.dims.y)) return 0; draw::pixel(cv, Coord3D{lengthType(x), lengthType(y), 0}, - colorFromPalette(*Palettes::active(), - static_cast(args[2]), - static_cast(args[3]))); + colorFromPalette(*Palettes::active(), byteArg(args[2]), byteArg(args[3]))); return 0; } @@ -963,6 +1061,10 @@ inline BuiltinTable lightBuiltins() { // smin(a, b, k) → the smooth minimum: two shapes melt into one surface. k = 0 is a // plain union. See mm_light_smin. t.add({"smin", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_smin, {}}); + // escape(cx, cy, jx, jy, iters) → the escape-time count for z = z*z + c, 0..255, 0 inside. + // Mandelbrot with a zero seed, Julia otherwise. The one piece of maths a script cannot + // express: it squares SIGNED values and script arithmetic is unsigned. + t.add({"escape", 5, /*returns*/ true, BuiltinKind::Call, &mm_light_escape, {}}); // beat(bpm, t) → 0..65535 sawtooth at bpm. The clock an animation is written against. t.add({"beat", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_beat, {}}); // beatsin(bpm, t, high) → a sine 0..high at bpm. The same shape an effect reaches for. diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index 66717670..a47d4eed 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -165,6 +165,11 @@ class MoonLiveScript { static_cast(decls[i].min), static_cast(decls[i].max)); } + // The member's initializer (`uint8_t bpm = 60;`) IS the control's default, and it is + // the only place one exists: /api/types probes a fresh module for defaults, and a + // scripted module's controls come from the script, so a probe with no script declares + // none. Carried on the control instead, which is what lights the UI's reset button. + controls.setDefault(controls.count() - 1, static_cast(decls[i].def)); } } diff --git a/src/platform/desktop/moonlive_asm_arm64.cpp b/src/platform/desktop/moonlive_asm_arm64.cpp index 5ae01432..0f78431e 100644 --- a/src/platform/desktop/moonlive_asm_arm64.cpp +++ b/src/platform/desktop/moonlive_asm_arm64.cpp @@ -165,6 +165,11 @@ void HostAssembler::store16(Reg base, Reg off, Reg val) { // strh wVal, [xBase, void HostAssembler::load16(Reg d, Reg base, int32_t imm) { emit32(0x79400000u | (((uint32_t(imm) >> 1) & 0xfff) << 10) | (mr(base) << 5) | mr(d)); } +// ldrsh wDst, [xBase, #imm]: the 32-bit-destination signed form (opc 11), so the sign fills the +// top 16 bits of the w register and the x register's upper half stays clear. +void HostAssembler::load16S(Reg d, Reg base, int32_t imm) { + emit32(0x79C00000u | (((uint32_t(imm) >> 1) & 0xfff) << 10) | (mr(base) << 5) | mr(d)); +} // ldrb wDst, [xBase, xOff] and ldrh wDst, [xBase, xOff]. The register-offset form takes the index // UNSCALED for a byte; for a halfword the LSL amount would scale it, and it is left at 0 so the // index the caller passes is a BYTE offset in both cases. That keeps one rule for the lowering: @@ -183,8 +188,11 @@ void HostAssembler::branchIfZero(Reg a, Label l) { // cbz wA, l (offset emit32(0x34000000u | mr(a)); } void HostAssembler::branchIf(Cond c, Label l) { // b.cond l (offset patched) - // arm64 condition codes: NE=1, HS/CS=2, LO/CC=3. - const uint8_t cond = (c == Cond::Lo) ? 0x3 : (c == Cond::Ne ? 0x1 : 0x2); + // arm64 condition codes: NE=1, HS/CS=2, LO/CC=3, GE=10. + // Every enumerator is listed rather than falling through to a default: an unhandled one would + // emit a plausible branch with the WRONG condition, which runs and does the opposite thing. + const uint8_t cond = (c == Cond::Lo) ? 0x3 : (c == Cond::Ne) ? 0x1 + : (c == Cond::Ge) ? 0xa : 0x2; addFixup(len_, l, FixKind::Branch); // the condition is already in the instruction emit32(0x54000000u | cond); } @@ -193,6 +201,7 @@ void HostAssembler::branchIf(Cond c, Label l) { // b.cond l (offset // same name, which is what lets the IR walk be written once. void HostAssembler::movReg(Reg d, Reg a) { addImm(d, a, 0); } // mov wD, wA (add wD, wA, #0) void HostAssembler::branchGeU(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Hs, l); } +void HostAssembler::branchGeS(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Ge, l); } void HostAssembler::branchNe(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Ne, l); } // movPtr: a full 64-bit address into a register, movz + three movk. diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h index 7843f817..8dd18c65 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -31,7 +31,8 @@ enum Reg : uint8_t { R0 = 0, R1, R2, R3, R4, R5, R6, R7, R8, R9, using Label = uint8_t; // Branch condition (only the ones the IR needs so far). -enum class Cond : uint8_t { Lo /* unsigned < */, Hs /* unsigned >= */, Ne /* != */ }; +enum class Cond : uint8_t { Lo /* unsigned < */, Hs /* unsigned >= */, Ne /* != */, + Ge /* SIGNED >= */ }; class HostAssembler { public: @@ -104,6 +105,7 @@ class HostAssembler { void load8(Reg d, Reg base, int32_t imm); // d = base[imm] (zero-extended byte) — control read void store16(Reg base, Reg off, Reg val); // halfword store: base[off..off+1] = val (low 16 bits) void load16(Reg d, Reg base, int32_t imm);// d = base[imm..imm+1] (zero-extended halfword) + void load16S(Reg d, Reg base, int32_t imm);// the same halfword, SIGN-extended void load8Idx(Reg d, Reg base, Reg off); // d = base[off] (zero-extended byte), index in a REG void load16Idx(Reg d, Reg base, Reg off); // d = base[off..off+1], index in a REG void movReg(Reg d, Reg a); // d = a @@ -113,6 +115,7 @@ class HostAssembler { // instruction. Naming the operation rather than the flags is what lets one lowering serve all // three: a backend that needs two instructions hides that here, where the encoding already is. void branchGeU(Reg a, Reg b, Label l); // if (unsigned)a >= b goto l + void branchGeS(Reg a, Reg b, Label l); // if (signed)a >= b goto l void branchNe(Reg a, Reg b, Label l); // if a != b goto l // Call a host built-in: d = fn(a, b, c). Preserves the host-arg registers (R0/R1/R2 = buf, // nLights, cpl) across the call by saving them on the stack, so they stay live for the diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp index 5b9d89cc..3b9a488f 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -543,6 +543,21 @@ void HostAssembler::load16(Reg d, Reg base, int32_t imm) { b[n++] = uint8_t(imm >> 16); b[n++] = uint8_t(imm >> 24); emitBytes(b, n); } +// movsx r32, word ptr [base + disp32] (0F BF /r): the sign-extending twin of movzx (0F B7), and +// the only byte that differs. Writing the 32-bit destination zeroes the register's upper half, +// so a negative arrives as a 32-bit value and the comparison width in cmp() matches it. +void HostAssembler::load16S(Reg d, Reg base, int32_t imm) { + const uint8_t dst = xr(d), b_reg = xr(base); + const bool needsSIB = ((b_reg & 7) == x64::RSP); + uint8_t b[9]; size_t n = 0; + b[n++] = rex_(false, dst >= 8, false, b_reg >= 8); + b[n++] = 0x0F; b[n++] = 0xBF; + b[n++] = modrm_(0b10, dst & 7, needsSIB ? 0b100 : (b_reg & 7)); + if (needsSIB) b[n++] = sib_(0, 0b100, b_reg & 7); + b[n++] = uint8_t(imm); b[n++] = uint8_t(imm >> 8); + b[n++] = uint8_t(imm >> 16); b[n++] = uint8_t(imm >> 24); + emitBytes(b, n); +} // movzx r32, byte ptr [base + off] (0F B6 /r SIB) — indexed 8-bit zero-extending load. void HostAssembler::load8Idx(Reg d, Reg base, Reg off) { const uint8_t op[2] = {0x0F, 0xB6}; @@ -556,11 +571,21 @@ void HostAssembler::load16Idx(Reg d, Reg base, Reg off) { // --- compare and branch ------------------------------------------------------------------------- -// cmp r64, r64 (REX.W 39 /r) — sets flags = a - b. +// cmp r32, r32 (39 /r): sets flags = a - b, THIRTY-TWO bit. +// +// 32-bit, not REX.W 64-bit, because a MoonLive value is 32 bits and arm64 already compares in `w` +// registers. While every value was zero-extended the two agreed and the width did not matter. A +// SIGNED compare makes them disagree on the same program: a 32-bit -1 sitting in a 64-bit register +// is 0x00000000FFFFFFFF, which a 64-bit signed compare reads as +4294967295. Comparing at the +// value's own width is what keeps the four backends running the same script the same way. +// +// REX is still emitted when either register is r8..r15, since that is what addresses them; only +// the W bit (the 64-bit operand size) is dropped. rex_ returns 0x40 for the no-bits case, which is +// a valid null REX prefix, so the three-byte form holds for every register pair. void HostAssembler::cmp(Reg a, Reg b) { const uint8_t left = xr(a), right = xr(b); uint8_t bytes[3] = { - rex_(true, right >= 8, false, left >= 8), + rex_(false, right >= 8, false, left >= 8), 0x39, modrm_(0b11, right & 7, left & 7), }; @@ -568,13 +593,14 @@ void HostAssembler::cmp(Reg a, Reg b) { } // Conditional branch, near-32-bit-relative (0F 8x rel32). Always the rel32 form — one width, -// so the fixup table is uniform. Condition tt values: NE=0x5, HS/AE=0x3, LO/B=0x2. +// so the fixup table is uniform. Condition tt values: NE=0x5, HS/AE=0x3, LO/B=0x2, GE=0xD. void HostAssembler::branchIf(Cond c, Label l) { uint8_t tt; switch (c) { case Cond::Ne: tt = 0x05; break; case Cond::Hs: tt = 0x03; break; // aka AE: unsigned >= case Cond::Lo: tt = 0x02; break; // aka B: unsigned < + case Cond::Ge: tt = 0x0D; break; // SIGNED >= default: tt = 0x05; break; } // Fixup site is the START of the branch instruction; patchBranches computes rel32 relative @@ -603,6 +629,7 @@ void HostAssembler::branchIfZero(Reg a, Label l) { // The fused compare-and-branch pair. void HostAssembler::branchGeU(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Hs, l); } +void HostAssembler::branchGeS(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Ge, l); } void HostAssembler::branchNe(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Ne, l); } // --- ret ---------------------------------------------------------------------------------------- diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index d7243985..f2af2144 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -200,6 +200,10 @@ void RiscvAssembler::store16(Reg base, Reg off, Reg val) { void RiscvAssembler::load16(Reg d, Reg base, int32_t imm) { emit32(((uint32_t(imm) & 0xfff) << 20) | (xr(base) << 15) | (5 << 12) | (xr(d) << 7) | 0x03); } +// lh rDst, imm(rBase): funct3 1 rather than lhu's 5, which is the whole difference. +void RiscvAssembler::load16S(Reg d, Reg base, int32_t imm) { + emit32(((uint32_t(imm) & 0xfff) << 20) | (xr(base) << 15) | (1 << 12) | (xr(d) << 7) | 0x03); +} // RISC-V has no register-offset addressing mode, so the address is computed first. Same shape as // store8/store16, which is why they share kScratchAddr. void RiscvAssembler::load8Idx(Reg d, Reg base, Reg off) { @@ -218,6 +222,10 @@ void RiscvAssembler::branchGeU(Reg a, Reg b, Label l) { addFixup(len_, l); emit32(encBranch(xr(a), xr(b), 7, 0)); // bgeu a, b, l } +void RiscvAssembler::branchGeS(Reg a, Reg b, Label l) { + addFixup(len_, l); + emit32(encBranch(xr(a), xr(b), 5, 0)); // bge a, b, l (funct3 5, vs 7 unsigned) +} void RiscvAssembler::branchNe(Reg a, Reg b, Label l) { addFixup(len_, l); emit32(encBranch(xr(a), xr(b), 1, 0)); // bne a, b, l diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index 2022d533..23bc1c86 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -90,10 +90,12 @@ class RiscvAssembler { void load8(Reg d, Reg base, int32_t imm); // lbu rDst, imm(rBase) — a control read void store16(Reg base, Reg off, Reg val); // add tmp,base,off ; sh val,0(tmp) void load16(Reg d, Reg base, int32_t imm);// lhu rDst, imm(rBase), a wide control read + void load16S(Reg d, Reg base, int32_t imm);// lh rDst, imm(rBase), SIGN-extended void load8Idx(Reg d, Reg base, Reg off); // add tmp,base,off ; lbu d,0(tmp) void load16Idx(Reg d, Reg base, Reg off); // add tmp,base,off ; lhu d,0(tmp) void branchIfZero(Reg a, Label l); // beqz a, l (bge x0, a... use bgeu against x0) void branchGeU(Reg a, Reg b, Label l); // bgeu a, b, l + void branchGeS(Reg a, Reg b, Label l); // bge a, b, l void branchNe(Reg a, Reg b, Label l); // bne a, b, l void call(Reg d, Reg a, Reg b, Reg c, const void* fn); // standard call to a host built-in /// Call a function in THIS block, by label: the script-to-script call. `jal ra, off` links the diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 58e6aeda..9c40ea35 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -320,6 +320,15 @@ void XtensaAssembler::load16(Reg d, Reg base, int32_t imm) { uint8_t((imm >> 1) & 0xff)}; emit(b, 3); } +// l16si aDst, aBase, #imm: the same RRI8 shape as l16ui, differing only in the `r` field, which +// is the HIGH nibble of the second byte (l8ui r=0, l16ui r=1, l16si r=9). The first byte carries +// the destination and the LSAI opcode and does not change. +// Xtensa has l16si but NO l8si, which is why int16_t is a member type here and int8_t is not. +void XtensaAssembler::load16S(Reg d, Reg base, int32_t imm) { + const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), uint8_t(0x90 | ar(base)), + uint8_t((imm >> 1) & 0xff)}; + emit(b, 3); +} // Xtensa has no register-offset load either. The computed address goes through kAddrScratch, the // same temp store8/store16 use, and the RRI8 offset is 0 so the halfword scaling never applies. @@ -363,9 +372,14 @@ void XtensaAssembler::branchIfZero(Reg a, Label l) { void XtensaAssembler::branchRelaxed(uint8_t condNibble, Reg a, Reg b, Label l) { // The inverted condition, skipping the 3-byte `j` that follows. Xtensa branch displacements are // relative to PC+4 (the same rule patchBranches uses), so clearing a 3-byte instruction is +2. - // bne(0x9) <-> beq(0x1); bgeu(0xb) <-> bltu(0x3). + // bne(0x9) <-> beq(0x1); bgeu(0xb) <-> bltu(0x3); bge(0xa) <-> blt(0x2). + // Every nibble this is called with is listed: an unlisted one would take the final branch and + // emit a WRONG condition rather than failing, and a mis-inverted branch is a program that runs + // and does the opposite thing. const uint8_t inv = condNibble == 0x9 ? 0x1 : condNibble == 0x1 ? 0x9 - : condNibble == 0xb ? 0x3 : 0xb; + : condNibble == 0xb ? 0x3 : condNibble == 0x3 ? 0xb + : condNibble == 0xa ? 0x2 : condNibble == 0x2 ? 0xa + : 0xb; const uint8_t br[3] = {uint8_t((ar(b) << 4) | 0x7), uint8_t((inv << 4) | ar(a)), 0x02}; emit(br, 3); addFixup(len_, l); @@ -374,6 +388,8 @@ void XtensaAssembler::branchRelaxed(uint8_t condNibble, Reg a, Reg b, Label l) { } // bgeu aA, aB, l (skip if a >= b, unsigned) void XtensaAssembler::branchGeU(Reg a, Reg b, Label l) { branchRelaxed(0xb, a, b, l); } +// bge aA, aB, l (skip if a >= b, SIGNED). Same relaxation, one nibble apart from bgeu. +void XtensaAssembler::branchGeS(Reg a, Reg b, Label l) { branchRelaxed(0xa, a, b, l); } // bne aA, aB, l void XtensaAssembler::branchNe(Reg a, Reg b, Label l) { branchRelaxed(0x9, a, b, l); } diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index 587b5679..a4370270 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -93,10 +93,12 @@ class XtensaAssembler { void load8(Reg d, Reg base, int32_t imm); // l8ui aDst, aBase, #imm — a control read void store16(Reg base, Reg off, Reg val); // s16i via computed address (add then s16i,0) void load16(Reg d, Reg base, int32_t imm);// l16ui aDst, aBase, #imm, a wide control read + void load16S(Reg d, Reg base, int32_t imm);// l16si aDst, aBase, #imm, SIGN-extended void load8Idx(Reg d, Reg base, Reg off); // add.n tmp,base,off ; l8ui d,tmp,0 void load16Idx(Reg d, Reg base, Reg off); // add.n tmp,base,off ; l16ui d,tmp,0 void branchIfZero(Reg a, Label l); // beqz aA, l (nLights==0 guard) void branchGeU(Reg a, Reg b, Label l); // bgeu aA, aB, l (Bounds: skip if a>=b) + void branchGeS(Reg a, Reg b, Label l); // bge aA, aB, l (a script's own comparison) void branchNe(Reg a, Reg b, Label l); // bne aA, aB, l (loop test) void call(Reg d, Reg a, Reg b, Reg c, const void* fn); // windowed call8 to a host built-in /// Call a function in THIS block, by label: the script-to-script call. diff --git a/src/ui/app.js b/src/ui/app.js index fe7e9197..e4a6f6a3 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1491,7 +1491,12 @@ function isUserEditableChild(mod, depth) { // Look up the factory default for a given module type's control. Returns undefined when // the type isn't in /api/types yet or the control has no default (display/progress). -function defaultFor(moduleType, ctrlName) { +function defaultFor(moduleType, ctrlName, ctrl) { + // A default carried ON the control wins: it is the only one that exists for a module whose + // controls are declared by data rather than by its C++ type (a MoonLive script's + // `uint8_t bpm = 60;`). /api/types probes a fresh instance, which for those has no script and + // so declares no controls at all. + if (ctrl && ctrl.default !== undefined) return ctrl.default; if (!moduleType) return undefined; const t = availableTypes.find(t => t.name === moduleType); if (!t || !t.defaults) return undefined; @@ -1544,7 +1549,7 @@ function createControl(moduleName, moduleType, ctrl) { row.appendChild(label); const key = moduleName + ":" + ctrl.name; - const def = defaultFor(moduleType, ctrl.name); + const def = defaultFor(moduleType, ctrl.name, ctrl); // numberField: a numeric control that opted out of the slider (server sets it for a value where each // integer is a discrete identity, not a magnitude — a PHY/I2C address, a channel). Render a plain @@ -3631,9 +3636,9 @@ function updateModuleControls(mod) { break; } } - // Reset-button state may change as the value drifts in/out of default. - // Defaults live in availableTypes (populated from /api/types) keyed by module type. - const def = defaultFor(mod.type, ctrl.name); + // Reset-button state may change as the value drifts in/out of default. A default on the + // control itself wins over the type-level ones from /api/types; see defaultFor. + const def = defaultFor(mod.type, ctrl.name, ctrl); if (def !== undefined && def !== null) { const btn = document.querySelector(`button.reset-btn[data-mid="${mid}"][data-key="${k}.reset"]`); if (btn) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 916dc832..e368a5eb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -47,6 +47,7 @@ add_executable(mm_tests unit/core/unit_moonlive_codegen_xtensa.cpp unit/core/unit_moonlive_codegen_riscv.cpp unit/core/unit_moonlive_codegen_x86_64.cpp + unit/core/unit_moonlive_codegen_arm64.cpp unit/core/unit_MoonModule.cpp unit/core/unit_MoonModule_control_change_gate.cpp unit/core/unit_MoonModule_lifecycle.cpp diff --git a/test/unit/core/moonlive_script_wrap.h b/test/unit/core/moonlive_script_wrap.h index 10d24428..f6ee9a39 100644 --- a/test/unit/core/moonlive_script_wrap.h +++ b/test/unit/core/moonlive_script_wrap.h @@ -43,7 +43,12 @@ inline const char* mmScriptAs(const char* entry, const char* body) { const char* declEnd = body; while (true) { while (*p == ' ' || *p == '\t' || *p == '\n') p++; - if (std::strncmp(p, "uint8_t", 7) != 0) break; + // Every member type the language has, not just uint8_t: a test declaring `int16_t d = -1;` + // means a member exactly as `uint8_t speed = 7;` does, and recognising only one of them + // silently drops the declaration into the function body, where it is not a member at all. + if (std::strncmp(p, "uint8_t", 7) != 0 && + std::strncmp(p, "uint16_t", 8) != 0 && + std::strncmp(p, "int16_t", 7) != 0) break; const char* semi = std::strchr(p, ';'); if (!semi) break; const char* eol = std::strchr(semi, '\n'); diff --git a/test/unit/core/unit_moonlive_codegen_arm64.cpp b/test/unit/core/unit_moonlive_codegen_arm64.cpp new file mode 100644 index 00000000..43ce6d4c --- /dev/null +++ b/test/unit/core/unit_moonlive_codegen_arm64.cpp @@ -0,0 +1,57 @@ +// @module MoonLive + +// The arm64 (Apple Silicon desktop) host backend's new signed-value encodings, checked BYTE for +// BYTE. Twin of unit_moonlive_codegen_x86_64.cpp for the same reason it exists: an encoding bug +// in JIT-emitted bytes surfaces as a fault inside anonymous executable memory, and a pinned byte +// sequence turns that into "this word is wrong". Runs only on arm64 hosts, where HostAssembler +// compiles as the arm64 branch of the platform backend; skipped elsewhere. +// +// Scoped to the signed additions (load16S, branchGeS): the pre-existing arm64 encodings are +// covered by every compile-through-run test on this host, which executes them for real. + +#include "doctest.h" + +#if defined(__aarch64__) && !defined(MM_MOONLIVE_FORCE_NO_HOST_JIT) + +#include "platform/desktop/moonlive_asm_host.h" + +#include + +using namespace mm::moonlive; + +namespace { +uint32_t word(const HostAssembler& a, size_t i) { + return uint32_t(a.bytes()[i]) | (uint32_t(a.bytes()[i + 1]) << 8) + | (uint32_t(a.bytes()[i + 2]) << 16) | (uint32_t(a.bytes()[i + 3]) << 24); +} +} // namespace + +// ldrsh (signed, opc 11) against ldrh (unsigned, opc 01): the top byte is the whole difference, +// and it is what makes an int16_t member read back negative rather than as 65436. +TEST_CASE("arm64: load16S emits ldrsh where load16 emits ldrh") { + HostAssembler u; u.load16(R0, R1, 4); u.finalize(); + HostAssembler s; s.load16S(R0, R1, 4); s.finalize(); + REQUIRE(u.size() == 4); + REQUIRE(s.size() == 4); + CHECK((word(u, 0) & 0xFFC00000u) == 0x79400000u); // ldrh w, [x, #imm] + CHECK((word(s, 0) & 0xFFC00000u) == 0x79C00000u); // ldrsh w, [x, #imm] + // Same halfword-scaled immediate field in both. + CHECK(((word(s, 0) >> 10) & 0xFFFu) == 2u); +} + +// b.ge (cond 0xA) against b.hs (cond 0x2): the condition nibble is what decides whether a +// negative compares below zero or above everything. +TEST_CASE("arm64: branchGeS branches on GE where branchGeU branches on HS") { + HostAssembler u; { auto l = u.newLabel(); u.branchGeU(R0, R1, l); u.bind(l); u.finalize(); } + HostAssembler s; { auto l = s.newLabel(); s.branchGeS(R0, R1, l); s.bind(l); s.finalize(); } + REQUIRE(u.size() == 8); // cmp + b.cond + REQUIRE(s.size() == 8); + CHECK((word(u, 0) & 0xFFE0001Fu) == 0x6B00001Fu); // cmp wA, wB (subs wzr): 32-bit, matching + // the x86-64 compare width + CHECK((word(u, 4) & 0xFF00000Fu) == 0x54000002u); // b.hs + CHECK((word(s, 4) & 0xFF00000Fu) == 0x5400000Au); // b.ge, SIGNED +} + +#else +TEST_CASE("arm64 codegen: skipped (not an arm64 host)") { CHECK(true); } +#endif diff --git a/test/unit/core/unit_moonlive_codegen_riscv.cpp b/test/unit/core/unit_moonlive_codegen_riscv.cpp index b4588b8b..b9d0a5fb 100644 --- a/test/unit/core/unit_moonlive_codegen_riscv.cpp +++ b/test/unit/core/unit_moonlive_codegen_riscv.cpp @@ -56,3 +56,29 @@ namespace mm { using namespace ::mm; using namespace ::mm::moonlive; #include "moonlive_device_codegen.inc" + +// The signed 16-bit load is lh (funct3 1) where the unsigned is lhu (funct3 5); the signed +// branch is bge (funct3 5) where the unsigned is bgeu (funct3 7). One field each, asserted on +// the encoder: a script-level test cannot tell these apart until a negative value flows, and by +// then the symptom is a picture, not a diff. +TEST_CASE("RISC-V load16S emits lh and branchGeS emits bge, one funct3 apart from unsigned") { + using Asm = mm_riscv_backend::mm::moonlive::RiscvAssembler; + using mm_riscv_backend::mm::moonlive::R0; + using mm_riscv_backend::mm::moonlive::R1; + auto word = [](const Asm& a, size_t i) { + return uint32_t(a.bytes()[i]) | (uint32_t(a.bytes()[i+1]) << 8) + | (uint32_t(a.bytes()[i+2]) << 16) | (uint32_t(a.bytes()[i+3]) << 24); + }; + Asm lu(64); lu.load16(R0, R1, 4); + Asm ls(64); ls.load16S(R0, R1, 4); + REQUIRE(lu.size() == 4); + REQUIRE(ls.size() == 4); + CHECK((word(lu, 0) & 0x7f) == 0x03); // load opcode + CHECK(((word(lu, 0) >> 12) & 7) == 5); // lhu + CHECK(((word(ls, 0) >> 12) & 7) == 1); // lh, sign-extending + Asm bu(64); { auto l = bu.newLabel(); bu.branchGeU(R0, R1, l); bu.bind(l); bu.finalize(); } + Asm bs(64); { auto l = bs.newLabel(); bs.branchGeS(R0, R1, l); bs.bind(l); bs.finalize(); } + CHECK((word(bu, 0) & 0x7f) == 0x63); // branch opcode + CHECK(((word(bu, 0) >> 12) & 7) == 7); // bgeu + CHECK(((word(bs, 0) >> 12) & 7) == 5); // bge, SIGNED +} diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp index c828e5fc..3d3c3e66 100644 --- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp +++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp @@ -317,19 +317,39 @@ TEST_CASE("x86_64: mulImm(R0, R1, 42) is imul r64, r/m64, imm32") { CHECK(A.bytes()[6] == 0x00); } -TEST_CASE("x86_64: branchGeU emits cmp + je-with-condition-Hs (0F 83 rel32)") { +TEST_CASE("x86_64: an unsigned comparison branches on the unsigned condition") { HostAssembler A; Label l = A.newLabel(); A.branchGeU(R0, R1, l); // cmp (3 bytes) + jae rel32 (6 bytes: 0F 83 xx xx xx xx). Total 9. A.bind(l); A.finalize(); REQUIRE(A.size() == 9); - CHECK(A.bytes()[0] == 0x48); // cmp REX.W - CHECK(A.bytes()[1] == 0x39); // cmp r/m64, r64 + CHECK(A.bytes()[0] == 0x40); // null REX: the compare is 32-bit, not REX.W 64-bit + CHECK(A.bytes()[1] == 0x39); // cmp r/m32, r32 CHECK(A.bytes()[3] == 0x0F); // jae opcode prefix CHECK(A.bytes()[4] == 0x83); // jae rel32 } +TEST_CASE("x86_64: a signed comparison branches on the signed condition, so a negative compares below zero") { + HostAssembler A; + Label l = A.newLabel(); + A.branchGeS(R0, R1, l); + A.bind(l); A.finalize(); + REQUIRE(A.size() == 9); + CHECK(A.bytes()[4] == 0x8D); // jge rel32, NOT jae (0x83) +} + +TEST_CASE("x86_64: the compare is 32 bits wide, the width a MoonLive value actually has") { + // Not REX.W. arm64 compares in `w` registers, so a 64-bit compare here would read a 32-bit + // negative (sitting in a 64-bit register as 0x00000000FFFFFFFF) as a large POSITIVE, and the + // two backends would run the same script differently the moment a comparison went signed. + HostAssembler A; + Label l = A.newLabel(); + A.branchGeS(R0, R1, l); + A.bind(l); A.finalize(); + CHECK((A.bytes()[0] & 0x08) == 0); // the W bit is clear +} + TEST_CASE("x86_64: branchNe emits cmp + jne (0F 85 rel32)") { HostAssembler A; Label l = A.newLabel(); diff --git a/test/unit/core/unit_moonlive_codegen_xtensa.cpp b/test/unit/core/unit_moonlive_codegen_xtensa.cpp index 74fd9bf3..c75b2939 100644 --- a/test/unit/core/unit_moonlive_codegen_xtensa.cpp +++ b/test/unit/core/unit_moonlive_codegen_xtensa.cpp @@ -229,3 +229,36 @@ TEST_CASE("Xtensa addImm never encodes an add of zero as the narrow form") { CHECK(a.bytes()[2] == 40); } } + +// The signed 16-bit load differs from the unsigned one ONLY in the r field (the second byte's +// high nibble: l16ui r=1, l16si r=9). Asserted on the encoder because this exact encoding +// shipped WRONG once: the 0x9 was first placed in the first byte's low nibble, the disassembler +// read garbage, and every int16_t member load was an illegal instruction. +TEST_CASE("Xtensa load16S emits l16si, one r-nibble away from l16ui") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + Asm u(64); u.load16(R0, R1, 4); + Asm s(64); s.load16S(R0, R1, 4); + REQUIRE(u.size() == 3); + REQUIRE(s.size() == 3); + CHECK((s.bytes()[0] & 0x0f) == 0x02); // LSAI opcode, same as l16ui + CHECK((u.bytes()[1] >> 4) == 0x1); // l16ui: r = 1 + CHECK((s.bytes()[1] >> 4) == 0x9); // l16si: r = 9 + CHECK(s.bytes()[2] == 2); // the RRI8 immediate is scaled by 2 +} + +// The relaxed branch emits the INVERTED condition over a jump, so signed bge appears as blt +// (0x2) where unsigned bgeu appears as bltu (0x3). This is the nibble the old inversion table's +// fallthrough would have gotten wrong, emitting the OPPOSITE condition. +TEST_CASE("Xtensa branchGeS inverts to blt where branchGeU inverts to bltu") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + Asm u(64); { auto l = u.newLabel(); u.branchGeU(R0, R1, l); u.bind(l); u.finalize(); } + Asm s(64); { auto l = s.newLabel(); s.branchGeS(R0, R1, l); s.bind(l); s.finalize(); } + REQUIRE(u.size() == 6); // inverted branch (3) + j (3) + REQUIRE(s.size() == 6); + CHECK((u.bytes()[1] >> 4) == 0x3); // bltu + CHECK((s.bytes()[1] >> 4) == 0x2); // blt: the SIGNED inversion +} diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 202b1f74..21077415 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -38,7 +38,7 @@ static std::vector render(const char* src, int nLights, uint32_t t = 0) } #endif -// The compile-through-run tests need a working host JIT. The assembler (moonlive_asm_host.cpp) +// The compile-through-run tests need a working host JIT. The assembler (the host backend (moonlive_asm_arm64/x86_64.cpp)) // covers arm64 and x86-64, so these run on every desktop the project supports; a host with // neither, or a --no-jit build, gets !ok ("codegen failed") and every "should compile" assertion // would fail. Guarded on the emit-header capability macro so they compile out there instead — @@ -570,4 +570,134 @@ TEST_CASE("dividing by zero yields zero rather than faulting") { CHECK(render(mmScript("setRGB(0, 100 / 0, 100 % 0, 0);"), 1)[0] == 0); CHECK(render(mmScript("setRGB(0, 100 / 0, 100 % 0, 0);"), 1)[1] == 0); } + +// A subtraction that goes below zero is the ordinary way to ask "which of these is bigger", and +// before this it silently answered the opposite: the difference wrapped to a huge unsigned value +// and every `< 0` test was false. Four separate rendering bugs in one session came from this. +TEST_CASE("a comparison against a subtraction that went negative takes the negative branch") { + CHECK(render(mmScript("if (10 - 200 < 0) { setRGB(0, 7, 0, 0); } " + "else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); + // ... and the positive direction still reads positive, so this is a fix and not an inversion. + CHECK(render(mmScript("if (200 - 10 < 0) { setRGB(0, 7, 0, 0); } " + "else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); +} + +// Every relational operator routes through the same two branch ops, so each spelling needs its own +// check: `>` swaps the operands and `>=` / `<=` go through the two-branch strict form. +TEST_CASE("every comparison operator orders a negative below a positive") { + CHECK(render(mmScript("if (0 - 5 > 1) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); + CHECK(render(mmScript("if (0 - 5 <= 1) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); + CHECK(render(mmScript("if (0 - 5 >= 1) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); +} + +// The one remaining trap in signed division: INT32_MIN / -1 overflows, which is UB and a SIGFPE +// on x86-64 where the other three ISAs quietly wrap. A script can write it (65536 * 32768 wraps +// the multiply to INT32_MIN), so the host guards it the same way it guards divide-by-zero. +TEST_CASE("dividing the most negative value by minus one saturates rather than faulting") { + // 32768 * 32768 * 2 wraps the multiply to exactly INT32_MIN (a literal cannot exceed 65535). + CHECK(render(mmScript("if (32768 * 32768 * 2 / (0 - 1) > 0) { setRGB(0, 7, 0, 0); } " + "else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); + CHECK(render(mmScript("setRGB(0, 32768 * 32768 * 2 % (0 - 1), 5, 0);"), 1)[1] == 5); +} + +// An int16_t ARRAY is refused at the declaration: element access lowers through the unsigned +// indexed load on every backend, so a negative element would silently read as a large positive +// where a scalar of the same type reads correctly. A refusal names the gap; a wrong number would +// not. +TEST_CASE("an int16_t array is refused with a diagnostic rather than mis-read") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { int16_t buf[4]; tick() { fill(0, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A coordinate far outside the plane must escape immediately, not overflow: the wrapped multiply +// below hands escape() the most negative int32 there is, whose square alone is 2^62. +TEST_CASE("escape treats an absurdly distant coordinate as escaped rather than overflowing") { + CHECK(render(mmScript("setRGB(0, escape(32768 * 32768 * 2, 32768 * 32768 * 2, 0, 0, 40), 7, 0);"), + 1)[0] > 0); +} + +// The escape-time fractal, pinned at the points every textbook names. escape() is the one loop a +// script cannot write itself (it squares signed fixed-point in 64 bits), so its contract is pinned +// here rather than by the script that uses it. +TEST_CASE("escape reports the inside of the Mandelbrot set as zero and the outside as a count") { + // The origin is inside the set forever; c = 2 + 2i runs away almost immediately. + CHECK(render(mmScript("setRGB(0, escape(0, 0, 0, 0, 40), 7, 0);"), 1)[0] == 0); + CHECK(render(mmScript("setRGB(0, escape(0, 0, 0, 0, 40), 7, 0);"), 1)[1] == 7); + CHECK(render(mmScript("setRGB(0, escape(16384, 16384, 0, 0, 40), 0, 0);"), 1)[0] > 0); +} + +TEST_CASE("escape near the set boundary counts more steps than far outside") { + // c = -1.2 + 0.3i sits near the boundary and survives longer than c = 1 + 1i, which is the + // graded banding every rendering of the set is made of. -1.2 in Q13 is -9830. + auto near_px = render(mmScript("setRGB(0, escape(0 - 9830, 2458, 0, 0, 40), 0, 0);"), 1); + auto far_px = render(mmScript("setRGB(0, escape(8192, 8192, 0, 0, 40), 0, 0);"), 1); + CHECK(near_px[0] > far_px[0]); +} + +TEST_CASE("a nonzero seed selects the Julia set rather than the Mandelbrot set") { + // The SAME pixel answers differently under the two modes, which is the whole point of the + // seed: the origin is inside the Mandelbrot set (0 forever), but under Julia seed + // (-0.4, 0.6) it iterates z = z*z + c from z = 0+0i and escapes with a graded count. + CHECK(render(mmScript("setRGB(0, escape(0, 0, 0, 0, 40), 7, 0);"), 1)[0] == 0); + CHECK(render(mmScript("setRGB(0, escape(0, 0, 0 - 3277, 4915, 40), 7, 0);"), 1)[0] > 0); +} + +// An int16_t member is how a script holds a value that goes below zero: a velocity, a delta, a +// distance from a center. Stored in the arena as two bytes and read back SIGN-EXTENDED, where a +// uint16_t member would return 65436 for -100. +TEST_CASE("an int16_t member written negative reads back negative") { + CHECK(render(mmScript("int16_t neg = -100; " + "if (neg < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); +} + +// ASSIGNED in tick(), not just seeded by the initializer: the store and the load are different +// ops, and the bug this pins wrote only ONE byte of the two-byte member, so the sign-extending +// load read a stale high byte and every stored coordinate collapsed to 0..255. A whole shader +// rendered one flat color, and the initializer-only test above stayed green throughout. +TEST_CASE("an int16_t member assigned a negative in tick reads back negative") { + CHECK(render(mmScript("int16_t v = 0; " + "v = 100 - 11000; " + "if (v < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); + // And the magnitude survives, not just the sign: -10900 halved is -5450, still negative, + // where a half-written member would hold a small positive. + CHECK(render(mmScript("int16_t v = 0; " + "v = 100 - 11000; " + "if (v / 2 < 0 - 5000) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); +} + +// The same value in a uint16_t member is a large positive, which is the distinction the type makes. +TEST_CASE("a uint16_t member holds the same bits as a large positive") { + CHECK(render(mmScript("uint16_t pos = 65436; " + "if (pos < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); +} + +// A brightness is a byte channel, and a script computing `n * 255` for "full" meant full. The +// truncating cast turned that into an arbitrary pattern (n=50 gave 206, n=128 gave 128) while +// every part of the expression still looked right. +TEST_CASE("a brightness above full saturates to full instead of wrapping to a dark band") { + // setPaletteColor(x, y, index, brightness): 50 * 255 is 12750, which truncated to a byte is + // 206. Saturating, it is 255, and palette entry 0 at full brightness is not black. + auto bright = render(mmScript("setPaletteColor(0, 0, 0, 50 * 255);"), 1); + auto full = render(mmScript("setPaletteColor(0, 0, 0, 255);"), 1); + CHECK(bright[0] == full[0]); + CHECK(bright[1] == full[1]); + CHECK(bright[2] == full[2]); +} + +TEST_CASE("a brightness that went below zero renders black rather than full") { + auto px = render(mmScript("setPaletteColor(0, 0, 0, 0 - 10);"), 1); + CHECK(px[0] == 0); + CHECK(px[1] == 0); + CHECK(px[2] == 0); +} + +// The loop guard deliberately stayed UNSIGNED when comparisons went signed: a loop counter is a +// count, and `for (i = 0; i < width; ...)` must run whatever a signed reading would make of it. +TEST_CASE("a loop over a count still runs every step after comparisons became signed") { + auto px = render(mmScript("for (i = 0; i < 4; i = i + 1) { setRGB(i, 9, 0, 0); }"), 4); + CHECK(px[0] == 9); + CHECK(px[3 * 3] == 9); +} #endif diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 49a9f3e3..a0567b90 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -1251,7 +1251,7 @@ TEST_CASE("a circle drawn through uv stays circular on a wide panel") { REQUIRE(eng.compile("class T { tick() {" " for (y = 0; y < 8; y = y + 1) {" " for (x = 0; x < 32; x = x + 1) {" - " if (polarR(uvX(x, 32, 8) - 32768, uvY(y, 32, 8) - 32768) < 6000) {" + " if (polarR(uvX(x, 32, 8), uvY(y, 32, 8)) < 6000) {" " setRGB(y * 32 + x, 255, 0, 0);" " } } } } }", kCtrlTable, kSys)); uint8_t px[32 * 8 * 3] = {}; @@ -1265,19 +1265,22 @@ TEST_CASE("a circle drawn through uv stays circular on a wide panel") { CHECK(litRows < 8); // and it fits inside the short axis rather than clipping } -// The bias convention every signed value in this language shares: 32768 is the origin, so a -// coordinate left of center reads below it and one to the right above it. -TEST_CASE("uv places the grid center at the origin") { +// A coordinate has an origin: the center of the grid is 0, the left half is NEGATIVE, and a script +// uses the number it is given. No bias to subtract, which is what made `uvX(...) - 32768` wrap on +// the left half and tear a shader's plane into blocks. +TEST_CASE("uv places the grid center at the origin, with the left half negative") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T { tick() {" - " setRGB(0, scale(uvX(0, 16, 16), 256), scale(uvX(15, 16, 16), 256)," - " scale(uvY(0, 16, 16), 256)); } }", kCtrlTable, kSys)); - uint8_t px[3] = {}; - eng.run(px, 1, 3, 0, moonlive::kEntryTick); + " if (uvX(0, 16, 16) < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }" + " if (uvX(15, 16, 16) > 0) { setRGB(1, 7, 0, 0); } else { setRGB(1, 3, 0, 0); }" + " if (uvY(0, 16, 16) < 0) { setRGB(2, 7, 0, 0); } else { setRGB(2, 3, 0, 0); }" + "} }", kCtrlTable, kSys)); + uint8_t px[9] = {}; + eng.run(px, 3, 3, 0, moonlive::kEntryTick); eng.free(); - CHECK(px[0] < 128); // the left edge sits below the origin - CHECK(px[1] > 128); // the right edge above it - CHECK(px[2] < 128); // and the same on the other axis + CHECK(px[0] == 7); // the left edge is below the origin + CHECK(px[3] == 7); // the right edge above it + CHECK(px[6] == 7); // and the same on the other axis } // smin is what makes two shapes read as ONE surface rather than as two stamps that overlap. The @@ -1375,17 +1378,19 @@ TEST_CASE("fading from a script with no layer does nothing") { // past the edge must saturate at the edge it passed. TEST_CASE("a coordinate far outside the grid saturates at that edge, not the opposite one") { moonlive::MoonLive eng; + // Compared rather than scaled: uv is signed now, and scale() takes the unsigned 0..65535 that + // beat() produces, so reading a coordinate through it would test the wrong thing. REQUIRE(eng.compile("class T { tick() {" - " setRGB(0, scale(uvX(65535 * 65535, 4, 4), 256)," - " scale(uvX(3, 4, 4), 256)," - " scale(uvY(65535 * 65535, 4, 4), 256)); } }", - kCtrlTable, kSys)); - uint8_t px[3] = {}; - eng.run(px, 1, 3, 0, moonlive::kEntryTick); + " if (uvX(3, 4, 4) > 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }" + " if (uvX(65535 * 65535, 4, 4) > 0) { setRGB(1, 7, 0, 0); } else { setRGB(1, 3, 0, 0); }" + " if (uvY(65535 * 65535, 4, 4) > 0) { setRGB(2, 7, 0, 0); } else { setRGB(2, 3, 0, 0); }" + "} }", kCtrlTable, kSys)); + uint8_t px[9] = {}; + eng.run(px, 3, 3, 0, moonlive::kEntryTick); eng.free(); - CHECK(px[1] > 128); // x = 3 on a 4-wide grid: right of center, as a control - CHECK(px[0] == 255); // and a huge x saturates at the RIGHT edge, not the left - CHECK(px[2] == 255); // same on the other axis + CHECK(px[0] == 7); // x = 3 on a 4-wide grid: right of center, as a control + CHECK(px[3] == 7); // a huge x saturates at the RIGHT edge, not the left + CHECK(px[6] == 7); // same on the other axis } #endif // MM_MOONLIVE_HAS_HOST_JIT — every case above needs compile() to SUCCEED, so diff --git a/test/unit/light/unit_PanelCardDriver.cpp b/test/unit/light/unit_PanelCardDriver.cpp index 518de13b..18c29371 100644 --- a/test/unit/light/unit_PanelCardDriver.cpp +++ b/test/unit/light/unit_PanelCardDriver.cpp @@ -79,6 +79,7 @@ TEST_CASE("PanelCardDriver sends one frame per row then one sync") { mm::PanelCardDriver driver; Wall wall(64, 4); setUp(driver, source, wall, 256); + driver.firmware = 1; // "v13 and newer": acts on the SECOND copy, so both go out mm::platform::setTestNowMs(1000); driver.tick(); @@ -99,7 +100,8 @@ TEST_CASE("PanelCardDriver sends a pre-v13 card one brightness and one sync") { mm::PanelCardDriver driver; Wall wall(64, 4); setUp(driver, source, wall, 256); - driver.firmware = 1; // "v12 and older" + // No assignment: "v12 and older" is index 0 and the DEFAULT, because the documented path + // downgrades the card to clear the v13 flicker. This test also pins that default. mm::platform::setTestNowMs(1000); driver.tick(); @@ -121,8 +123,8 @@ TEST_CASE("PanelCardDriver stops at the last row its buffer covers") { mm::platform::setTestNowMs(1000); driver.tick(); - REQUIRE(mm::platform::ethTestFrameCount() == 6); // 2 brightness + 2 rows + 2 sync, not 8 rows - CHECK(frameType(5) == mm::COLORLIGHT_TYPE_SYNC); + REQUIRE(mm::platform::ethTestFrameCount() == 4); // brightness + 2 rows + sync, not 8 rows + CHECK(frameType(3) == mm::COLORLIGHT_TYPE_SYNC); } // A row wider than one packet splits into several, each carrying its own pixel offset — the wide- @@ -136,13 +138,13 @@ TEST_CASE("PanelCardDriver splits a row wider than one packet") { mm::platform::setTestNowMs(1000); driver.tick(); - REQUIRE(mm::platform::ethTestFrameCount() == 6); // 2 brightness + 2 row packets + 2 sync - // First chunk: offset 0, a full 497 pixels. - const uint8_t* a = mm::platform::ethTestFrameData(2); + REQUIRE(mm::platform::ethTestFrameCount() == 4); // brightness + 2 row packets + sync + // First chunk: offset 0, a full 497 pixels. Frame 1: one brightness precedes the rows. + const uint8_t* a = mm::platform::ethTestFrameData(1); CHECK(((a[15] << 8) | a[16]) == 0); CHECK(((a[17] << 8) | a[18]) == mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); // Second chunk: continues at 497, carrying the remaining 103. - const uint8_t* b = mm::platform::ethTestFrameData(3); + const uint8_t* b = mm::platform::ethTestFrameData(2); CHECK(((b[15] << 8) | b[16]) == mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); CHECK(((b[17] << 8) | b[18]) == 600 - mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); } @@ -160,8 +162,8 @@ TEST_CASE("PanelCardDriver puts rendered pixels on the wire") { mm::platform::setTestNowMs(1000); driver.tick(); - REQUIRE(mm::platform::ethTestFrameCount() == 5); // 2 brightness + row + 2 sync - const uint8_t* row = mm::platform::ethTestFrameData(2); + REQUIRE(mm::platform::ethTestFrameCount() == 3); // brightness + row + sync + const uint8_t* row = mm::platform::ethTestFrameData(1); CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 0] == 10); CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 1] == 20); CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 2] == 30); @@ -180,7 +182,7 @@ TEST_CASE("PanelCardDriver rate-limits to its fps setting") { mm::platform::setTestNowMs(1000); driver.tick(); const size_t after1 = mm::platform::ethTestFrameCount(); - CHECK(after1 == 5); // 2 brightness + row + 2 sync + CHECK(after1 == 3); // brightness + row + sync mm::platform::setTestNowMs(1010); // too soon driver.tick(); @@ -222,7 +224,7 @@ TEST_CASE("PanelCardDriver survives a failing link") { mm::platform::setTestEthSendFails(false); mm::platform::setTestNowMs(1100); driver.tick(); - CHECK(mm::platform::ethTestFrameCount() == 5); // recovers on the next tick + CHECK(mm::platform::ethTestFrameCount() == 3); // recovers on the next tick } // The correction-applied buffer is sized off the hot path, so tick() never allocates — the same @@ -313,13 +315,13 @@ TEST_CASE("PanelCardDriver sends the wall a PanelsLayout describes") { mm::platform::setTestNowMs(1000); driver.tick(); - // 2 brightness + 128 rows (one packet each, 128 <= 497) + 2 sync - REQUIRE(mm::platform::ethTestFrameCount() == 132); + // brightness + 128 rows (one packet each, 128 <= 497) + sync + REQUIRE(mm::platform::ethTestFrameCount() == 130); // Rows are numbered across the whole card, not restarted per panel. - const uint8_t* firstRow = mm::platform::ethTestFrameData(2); + const uint8_t* firstRow = mm::platform::ethTestFrameData(1); REQUIRE(firstRow != nullptr); CHECK(((firstRow[13] << 8) | firstRow[14]) == 0); - const uint8_t* lastRow = mm::platform::ethTestFrameData(129); + const uint8_t* lastRow = mm::platform::ethTestFrameData(128); REQUIRE(lastRow != nullptr); CHECK(((lastRow[13] << 8) | lastRow[14]) == 127); } @@ -357,8 +359,8 @@ TEST_CASE("PanelCardDriver widens the row when a PanelsLayout chains panels acro mm::platform::setTestNowMs(1000); driver.tick(); - // 2 brightness + 64 rows + 2 sync — still one packet per row, since 256 <= 497. - REQUIRE(mm::platform::ethTestFrameCount() == 68); + // brightness + 64 rows + sync: still one packet per row, since 256 <= 497. + REQUIRE(mm::platform::ethTestFrameCount() == 66); const uint8_t* row = mm::platform::ethTestFrameData(2); REQUIRE(row != nullptr); CHECK(((row[17] << 8) | row[18]) == 256); // pixels in this packet @@ -428,7 +430,11 @@ TEST_CASE("PanelCardDriver sends nothing when the buffer covers no row") { // re-entered and the guard under test never runs. static void wedge(mm::PanelCardDriver& driver, uint32_t fromMs) { mm::platform::setTestEthSendFails(true); - for (int i = 0; i < 200; i++) { + // Enough TICKS to clear the driver's 500-consecutive-failure wedge threshold. Each tick sends + // one brightness + rows + one sync, so the frame count per tick depends on the wall: 250 ticks + // is comfortably past 500 for the small walls these tests build, with headroom rather than an + // exact figure, because the point is that a long run of failures accumulates. + for (int i = 0; i < 250; i++) { mm::platform::setTestNowMs(fromMs + i * 30); driver.tick(); }