From f985a366233f4d14efafcffbc828dc148281de89 Mon Sep 17 00:00:00 2001 From: ewowi Date: Sun, 23 Aug 2026 23:49:00 +0200 Subject: [PATCH 1/3] Scripts get five types: int, byte, bool, fixed and string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MoonLive script now says what a value MEANS instead of how many bytes it takes: int, byte, bool, fixed and string replace uint8_t/uint16_t/int16_t, and one addControl replaces the width-matched addUint8/addUint16 pair. `fixed` is fractional arithmetic without a float, so a shader writes 0.5 and -1.2 rather than scaled integers, and metal and fractal read as the numbers they mean. Performance: desktop 186us / 5,376 FPS, esp32 2,151us / 464 FPS. The Xtensa grid layout got 2 bytes SMALLER: a 4-byte slot uses the narrow l32i.n where the old halfword access needed three. **Core** - CtrlType is five semantic types; every SCALAR takes one uniform 4-byte slot, ARRAYS pack by element (byte[] 1, int[] 4). The two near-duplicate arena cursors collapse into one - expression type tracking without an AST: a type rides the parsed value, and mixing int with fixed is a compile error naming toFixed/toInt. An integer LITERAL adopts fixed at a meet point by patching its own Const, so `v * 2` and `if (v < 0)` read naturally while a variable still names its conversion - fixed multiply lowers to Mulhi + Mul + two shifts, three instructions and no call; fixed divide goes through the fdiv host call, which widens in int64 (any 32-bit pre-shift wraps past 128.0) - new IR: LoadCtrl32, StoreCtrl32, Mulhi, Shl, Shr, Sar. Deleted: LoadCtrl16, LoadCtrl16S, StoreCtrl16, ctrlIsSigned, Builtin::refType and the unreachable width-2 array path - ControlType::Int32, so an int member surfaces as an honest control - dividing by zero SATURATES toward the numerator's sign (IEEE's infinity, mapped onto an int) rather than returning 0: k/dist at the centre of a ripple is the peak the eye expects, and no script needs a zero-check of its own **Light domain** - uvX/uvY and escape() speak Q16.16, so uv output flows into a fixed member and into the fractal with no rescaling. sin/cos/beat keep their unsigned convention: a coordinate has an origin, a wave does not - builtins declare which arguments are fixed, so the parser type-checks against the table rather than a name **Platform** - four primitives per backend (mulhi, shlImm, shrImm, sarImm) plus 32-bit slot access, Xtensa/RISC-V/arm64/x86-64 - FIXED ON THE BENCH: every hand-built Xtensa encoding was byte-reversed. The two ESP objdumps print different conventions (esp32-elf the 24-bit word, esp32s3-elf memory bytes), and the reversed slli decoded as `l32r a1` β€” a stack-pointer clobber that hung the board with no panic text while every host test stayed green. All encoders now emit words through emit3/emit2 - movImm materialises the whole int32 range on arm64 and Xtensa; both silently masked to 16 bits before, which a Q16.16 literal was the first value to expose **Scripts** - all 26 migrated; metal and fractal hold uv in fixed members - fractal: the Julia seed rides the cardioid with a noise-breathed radius, so the coastlines vary instead of cycling **Tests** - the five types, fixed arithmetic and the type wall at every boundary: array index, element store, loop header, builtin argument, assignment - every shipped script compiles on the HOST backend β€” the device sweeps covered Xtensa and RISC-V, nothing covered the backend every desktop runs - byte-level encoding tests per ISA, pinned in memory order with the reversal recorded **Docs/CI** - MoonLiveEffect/Layout/Modifier and moonlive/README migrated; the roadmap section marked shipped with what the design did not anticipate - release.yml triggers on moondeck/ci changes, so a packaging fix reaches the jobs that run it - CLAUDE.md: archiving a plan, not deleting it - repo-health.json had 15 unresolved conflict markers committed into it, so every run since failed to parse the baseline and lost the esp32 numbers and ten firmware sizes. Resolved and restored **Reviews** - πŸ‘Ύ Reviewer (16 findings, 15 fixed): array element reads leaked the index's type (heat[3] * 0.5 patched the index and read the wrong element) β†’ fixed; array stores checked neither index nor value β†’ fixed; the lexer did numeric arithmetic in `long`, 32-bit on the device where scripts compile, so the overflow guard could never fire β†’ int64; for-headers accepted a fixed limit, ~65,536 iterations and a render-thread stall β†’ refused; fixed % fixed mistyped as int β†’ fixed; addControl accepted a negative low bound on a byte, publishing min 251 max 100 β†’ refused; toFixed of an out-of-range literal wrapped β†’ compile error; Xtensa shrImm silently emitted shift-16 for any n>15 β†’ refuses; reserved names, byte b = 0.0, contradictory string diagnostics, comment drift β†’ fixed. DEFERRED: fixed[] arrays are refused with a diagnostic rather than half-working, since element type-tracking needs the array's type to reach both read and write - one of my own tests passed for the wrong reason (it divided by a literal that wrapped); rewritten to assert saturation by comparison Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 4 + CLAUDE.md | 2 +- docs/backlog/moonlive-language-roadmap.md | 27 +- docs/metrics/repo-health.json | 113 +--- docs/metrics/repo-health.md | 72 +-- docs/moonmodules/light/MoonLiveEffect.md | 65 +- docs/moonmodules/light/MoonLiveLayout.md | 14 +- docs/moonmodules/light/MoonLiveModifier.md | 2 +- moonlive/README.md | 16 +- moonlive/effects/ballpit.mle | 14 +- moonlive/effects/balls.mle | 20 +- moonlive/effects/comet-trail.mle | 16 +- moonlive/effects/crosshair.mle | 4 +- moonlive/effects/ember.mle | 14 +- moonlive/effects/fountain.mle | 12 +- moonlive/effects/fractal.mle | 51 +- moonlive/effects/lines.mle | 4 +- moonlive/effects/metal.mle | 42 +- moonlive/effects/noise.mle | 8 +- moonlive/effects/octopus.mle | 12 +- moonlive/effects/plasma.mle | 8 +- moonlive/effects/rain.mle | 12 +- moonlive/effects/ripples.mle | 8 +- moonlive/layouts/diagonal.mll | 4 +- moonlive/layouts/grid.mll | 8 +- moonlive/layouts/lattice.mll | 12 +- moonlive/layouts/reversed-row.mll | 4 +- moonlive/layouts/ring.mll | 8 +- moonlive/layouts/rose.mll | 8 +- moonlive/layouts/two-rows.mll | 4 +- moonlive/modifiers/shift.mlm | 4 +- src/core/Control.cpp | 13 + src/core/Control.h | 13 + src/core/moonlive/MoonLive.cpp | 18 +- src/core/moonlive/MoonLive.h | 49 +- src/core/moonlive/MoonLiveBuiltins.h | 50 +- src/core/moonlive/MoonLiveCompiler.cpp | 464 +++++++++++--- src/core/moonlive/MoonLiveIr.h | 41 +- src/core/moonlive/MoonLiveSpill.cpp | 13 +- src/core/moonlive/moonlive_lower.h | 40 +- src/light/moonlive/MoonLiveBuiltins_light.h | 153 +++-- src/light/moonlive/MoonLiveScript.h | 42 +- src/platform/desktop/moonlive_asm_arm64.cpp | 63 +- src/platform/desktop/moonlive_asm_host.h | 12 +- src/platform/desktop/moonlive_asm_x86_64.cpp | 113 ++-- src/platform/esp32/moonlive_asm_riscv.cpp | 54 +- src/platform/esp32/moonlive_asm_riscv.h | 12 +- src/platform/esp32/moonlive_asm_xtensa.cpp | 106 +++- src/platform/esp32/moonlive_asm_xtensa.h | 12 +- src/ui/app.js | 23 +- test/CMakeLists.txt | 1 + .../scenario_MoonLiveEffect_controls.json | 12 +- .../scenario_MoonLiveEffect_livescript.json | 6 +- .../light/scenario_MoonLive_pipeline.json | 16 +- .../light/scenario_modifier_swap.json | 4 +- .../light/scenario_peripheral_grid_sweep.json | 8 +- test/unit/core/moonlive_device_codegen.inc | 4 +- test/unit/core/moonlive_script_wrap.h | 24 +- test/unit/core/moonlive_structural.inc | 2 +- test/unit/core/unit_Control_int32.cpp | 75 +++ .../unit/core/unit_moonlive_codegen_arm64.cpp | 60 +- .../unit/core/unit_moonlive_codegen_riscv.cpp | 57 +- .../core/unit_moonlive_codegen_xtensa.cpp | 114 +++- test/unit/core/unit_moonlive_compiler.cpp | 564 ++++++++++++++++-- test/unit/core/unit_moonlive_fill.cpp | 269 +++++---- test/unit/core/unit_moonlive_ir.cpp | 10 +- test/unit/core/unit_moonlive_spill.cpp | 4 +- test/unit/light/unit_MoonLiveLayout.cpp | 56 +- test/unit/light/unit_MoonLiveParticles.cpp | 4 +- test/unit/light/unit_MoonLiveScripts.cpp | 10 +- 70 files changed, 2236 insertions(+), 947 deletions(-) create mode 100644 test/unit/core/unit_Control_int32.cpp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd08811b..ee20b73b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ on: - 'test/**' - 'esp32/**' - 'moondeck/build/**' + # package_desktop.py builds and packages the three desktop jobs; without this line a + # packaging fix cannot trigger the jobs that run it (the NSIS-escape fix needed a + # manual dispatch because only moondeck/ci changed). + - 'moondeck/ci/**' - 'CMakeLists.txt' - 'library.json' - '.github/workflows/release.yml' diff --git a/CLAUDE.md b/CLAUDE.md index d18a4875..46c3e563 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ fix, keeping main clean) β€” creating one silently moves work somewhere the PO i 1. **Pick.** One module/effect/driver/capability β€” the product owner picks what to build next. 2. **Spec.** Specs before code: the module spec and the UI spec sufficient to implement from (a draft may sit in the backlog until it ships); when in doubt, ask. -3. **Plan.** Plan mode before every feature; save the approved plan to `docs/history/plans/` as `Plan-YYYYMMDD - .md` β€” a temporary document: it ends up as the PR description and the file is deleted once the plan is realized; the merged PR is the design record. **Deleting a plan is the product owner's call β€” never the agent's.** "The code is written" is not "the plan is realized": a plan is realized when its *verification* is done too, including the judgement steps (thresholds tuned, results read together, the bench check). Ask; do not infer it from a green build. For a restructure ("make it simpler/cleaner"): enumerate 2–4 end states, name what each gains and loses, pick the leanest that solves the actual problem; propose as a question, implement only what's picked; surface follow-ups before starting so it's one coherent refactor. +3. **Plan.** Plan mode before every feature; save the approved plan to `docs/history/plans/` as `Plan-YYYYMMDD - <title>.md` β€” a temporary document: it ends up as the PR description and the file is archived once the plan is realized; the merged PR is the design record. **Archiving a plan is the product owner's call β€” never the agent's.** "The code is written" is not "the plan is realized": a plan is realized when its *verification* is done too, including the judgement steps (thresholds tuned, results read together, the bench check). Ask; do not infer it from a green build. For a restructure ("make it simpler/cleaner"): enumerate 2–4 end states, name what each gains and loses, pick the leanest that solves the actual problem; propose as a question, implement only what's picked; surface follow-ups before starting so it's one coherent refactor. ### Build diff --git a/docs/backlog/moonlive-language-roadmap.md b/docs/backlog/moonlive-language-roadmap.md index 91565fa5..aeabea33 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`, `int16_t` | no float | +| ~~numeric types~~ | ~~`uint8_t`, `uint16_t`, `int16_t`~~ β†’ **`int`, `byte`, `bool`, `fixed`, `string`** βœ… | still no float: `fixed` is Q16.16 | | ~~builtin table~~ | ~~16, and 16 used~~ β†’ **64** βœ… | `BuiltinTable::kMax` β€” raised, with an overflow assert | The branch budget was binary-searched with generated scripts: **6 `if`/`else` + 2 `for` compiles, @@ -102,6 +102,31 @@ Item 5 is worth noting against the arena work below: a particle pool as a HANDLE does not spend its own 64 bytes on particle state at all. That is a better answer than widening the arena, and it is already designed. +## The type system: βœ… *shipped* + +Shipped as designed, with three things worth carrying forward that the design did not anticipate: + +- **`fixed` needed three new assembler primitives**, not the one the design implied. A Q16.16 + multiply is `mulhi` + `mul` + two shifts, and the JIT had no shift instruction and no + multiply-high at all β€” `mulReg` is a plain 32-bit multiply. `shlImm`, `sarImm`, `shrImm` and + `mulhi` went into all four backends, every encoding checked byte-for-byte against the real + assembler. Worth it: the alternative made every fixed multiply a host call inside a per-pixel + loop, and made `int`↔`fixed` conversion β€” one shift β€” a function call. +- **An integer literal ADOPTS fixed at a meet point.** The design said any mix is an error; that + made `v * 2` and `if (v < 0)` unwritable. A bare literal now converts at compile time by + patching its own `Const` (free at run time), while a *variable* still names its conversion, + because a literal's meaning is visible at the site and a variable's scaling is not. +- **`movImm` truncated above 16 bits on arm64 and Xtensa.** A latent bug the whole time, masked + `& 0xffff` under a comment warning about exactly that failure mode: invisible while literals + capped at 65535, and fatal the moment a Q16.16 literal (2.0 is 131072) rode a `Const`. Both + backends now materialize the full 32 bits. + +`bool` truncates rather than normalizing (`flag = 256` reads false), because normalizing needs a +compare-and-select the IR has no op for β€” there is no `Sub` and no bitwise op. Waiting for a script +that writes a non-boolean expression into a bool. + +The design as agreed follows, unchanged. + ## The type system: the settled design (2026-08-23) Decided with the product owner after the signed-values work, whose four bugs were all diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 0ed7dc1b..2d32f3eb 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,40 +1,23 @@ { -<<<<<<< HEAD - "commit": "4c91f700", -======= - "commit": "ab508402", ->>>>>>> main + "commit": "a3f1cd54", "flash": { + "esp32s3-n16r8": 1820064, + "desktop": 1231192, "esp32": 1764416, "esp32p4rev1-eth": 1653696, "esp32p4rev1-eth-wifi": 1933472, -<<<<<<< HEAD - "esp32s3-n16r8": 1804944, -======= - "esp32s3-n16r8": 1813456, ->>>>>>> main "esp32s3-n8r8": 1753232, "esp32s31": 2079904, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, "qemu": 1318160, - "esp32p4rev3-eth": 1643760, -<<<<<<< HEAD - "desktop": 1214976 + "esp32p4rev3-eth": 1643760 }, "perf": { "desktop": { - "tick_us": 356, - "fps": 2808 -======= - "desktop": 1230168 - }, - "perf": { - "desktop": { - "tick_us": 182, - "fps": 5494 ->>>>>>> main + "tick_us": 186, + "fps": 5376 }, "esp32": { "tick_us": 2151, @@ -42,96 +25,54 @@ } }, "loc": { -<<<<<<< HEAD - "core": 19600, - "light": 25712, - "platform": 14900, - "ui": 7023, - "test": 45720, - "moondeck": 21801 - }, - "comments": { - "core": { - "lines": 7693, - "ratio": 0.425 -======= - "core": 19684, - "light": 25824, - "platform": 14891, - "ui": 7028, - "test": 45902, - "moondeck": 21626 + "core": 20106, + "light": 25873, + "platform": 15109, + "ui": 7033, + "test": 46696, + "moondeck": 21847 }, "comments": { "core": { - "lines": 7746, + "lines": 7914, "ratio": 0.426 ->>>>>>> main }, "light": { - "lines": 10237, + "lines": 10271, "ratio": 0.438 }, "platform": { -<<<<<<< HEAD - "lines": 5306, - "ratio": 0.391 - }, - "ui": { - "lines": 1857, - "ratio": 0.281 - }, - "test": { - "lines": 8377, - "ratio": 0.21 - }, - "moondeck": { - "lines": 3507, - "ratio": 0.184 - } - }, - "tests": { - "cases": 1493, - "scenarios": 23 - }, - "docs": { - "md_files": 191, - "md_lines": 27684, - "plans_files": 97, - "backlog_lines": 4319, -======= - "lines": 5303, - "ratio": 0.391 + "lines": 5369, + "ratio": 0.39 }, "ui": { "lines": 1861, "ratio": 0.281 }, "test": { - "lines": 8438, - "ratio": 0.211 + "lines": 8628, + "ratio": 0.212 }, "moondeck": { - "lines": 3504, + "lines": 3529, "ratio": 0.185 } }, "tests": { - "cases": 1511, + "cases": 1561, "scenarios": 23 }, "docs": { - "md_files": 189, - "md_lines": 27631, - "plans_files": 96, - "backlog_lines": 4341, ->>>>>>> main - "lessons_lines": 576, + "md_files": 191, + "md_lines": 27981, + "plans_files": 97, + "backlog_lines": 4451, + "lessons_lines": 592, "claude_md_lines": 136 }, "complexity": { - "functions": 2716, - "over_threshold": 165, + "functions": 2747, + "over_threshold": 168, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index ad03a694..7c88e83f 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,10 +1,6 @@ # Repo health -<<<<<<< HEAD -Measured at `4c91f700`. 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.** ->>>>>>> main +Measured at `a3f1cd54`. 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. @@ -12,11 +8,7 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -<<<<<<< HEAD -| desktop | 1,186 KB | -======= -| desktop | 1,201 KB | ->>>>>>> main +| desktop | 1,202 KB | | esp32 | 1,723 KB | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | @@ -24,11 +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 | -<<<<<<< HEAD -| esp32s3-n16r8 | 1,763 KB | -======= -| esp32s3-n16r8 | 1,771 KB (+0 KB) ⚠ | ->>>>>>> main +| esp32s3-n16r8 | 1,777 KB | | esp32s3-n8r8 | 1,712 KB | | esp32s31 | 2,031 KB | | qemu | 1,287 KB | @@ -37,67 +25,43 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -<<<<<<< HEAD -| desktop | 356 Β΅s (βˆ’40 Β΅s) βœ“ | 2,808 (+283) βœ“ | -======= -| desktop | 182 Β΅s (βˆ’309 Β΅s) βœ“ | 5,494 (+3,458) βœ“ | ->>>>>>> main +| desktop | 186 Β΅s | 5,376 | | esp32 | 2,151 Β΅s | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -<<<<<<< HEAD -| core | 19,600 | 7,693 | 42.5 % | -| light | 25,712 | 10,161 | 43.6 % | -| platform | 14,900 | 5,306 | 39.1 % | -| ui | 7,023 | 1,857 | 28.1 % | -| test | 45,720 (+24) ⚠ | 8,377 | 21.0 % | -| moondeck | 21,801 (+47) ⚠ | 3,507 | 18.4 % | -======= -| 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 % | ->>>>>>> main +| core | 20,106 | 7,914 | 42.6 % | +| light | 25,873 | 10,271 | 43.8 % | +| platform | 15,109 | 5,369 | 39.0 % | +| ui | 7,033 | 1,861 | 28.1 % | +| test | 46,696 | 8,628 | 21.2 % | +| moondeck | 21,847 | 3,529 | 18.5 % | ## Tests | Kind | Count | |---|---:| -<<<<<<< HEAD -| unit cases | 1,493 (+1) βœ“ | -======= -| unit cases | 1,511 (+7) βœ“ | ->>>>>>> main +| unit cases | 1,561 | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,716 | -| over threshold | 165 | +| functions | 2,747 | +| over threshold | 168 | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -<<<<<<< HEAD -| markdown files | 191 (+2) ⚠ | -| markdown lines | 27,684 (+177) ⚠ | -| plan files | 97 (+1) ⚠ | -| backlog lines | 4,319 (+8) ⚠ | -======= -| markdown files | 189 | -| markdown lines | 27,631 (+1) ⚠ | -| plan files | 96 | -| backlog lines | 4,341 (βˆ’1) βœ“ | ->>>>>>> main -| lessons lines | 576 | +| markdown files | 191 | +| markdown lines | 27,981 | +| plan files | 97 | +| backlog lines | 4,451 | +| lessons lines | 592 | | CLAUDE.md lines | 136 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 01dc0a31..694f7a3b 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -34,19 +34,19 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 Type in the box and the script compiles when you click away, press Ctrl/Cmd+S, or press Save; a dot on the Save button marks unsaved work. A valid script swaps in on the next tick. A failed compile frees the old code, shows the diagnostic in the module status, and renders dark until it is fixed, so a typo costs a message rather than a reboot. Fixing it in place is enough: nothing has to be renamed. The card also creates and deletes scripts (delete asks twice), and the same editor is what the File Manager opens from a file row. The control is [`filepath`](../core/ui.md#control-types), which is generic: the module says only where its files are and which extension they carry. -- **Scripted controls**: a script declares members, then says which of them the UI shows by calling `addUint8` (or `addUint16`) inside a `defineControls()`, the same call a compiled module makes. Each becomes a real MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: +- **Scripted controls**: a script declares members, then says which of them the UI shows by calling `addControl` inside a `defineControls()`, the same call a compiled module makes. Each becomes a real MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c class SpeedyEffect { - uint8_t speed = 50; - uint8_t hue = 128; - uint16_t dwell = 900; // a value a byte cannot hold - uint8_t phase = 0; // a member, not a control: the UI never shows it + byte speed = 50; + byte hue = 128; + int dwell = 900; // a value a byte cannot hold + byte phase = 0; // a member, not a control: the UI never shows it defineControls() { - addUint8("speed", speed, 0, 99); - addUint8("hue", hue, 0, 255); - addUint16("dwell", dwell, 0, 1000); + addControl("speed", speed, 0, 99); + addControl("hue", hue, 0, 255); + addControl("dwell", dwell, 0, 1000); } tick() { setRGB(speed, hue, phase, 255); } @@ -59,14 +59,42 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 simply the script's own state. The compiled form is the same call with a receiver: `controls_.addUint8("speed", speed, 1, 255)` - (and `controls_.addUint16("dwell", dwell, 0, 1000)` for a wide member, which reaches the UI as a - 16-bit control carrying its full range and value, not a byte). The member is named by identifier rather than by repeating the string, so a typo is a compile error here as it is there, and the quoted name is the UI label, free to differ from the member's name. The **default** comes from the member's initializer, so there is one home for the starting value. The range arguments are ordinary expressions, like every other argument in the language: `addUint8("speed", speed, base, base * 4 + 5)` is valid. + β€” a compiled module still names the C++ width it binds, because there the member really is a + `uint8_t`. A script does not: its `addControl` reads the widget from how the member was declared. + The member is named by identifier rather than by repeating the string, so a typo is a compile error here as it is there, and the quoted name is the UI label, free to differ from the member's name. The **default** comes from the member's initializer, so there is one home for the starting value. The range arguments are ordinary expressions, like every other argument in the language: `addControl("speed", speed, base, base * 4 + 5)` is valid. `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 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. + **One call for every type**: which widget appears follows from how the member was declared, so a call and a declaration can no longer disagree. A `byte` becomes a 0..255 slider, a `bool` a toggle, an `int` a full-range number. A range the member's type cannot hold is refused rather than truncated (`addControl("n", n, 0, 900)` on a `byte`), because a slider whose top silently wraps is worse than one that never appears. A control binds a single member, never an array, and a `fixed` or `string` member is refused: neither has a widget yet. - **`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`. +### The five types + +A type says what a value **means**; the storage is the compiler's business. Every **scalar** occupies the same 4-byte slot whatever its type, and only **arrays** pack by element β€” which is where the width still earns its keep, since a `byte[]` heat map costs a quarter of an `int[]` one and the classic ESP32 has no PSRAM to absorb the difference. + +| Type | Range | For | +|---|---|---| +| `int` | βˆ’2,147,483,648 … 2,147,483,647 | counts, indices, milliseconds, anything whole | +| `byte` | 0 … 255 | a channel, a palette index, a heat cell β€” the LED's own range | +| `bool` | `true` / `false` | a flag | +| `fixed` | βˆ’32,768.0 … 32,767.99998, in steps of 1/65,536 | coordinates and anything fractional | +| `string` | one of the script's own literals | a name passed to a builtin | + +An initializer is range-checked against its type, so `byte n = 300;` is a compile error naming the member rather than a silent 44. Arrays are declared `byte heat[16];` and start at zero; a `string` array is refused, since there is no runtime string to fill one with. + +### `fixed`: fractional arithmetic without a float + +`fixed` is Q16.16 β€” the number is stored scaled by 65,536, which is how every coordinate in the engine has always worked, now spelled the way a script reads it. There is no float anywhere: fixed-point is bit-identical on all four backends, which is also what makes an effect reproducible. + +```c +fixed ux = 0.0; +ux = uvX(x, width, height); // uvX and uvY hand back a fixed coordinate +ux = ux * 2 + 0.5; // ordinary arithmetic, decimals written as decimals +setRGB(0, toInt(ux * 100), 0, 255); +``` + +**Mixing a whole number and a fixed value is a compile error** naming the conversion to write, because at run time the two are the same 32 bits and a silent mix is a number 65,536 times off with nothing reporting it. `toFixed(v)` and `toInt(v)` convert explicitly, each one instruction. + +The exception is an integer **literal**, which adopts the fixed side at a meet point and converts at compile time: `ux * 2`, `if (ux < 0)`, and `ux = 5;` all read naturally and cost nothing at run time. A *variable* never adopts β€” its scaling is not visible where it is used, so it keeps the explicit rule. ### System variables β€” what the engine hands a script @@ -106,13 +134,14 @@ Registered by the light domain, not built into the compiler (the core owns only | `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 | +| `a / b`, `a % b` | divide and remainder. Both are host calls: cheap on a cold path, deliberate per light. Dividing by zero **saturates** toward the numerator's sign rather than faulting, so no script needs a zero-check of its own; the remainder is 0 | +| `toFixed(v)`, `toInt(v)` | convert between a whole number and a `fixed` one, each a single instruction | | `smoothstep(e0, e1, v)` | a soft `0..65535` ramp between two edges, the anti-aliasing primitive | -| `uvX(x, w, h)`, `uvY(y, w, h)` | shader space: centered, normalized on the short side so a circle stays round on a wide panel | +| `uvX(x, w, h)`, `uvY(y, w, h)` | shader space, as a `fixed` value: centered on 0.0, normalized on the short side so a circle stays round on a wide panel | | `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 | +| `escape(cx, cy, jx, jy, iters)` | the Mandelbrot/Julia escape count, `0..255`, `0` inside the set. Zero seed = Mandelbrot; the four coordinates are `fixed`, so uv output flows straight in. 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 | @@ -135,7 +164,7 @@ vocabulary follows the [WLED Particle System](https://github.com/wled/WLED) by D `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`. +`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. They return a **`fixed`** value (Q16.16), so a script holds one in a `fixed` member and does ordinary arithmetic on it; `escape()` takes four of them, which is what lets uv output flow straight into a fractal. `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. @@ -154,7 +183,7 @@ Two rules a script author meets: ### Wire contract β€” control declaration -The controls are **declared by the script** (one per `addUint8` call in its `defineControls()`), then **surfaced in `/api/state`**, the device JSON view the integrator consumes, as regular `uint8` controls alongside `script`. So an integrator sees and writes them exactly like any other control β€” e.g. `POST /api/control` with `{"module": "ML", "control": "speed", "value": 80}`; they're fully present in the device JSON, just authored in the script rather than fixed in the module. The script's `\n` line breaks are standard JSON string escapes the device decodes, so a multi-line script round-trips through `/api/file`. +The controls are **declared by the script** (one per `addControl` call in its `defineControls()`), then **surfaced in `/api/state`**, the device JSON view the integrator consumes, as regular controls alongside `script` (a `byte` member as uint8, a `bool` as bool, an `int` as int32). So an integrator sees and writes them exactly like any other control β€” e.g. `POST /api/control` with `{"module": "ML", "control": "speed", "value": 80}`; they're fully present in the device JSON, just authored in the script rather than fixed in the module. The script's `\n` line breaks are standard JSON string escapes the device decodes, so a multi-line script round-trips through `/api/file`. ## What the card tells you: size, memory, and how close to a wall @@ -186,7 +215,7 @@ A script can exhaust ten limits, but only five are ones an author can act on: | limit | ceiling | what to do | |---|---|---| | code size | 16 KB | split or simplify the script | -| controls | 8 | remove an `addUint8` | +| controls | 8 | remove an `addControl` | | members | 8 | shares the budget with controls | | functions | 8 | merge two helpers | | string bytes | 128 | shorter control labels | diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index e0b4b742..ecb3c50f 100644 --- a/docs/moonmodules/light/MoonLiveLayout.md +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -12,12 +12,12 @@ The script places every light itself, with a loop. That is the difference from a ```c class GridLayout { - uint8_t cols = 16; - uint8_t rows = 16; + byte cols = 16; + byte rows = 16; defineControls() { - addUint8("cols", cols, 1, 64); - addUint8("rows", rows, 1, 64); + addControl("cols", cols, 1, 64); + addControl("rows", rows, 1, 64); } placeLights() { @@ -49,7 +49,7 @@ for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); } for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); } // a circle: lights and grid cells are not the same number -// (`count` and `radius` are members, surfaced by addUint8 in defineControls) +// (`count` and `radius` are members, surfaced by addControl in defineControls) for (i = 0; i < count; i = i + 1) { addLight(scale(cos(i * turn(count)), radius * 2 + 1), scale(sin(i * turn(count)), radius * 2 + 1), 0); @@ -58,7 +58,7 @@ for (i = 0; i < count; i = i + 1) { ### What a script can read -A script reads whatever it declares. `uint8_t cols = 16;` is a member the script owns; naming it in `defineControls()` with `addUint8("cols", cols, 1, 64)` also makes it a real slider in the UI, and the loop reads it, which is how a panel gets resized without editing code. A `uint16_t` member is surfaced the same way with `addUint16`, which the call must match. A member no such call names stays private to the script. +A script reads whatever it declares. `byte cols = 16;` is a member the script owns; naming it in `defineControls()` with `addControl("cols", cols, 1, 64)` also makes it a real slider in the UI, and the loop reads it, which is how a panel gets resized without editing code. A member whose value a byte cannot hold is declared `int` and surfaced by the same call β€” the widget follows the type, so the two cannot disagree. A member no such call names stays private to the script. `t` is the one [system variable](MoonLiveEffect.md#system-variables-what-the-engine-hands-a-script) a layout is given, and it is always **0** here: the script runs twice per rebuild (once to count, once to place) and must agree with itself, so it is handed a fixed clock rather than a live one β€” a moving `t` would let the two passes disagree on how many lights there are. `width`/`height`/`depth` name the grid a layout is *defining*, so asking for one is a compile error rather than a silent zero; `x` and `y` are free to use as loop counters. @@ -82,7 +82,7 @@ So it runs twice. On the first pass `addLight` counts; on the second it emits ea A serpentine (every other row reversed) is what `if` makes expressible, and it is the common panel wiring: ```c -uint8_t odd = 0; +byte odd = 0; for (y = 0; y < rows; y = y + 1) { for (x = 0; x < cols; x = x + 1) { if (odd == 0) { addLight(x, y, 0); } diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index 608eab42..90398090 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -60,7 +60,7 @@ Past half full, the status also names the tightest limit the script is approachi |---|---| | `script` | the file name under `/moonlive/`; naming it (or re-naming it after an edit) recompiles and re-maps live | -Plus one control per `addUint8` / `addUint16` in the script's `defineControls()`: `addUint8("amount", amount, 0, 64)` +Plus one control per `addControl` in the script's `defineControls()`: `addControl("amount", amount, 0, 64)` becomes a slider, and moving it rebuilds the mapping just as editing the script does. Editing the script asks the Layer to rebuild its mapping, so a change is visible immediately. A script that fails to compile shows the parse error on the module and the mapping falls back to passing coordinates straight through β€” the transform disappears until the script parses again, and the device keeps rendering throughout. diff --git a/moonlive/README.md b/moonlive/README.md index 7c6408ed..37a7aaa1 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -13,9 +13,9 @@ A class may also define functions of its own and **call them**, including callin ``` class CrosshairEffect { - uint8_t bpm = 30; + byte bpm = 30; - defineControls() { addUint8("bpm", bpm, 1, 240); } + defineControls() { addControl("bpm", bpm, 1, 240); } column() { for (y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } } tick() { fill(0, 0, 0); column(); } @@ -27,10 +27,10 @@ lets one helper call another and lets a function recurse. A function takes no ar nothing yet, so a helper does a whole job rather than computing a value. `effects/crosshair.mle` is the worked example. -**A declaration is a MEMBER; `defineControls()` decides what the UI shows.** `uint8_t bpm = 30;` is +**A declaration is a MEMBER; `defineControls()` decides what the UI shows.** `byte bpm = 30;` is state the script owns: visible in every function, surviving every tick. Naming it in -`defineControls()` with `addUint8("bpm", bpm, 1, 240)` also puts it on the UI as a slider, which is -the same call a compiled module makes. A member no `addUint8` names stays private to the script, +`defineControls()` with `addControl("bpm", bpm, 1, 240)` also puts it on the UI as a slider, which is +the same call a compiled module makes. A member no `addControl` names stays private to the script, which is how a stateful effect holds a value the user should not see. The default comes from the declaration, the range from the call, and the quoted name is the UI @@ -52,13 +52,13 @@ if (heat[i] > 40) { setRGB(i, 255, 90, 0); } else { setRGB(i, 0, 0, 0); } ``` -**Members can be wider than a byte, and can be arrays.** `uint8_t` spans 0..255; `uint16_t` spans +**Members can be wider than a byte, and can be arrays.** `byte` spans 0..255; `int` spans 0..65535, which is what a position on a wall wider than 255 needs. An array is declared with a literal length and starts at zero: ```c -uint16_t phase = 900; // a value a byte cannot hold -uint8_t heat[16]; // sixteen elements, all zero to begin with +int phase = 900; // a value a byte cannot hold +byte heat[16]; // sixteen elements, all zero to begin with ``` An index is an arbitrary expression (`heat[i * 2 + 1]`), and an index outside the array is diff --git a/moonlive/effects/ballpit.mle b/moonlive/effects/ballpit.mle index 9b20c565..5ab3ee23 100644 --- a/moonlive/effects/ballpit.mle +++ b/moonlive/effects/ballpit.mle @@ -2,17 +2,17 @@ // collide() is the trick: without it they fall straight through one another. class BallpitEffect { - uint8_t balls = 24; - uint8_t size = 2; - uint8_t bouncy = 180; + byte balls = 24; + byte size = 2; + byte bouncy = 180; - uint16_t last = 0; + bool last = false; defineControls() { pool(64); - addUint8("balls", balls, 4, 60); - addUint8("size", size, 1, 5); - addUint8("bouncy", bouncy, 60, 255); + addControl("balls", balls, 4, 60); + addControl("size", size, 1, 5); + addControl("bouncy", bouncy, 60, 255); } tick() { diff --git a/moonlive/effects/balls.mle b/moonlive/effects/balls.mle index 7aaa4e45..aecd0c29 100644 --- a/moonlive/effects/balls.mle +++ b/moonlive/effects/balls.mle @@ -2,19 +2,19 @@ // Ported from MoonLight's E_balls.sc. class BallsEffect { - uint8_t count = 4; - uint8_t size = 5; - uint8_t bpm = 20; + byte count = 4; + byte size = 5; + byte bpm = 20; - uint8_t b = 0; - uint8_t radius = 4; - uint8_t px = 0; - uint8_t py = 0; + byte b = 0; + byte radius = 4; + byte px = 0; + byte py = 0; defineControls() { - addUint8("count", count, 1, 4); - addUint8("size", size, 1, 10); - addUint8("bpm", bpm, 1, 120); + addControl("count", count, 1, 4); + addControl("size", size, 1, 10); + addControl("bpm", bpm, 1, 120); } drawBall() { diff --git a/moonlive/effects/comet-trail.mle b/moonlive/effects/comet-trail.mle index 79a165ec..2827f040 100644 --- a/moonlive/effects/comet-trail.mle +++ b/moonlive/effects/comet-trail.mle @@ -2,18 +2,18 @@ // Turn spread to 0 for a tight ribbon, up for a wide cloud. class CometTrailEffect { - uint8_t speed = 30; - uint8_t spread = 40; - uint8_t sparks = 3; + byte speed = 30; + byte spread = 40; + byte sparks = 3; - uint16_t hx = 0; - uint16_t hy = 0; + int hx = 0; + int hy = 0; defineControls() { pool(400); - addUint8("speed", speed, 4, 120); - addUint8("spread", spread, 0, 200); - addUint8("sparks", sparks, 1, 10); + addControl("speed", speed, 4, 120); + addControl("spread", spread, 0, 200); + addControl("sparks", sparks, 1, 10); } tick() { diff --git a/moonlive/effects/crosshair.mle b/moonlive/effects/crosshair.mle index 7b7a14fd..4b28e87c 100644 --- a/moonlive/effects/crosshair.mle +++ b/moonlive/effects/crosshair.mle @@ -9,10 +9,10 @@ // rather than computing a value. Parameters and members that a caller can set are the next steps; // when they arrive, the shape of this script does not change, the helpers just get shorter. class CrosshairEffect { - uint8_t bpm = 30; + byte bpm = 30; defineControls() { - addUint8("bpm", bpm, 1, 240); + addControl("bpm", bpm, 1, 240); } column() { diff --git a/moonlive/effects/ember.mle b/moonlive/effects/ember.mle index 0c37d4c4..250b6887 100644 --- a/moonlive/effects/ember.mle +++ b/moonlive/effects/ember.mle @@ -3,15 +3,15 @@ // it a simulation rather than a formula. class EmberEffect { - uint8_t cool = 30; - uint8_t spark = 60; - uint8_t cycle = 20; - uint8_t heat[16]; + byte cool = 30; + byte spark = 60; + byte cycle = 20; + byte heat[16]; defineControls() { - addUint8("cool", cool, 1, 120); - addUint8("spark", spark, 0, 200); - addUint8("cycle", cycle, 1, 120); + addControl("cool", cool, 1, 120); + addControl("spark", spark, 0, 200); + addControl("cycle", cycle, 1, 120); } tick() { diff --git a/moonlive/effects/fountain.mle b/moonlive/effects/fountain.mle index 204817c2..74f2bb80 100644 --- a/moonlive/effects/fountain.mle +++ b/moonlive/effects/fountain.mle @@ -2,15 +2,15 @@ // The arc is not drawn: sparks leave at an angle and gravity decides where they turn over. class FountainEffect { - uint8_t lift = 90; - uint8_t pull = 18; - uint8_t sparks = 4; + byte lift = 90; + byte pull = 18; + byte sparks = 4; defineControls() { pool(300); - addUint8("lift", lift, 20, 200); - addUint8("pull", pull, 4, 60); - addUint8("sparks", sparks, 1, 12); + addControl("lift", lift, 20, 200); + addControl("pull", pull, 4, 60); + addControl("sparks", sparks, 1, 12); } tick() { diff --git a/moonlive/effects/fractal.mle b/moonlive/effects/fractal.mle index 5dc57c0e..5b58f54a 100644 --- a/moonlive/effects/fractal.mle +++ b/moonlive/effects/fractal.mle @@ -2,36 +2,49 @@ // 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; + byte bpm = 6; + byte iters = 40; + byte zoom = 34; + byte seed = 128; - int16_t cx = 0; - uint16_t n = 0; + fixed cx = 0.0; + fixed jx = 0.0; + fixed jy = 0.0; + int n = 0; defineControls() { - addUint8("bpm", bpm, 0, 30); - addUint8("iters", iters, 8, 64); - addUint8("zoom", zoom, 12, 40); - addUint8("seed", seed, 0, 128); + addControl("bpm", bpm, 0, 30); + addControl("iters", iters, 8, 64); + addControl("zoom", zoom, 12, 40); + addControl("seed", seed, 0, 128); } tick() { + // The Julia seed walks a cardioid once per beat, the same for every pixel. + // The wave spans -32768..32767 and the seed 0..128; together they scale to a Julia seed + // under 1.0, which is the band where the set has structure. seed 0 gives 0 and selects + // Mandelbrot. DIVIDED BEFORE MULTIPLIED: the other order pushes the intermediate past what + // fixed can hold (32767.0 * 128 wraps), where wave/2560 is at most 12.8 and stays in range. + // The walk traces the Mandelbrot cardioid's own boundary β€” every point there pinches alike, + // so a bare orbit looks cyclic. Perlin noise breathes the RADIUS across the boundary + // (0.87..1.12): slightly inside gives fat connected blobs, slightly outside shattered dust, + // and the noise never repeats, so the cuts vary from shallow to deep. + jx = (toFixed(cos(beat(bpm, t)) - 32768) / 2560 * toFixed(seed) / 3277 + - toFixed(cos(beat(bpm, t) * 2) - 32768) / 5120 * toFixed(seed) / 3277) + * toFixed(870 + noise(t / 4, 0, 0)) / 1000; + jy = (toFixed(sin(beat(bpm, t)) - 32768) / 2560 * toFixed(seed) / 3277 + - toFixed(sin(beat(bpm, t) * 2) - 32768) / 5120 * toFixed(seed) / 3277) + * toFixed(870 + noise(t / 4, 0, 0)) / 1000; + 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; } + cx = uvX(x, width, height) * toFixed(zoom) / 40; + if (seed == 0) { cx = cx - 0.55; } - 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); + n = escape(cx, uvY(y, width, height) * toFixed(zoom) / 40, jx, jy, iters); // 0 = inside the set: stays black, the silhouette is the shape. - setPaletteColor(x, y, n, n * 255); + setPaletteColor(x, y, mod(n * 4, 256), n * 255); } } } diff --git a/moonlive/effects/lines.mle b/moonlive/effects/lines.mle index 0aea1ac0..e119864f 100644 --- a/moonlive/effects/lines.mle +++ b/moonlive/effects/lines.mle @@ -6,10 +6,10 @@ // script used to spell out. class LinesEffect { - uint8_t bpm = 30; + byte bpm = 30; defineControls() { - addUint8("bpm", bpm, 1, 240); + addControl("bpm", bpm, 1, 240); } tick() { diff --git a/moonlive/effects/metal.mle b/moonlive/effects/metal.mle index 67d5c5ad..7b1ecd0f 100644 --- a/moonlive/effects/metal.mle +++ b/moonlive/effects/metal.mle @@ -2,18 +2,20 @@ // 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; + byte bpm = 14; + byte blend = 40; + byte glow = 30; - int16_t ux = 0; - int16_t uy = 0; - int16_t d = 0; + fixed ux = 0.0; + fixed uy = 0.0; + fixed cx = 0.0; + fixed cy = 0.0; + int d = 0; defineControls() { - addUint8("bpm", bpm, 1, 60); - addUint8("blend", blend, 0, 120); - addUint8("glow", glow, 4, 120); + addControl("bpm", bpm, 1, 60); + addControl("blend", blend, 0, 120); + addControl("glow", glow, 4, 120); } tick() { @@ -22,16 +24,24 @@ class MetalEffect { ux = uvX(x, width, height); uy = uvY(y, width, height); - // 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); + // Each blob is a distance to a center that drifts on the clock. beatsin sweeps 0..30000, + // recentred and scaled into uv's own range. polarR takes whole numbers, so the coordinate + // is scaled up before the conversion: toInt alone would discard the fraction that IS the + // shape. 1024 units per uv unit, so a blob radius of 0.35 is 358. + cx = ux - toFixed(beatsin(bpm, t, 30000) - 15000) / 25000; + cy = uy - toFixed(beatsin(bpm + 5, t, 30000) - 15000) / 25000; + d = polarR(toInt(cx * 1024), toInt(cy * 1024)) - 358; + + cx = ux - toFixed(beatsin(bpm + 3, t, 30000) - 15000) / 25000; + d = smin(d, polarR(toInt(cx * 1024), toInt(uy * 1024)) - 307, blend); + + cy = uy - toFixed(beatsin(bpm + 7, t, 30000) - 15000) / 25000; + d = smin(d, polarR(toInt(ux * 1024), toInt(cy * 1024)) - 307, blend); // 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)); } + else { setPaletteColor(x, y, scale(d * 128, 256), + scale(smoothstep(0, glow * 8, glow * 8 - d), 256)); } } } } diff --git a/moonlive/effects/noise.mle b/moonlive/effects/noise.mle index 48516c6a..f5ac046e 100644 --- a/moonlive/effects/noise.mle +++ b/moonlive/effects/noise.mle @@ -2,12 +2,12 @@ // Ported from MoonLight's E_noise.sc. class NoiseEffect { - uint8_t speed = 20; - uint8_t zoom = 8; + byte speed = 20; + byte zoom = 8; defineControls() { - addUint8("speed", speed, 1, 120); - addUint8("zoom", zoom, 1, 32); + addControl("speed", speed, 1, 120); + addControl("zoom", zoom, 1, 32); } tick() { diff --git a/moonlive/effects/octopus.mle b/moonlive/effects/octopus.mle index bd280787..610ca47c 100644 --- a/moonlive/effects/octopus.mle +++ b/moonlive/effects/octopus.mle @@ -7,14 +7,14 @@ // budget β€” and a lookup table is a cache, not state, so recomputing costs correctness nothing. class OctopusEffect { - uint8_t speed = 20; - uint8_t branches = 4; - uint8_t cx = 0; - uint8_t cy = 0; + byte speed = 20; + byte branches = 4; + byte cx = 0; + byte cy = 0; defineControls() { - addUint8("speed", speed, 1, 120); - addUint8("branches", branches, 1, 8); + addControl("speed", speed, 1, 120); + addControl("branches", branches, 1, 8); } tick() { diff --git a/moonlive/effects/plasma.mle b/moonlive/effects/plasma.mle index ee15e748..4267d850 100644 --- a/moonlive/effects/plasma.mle +++ b/moonlive/effects/plasma.mle @@ -7,12 +7,12 @@ // through the same path an effect always does. class PlasmaEffect { - uint8_t bpm = 12; - uint8_t zoom = 24; + byte bpm = 12; + byte zoom = 24; defineControls() { - addUint8("bpm", bpm, 1, 120); - addUint8("zoom", zoom, 1, 64); + addControl("bpm", bpm, 1, 120); + addControl("zoom", zoom, 1, 64); } tick() { diff --git a/moonlive/effects/rain.mle b/moonlive/effects/rain.mle index 72bfe54c..dc02cb8e 100644 --- a/moonlive/effects/rain.mle +++ b/moonlive/effects/rain.mle @@ -2,15 +2,15 @@ // Wind is the launch angle, not a force, so gravity curves each drop as it falls. class RainEffect { - uint8_t fall = 24; - uint8_t wind = 128; - uint8_t drops = 3; + byte fall = 24; + byte wind = 128; + byte drops = 3; defineControls() { pool(400); - addUint8("fall", fall, 4, 80); - addUint8("wind", wind, 0, 255); - addUint8("drops", drops, 1, 12); + addControl("fall", fall, 4, 80); + addControl("wind", wind, 0, 255); + addControl("drops", drops, 1, 12); } tick() { diff --git a/moonlive/effects/ripples.mle b/moonlive/effects/ripples.mle index 96fb8328..8e15664b 100644 --- a/moonlive/effects/ripples.mle +++ b/moonlive/effects/ripples.mle @@ -8,12 +8,12 @@ // the working stress test for the call path. class RipplesEffect { - uint8_t bpm = 10; - uint8_t rings = 8; + byte bpm = 10; + byte rings = 8; defineControls() { - addUint8("bpm", bpm, 1, 120); - addUint8("rings", rings, 1, 32); + addControl("bpm", bpm, 1, 120); + addControl("rings", rings, 1, 32); } tick() { diff --git a/moonlive/layouts/diagonal.mll b/moonlive/layouts/diagonal.mll index a5128188..ce3754d3 100644 --- a/moonlive/layouts/diagonal.mll +++ b/moonlive/layouts/diagonal.mll @@ -1,10 +1,10 @@ // A diagonal run β€” light i at (i, i). The kind of fixture that otherwise needs its own class. class DiagonalLayout { - uint8_t count = 16; + byte count = 16; defineControls() { - addUint8("count", count, 1, 64); + addControl("count", count, 1, 64); } placeLights() { diff --git a/moonlive/layouts/grid.mll b/moonlive/layouts/grid.mll index 41f14051..2f8866b2 100644 --- a/moonlive/layouts/grid.mll +++ b/moonlive/layouts/grid.mll @@ -2,12 +2,12 @@ // `cols`/`rows` are this layout's own controls; the logical grid comes from what it places. class GridLayout { - uint8_t cols = 16; - uint8_t rows = 16; + byte cols = 16; + byte rows = 16; defineControls() { - addUint8("cols", cols, 1, 128); - addUint8("rows", rows, 1, 128); + addControl("cols", cols, 1, 128); + addControl("rows", rows, 1, 128); } placeLights() { diff --git a/moonlive/layouts/lattice.mll b/moonlive/layouts/lattice.mll index 2ae74bfb..ce4fee40 100644 --- a/moonlive/layouts/lattice.mll +++ b/moonlive/layouts/lattice.mll @@ -4,14 +4,14 @@ // but not the S3; two loops (grid.mlv) fit everywhere. class LatticeLayout { - uint8_t cols = 4; - uint8_t rows = 3; - uint8_t layers = 5; + byte cols = 4; + byte rows = 3; + byte layers = 5; defineControls() { - addUint8("cols", cols, 1, 32); - addUint8("rows", rows, 1, 32); - addUint8("layers", layers, 1, 32); + addControl("cols", cols, 1, 32); + addControl("rows", rows, 1, 32); + addControl("layers", layers, 1, 32); } placeLights() { diff --git a/moonlive/layouts/reversed-row.mll b/moonlive/layouts/reversed-row.mll index f98f101f..83b54f53 100644 --- a/moonlive/layouts/reversed-row.mll +++ b/moonlive/layouts/reversed-row.mll @@ -1,10 +1,10 @@ // A strand wired right to left: light 0 sits at the far end. class ReversedRowLayout { - uint8_t cols = 16; + byte cols = 16; defineControls() { - addUint8("cols", cols, 1, 64); + addControl("cols", cols, 1, 64); } placeLights() { diff --git a/moonlive/layouts/ring.mll b/moonlive/layouts/ring.mll index 31e00d62..328ef04f 100644 --- a/moonlive/layouts/ring.mll +++ b/moonlive/layouts/ring.mll @@ -3,12 +3,12 @@ // `cos`/`sin` run 0..65535 centred at 32768, so scaling by the DIAMETER lands the whole circle. class RingLayout { - uint16_t count = 24; - uint8_t radius = 5; + int count = 24; + byte radius = 5; defineControls() { - addUint16("count", count, 3, 1000); - addUint8("radius", radius, 1, 127); + addControl("count", count, 3, 1000); + addControl("radius", radius, 1, 127); } placeLights() { diff --git a/moonlive/layouts/rose.mll b/moonlive/layouts/rose.mll index 84b12650..e57d7d43 100644 --- a/moonlive/layouts/rose.mll +++ b/moonlive/layouts/rose.mll @@ -8,12 +8,12 @@ // walk runs once per edit, so clarity beats the repeated call. class RoseLayout { - uint8_t petals = 2; - uint8_t radius = 15; + byte petals = 2; + byte radius = 15; defineControls() { - addUint8("petals", petals, 1, 8); - addUint8("radius", radius, 4, 30); + addControl("petals", petals, 1, 8); + addControl("radius", radius, 4, 30); } placeLights() { diff --git a/moonlive/layouts/two-rows.mll b/moonlive/layouts/two-rows.mll index 44fe7aff..2071517b 100644 --- a/moonlive/layouts/two-rows.mll +++ b/moonlive/layouts/two-rows.mll @@ -2,10 +2,10 @@ // The return row counts x DOWN -- the strand turns around at the far end. class TwoRowsLayout { - uint8_t cols = 16; + byte cols = 16; defineControls() { - addUint8("cols", cols, 1, 64); + addControl("cols", cols, 1, 64); } placeLights() { diff --git a/moonlive/modifiers/shift.mlm b/moonlive/modifiers/shift.mlm index 61ecfcaa..0bcaaf8c 100644 --- a/moonlive/modifiers/shift.mlm +++ b/moonlive/modifiers/shift.mlm @@ -2,10 +2,10 @@ // 256: past that it wraps and the light reappears at the left edge. class ShiftModifier { - uint8_t amount = 4; + byte amount = 4; defineControls() { - addUint8("amount", amount, 0, 64); + addControl("amount", amount, 0, 64); } modifyLogical() { diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 26854866..341e5b17 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -25,6 +25,7 @@ const char* controlTypeName(ControlType t) { case ControlType::Uint8: return "uint8"; case ControlType::Uint16: return "uint16"; case ControlType::Int16: return "int16"; + case ControlType::Int32: return "int32"; case ControlType::Pin: return "pin"; case ControlType::Bool: return "bool"; case ControlType::Text: return "text"; @@ -92,6 +93,10 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { case ControlType::Int16: sink.appendf("%d", *static_cast<int16_t*>(c.ptr)); return; + case ControlType::Int32: + // int is 32-bit on every target; int32_t is `long` on Xtensa, so %d alone mismatches. + sink.appendf("%d", static_cast<int>(*static_cast<int32_t*>(c.ptr))); + return; case ControlType::Pin: // int8_t storage; serialized as a plain integer sink.appendf("%d", *static_cast<int8_t*>(c.ptr)); return; @@ -166,6 +171,7 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { case ControlType::Uint8: case ControlType::Uint16: case ControlType::Int16: + case ControlType::Int32: case ControlType::Pin: // Numeric controls carry a real [min,max]; the slider types render it // as a range, Pin uses it only as a documented valid-GPIO span (the UI @@ -303,6 +309,13 @@ ApplyResult applyControlValue(const ControlDescriptor& c, } return clampInto(static_cast<int16_t*>(c.ptr), v, c.min, c.max); } + case ControlType::Int32: { + int v = mm::json::parseInt(json, key); + if (policy == ApplyPolicy::Strict && (v < c.min || v > c.max)) { + return ApplyResult::OutOfRange; + } + return clampInto(static_cast<int32_t*>(c.ptr), v, c.min, c.max); + } case ControlType::Pin: { // int8_t storage; [min,max] = valid-GPIO span int v = mm::json::parseInt(json, key); if (policy == ApplyPolicy::Strict && (v < c.min || v > c.max)) { diff --git a/src/core/Control.h b/src/core/Control.h index 90402884..1503e446 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -106,6 +106,10 @@ enum class ControlType : uint8_t { Int16, ///< signed 16-bit, min/max β€” for coordinate-style controls where negatives ///< are legal (the light grid coordinate type is int16). A bounded slider ///< (unbounded β†’ a Β±percentage slider). DMX-mappable. + Int32, ///< signed 32-bit, min/max β€” where a value genuinely exceeds 16 bits and a + ///< narrower type would wrap. A MoonLive `int` member is the case that + ///< introduced it: every script scalar occupies a 4-byte slot. A bounded + ///< slider, same contract as Int16. DMX-mappable via the range. Pin, ///< a GPIO number (int8_t storage, -1 = unused/default). Distinct from Int16 ///< so the UI renders a plain number input, not a slider (a GPIO has no ///< meaningful drag range; pins span 0..~52). min/max clamp writes server-side. @@ -395,6 +399,15 @@ class ControlList { controls_[count_++] = {&var, name, 0, ControlType::Int16, min, max}; } + /// Bind an `int32_t` where the value does not fit 16 bits. min/max default to the + /// full type range (no UI constraint); pass explicit bounds for a bounded slider + + /// server-side write clamp β€” same contract as addInt16. + void addInt32(const char* name, int32_t& var, + int32_t min = INT32_MIN, int32_t max = INT32_MAX) { + grow(); + controls_[count_++] = {&var, name, 0, ControlType::Int32, min, max}; + } + // A GPIO pin number (int8_t storage β€” one byte; -1 = unused/default). A GPIO // never exceeds ~54 on any ESP32-family chip, so int8 (βˆ’128..127) is ample and // smaller than int16. Renders as a plain number input, not a slider (see diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index 84e114ef..f7e88077 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -168,16 +168,22 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { prev->name[n] == '\0' && prev->type == decls[i].type && prev->count == decls[i].count; if (!same) { - // Seed the member's WHOLE extent: every element, at its width, little-endian to match - // every backend's halfword load. Writing only the first element left an ARRAY holding + // Seed the member's WHOLE extent: every element, at its width, little-endian to + // match every backend's load. Writing only the first element left an ARRAY holding // the previous program's bytes from element 1 on, which is what "an array starts at - // zero" has to mean; writing only the low byte left a uint16_t's high half stale. - const uint8_t w = ctrlWidth(decls[i].type); + // zero" has to mean; writing only the low byte left the rest of a wider member stale, + // which turned `int neg = -100;` into 156 (the sign bytes never reached the slot). + // + // A SCALAR is one 4-byte slot; an ARRAY packs at its element width. Both spelled here + // as "write w bytes per element", so the two cases are one loop rather than two. + const uint8_t w = decls[i].count > 1 ? ctrlWidth(decls[i].type) + : ctrlSlotBytes(decls[i].type); + const uint32_t v = static_cast<uint32_t>(decls[i].def); for (uint16_t e = 0; e < decls[i].count; e++) { const uint16_t at = uint16_t(off + e * w); if (at + w > kCtrlBytes) break; // the parser bounds it; belt and braces - ctrlArena_[at] = static_cast<uint8_t>(decls[i].def & 0xff); - if (w == 2) ctrlArena_[at + 1] = static_cast<uint8_t>(decls[i].def >> 8); + for (uint8_t b = 0; b < w; b++) + ctrlArena_[at + b] = static_cast<uint8_t>((v >> (8 * b)) & 0xff); } } if (kept < kMaxCtrls) { diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index 8c829b4a..2d5b3408 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -102,20 +102,38 @@ class MoonLive { else if (anim_) anim_(buf, nLights, cpl, t); // hand-encoded animated fill } + /// A member's 4-byte slot, little-endian, which is the layout every backend's 32-bit load and + /// store already uses. One home for it: the engine, the seeding pass and the control binding + /// all reach a slot through these two rather than each spelling the byte order themselves. + int32_t readSlot(uint8_t offset) const { + if (!ctrlArena_ || offset + 4 > kArenaBytes) return 0; + return int32_t(uint32_t(ctrlArena_[offset]) | (uint32_t(ctrlArena_[offset + 1]) << 8) | + (uint32_t(ctrlArena_[offset + 2]) << 16) | + (uint32_t(ctrlArena_[offset + 3]) << 24)); + } + void writeSlot(uint8_t offset, int32_t v) { + if (!ctrlArena_ || offset + 4 > kArenaBytes) return; + const uint32_t u = uint32_t(v); + ctrlArena_[offset] = uint8_t(u & 0xff); + ctrlArena_[offset + 1] = uint8_t((u >> 8) & 0xff); + ctrlArena_[offset + 2] = uint8_t((u >> 16) & 0xff); + ctrlArena_[offset + 3] = uint8_t((u >> 24) & 0xff); + } + /// Append a control the running `defineControls()` declared. The binding installs a sink that - /// lands here, so the control list is built by the script CALLING addUint8, exactly as a + /// lands here, so the control list is built by the script CALLING addControl, exactly as a /// compiled module's list is built by its defineControls() running. /// /// `name` must outlive the engine: it points into the string pool this engine owns, which is /// what the compiler interned it into. - void addDeclaredControl(const char* name, uint8_t offset, uint16_t lo, uint16_t hi, - CtrlType type = CtrlType::Uint8) { + void addDeclaredControl(const char* name, uint8_t offset, int32_t lo, int32_t hi, + CtrlType type = CtrlType::Int) { if (controlCount_ >= kMaxCtrls || !name || offset >= kArenaBytes) return; if (lo > hi) return; - // A wide control owns TWO arena bytes, so the second one has to exist. The compiler already + // A scalar owns a whole 4-byte SLOT, so all four bytes have to exist. The compiler already // aligned and bounded the member; this is the engine refusing to publish a control whose - // high byte would sit outside the arena. - if (ctrlWidth(type) == 2 && offset + 1 >= kArenaBytes) return; + // slot would run past the arena. + if (offset + ctrlSlotBytes(type) > kArenaBytes) return; // Two controls on one member would give the UI two cards writing the same byte, each // overwriting the other, and two labels the same persistence key. for (uint8_t i = 0; i < controlCount_; i++) @@ -129,19 +147,14 @@ class MoonLive { // record. Clamping only the record would leave the out-of-range value driving the effect // while the UI showed a slider that could not reach it. This is the one place that knows // both the range and the live byte at the same moment. - // Read at the DECLARED width, little-endian to match every backend's halfword load, so a - // wide control's default is the member's whole value rather than its low byte. - uint16_t def = lo; - if (ctrlArena_) { - def = ctrlArena_[offset]; - if (ctrlWidth(type) == 2) def |= static_cast<uint16_t>(ctrlArena_[offset + 1]) << 8; - } + // Read the whole SLOT, little-endian to match every backend's 32-bit load, so the default + // is the member's entire value rather than its low byte. Signed: a fixed or int member + // legitimately holds a negative one. + int32_t def = lo; + if (ctrlArena_) def = readSlot(offset); if (def < lo) def = lo; else if (def > hi) def = hi; - if (ctrlArena_) { - ctrlArena_[offset] = static_cast<uint8_t>(def & 0xff); - if (ctrlWidth(type) == 2) ctrlArena_[offset + 1] = static_cast<uint8_t>(def >> 8); - } + if (ctrlArena_) writeSlot(offset, def); controls_[controlCount_] = {name, lo, hi, def, 0, type, offset}; // nameLen is what the binding reports; measured here rather than passed, so a caller // cannot disagree with the string it handed over. @@ -270,7 +283,7 @@ class MoonLive { // declared default. 2 bytes per row, 16 across the table. struct SeededMember { uint8_t offset = 0; - CtrlType type = CtrlType::Uint8; + CtrlType type = CtrlType::Int; uint8_t count = 1; char name[kSeedNameLen] = {}; }; diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index ced5a4eb..5ff64ec8 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -23,17 +23,36 @@ 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. -// 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 }; +// A script member's TYPE. A semantic, not a storage width: every SCALAR occupies one uniform +// 4-byte slot whatever its type, and only ARRAYS pack by element. That is what removes the width +// machinery a script used to spell for itself (uint8_t/uint16_t/int16_t), which is where four +// bugs came from β€” a wrapped member, a sentinel read through a 16-bit window, a one-byte store +// into a two-byte member, a sign-blind array load. Here rather than with the IR because a builtin +// descriptor names the type its by-reference argument takes. +// +// Byte and Bool are masked and normalized on STORE, so a slot always already holds what its type +// promises and every read is one plain 32-bit load. Fixed is Q16.16 on that same slot. Str holds +// an offset into the compiled program's string pool. +enum class CtrlType : uint8_t { Int, Byte, Bool, Fixed, Str }; +/// Bytes ONE ELEMENT occupies. A scalar always takes a whole 4-byte slot (see ctrlSlotBytes); +/// this is the array element width, which is where packing still pays for itself: a byte[] heat +/// map costs a quarter of what an int[] would, and on the classic ESP32 there is no PSRAM to +/// absorb the difference. constexpr uint8_t ctrlWidth(CtrlType t) { - return (t == CtrlType::Uint16 || t == CtrlType::Int16) ? 2 : 1; + return (t == CtrlType::Byte || t == CtrlType::Bool) ? 1 : 4; +} + +/// Bytes a SCALAR of this type occupies: always 4, whatever the type. Spelled as a function +/// rather than a bare constant so the uniformity is stated at every call site that used to ask +/// for a width. +constexpr uint8_t ctrlSlotBytes(CtrlType) { return 4; } + +/// Does a value of this type need masking on the way into its slot? Byte keeps its slot's upper +/// bytes zero, which is what lets a byte control's descriptor point at the slot's low byte. +constexpr bool ctrlMasksOnStore(CtrlType t) { + return t == CtrlType::Byte || t == CtrlType::Bool; } -constexpr bool ctrlIsSigned(CtrlType t) { return t == CtrlType::Int16; } // Neutral inline opcodes β€” "store shapes a backend can emit", not "LED operations". A host maps @@ -92,11 +111,16 @@ struct Builtin { // host a pointer built from a color byte. Stated per builtin for the same reason byRef is, // rather than special-cased by name in the parser. uint8_t byStr = 0; - // The member WIDTH a by-reference argument must have, for the control-declaring builtins: - // addUint8 takes a uint8_t member, addUint16 a uint16_t one. Stated here for the same reason - // byRef and byStr are, rather than the parser matching on the builtin's name β€” a name test - // would silently mis-classify the next by-reference builtin somebody adds. - CtrlType refType = CtrlType::Uint8; + // Which arguments are FIXED (Q16.16) rather than whole numbers, a bit per position, and + // whether the RESULT is. Stated per builtin for the same reason byRef and byStr are: the + // parser type-checks against this rather than matching on a name, so the next builtin that + // speaks fixed declares it here and the checker follows. + // + // Almost every builtin is whole numbers: a channel, a light index, an angle16, a count. The + // exceptions are the ones a shader hands coordinates to β€” uvX/uvY return a fixed coordinate, + // and escape() takes four of them. + uint8_t fixedArgs = 0; + bool fixedReturn = false; }; /// Assert a host's builtin table did not silently drop a registration. diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 671d8c46..5f2d2886 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -18,6 +18,8 @@ struct Lexer { const char* p; Tok kind = Tok::Error; long number = 0; + // Whether the literal just lexed carried a decimal point, i.e. it is a Q16.16 value. + bool numberIsFixed = false; const char* identBeg = nullptr; size_t identLen = 0; const char* tokBeg = nullptr; @@ -32,11 +34,35 @@ struct Lexer { static bool isIdentCont(char c) { return isIdentStart(c) || isDigit(c); } uint16_t col() const { return static_cast<uint16_t>((tokBeg - srcBeg) + 1); } - // Read a run of digits into v (capped); returns true if at least one digit was consumed. - bool readNumber(long& v) { + // Read a number into v, and say whether it carried a decimal point. + // + // A decimal point makes it a FIXED literal: `1.5` reads as the Q16.16 word 98304, so a script + // writes the number it means rather than the scaled integer. The fraction is accumulated as a + // numerator over a power of ten and scaled once, which keeps it exact for the digits a script + // would write. + // + // Overflow FAILS rather than truncating: the old cap stopped consuming digits at 1000000 and + // left the value silently wrong, so `99999999` became a number nobody wrote. + bool readNumber(long& v, bool& isFixed, bool& overflowed) { if (!isDigit(*p)) return false; - v = 0; - while (isDigit(*p)) { v = v * 10 + (*p - '0'); p++; if (v > 1000000) break; } + v = 0; isFixed = false; overflowed = false; + while (isDigit(*p)) { + v = v * 10 + (*p - '0'); + p++; + if (v > 2147483647L) { overflowed = true; return true; } + } + if (*p == '.' && isDigit(p[1])) { + isFixed = true; + p++; + long num = 0, den = 1; + while (isDigit(*p)) { + if (den <= 100000000L) { num = num * 10 + (*p - '0'); den *= 10; } + p++; // digits past the useful precision are read + } // and dropped, rather than shifting the value + if (v > 32767L) { overflowed = true; return true; } + // The integer part scales by 65536; the fraction is num/den of that. + v = (v << 16) + (num * 65536L + den / 2) / den; + } return true; } @@ -96,8 +122,10 @@ struct Lexer { kind = Tok::String; return; } if (isDigit(c)) { - long v = 0; readNumber(v); - number = v; kind = Tok::Number; return; + long v = 0; bool fx = false, over = false; + readNumber(v, fx, over); + if (over) { err = "number out of range"; kind = Tok::Error; return; } + number = v; numberIsFixed = fx; kind = Tok::Number; return; } if (isIdentStart(c)) { identBeg = p; @@ -160,6 +188,22 @@ struct Parser { DeclaredControl members[kMaxCtrls] = {}; // Arena bytes the members declared so far occupy: the cursor the next declaration is placed at. // Separate from memberCount now that a member's size is not always one byte. + // THE TYPE OF THE VALUE JUST PARSED. The front end is a single-pass parser with no AST, so a + // type rides alongside the register rather than hanging off a tree node: every parse function + // sets this before returning, and the places where two values meet compare it. + // + // Only `fixed` is tracked distinctly. byte and bool DECAY to int the moment they are read β€” + // they are semantics on storage, not on arithmetic β€” and a string never enters an expression. + // So this answers one question: is this value Q16.16, or a plain integer? + bool exprIsFixed = false; + // When the value just parsed is EXACTLY one integer literal, the index of its Const op; + // -1 otherwise. This is what lets `v * 2` and `if (v < 0)` work on a fixed value: an integer + // LITERAL meeting a fixed operand converts at compile time by patching the already-emitted + // Const (the number the script wrote, rescaled β€” free at run time), where a fixed-meets-int + // VARIABLE stays a compile error naming toFixed/toInt. The distinction is safety: a literal's + // meaning is visible at the call site; a variable's scaling is not. + int exprLitConst = -1; + uint8_t memberBytes = 0; uint8_t memberCount = 0; @@ -243,13 +287,43 @@ struct Parser { // the three backends ALREADY have (Const/Add/Mul) β€” a - b is emitted as a + (b * -1), because // no ISA here has a subtract and Xtensa's add-immediate encodes only 1..15, so negating the // immediate would silently produce a wrong constant. + /// Both operands of a binary operator must agree, and an INTEGER LITERAL meeting a fixed + /// operand agrees by converting: its Const is patched to the same number in Q16.16, costing + /// nothing at run time. Anything else mixed is a compile error naming the conversion to + /// write, because the alternative is a number 65,536 times off with nothing reporting it: the + /// two representations are indistinguishable at run time. + /// + /// Returns the type of the combined expression (true = fixed) via `outFixed`. + bool meet(bool lhsFixed, int lhsLit, bool rhsFixed, int rhsLit, bool& outFixed) { + if (lhsFixed == rhsFixed) { outFixed = lhsFixed; return true; } + const int lit = lhsFixed ? rhsLit : lhsLit; // the int side, if it is a bare literal + if (lit >= 0) { + const int32_t v = ir.ops[lit].imm; + if (v < -32768 || v > 32767) { + fail("this number is out of range for a fixed value"); + return false; + } + ir.ops[lit].imm = v << 16; + outFixed = true; + return true; + } + fail("this mixes a whole number and a fixed value: write toFixed(x) or toInt(x)"); + return false; + } + VReg parseExpr() { VReg lhs = parseTerm(); while (!failed && (lex.kind == Tok::Plus || lex.kind == Tok::Minus)) { const bool negate = (lex.kind == Tok::Minus); lex.advance(); + const bool lhsFixed = exprIsFixed; + const int lhsLit = exprLitConst; VReg rhs = parseTerm(); if (failed) return 0; + bool outFixed = false; + if (!meet(lhsFixed, lhsLit, exprIsFixed, exprLitConst, outFixed)) return 0; + exprIsFixed = outFixed; + exprLitConst = -1; // a combined value is no longer one literal if (negate) { VReg m = alloc(); emit({IrOp::Const, m, 0,0,0,0, -1, nullptr, {}}); @@ -300,18 +374,53 @@ struct Parser { while (!failed && (lex.kind == Tok::Star || lex.kind == Tok::Slash || lex.kind == Tok::Percent)) { const Tok op = lex.kind; + const bool lhsFixed = exprIsFixed; + const int lhsLit = exprLitConst; lex.advance(); VReg rhs = parsePrimary(); if (failed) return 0; - if (op == Tok::Star) { + bool bothFixed = false; + if (!meet(lhsFixed, lhsLit, exprIsFixed, exprLitConst, bothFixed)) return 0; + exprLitConst = -1; + if (op == Tok::Star && bothFixed) { + // Q16.16 * Q16.16 has THIRTY-TWO fraction bits, so the product has to come back + // down by 16. The answer is the 64-bit product's middle word: the high half's low + // 16 bits joined to the low half's top 16. Three instructions and no call, which + // is why the backends grew mulhi rather than routing this through a host function. + VReg hi = alloc(); + emit({IrOp::Mulhi, hi, lhs, rhs, 0,0, 0, nullptr, {}}); + VReg lo = alloc(); + emit({IrOp::Mul, lo, lhs, rhs, 0,0, 0, nullptr, {}}); + emit({IrOp::Shr, lo, lo, 0,0,0, 16, nullptr, {}}); // LOGICAL: the low word is unsigned + emit({IrOp::Shl, hi, hi, 0,0,0, 16, nullptr, {}}); + VReg dst = alloc(); + emit({IrOp::Add, dst, hi, lo, 0,0, 0, nullptr, {}}); + freeTemp(hi); freeTemp(lo); freeTemp(lhs); freeTemp(rhs); + lhs = dst; + exprIsFixed = true; + } else if (op == Tok::Star) { VReg dst = alloc(); emit({IrOp::Mul, dst, lhs, rhs, 0,0, 0, nullptr, {}}); freeTemp(lhs); freeTemp(rhs); lhs = dst; + exprIsFixed = false; + } else if (op == Tok::Slash && bothFixed) { + // Q16.16 / Q16.16 goes through its own host call: the quotient needs the + // numerator widened to (a << 16) BEFORE the divide, and no 32-bit register can + // hold that past |128.0|. A first attempt split the shift around an integer + // divide (8 before, 8 after) and silently wrapped for larger values, which froze + // two shipped shaders. fdiv does the widening in int64 in the host β€” exact over + // the whole range, and a divide is a host call on every ISA here anyway. + lhs = emitBinaryCall("fdiv", 4, lhs, rhs, "'/' on fixed needs the fdiv built-in"); + exprIsFixed = true; } else if (op == Tok::Slash) { lhs = emitBinaryCall("div", 3, lhs, rhs, "'/' needs a div(a, b) built-in"); + exprIsFixed = false; } else { + // (a*2^16) mod (b*2^16) IS (a mod b)*2^16, so the remainder of two fixed values + // is fixed β€” which is what makes the fractional-part idiom `x % 1.0` work. lhs = emitBinaryCall("mod", 3, lhs, rhs, "'%' needs a mod(a, b) built-in"); + exprIsFixed = bothFixed; } if (failed) return 0; } @@ -322,6 +431,38 @@ struct Parser { // offset); an ident followed by `(` is a call. VReg parsePrimary() { if (failed) return 0; + // An int unless something below says otherwise, and not a bare literal unless the number + // branch says so. Set here rather than in each branch so a new kind of primary cannot + // forget to answer either question. + exprIsFixed = false; + exprLitConst = -1; + // toFixed(v) / toInt(v): the conversions, EXPLICIT because a silent one is a number + // 65,536 times off. Each is a single shift, recognized here rather than registered as a + // builtin so it costs an instruction and not a host call. + if (lex.kind == Tok::Ident && (atKeyword("toFixed", 7) || atKeyword("toInt", 5))) { + const bool up = atKeyword("toFixed", 7); + lex.advance(); + if (!expect(Tok::LParen, "expected '(' after the conversion")) return 0; + VReg v = parseExpr(); + if (failed) return 0; + if (up && exprIsFixed) { fail("this value is already fixed"); return 0; } + if (!up && !exprIsFixed) { fail("this value is already a whole number"); return 0; } + // A LITERAL converting up is range-checked here, exactly as adoption checks one at a + // meet point: `toFixed(40000)` would otherwise shift past what Q16.16 holds and wrap + // into a number nobody wrote. A computed value cannot be checked at compile time and + // saturates at run time like any other overflow. + if (up && exprLitConst >= 0) { + const int32_t lit = ir.ops[exprLitConst].imm; + if (lit < -32768 || lit > 32767) + { fail("this number is out of range for a fixed value"); return 0; } + } + if (!expect(Tok::RParen, "expected ')' to close the conversion")) return 0; + VReg dst = alloc(); + emit({up ? IrOp::Shl : IrOp::Sar, dst, v, 0,0,0, 16, nullptr, {}}); + freeTemp(v); + exprIsFixed = up; + return dst; + } if (lex.kind == Tok::LParen) { // grouping lex.advance(); VReg v = parseExpr(); @@ -339,10 +480,28 @@ struct Parser { freeTemp(m); freeTemp(v); return dst; } + // `true` and `false`, the way a bool is written. Ordinary literals rather than a separate + // token kind: they evaluate to 1 and 0, so every existing comparison and arithmetic path + // takes them unchanged, and a script says `bool on = true;` instead of spelling a C-ism. + if (lex.kind == Tok::Ident && (atKeyword("true", 4) || atKeyword("false", 5))) { + VReg v = alloc(); + emit({IrOp::Const, v, 0,0,0,0, atKeyword("true", 4) ? 1 : 0, nullptr, {}}); + lex.advance(); + return v; + } if (lex.kind == Tok::Number) { - if (lex.number < 0 || lex.number > 65535) { fail("number out of range (0..65535)"); return 0; } + // The whole signed range: a member holds 32 bits, so a literal that fits one is + // legal. The old 0..65535 cap was the widest MEMBER of the day, which made a literal + // and the member it was assigned to disagree about what a number could be. + if (lex.number < INT32_MIN || lex.number > INT32_MAX) + { fail("number out of range"); return 0; } VReg v = alloc(); emit({IrOp::Const, v, 0,0,0,0, static_cast<int32_t>(lex.number), nullptr, {}}); + // A decimal point made it a fixed value at the lexer; the word is already scaled. + exprIsFixed = lex.numberIsFixed; + // A bare integer literal may still ADOPT fixed at a meet point (see meet()), which + // patches this very op. Recording the index here is what makes that possible. + if (!lex.numberIsFixed) exprLitConst = int(ir.count) - 1; lex.advance(); return v; } @@ -386,8 +545,16 @@ struct Parser { lex.advance(); VReg idx = parseExpr(); if (failed) return 0; + // An INDEX counts elements, so it is a whole number like a loop counter. + if (exprIsFixed) { fail("an array index is a whole number: write toInt(x)"); return 0; } if (!expect(Tok::RBracket, "expected ']' to close an array index")) { freeTemp(idx); return 0; } VReg v = alloc(); + // The value this expression yields is an ELEMENT, so its type is the array's. + // Leaving the index's state here made `heat[3] * 0.5` adopt the INDEX literal + // β€” patching the 3 to 196608, clamping to the last element, and reading a byte + // as though it were fixed. + exprIsFixed = (members[mi].type == CtrlType::Fixed); + exprLitConst = -1; emit({IrOp::LoadIdx, v, idx, 0, 0, 0, idxPack(members[mi].offset, ctrlWidth(members[mi].type), members[mi].count), nullptr, {}}); @@ -396,16 +563,21 @@ struct Parser { } if (members[mi].count > 1) { fail("an array needs an index: write name[i]"); return 0; } VReg v = alloc(); - // 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, {}}); + exprIsFixed = (members[mi].type == CtrlType::Fixed); + // ONE load for every scalar type. A slot is 4 bytes and already holds what its + // type promises (byte and bool are masked on the way in), so there is no width or + // sign to choose here: the three-way pick this replaces is exactly where a + // sign-blind read used to turn -100 into 65436. + emit({IrOp::LoadCtrl32, v, 0,0,0,0, members[mi].offset, nullptr, {}}); return v; } VReg out = 0; + const Builtin* called = table.find(lex.identBeg, lex.identLen); parseCall(&out); // otherwise a call used as an expression must return a value + // The result's type is the builtin's business: uvX/uvY hand back a fixed coordinate, + // everything else a whole number. + exprIsFixed = called && called->fixedReturn; + exprLitConst = -1; return out; } fail("expected a number, a control name, or a function call"); @@ -510,26 +682,40 @@ struct Parser { if (lex.kind != Tok::Ident) { fail("expected the member this control is bound to"); return; } const int mi = findMember(lex.identBeg, lex.identLen); if (mi < 0) { fail("no member of that name is declared in this class"); return; } - // The BUILTIN'S width must match the MEMBER'S. addUint8 on a uint16_t member - // would drive only its low byte and addUint16 on a uint8_t member would write - // past it, both silently β€” so the mismatch is a diagnostic naming the call to - // use instead. A control also drives one value, never an array: binding one - // 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" - : "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. + // A control surfaces a value the UI can drive, so the member's type has to be + // one the UI has a widget for. int, byte and bool do; fixed and string do not + // yet, and saying so beats publishing a slider that writes a Q16.16 word the + // user cannot reason about. A control also drives one value, never an array: + // binding one would move element 0 and leave the rest, with nothing on screen + // saying so. + if (members[mi].type == CtrlType::Fixed || members[mi].type == CtrlType::Str) + { fail("a control binds an int, byte or bool member"); return; } if (members[mi].count > 1) { fail("a control binds a single member, not an array"); return; } + // The offset AND the member's type travel in one word: the arena is 64 bytes, + // so the low byte carries the offset and the next byte the type. addControl + // needs the type to know which widget to publish, and it has no other way to + // learn it β€” the builtin sees values, not declarations. v = alloc(); - emit({IrOp::Const, v, 0,0,0,0, members[mi].offset, nullptr, {}}); + emit({IrOp::Const, v, 0,0,0,0, + members[mi].offset | (int32_t(members[mi].type) << 8), nullptr, {}}); lex.advance(); } else { + const bool wantFixed = ((fn->fixedArgs >> n) & 1u) != 0; v = parseExpr(); + if (failed) return; + // Each argument is checked against what the BUILTIN declares. Most take whole + // numbers β€” a channel, a light index, an angle16 β€” and a fixed value crossing + // uncoverted would read 65,536 times off. escape() is the exception: it takes + // four fixed coordinates, so uv output flows straight in. + if (wantFixed != exprIsFixed) { + bool adopted = false; + if (!wantFixed || !meet(true, -1, exprIsFixed, exprLitConst, adopted)) { + fail(wantFixed ? "this argument is a fixed value: write toFixed(x)" + : "this argument is a whole number: write toInt(x)"); + return; + } + } } if (failed) return; if (slotHighWater >= kMaxLocals) { fail("too many arguments to hold"); return; } @@ -607,17 +793,24 @@ struct Parser { // A control name must not shadow a builtin: a declared `random16` would make `random16(…)` // ambiguous (control read vs call). Reject it at the source so the resolution never collides. if (table.find(name, nameLen)) { fail("member name shadows a built-in function"); return; } + // The conversions and the boolean literals are resolved BEFORE a member name, so a member + // called one of these could be declared and then never read. Refused for the same reason a + // builtin name is: the alternative is a member that silently does not exist. + if (isReservedWord(name, nameLen)) { fail("member name is a reserved word"); return; } lex.advance(); - // An ARRAY: `uint8_t heat[16];`. The length is a literal, not an expression, because the + // An ARRAY: `byte heat[16];`. The length is a literal, not an expression, because the // arena is sized at compile time: a length read from a control would make the member's // 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; } + // A string is a reference into the compiled program's pool, so an array of them would + // be an array of references with no way to fill it: there is no runtime string. + if (type == CtrlType::Str) { fail("string arrays are not supported"); return; } + // A fixed ARRAY waits for element type-tracking to be worth having: the element's + // type has to reach the expression that reads it and the value that writes it, which + // scalars get from their declaration and elements would need per-array. Refused + // rather than half-working, the same stance string arrays take. + if (type == CtrlType::Fixed) { fail("fixed arrays are not supported yet"); 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; } @@ -627,10 +820,13 @@ struct Parser { if (!expect(Tok::Semicolon, "expected ';': an array has no initializer")) return; if (lex.kind == Tok::Error) { fail(lex.err); return; } if (memberCount >= kMaxCtrls) { fail("too many members"); return; } - const uint8_t align = ctrlWidth(type); + // Elements pack at their own width β€” a byte[] is one byte each, which is what keeps a + // heat map at 1x on a board with no PSRAM β€” but the array STARTS on a 4-byte boundary + // so the scalar slots around it stay aligned. + const uint8_t elem = ctrlWidth(type); uint16_t at = memberBytes; - if (align > 1 && (at % align) != 0) at = uint16_t(at + (align - at % align)); - const uint16_t need = uint16_t(count) * align; + if ((at % 4) != 0) at = uint16_t(at + (4 - at % 4)); + const uint16_t need = uint16_t(count) * elem; if (at + need > kCtrlBytes) { fail("the class declares more member data than the arena holds"); return; } // Zero, not a written initializer: an element-wise initializer list would be a second // syntax for what a `for` in the script already expresses, and every element seeding to @@ -648,23 +844,66 @@ struct Parser { // one point where a number is the only thing that can follow. bool negated = false; if (lex.kind == Tok::Minus) { negated = true; lex.advance(); } + // `true`/`false` seed a bool the way a script writes one. + if (lex.kind == Tok::Ident && (atKeyword("true", 4) || atKeyword("false", 5))) { + if (type != CtrlType::Bool) { fail("true and false initialize a bool member"); return; } + const long b = atKeyword("true", 4) ? 1 : 0; + lex.advance(); + if (!expect(Tok::Semicolon, "expected ';' after the member declaration")) return; + if (lex.kind == Tok::Error) { fail(lex.err); return; } + if (memberCount >= kMaxCtrls) { fail("too many members"); return; } + uint16_t bat = memberBytes; + if ((bat % 4) != 0) bat = uint16_t(bat + (4 - bat % 4)); + if (bat + 4 > kCtrlBytes) + { fail("the class declares more member data than the arena holds"); return; } + members[memberCount] = {name, 0, 1, static_cast<int32_t>(b), + static_cast<uint8_t>(nameLen), type, + static_cast<uint8_t>(bat), 1}; + memberBytes = static_cast<uint8_t>(bat + 4); + memberCount++; + return; + } + // A quoted initializer reaches here only for a string member; say what is actually true + // rather than asking for a number, which the string case then refuses in the other + // direction. The two messages used to point at each other. + if (lex.kind == Tok::String) { fail("a string member cannot be initialized yet"); return; } if (lex.kind != Tok::Number) { fail("expected a default value (a number)"); return; } 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; + // Range-checked against the DECLARED type, so a member cannot be given a value it would + // silently truncate. `byte n = 300;` is refused here rather than becoming 44 at run time, + // which is the class of bug this type system exists to remove: the old widths turned an + // out-of-range initializer into an arbitrary in-range one with nothing reporting it. + long defMin = INT32_MIN, defMax = INT32_MAX; + const char* rangeErr = nullptr; + switch (type) { + case CtrlType::Byte: defMin = 0; defMax = 255; + rangeErr = "byte default out of range (0..255)"; + if (lex.numberIsFixed) + { fail("a byte member takes a whole number"); return; } + break; + case CtrlType::Bool: defMin = 0; defMax = 1; + rangeErr = "bool default is 0 or 1"; + if (lex.numberIsFixed) + { fail("a bool member takes true or false"); return; } + break; + case CtrlType::Str: fail("a string member cannot be initialized yet"); return; + case CtrlType::Int: rangeErr = "default out of range"; + if (lex.numberIsFixed) + { fail("an int member takes a whole number"); return; } + break; + case CtrlType::Fixed: + // A whole number seeding a fixed member is converted HERE, at compile time: the + // script writes `fixed zoom = 2;` and means 2.0, and no runtime shift is spent on + // a constant. A decimal literal arrived scaled already. + if (!lex.numberIsFixed) { + if (lex.number < -32768 || lex.number > 32767) + { fail("fixed default out of range (-32768.0..32767.99998)"); return; } + lex.number = lex.number << 16; + } + rangeErr = "fixed default out of range (-32768.0..32767.99998)"; + break; } + if (lex.number < defMin || lex.number > defMax) { fail(rangeErr); return; } long def = lex.number; lex.advance(); if (!expect(Tok::Semicolon, "expected ';' after the member declaration")) return; @@ -679,40 +918,53 @@ struct Parser { // byte consumes several, so the n-th member is no longer at byte n. Checked against the // arena's byte budget rather than against the record count, because those are now two // different limits and a script can exhaust either one first. - // A wide member is placed on an EVEN byte. Two of the three backends scale a halfword - // load's immediate by the access size (arm64 ldrh, Xtensa l16ui), so an odd offset is not - // encodable at all: the alignment is the ISA's rule, honored here once rather than worked - // around in two assemblers. - const uint8_t align = ctrlWidth(type); + // Every scalar takes one 4-byte SLOT on a 4-byte boundary, whatever its type. That is the + // whole storage rule: no per-type width to align to, and the backends' 32-bit load and + // store scale their immediate by 4, which every arena offset already satisfies. uint16_t at = memberBytes; - if (align > 1 && (at % align) != 0) at = uint16_t(at + (align - at % align)); - const uint16_t need = uint16_t(ctrlWidth(type)); + if ((at % 4) != 0) at = uint16_t(at + (4 - at % 4)); + const uint16_t need = ctrlSlotBytes(type); if (at + need > kCtrlBytes) { fail("the class declares more member data than the arena holds"); return; } - // def is uint16_t on the record precisely so a wide member's initializer survives; casting - // it to a byte here truncated `uint16_t phase = 1000;` to 232. Invisible to a test that + // def is int32_t on the record so a member's whole initializer survives; casting it + // narrower here truncated `uint16_t phase = 1000;` to 232. Invisible to a test that // observes through setRGB, because the error is always a multiple of 256. - members[memberCount] = {name, 0, 255, static_cast<uint16_t>(def), + members[memberCount] = {name, 0, 255, static_cast<int32_t>(def), static_cast<uint8_t>(nameLen), type, static_cast<uint8_t>(at), 1}; memberBytes = static_cast<uint8_t>(at + need); memberCount++; } + /// The words the expression parser resolves before it looks for a member: the two conversions + /// and the two boolean literals. A member may not take one of these names. + static bool isReservedWord(const char* n, size_t len) { + static const struct { const char* w; size_t len; } kWords[] = { + {"toFixed", 7}, {"toInt", 5}, {"true", 4}, {"false", 5}}; + for (const auto& k : kWords) + if (len == k.len && std::strncmp(n, k.w, k.len) == 0) return true; + return false; + } + // Is the current Ident this exact keyword? Keywords are matched by text rather than lexed as // their own token kind: the set is tiny, and a script may still use `class` or `for` as part of // a longer identifier, which a length-checked compare gets right for free. bool atKeyword(const char* kw, size_t len) const { 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)? + // The five type keywords. Names a script author would reach for, not the storage widths the + // language used to make them spell: a type says what a value MEANS, and the slot it occupies + // is the compiler's business. bool atTypeKeyword() const { - return atKeyword("uint8_t", 7) || atKeyword("uint16_t", 8) || atKeyword("int16_t", 7); + return atKeyword("int", 3) || atKeyword("byte", 4) || atKeyword("bool", 4) || + atKeyword("fixed", 5) || atKeyword("string", 6); } /// The type the current keyword names. Only called when atTypeKeyword() is true. CtrlType currentType() const { - if (atKeyword("uint16_t", 8)) return CtrlType::Uint16; - if (atKeyword("int16_t", 7)) return CtrlType::Int16; - return CtrlType::Uint8; + if (atKeyword("byte", 4)) return CtrlType::Byte; + if (atKeyword("bool", 4)) return CtrlType::Bool; + if (atKeyword("fixed", 5)) return CtrlType::Fixed; + if (atKeyword("string", 6)) return CtrlType::Str; + return CtrlType::Int; } // program := { decl } { stmt }. Declarations (control vars) come first, then one-or-more @@ -758,6 +1010,8 @@ struct Parser { lex.advance(); if (!expect(Tok::Assign, "expected '=' in the for's first clause")) return false; VReg init = parseExpr(); + // the init: A loop COUNTS, so every clause of its header is a whole number. Without this, a fixed limit runs the body ~65,536 times: a multi-second stall on the render thread rather than a diagnostic. + if (exprIsFixed) { fail("a loop counts in whole numbers: write toInt(x)"); return false; } if (failed) return false; // The counter starts life in its slot; the temp that computed it is released immediately. // Bounded on slotHighWater, not just localCount: a call RELEASES its argument staging slots @@ -786,6 +1040,7 @@ struct Parser { // so it has to survive the body β€” and a body containing a call would otherwise have to keep // it in a register across that call. VReg limitTmp = parseExpr(); + if (exprIsFixed) { fail("a loop counts in whole numbers: write toInt(x)"); return false; } if (failed) return false; if (slotHighWater >= kMaxLocals) { fail("too many loop variables"); return false; } const uint8_t limitSlot = slotHighWater++; @@ -842,6 +1097,7 @@ struct Parser { Lexer save = lex; lex = stepLex; VReg s = parseExpr(); + if (exprIsFixed) { fail("a loop counts in whole numbers: write toInt(x)"); return false; } if (failed) return false; // parseExpr stops at the first token it cannot consume, so without this the step // silently ignores whatever follows it β€” `i = i + 1 garbage` compiled clean. The @@ -903,10 +1159,25 @@ struct Parser { lex.advance(); VReg idx = parseExpr(); if (failed) return false; + if (exprIsFixed) { fail("an array index is a whole number: write toInt(x)"); return false; } if (!expect(Tok::RBracket, "expected ']' to close an array index")) { freeTemp(idx); return false; } if (!expect(Tok::Assign, "expected '=' in an assignment")) { freeTemp(idx); return false; } VReg v = parseExpr(); if (failed) { freeTemp(idx); return false; } + // The same wall the scalar store enforces: an element takes what its type holds, or + // the stored word is scaled 65,536 away from what the script meant. A literal adopts + // for a fixed[] element exactly as it does at a binary operator. + { + const bool wantFixed = (members[ai].type == CtrlType::Fixed); + if (wantFixed != exprIsFixed) { + bool adopted = false; + if (!wantFixed || !meet(true, -1, exprIsFixed, exprLitConst, adopted)) { + fail(wantFixed ? "a fixed element takes a fixed value: write toFixed(x)" + : "this element takes a whole number: write toInt(x)"); + return false; + } + } + } emit({IrOp::StoreIdx, 0, idx, v, 0, 0, idxPack(members[ai].offset, ctrlWidth(members[ai].type), members[ai].count), nullptr, {}}); @@ -933,17 +1204,58 @@ struct Parser { } VReg v = parseExpr(); if (failed) return false; + // The wall holds at the STORE too: a fixed member takes a fixed value and every other + // member a whole number, or the slot would hold bits scaled 65,536 away from what the + // script meant. Locals are loop counters, always whole numbers. + if (mi >= 0) { + const bool wantFixed = (members[mi].type == CtrlType::Fixed); + if (wantFixed != exprIsFixed) { + // `c = 5;` on a fixed member converts the literal at compile time, exactly as the + // initializer does; anything computed still names its conversion. + bool adopted = false; + if (wantFixed && !meet(true, -1, exprIsFixed, exprLitConst, adopted) ) return false; + if (!wantFixed) { + fail("this member takes a whole number: write toInt(x)"); + return false; + } + } + } else if (exprIsFixed) { + fail("a loop variable takes a whole number: write toInt(x)"); + return false; + } if (li >= 0) emit({IrOp::Spill, 0, v, 0,0,0, locals[li].slot, nullptr, {}}); - // 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, {}}); + // The store NARROWS: a byte or bool member takes StoreCtrl, which writes one byte, so the + // value truncates in the instruction itself and the slot's upper three bytes keep the zero + // they were seeded with. Nothing else can ever write them, which is what lets a byte + // control's descriptor point at the slot's low byte and still see the member's whole + // value. int and fixed take StoreCtrl32 and the whole slot. Same reasoning the ARRAY path + // has always used, where store8 into a byte[] element narrows the same way. + else emit({storeOpFor(members[mi].type), 0, v, 0,0,0, + members[mi].offset, nullptr, {}}); freeTemp(v); return expect(Tok::Semicolon, "expected ';' after an assignment"); } + /// The store op a member's type needs. + /// + /// A byte and a bool are NARROWED BY THE STORE ITSELF: StoreCtrl writes one byte, so the + /// value truncates in the instruction and the slot's upper three bytes keep the zero they + /// were seeded with. Nothing else can ever write them, which is what lets a byte control's + /// descriptor point at the slot's low byte and read the member's whole value. The array path + /// has always narrowed this way (store8 into a byte[] element); this makes a scalar match. + /// + /// int and fixed take the whole slot, so they store all four bytes. + /// + /// A bool truncates the same way, so it holds 0..255 rather than strictly 0 or 1: a script + /// writing `flag = 7` reads 7 back, and every use of a bool is a comparison, where any + /// non-zero is true. The one value that does NOT survive is a multiple of 256, which + /// truncates to 0 β€” `flag = count` where count is 256 reads false. Normalizing would need a + /// compare-and-select the IR has no op for (there is no bitwise op and no Sub), so it waits + /// for a script that writes a non-boolean expression into a bool. + static IrOp storeOpFor(CtrlType type) { + return ctrlWidth(type) == 1 ? IrOp::StoreCtrl : IrOp::StoreCtrl32; + } + /// `if (a OP b) { … }` with an optional `else { … }`. /// /// The six comparisons lower onto the TWO branch ops the loops already use. The emitted branch @@ -978,9 +1290,17 @@ struct Parser { fail("expected a comparison: <, <=, >, >=, == or !="); return false; } + const bool aFixed = exprIsFixed; + const int aLit = exprLitConst; lex.advance(); VReg b = parseExpr(); if (failed) { freeTemp(a); return false; } + // Same wall as the operators: agreeing sides compare correctly as raw words (Q16.16 + // preserves order), and disagreeing sides would compare numbers 65,536 apart in meaning. + // A literal adopts the fixed side here too, which is what makes `if (v < 0)` natural. + bool cmpFixed = false; + if (!meet(aFixed, aLit, exprIsFixed, exprLitConst, cmpFixed)) + { freeTemp(b); freeTemp(a); return false; } if (!expect(Tok::RParen, "expected ')' to close the if condition")) { freeTemp(b); freeTemp(a); return false; } if (!expect(Tok::LBrace, "expected '{': an if body is braced")) { freeTemp(b); freeTemp(a); return false; } diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index aa936dbf..7190a9f1 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -57,6 +57,13 @@ enum class IrOp : uint8_t { Add, // dst = a + b AddImm, // dst = a + imm Mul, // dst = a * b + Mulhi, // dst = the SIGNED high 32 bits of a * b. With Mul it spells a Q16.16 multiply: + // the 64-bit product's middle word is (Mulhi << 16) | (Mul >>> 16). + Shl, // dst = a << imm β€” also how toFixed(v) is spelled (imm 16) + Shr, // dst = a >> imm, LOGICAL (zero-filling). The low word of a 64-bit product is + // unsigned, so a fixed multiply needs this rather than Sar for its bottom half. + Sar, // dst = a >> imm, ARITHMETIC (sign-filling) β€” toInt(v) is this with imm 16. + // Logical would turn every negative fixed value into a large positive int. Call, // dst = (*callFn)(&frame[imm], b, arena) β€” call a host-registered function. // `imm` is the frame slot where the arguments start and `b` is how many there are: // the parser stages every argument into consecutive slots, so a call carries a @@ -93,16 +100,14 @@ 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 - // width fail to COMPILE, where a field it silently ignored would emit a byte - // access against a two-byte member and lose the high half at run time. - StoreCtrl16, // *(uint16_t*)((uint8_t*)kArg4 + imm) = a: write a WIDE member. + LoadCtrl32, // dst = *(int32_t*)((const uint8_t*)kArg4 + imm): read a member's whole 4-byte + // SLOT. Every scalar occupies one, whatever its type: byte and bool are masked + // on the way in, so the slot's upper bytes are already what the type promises, + // and fixed and int are the raw word. Signed because that is the only reading + // that serves all four: a byte or bool slot never has bit 31 set. + StoreCtrl32, // *(int32_t*)((uint8_t*)kArg4 + imm) = a: write a member's whole slot. Takes the + // offset as an IMMEDIATE, unlike StoreCtrl which burns a movImm into + // a scratch register first β€” the load path's shape, and one instruction shorter. LoadIdx, // dst = arena[base + a * width]: read an ARRAY element, index in vreg `a`. StoreIdx, // arena[base + a * width] = b: write an ARRAY element, index in vreg `a`. // base, width and count are PACKED INTO `imm` (idxPack/idxBase/idxWidth/idxCount), @@ -138,7 +143,7 @@ enum class IrOp : uint8_t { 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. + // wrong way, which is why a width lives in its own op rather than a field. 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 @@ -177,15 +182,17 @@ struct IrInst { struct DeclaredControl { const char* name = nullptr; // script-declared name (points into the source buffer) // The UI range, as wide as the widest member a control can bind: addUint8 declares 0..255 and - // addUint16 the full 16-bit span, so the field has to hold the wider one. - uint16_t min = 0, max = 255; + // an int member the full 32-bit span, so the field has to hold the wider one. + int32_t min = 0, max = 255; // The initializer, wide enough for the widest member type. Separate from the range because a - // member may be seeded to a value outside what its slider spans. - uint16_t def = 0; + // member may be seeded to a value outside what its slider spans. Signed and 32-bit because a + // scalar occupies a 4-byte slot: an `int` member's range and default span the whole type. + int32_t def = 0; uint8_t nameLen = 0; // length (the source is not NUL-terminated per token) - CtrlType type = CtrlType::Uint8; + CtrlType type = CtrlType::Int; // Byte offset into the controls arena, assigned as a running CURSOR in declaration order. Not - // the declaration index: a Uint16 costs two bytes and an array costs count * width, so the + // the declaration index: a scalar costs a whole 4-byte slot and an array costs count * +// element width, so the // n-th member is no longer at byte n. Everything downstream (the bindings' cached slot // pointers, persistence, addUint8's by-reference argument) already keys on this offset, which // is why widening a member does not reach any of them. diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index 10ce55fb..1355e762 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -66,9 +66,8 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { // the allocator rewrites anything. The pointer is reached through host(kArg4) at lowering // time and needs no live interval here. case IrOp::StoreCtrl: - case IrOp::StoreCtrl16: out[0] = in.a; return 1; - case IrOp::LoadCtrl16: - case IrOp::LoadCtrl16S: out[0] = kArg4; return 1; // reads the arena pointer + case IrOp::StoreCtrl32: out[0] = in.a; return 1; + case IrOp::LoadCtrl32: 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 @@ -77,6 +76,12 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { // do the same, so kArg4 needs no live interval here either. case IrOp::LoadIdx: out[0] = in.a; return 1; case IrOp::StoreIdx: out[0] = in.a; out[1] = in.b; return 2; + // Shl/Sar carry their shift amount in `imm`, so the vreg source is the value alone; Mulhi + // reads both operands exactly as Mul does. + case IrOp::Shl: + case IrOp::Shr: + case IrOp::Sar: out[0] = in.a; return 1; + case IrOp::Mulhi: out[0] = in.a; out[1] = in.b; return 2; case IrOp::Add: case IrOp::Mul: case IrOp::BranchGe: @@ -110,7 +115,7 @@ bool writesDst(IrOp op) { // 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: - case IrOp::StoreCtrl16: + case IrOp::StoreCtrl32: // CallScript writes no dst either: a script function returns nothing today, so the call is // a statement rather than an expression. When it gains a return value this moves. case IrOp::CallScript: diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index f80fe4af..84c5e981 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -23,8 +23,8 @@ // The assembler contract, which all three satisfy: // ctor(size_t cap), newLabel, bind, prologue(uint8_t), epilogue, alignForEntry, finalize, // bytes, size, overflowed, spillStore, spillLoad, slotAddr, -// movImm, movPtr, movReg, addImm, addReg, mulReg, store8, load8, store16, load16, -// load8Idx, load16Idx, +// movImm, movPtr, movReg, addImm, addReg, mulReg, mulhi, shlImm, shrImm, sarImm, +// store8, load8, load32, store32, load8Idx, load32Idx, store32Idx, // branchIfZero, branchGeU, branchNe, call, callLabel, and kMaxSpillSlots. // The branches are the FUSED forms (compare-and-branch as one call). arm64 has no such // instruction and spells each as cmp + b.cond inside its assembler, which is exactly where a @@ -236,6 +236,21 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee case IrOp::Add: a.addReg(reg(op.dst), reg(op.a), reg(op.b)); break; case IrOp::AddImm: a.addImm(reg(op.dst), reg(op.a), op.imm); break; case IrOp::Mul: a.mulReg(reg(op.dst), reg(op.a), reg(op.b)); break; + case IrOp::Mulhi: a.mulhi(reg(op.dst), reg(op.a), reg(op.b)); break; + // The shift amount is an immediate 1..31. A zero shift is a no-op the front end never + // emits (Xtensa cannot even encode it: slli's field holds 32-n). + case IrOp::Shl: + if (op.imm > 0 && op.imm < 32) a.shlImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); + else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + break; + case IrOp::Shr: + if (op.imm > 0 && op.imm < 32) a.shrImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); + else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + break; + case IrOp::Sar: + if (op.imm > 0 && op.imm < 32) a.sarImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); + else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + break; // A real register move, NOT add-immediate-zero: Xtensa's addi.n cannot encode 0, since // the ISA reuses that slot for -1, so `dst = a + 0` silently computed a - 1. A loop // counter initialized through Mov therefore started at -1, the unsigned loop guard saw @@ -258,8 +273,11 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee 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) + // A member's whole 4-byte SLOT. Unlike the byte and halfword stores below, the offset + // rides the instruction as an immediate: nothing computes a slot address at run time, + // so there is no reason to spend a register and a movImm on a constant. + case IrOp::LoadCtrl32: a.load32(reg(op.dst), host(kArg4), op.imm); break; + case IrOp::StoreCtrl32: a.store32(host(kArg4), op.imm, reg(op.a)); break; // 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 // rule the backends would each have to know. @@ -289,23 +307,17 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee a.movImm(sAddr, width); a.mulReg(idx, idx, sAddr); // idx *= width (a byte offset now) a.addImm(idx, idx, idxBase(op.imm)); // ... plus the array's base + // Two element widths, which is all ctrlWidth can produce: 1 for byte[] and + // bool[], 4 for int[] and fixed[]. if (op.op == IrOp::LoadIdx) { - if (width == 2) a.load16Idx(reg(op.dst), host(kArg4), idx); + if (width == 4) a.load32Idx(reg(op.dst), host(kArg4), idx); else a.load8Idx(reg(op.dst), host(kArg4), idx); } else { - if (width == 2) a.store16(host(kArg4), idx, reg(op.b)); + if (width == 4) a.store32Idx(host(kArg4), idx, reg(op.b)); else a.store8(host(kArg4), idx, reg(op.b)); } break; } - case IrOp::StoreCtrl16: { - // Same shape as the byte store: the offset goes through a register because the - // per-light writer computes its index, and sCtr rather than sAddr because store16 - // clobbers its own address temp. - a.movImm(sCtr, op.imm); - a.store16(host(kArg4), sCtr, reg(op.a)); - break; - } case IrOp::StoreCtrl: { // ctrls[imm] = a. store8 addresses through a REGISTER holding the offset, because // it is the per-light writer's shape where the index is computed, so the constant diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index e7373745..dc0fa563 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -128,20 +128,45 @@ extern "C" inline uint32_t mm_light_mod(const uintptr_t* args, uint32_t, const u // 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. +// b == 0 SATURATES with the numerator's sign β€” IEEE 754's Β±infinity mapped onto an int, and what +// libfixmath does on divide overflow. The value is also the visually right one: `k / dist` at +// dist == 0 is the CENTER of a ripple, where max reads as the peak the eye expects and 0 punched +// a dark hole exactly there. 0/0 stays 0 (no direction to saturate toward). mod keeps returning +// 0: there is no "infinite remainder". Either way a script degrades, never faults, and needs no +// zero-check of its own. // 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 int32_t a = static_cast<int32_t>(uint32_t(args[0])); const int32_t b = static_cast<int32_t>(uint32_t(args[1])); + if (b == 0) + return static_cast<uint32_t>(a > 0 ? INT32_MAX : a < 0 ? INT32_MIN : 0); // 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<uint32_t>(INT32_MAX); return static_cast<uint32_t>(a / b); } +// fdiv(a, b) β†’ the Q16.16 quotient, what the '/' OPERATOR lowers to when both sides are fixed. +// A separate host call from div because the numerator must widen: the quotient of two Q16.16 +// values needs (a << 16) / b, and shifting a 32-bit fixed value left by 16 in registers wraps for +// anything past |128.0| β€” which is exactly what froze two shipped shaders. int64 in the host is +// exact over the whole range, and a divide is a host call on every ISA here anyway (libfixmath's +// fix16_div does the same widening for the same reason). +// b == 0 saturates with the numerator's sign, matching div; a quotient outside int32 saturates +// too, rather than wrapping into a number nobody wrote. +extern "C" inline uint32_t mm_light_fdiv(const uintptr_t* args, uint32_t, const uint8_t*) { + const int32_t a = static_cast<int32_t>(uint32_t(args[0])); + const int32_t b = static_cast<int32_t>(uint32_t(args[1])); + if (b == 0) + return static_cast<uint32_t>(a > 0 ? INT32_MAX : a < 0 ? INT32_MIN : 0); + const int64_t q = (static_cast<int64_t>(a) << 16) / b; + if (q > INT32_MAX) return static_cast<uint32_t>(INT32_MAX); + if (q < INT32_MIN) return static_cast<uint32_t>(INT32_MIN); + return static_cast<uint32_t>(static_cast<int32_t>(q)); +} + // smoothstep(edge0, edge1, v) β†’ a soft 0..65535 ramp between the edges, GLSL's own and the // anti-aliasing workhorse: wherever a script would draw a hard jaggy edge with an `if`, running // the distance through this softens it over a width the script picks. `smoothstep(0, w, w - d)` @@ -192,14 +217,21 @@ extern "C" inline uint32_t mm_light_uvAxis(const uintptr_t* args, bool wantY) { const int64_t s = sw < sh ? sw : sh; // normalize on the SHORT side: that is what // keeps a circle circular on a wide panel 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); - // 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. + // Q16.16: 65536 is 1.0, the scale every `fixed` value in the language uses. Applied before the + // divide rather than as a shift after it, so there is one rounding step rather than two. + const int64_t v = ((px * 2 - extent + 1) * 65536) / s; + // Β±4.0, which is well past the Β±1 the short side normalizes to: a wide panel's long axis runs + // past 1.0 by its aspect ratio, and 4x covers any panel anyone builds. + const int64_t c = v < -262144 ? -262144 : (v > 262144 ? 262144 : v); + // SIGNED and FIXED, with no bias. A coordinate has an origin: the center of the grid is 0.0, + // the left half is negative, and a script holds the result in a `fixed` member and does + // ordinary arithmetic on it. 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/beat KEEP their unsigned 0..65535 convention, deliberately: a wave has no origin, + // and `scale(sin(a), width)` sweeping a full axis is the idiom 14 shipped call sites rely on. + // A coordinate has an origin, a wave does not. return static_cast<uint32_t>(static_cast<int32_t>(c)); } extern "C" inline uint32_t mm_light_uvX(const uintptr_t* args, uint32_t, const uint8_t*) { @@ -218,28 +250,28 @@ extern "C" inline uint32_t mm_light_uvY(const uintptr_t* args, uint32_t, const u // 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. +// Q16.16, the language's `fixed`: 1.0 is 65536, matching uvX/uvY. A script hands over uv +// coordinates directly, holds them in `fixed` members, and does ordinary arithmetic on them with +// no rescaling anywhere. // -// 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. +// The products are int64 and have to be. z*z at the escape radius is 4.0, whose Q32 square is +// about 7.4e10 β€” 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 + // Inputs clamped to |8.0| in Q16.16. 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) { + // passing 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 qfx = [](uintptr_t a) { const int32_t v = signedArg(a); - return v < -65536 ? -65536 : (v > 65536 ? 65536 : v); + return v < -524288 ? -524288 : (v > 524288 ? 524288 : v); }; - const int32_t cx = q13(args[0]), cy = q13(args[1]); - const int32_t jx = q13(args[2]), jy = q13(args[3]); + const int32_t cx = qfx(args[0]), cy = qfx(args[1]); + const int32_t jx = qfx(args[2]), jy = qfx(args[3]); uint32_t iters = uint32_t(args[4]); if (iters > 64) iters = 64; if (iters == 0) return 0; @@ -251,8 +283,8 @@ extern "C" inline uint32_t mm_light_escape(const uintptr_t* args, uint32_t, cons 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 + constexpr int kShift = 16; + constexpr int64_t kEscape = int64_t(4) << (kShift * 2); // |z|^2 > 4.0, in Q32 uint32_t n = 0; for (; n < iters; ++n) { @@ -445,7 +477,7 @@ struct AddLightSink { AddLightFn fn = nullptr; void* ctx = nullptr; }; /// arena bytes a write touches. It is checked against the member's own type by the compiler, so /// by the time a call arrives here the two already agree. using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, - uint16_t lo, uint16_t hi, CtrlType type); + int32_t lo, int32_t hi, CtrlType type); struct AddControlSink { AddControlFn fn = nullptr; void* ctx = nullptr; }; /// Where fade(amt) sends its request. The binding forwards it to the LAYER rather than to the @@ -627,8 +659,10 @@ inline void setAddLightSink(AddLightFn fn, void* ctx) { // this runs. What is left is the call itself, which exists so that a script declares a control the // way a compiled module does: `defineControls()` is an ordinary function the binding calls after a // successful compile, and this is an ordinary builtin it calls. -// Shared by addUint8 and addUint16: identical but for the width they declare, so the bound check -// and the sink call live once rather than in two copies that could drift. +// The one control declaration. What kind of control it becomes is read from the MEMBER'S declared +// type rather than chosen by the call, which is what removed the width-matched pair this replaces: +// addUint8 on a wide member drove only its low byte and addUint16 on a narrow one wrote past it, +// both silently, and the script author had to keep call and declaration in agreement by hand. inline uint32_t addControlDecl(const uintptr_t* args, CtrlType type) { // args: (name, memberOffset, min, max). The name is a pointer into the compiled program's // string pool, which outlives the run; the offset is the member's arena byte, which the @@ -636,26 +670,28 @@ inline uint32_t addControlDecl(const uintptr_t* args, CtrlType type) { const char* name = reinterpret_cast<const char*>(args[0]); const AddControlSink s = addControlSink(); if (!name || !s.fn || !s.ctx) return 0; // no binding listening: the call is a no-op - // The range is an ARBITRARY EXPRESSION, so `addUint8("n", n, 0, x * 64)` can compute past what - // the declared width holds. Truncating would publish a slider whose top silently wraps to a - // small number; refusing the declaration leaves the control absent, which the user can see. - const uintptr_t limit = (type == CtrlType::Uint16) ? 65535u : 255u; - if (args[2] > limit || args[3] > limit) return 0; + // The range is an ARBITRARY EXPRESSION, so `addControl("n", n, 0, x * 64)` can compute past + // what the member's type holds. Truncating would publish a slider whose top silently wraps to + // a small number; refusing the declaration leaves the control absent, which the user can see. + const int32_t lo = int32_t(args[2]), hi = int32_t(args[3]); + const int32_t limit = (type == CtrlType::Byte) ? 255 : (type == CtrlType::Bool) ? 1 : INT32_MAX; + if (lo > limit || hi > limit) return 0; + // A byte and a bool are UNSIGNED, and the binding casts the range to uint8_t: a negative low + // bound became min 251 with max 100, a slider that could reach nothing. The old uintptr_t + // compare caught this for free (a negative wrapped huge); with a signed range it needs saying. + if ((type == CtrlType::Byte || type == CtrlType::Bool) && lo < 0) return 0; // Same stance for an INVERTED range: with min > max the write path's `v < min || v > max` is // true for every value, so the slider would appear and then refuse everything the user does to // it. Refusing the declaration leaves it absent, which is visible. - if (args[2] > args[3]) return 0; - s.fn(s.ctx, name, static_cast<uint8_t>(args[1]), - static_cast<uint16_t>(args[2]), static_cast<uint16_t>(args[3]), type); + if (lo > hi) return 0; + s.fn(s.ctx, name, static_cast<uint8_t>(args[1] & 0xff), lo, hi, type); return 0; } -extern "C" inline uint32_t mm_light_addUint8(const uintptr_t* args, uint32_t, const uint8_t*) { - return addControlDecl(args, CtrlType::Uint8); -} - -extern "C" inline uint32_t mm_light_addUint16(const uintptr_t* args, uint32_t, const uint8_t*) { - return addControlDecl(args, CtrlType::Uint16); +// The member's own type decides the control; this call only says "surface it, within this range". +// The compiler packs both into args[1]: the low byte is the arena offset, the next the CtrlType. +extern "C" inline uint32_t mm_light_addControl(const uintptr_t* args, uint32_t, const uint8_t*) { + return addControlDecl(args, static_cast<CtrlType>((args[1] >> 8) & 0xff)); } extern "C" inline uint32_t mm_light_addLight(const uintptr_t* args, uint32_t, const uint8_t*) { @@ -1055,16 +1091,17 @@ inline BuiltinTable lightBuiltins() { // glow; signed arguments, re-centered like polarA. See mm_light_smoothstep. t.add({"smoothstep", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_smoothstep, {}}); // uvX(x, w, h) / uvY(y, w, h) β†’ shader space, centered and short-side normalized so a circle - // stays a circle on a wide panel. Biased at 32768. See mm_light_uvAxis. - t.add({"uvX", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_uvX, {}}); - t.add({"uvY", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_uvY, {}}); + // stays a circle on a wide panel. SIGNED fixed (Q16.16), centered on 0.0, no bias. + // See mm_light_uvAxis. + t.add({"uvX", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_uvX, {}, /*byRef*/ 0, /*byStr*/ 0, /*fixedArgs*/ 0, /*fixedReturn*/ true}); + t.add({"uvY", 3, /*returns*/ true, BuiltinKind::Call, &mm_light_uvY, {}, /*byRef*/ 0, /*byStr*/ 0, /*fixedArgs*/ 0, /*fixedReturn*/ true}); // 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, {}}); + t.add({"escape", 5, /*returns*/ true, BuiltinKind::Call, &mm_light_escape, {}, /*byRef*/ 0, /*byStr*/ 0, /*fixedArgs*/ 0x0f}); // 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. @@ -1093,20 +1130,20 @@ inline BuiltinTable lightBuiltins() { t.add({"addLight", 3, /*returns*/ false, BuiltinKind::Call, &mm_light_addLight, {}}); // line(x1, y1, x2, y2, r, g, b) β†’ a segment on the canvas, via the shared draw::line. t.add({"line", 7, /*returns*/ false, BuiltinKind::Call, &mm_light_line, {}}); - // addUint8(name, member, min, max) β†’ declare a control on a member, the same call a compiled - // module makes (`controls_.addUint8("speed", speed, 1, 255)`). Bit 1 of byRef marks the second - // argument as the MEMBER, so the compiler passes its arena offset rather than its value, which - // is what makes the script read as the reference a compiled module passes. - t.add({"addUint8", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addUint8, {}, - /*byRef*/ 0x2, /*byStr*/ 0x1, /*refType*/ CtrlType::Uint8}); - // addUint16(name, member, min, max) β†’ the same call against a uint16_t member, so a script can - // expose a value a byte cannot hold (a dwell time, a 0..1000 scale) instead of packing it into - // two byte controls. Same by-ref/by-str marking: only the declared width differs. - t.add({"addUint16", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addUint16, {}, - /*byRef*/ 0x2, /*byStr*/ 0x1, /*refType*/ CtrlType::Uint16}); + // addControl(name, member, min, max) β†’ surface a member in the UI, the same shape a compiled + // module uses. Bit 1 of byRef marks the second argument as the MEMBER, so the compiler passes + // its arena offset (and its type) rather than its value, which is what makes the script read + // as the reference a compiled module passes. ONE call for every type: which widget appears + // follows from how the member was declared, so the two can no longer disagree. + t.add({"addControl", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addControl, {}, + /*byRef*/ 0x2, /*byStr*/ 0x1}); // setPaletteColor(x, y, i, bri) β†’ one palette-coloured pixel. The form a script should reach // for: one call, one brightness evaluation, and no buffer-layout arithmetic at the call site. t.add({"setPaletteColor", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColor, {}}); + // fdiv(a, b) β†’ the fixed '/' β€” see mm_light_fdiv. Both operands and the result + // are fixed; resolved by name exactly as div is, so core stays domain-neutral. + t.add({"fdiv", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_fdiv, {}, + /*byRef*/ 0, /*byStr*/ 0, /*fixedArgs*/ 0x3, /*fixedReturn*/ true}); // paletteR/G/B(i, bri) β†’ one channel each, for a script that needs the components. Kept // because setPaletteColor writes a pixel and cannot serve a script that wants the value. t.add({"paletteR", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_paletteR, {}}); @@ -1141,7 +1178,7 @@ inline void runDefineControls(MoonLive& engine, PoolSizeFn sizePool = nullptr, v // declare nothing. Keeping the previous set is the honest degrade, and the run is skipped // rather than executed into a dead sink. if (!setAddControlSink([](void* ctx, const char* n, uint8_t off, - uint16_t lo, uint16_t hi, CtrlType type) { + int32_t lo, int32_t hi, CtrlType type) { static_cast<MoonLive*>(ctx)->addDeclaredControl(n, off, lo, hi, type); }, &engine)) return; if (sizePool) setPoolSizeSink(sizePool, poolCtx); diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index a47d4eed..5ba6c315 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -144,28 +144,40 @@ class MoonLiveScript { /// Publish every control the compiled script declared into `controls`, bound by reference to /// the engine's live arena slot so a slider write lands where the running native code reads it. /// The ONE home for this: all three bindings (effect, layout, modifier) publish identically, - /// and the width dispatch below is the kind of reasoning that should be stated once. + /// and the type dispatch below is the kind of reasoning that should be stated once. void publishDeclaredControls(ControlList& controls) { uint8_t n = 0; const moonlive::DeclaredControl* decls = engine_.declaredControls(n); for (uint8_t i = 0; i < n; i++) { uint8_t* slot = engine_.controlSlot(decls[i].offset); if (!slot) continue; // engine not compiled yet β€” controls appear after prepare - // Published at the width the script declared. A uint16_t member reaches the UI as a - // 16-bit control writing both its arena bytes; publishing it as a uint8 would drive - // only the low one and leave the high half holding whatever it had. - if (decls[i].type == moonlive::CtrlType::Uint16) { - // Safe to view as a uint16_t: the compiler aligns every wide member to an even - // arena offset (two backends cannot encode an odd halfword offset at all), and the - // arena base comes from platform::alloc, which is aligned for any fundamental type. - controls.addUint16(decls[i].name, *reinterpret_cast<uint16_t*>(slot), - decls[i].min, decls[i].max); - } else { - controls.addUint8(decls[i].name, *slot, - static_cast<uint8_t>(decls[i].min), - static_cast<uint8_t>(decls[i].max)); + // Published as the widget the member's TYPE calls for. Every scalar occupies the same + // 4-byte slot, so this is no longer a width dispatch: it is the semantic one, and the + // storage underneath is identical in all three cases. + // + // Safe to view the slot as its type: the compiler aligns every member to a 4-byte + // arena offset, and the arena base comes from platform::alloc, which is aligned for + // any fundamental type. + // + // byte and bool point at the slot's LOW BYTE, which is only correct because the two + // are masked on store: the upper three bytes are always zero, so a 1-byte control + // reading and writing that byte sees the member's whole value. On a big-endian target + // the low byte would be at offset+3 β€” no supported target is one. + switch (decls[i].type) { + case moonlive::CtrlType::Bool: + controls.addBool(decls[i].name, *reinterpret_cast<bool*>(slot)); + break; + case moonlive::CtrlType::Byte: + controls.addUint8(decls[i].name, *slot, + static_cast<uint8_t>(decls[i].min), + static_cast<uint8_t>(decls[i].max)); + break; + default: // Int; Fixed and Str never reach here (the compiler refuses to bind one) + controls.addInt32(decls[i].name, *reinterpret_cast<int32_t*>(slot), + decls[i].min, decls[i].max); + break; } - // The member's initializer (`uint8_t bpm = 60;`) IS the control's default, and it is + // The member's initializer (`byte 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. diff --git a/src/platform/desktop/moonlive_asm_arm64.cpp b/src/platform/desktop/moonlive_asm_arm64.cpp index 0f78431e..7ce512ec 100644 --- a/src/platform/desktop/moonlive_asm_arm64.cpp +++ b/src/platform/desktop/moonlive_asm_arm64.cpp @@ -126,13 +126,22 @@ void HostAssembler::movImm(Reg d, int32_t imm) { // light, and in a host-call argument it is nonsense. movn is the negative form: it writes // ~imm16, so movn #(~imm) materialises the true negative value. if (imm < 0) { - // movn writes ~imm16, so it reaches -65536..-1 exactly. Below that the complement no longer - // fits the 16-bit field and the constant would come out wrong in silence. - if (imm < -65536) { overflow_ = true; return; } - emit32(0x12800000u | ((uint32_t(~imm) & 0xffff) << 5) | mr(d)); // movn wD, #~imm16 + // movn writes ~imm16, reaching -65536..-1 in one instruction. Below that, movk patches + // the high half over it: movn seeds every bit set, so only the two 16-bit fields need + // stating. This used to overflow_ instead β€” the compiler's Const never went that low + // until a fixed literal could ride one. + const uint32_t u = uint32_t(imm); + emit32(0x12800000u | ((uint32_t(~imm) & 0xffff) << 5) | mr(d)); // movn wD, #~imm16 (low) + if (imm < -65536) + emit32(0x72a00000u | (((u >> 16) & 0xffff) << 5) | mr(d)); // movk wD, #hi16, lsl 16 return; } emit32(0x52800000u | ((uint32_t(imm) & 0xffff) << 5) | mr(d)); // movz wD, #imm16 + if (uint32_t(imm) > 0xffffu) + // The high half, patched over the movz. Without this every constant above 65535 silently + // materialized as its low 16 bits β€” invisible while the language capped literals there, + // and the first thing a Q16.16 literal (2.0 is 131072) stepped on. + emit32(0x72a00000u | ((uint32_t(imm) >> 16) << 5) | mr(d)); // movk wD, #hi16, lsl 16 } void HostAssembler::addImm(Reg d, Reg a, int32_t imm) { // add xD, xA, #imm12 (64-bit) emit32(0x91000000u | ((uint32_t(imm) & 0xfff) << 10) | (mr(a) << 5) | mr(d)); @@ -149,26 +158,45 @@ void HostAssembler::mulImm(Reg d, Reg a, int32_t imm) { // d = a * imm via mo void HostAssembler::mulReg(Reg d, Reg a, Reg b) { // mul wD, wA, wB emit32(0x1b007c00u | (mr(b) << 16) | (mr(a) << 5) | mr(d)); } +// smull xD, wA, wB then lsr xD, xD, #32 β€” the signed 64-bit product's high word. arm64 also has +// smulh, but that is a 64x64 form: with 32-bit vregs, widening the multiply is both correct and +// one instruction shorter than sign-extending first. +void HostAssembler::mulhi(Reg d, Reg a, Reg b) { + emit32(0x9b207c00u | (mr(b) << 16) | (mr(a) << 5) | mr(d)); // smull xD, wA, wB + emit32(0xd360fc00u | (mr(d) << 5) | mr(d)); // lsr xD, xD, #32 +} +// lsl wD, wA, #n is an alias of ubfm; asr wD, wA, #n of sbfm. Both take the 32-bit immr/imms +// form, which is why the width bit (31) stays clear here. +void HostAssembler::shlImm(Reg d, Reg a, uint8_t n) { + const uint32_t immr = (32u - n) & 31u, imms = 31u - n; + emit32(0x53000000u | (immr << 16) | (imms << 10) | (mr(a) << 5) | mr(d)); +} +void HostAssembler::sarImm(Reg d, Reg a, uint8_t n) { + emit32(0x13000000u | (uint32_t(n) << 16) | (31u << 10) | (mr(a) << 5) | mr(d)); +} +// lsr is ubfm with imms fixed at 31: the same shape as asr but zero-filling. +void HostAssembler::shrImm(Reg d, Reg a, uint8_t n) { + emit32(0x53000000u | (uint32_t(n) << 16) | (31u << 10) | (mr(a) << 5) | mr(d)); +} void HostAssembler::store8(Reg base, Reg off, Reg val) { // strb wVal, [xBase, xOff] emit32(0x38206800u | (mr(off) << 16) | (mr(base) << 5) | mr(val)); } void HostAssembler::load8(Reg d, Reg base, int32_t imm) { // ldrb wDst, [xBase, #imm12] emit32(0x39400000u | ((uint32_t(imm) & 0xfff) << 10) | (mr(base) << 5) | mr(d)); } -void HostAssembler::store16(Reg base, Reg off, Reg val) { // strh wVal, [xBase, xOff] - emit32(0x78206800u | (mr(off) << 16) | (mr(base) << 5) | mr(val)); +// The 4-byte slot access. ldr/str with a 32-bit w destination: the immediate is scaled by 4 +// (every arena offset is a multiple of it), and the register-offset forms use the LSL-0 option. +void HostAssembler::load32(Reg d, Reg base, int32_t imm) { + emit32(0xb9400000u | (((uint32_t(imm) >> 2) & 0xfff) << 10) | (mr(base) << 5) | mr(d)); +} +void HostAssembler::store32(Reg base, int32_t imm, Reg val) { + emit32(0xb9000000u | (((uint32_t(imm) >> 2) & 0xfff) << 10) | (mr(base) << 5) | mr(val)); } -// ldrh wDst, [xBase, #imm12]. The immediate is SCALED by the access size, so the field holds -// imm/2 and an odd offset cannot be encoded at all: a halfword member is placed on an even byte -// (see the arena cursor), which is what makes the scaled form usable rather than a constraint -// invented here. -void HostAssembler::load16(Reg d, Reg base, int32_t imm) { - emit32(0x79400000u | (((uint32_t(imm) >> 1) & 0xfff) << 10) | (mr(base) << 5) | mr(d)); +void HostAssembler::load32Idx(Reg d, Reg base, Reg off) { + emit32(0xb8606800u | (mr(off) << 16) | (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)); +void HostAssembler::store32Idx(Reg base, Reg off, Reg val) { + emit32(0xb8206800u | (mr(off) << 16) | (mr(base) << 5) | mr(val)); } // 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 @@ -177,9 +205,6 @@ void HostAssembler::load16S(Reg d, Reg base, int32_t imm) { void HostAssembler::load8Idx(Reg d, Reg base, Reg off) { // ldrb wDst, [xBase, xOff] emit32(0x38606800u | (mr(off) << 16) | (mr(base) << 5) | mr(d)); } -void HostAssembler::load16Idx(Reg d, Reg base, Reg off) { // ldrh wDst, [xBase, xOff] - emit32(0x78606800u | (mr(off) << 16) | (mr(base) << 5) | mr(d)); -} void HostAssembler::cmp(Reg a, Reg b) { // cmp wA, wB (subs wzr, wA, wB) emit32(0x6b00001fu | (mr(b) << 16) | (mr(a) << 5)); } diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h index 8dd18c65..915a7cf8 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -101,13 +101,17 @@ class HostAssembler { void addReg(Reg d, Reg a, Reg b); // d = a + b void mulImm(Reg d, Reg a, int32_t imm); // d = a * imm (index scaling by a constant) void mulReg(Reg d, Reg a, Reg b); // d = a * b (index scaling by a runtime cpl) + void mulhi(Reg d, Reg a, Reg b); // d = the SIGNED high 32 bits of a * b (Q16.16 multiply) + void shlImm(Reg d, Reg a, uint8_t n);// d = a << n + void sarImm(Reg d, Reg a, uint8_t n);// d = a >> n, ARITHMETIC (sign-filling) + void shrImm(Reg d, Reg a, uint8_t n);// d = a >> n, LOGICAL (zero-filling) void store8(Reg base, Reg off, Reg val); // byte store: base[off] = val (low 8 bits) 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 load32(Reg d, Reg base, int32_t imm); // d = base[imm..imm+3] β€” a whole 4-byte slot + void store32(Reg base, int32_t imm, Reg val);// base[imm..imm+3] = val (offset IMMEDIATE) + void load32Idx(Reg d, Reg base, Reg off); // d = base[off..off+3], index in a REG + void store32Idx(Reg base, Reg off, Reg val);// base[off..off+3] = val, index in a REG 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 void branchIfZero(Reg a, Label l); // if a == 0 goto l // The FUSED compare-and-branch forms, which is how the shared lowering spells a conditional. diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp index 3b9a488f..4d697b5d 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -502,56 +502,104 @@ void HostAssembler::emitIndexed(const uint8_t* opcode, size_t opLen, bool prefix emitBytes(b, n); } -// mov byte ptr [base + off], val_l (88 /r with SIB): indexed 1-byte store, the pixel write. -void HostAssembler::store8(Reg base, Reg off, Reg val) { - const uint8_t op = 0x88; - emitIndexed(&op, 1, /*prefix66=*/false, /*forceRex=*/true, xr(val), xr(base), xr(off)); +// The signed high 32 bits of a * b. A vreg holds a 32-bit value, so both operands are +// sign-extended to 64 bits before a 64-bit imul; the arithmetic shift then takes the high word. +// +// rax is the scratch, exactly as call() uses it: it is the last vreg (R13) and the assembler +// already relies on saving it before borrowing it. Every OTHER volatile register (r9/r10/r11) +// is inside the vreg pool, so using one would clobber a live virtual register. +void HostAssembler::mulhi(Reg d, Reg a, Reg b) { + const uint8_t dst = xr(d), ra = xr(a), rb = xr(b); + uint8_t save[2] = {0x50 | (x64::RAX & 7), 0x00}; // push rax + emitBytes(save, 1); + uint8_t ext_b[3] = {rex_(true, x64::RAX >= 8, false, rb >= 8), 0x63, + modrm_(0b11, x64::RAX & 7, rb & 7)}; + emitBytes(ext_b, 3); // movsxd rax, bD + uint8_t ext_a[3] = {rex_(true, dst >= 8, false, ra >= 8), 0x63, modrm_(0b11, dst & 7, ra & 7)}; + emitBytes(ext_a, 3); // movsxd rD, aD + uint8_t mul[4] = {rex_(true, dst >= 8, false, x64::RAX >= 8), 0x0F, 0xAF, + modrm_(0b11, dst & 7, x64::RAX & 7)}; + emitBytes(mul, 4); // imul rD, rax + uint8_t sar[4] = {rex_(true, false, false, dst >= 8), 0xC1, modrm_(0b11, 7, dst & 7), 32}; + emitBytes(sar, 4); // sar rD, 32 + uint8_t rest[1] = {uint8_t(0x58 | (x64::RAX & 7))}; // pop rax + emitBytes(rest, 1); +} +// 32-bit shifts: C1 /4 ib is shl, C1 /7 ib is sar. No REX.W β€” a vreg is 32 bits, and the +// arithmetic shift must fill from bit 31, not bit 63. +void HostAssembler::shlImm(Reg d, Reg a, uint8_t n) { + if (d != a) emitMovRegReg(this, xr(d), xr(a)); + const uint8_t dst = xr(d); + if (dst >= 8) { uint8_t r[1] = {rex_(false, false, false, true)}; emitBytes(r, 1); } + uint8_t b[3] = {0xC1, modrm_(0b11, 4, dst & 7), n}; + emitBytes(b, 3); } -// mov r64_low16, [base + off] β€” index-in-reg. Used for control byte reads. -// x86 zero-extends 8-bit loads to 32 bits automatically (movzx). The 64-bit destination is -// implicitly zero-extended above bit 31, matching the arm64 ldrb behavior. -void HostAssembler::load8(Reg d, Reg base, int32_t imm) { +void HostAssembler::shrImm(Reg d, Reg a, uint8_t n) { + if (d != a) emitMovRegReg(this, xr(d), xr(a)); + const uint8_t dst = xr(d); + if (dst >= 8) { uint8_t r[1] = {rex_(false, false, false, true)}; emitBytes(r, 1); } + uint8_t b[3] = {0xC1, modrm_(0b11, 5, dst & 7), n}; // C1 /5 ib = shr + emitBytes(b, 3); +} +void HostAssembler::sarImm(Reg d, Reg a, uint8_t n) { + if (d != a) emitMovRegReg(this, xr(d), xr(a)); + const uint8_t dst = xr(d); + if (dst >= 8) { uint8_t r[1] = {rex_(false, false, false, true)}; emitBytes(r, 1); } + uint8_t b[3] = {0xC1, modrm_(0b11, 7, dst & 7), n}; + emitBytes(b, 3); +} +// The 4-byte slot access. mov r32 <- [base+disp32] (8B /r) and its store twin (89 /r); the +// indexed forms reuse emitIndexed, which already handles the SIB byte and the rbp/r13 base that +// needs an explicit zero displacement. +void HostAssembler::load32(Reg d, Reg base, int32_t imm) { const uint8_t dst = xr(d), b_reg = xr(base); - // movzx r32, byte ptr [base + disp32] (0F B6 /r) β€” zero-extend to 32; the r32 write clears - // the upper 32 bits of the r64. 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); // no REX.W needed (r32 dest zero-extends) - b[n++] = 0x0F; b[n++] = 0xB6; + if (dst >= 8 || b_reg >= 8) b[n++] = rex_(false, dst >= 8, false, b_reg >= 8); + b[n++] = 0x8B; 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); } -// mov word ptr [base + off], val_l16 (66 89 /r SIB) β€” indexed 2-byte store. -// The 66 prefix switches operand size to 16 bits for a 32-bit-mode instruction. -void HostAssembler::store16(Reg base, Reg off, Reg val) { - const uint8_t op = 0x89; - emitIndexed(&op, 1, /*prefix66=*/true, /*forceRex=*/false, xr(val), xr(base), xr(off)); -} -// movzx r32, word ptr [base + disp32] (0F B7 /r) β€” 16-bit zero-extending load. Immediate offset. -void HostAssembler::load16(Reg d, Reg base, int32_t imm) { - const uint8_t dst = xr(d), b_reg = xr(base); +void HostAssembler::store32(Reg base, int32_t imm, Reg val) { + const uint8_t src = xr(val), 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++] = 0xB7; - b[n++] = modrm_(0b10, dst & 7, needsSIB ? 0b100 : (b_reg & 7)); + if (src >= 8 || b_reg >= 8) b[n++] = rex_(false, src >= 8, false, b_reg >= 8); + b[n++] = 0x89; + b[n++] = modrm_(0b10, src & 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); } -// 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) { +void HostAssembler::load32Idx(Reg d, Reg base, Reg off) { + const uint8_t op = 0x8B; + emitIndexed(&op, 1, /*prefix66=*/false, /*forceRex=*/false, xr(d), xr(base), xr(off)); +} +void HostAssembler::store32Idx(Reg base, Reg off, Reg val) { + const uint8_t op = 0x89; + emitIndexed(&op, 1, /*prefix66=*/false, /*forceRex=*/false, xr(val), xr(base), xr(off)); +} + +// mov byte ptr [base + off], val_l (88 /r with SIB): indexed 1-byte store, the pixel write. +void HostAssembler::store8(Reg base, Reg off, Reg val) { + const uint8_t op = 0x88; + emitIndexed(&op, 1, /*prefix66=*/false, /*forceRex=*/true, xr(val), xr(base), xr(off)); +} +// mov r64_low16, [base + off] β€” index-in-reg. Used for control byte reads. +// x86 zero-extends 8-bit loads to 32 bits automatically (movzx). The 64-bit destination is +// implicitly zero-extended above bit 31, matching the arm64 ldrb behavior. +void HostAssembler::load8(Reg d, Reg base, int32_t imm) { const uint8_t dst = xr(d), b_reg = xr(base); + // movzx r32, byte ptr [base + disp32] (0F B6 /r) β€” zero-extend to 32; the r32 write clears + // the upper 32 bits of the r64. 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++] = rex_(false, dst >= 8, false, b_reg >= 8); // no REX.W needed (r32 dest zero-extends) + b[n++] = 0x0F; b[n++] = 0xB6; 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); @@ -563,11 +611,6 @@ void HostAssembler::load8Idx(Reg d, Reg base, Reg off) { const uint8_t op[2] = {0x0F, 0xB6}; emitIndexed(op, 2, /*prefix66=*/false, /*forceRex=*/false, xr(d), xr(base), xr(off)); } -// movzx r32, word ptr [base + off] (0F B7 /r SIB) β€” indexed 16-bit zero-extending load. -void HostAssembler::load16Idx(Reg d, Reg base, Reg off) { - const uint8_t op[2] = {0x0F, 0xB7}; - emitIndexed(op, 2, /*prefix66=*/false, /*forceRex=*/false, xr(d), xr(base), xr(off)); -} // --- compare and branch ------------------------------------------------------------------------- diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index f2af2144..845bf91c 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -84,6 +84,24 @@ static uint32_t encAdd(uint8_t rd, uint8_t rs1, uint8_t rs2) { static uint32_t encMul(uint8_t rd, uint8_t rs1, uint8_t rs2) { return (1u << 25) | (rs2 << 20) | (rs1 << 15) | (0 << 12) | (rd << 7) | 0x33; } +// mulh rd, rs1, rs2 β€” the same M-extension encoding as mul with funct3 = 1: the SIGNED high 32 +// bits of the product. Paired with mul it forms the Q16.16 multiply's middle word. +static uint32_t encMulh(uint8_t rd, uint8_t rs1, uint8_t rs2) { + return (1u << 25) | (rs2 << 20) | (rs1 << 15) | (1u << 12) | (rd << 7) | 0x33; +} +// slli / srai rd, rs1, shamt β€” I-type with the shift amount in the immediate. srai sets bit 30 +// of the immediate field, which is what makes the shift arithmetic (sign-filling) rather than +// logical. +static uint32_t encSlli(uint8_t rd, uint8_t rs1, uint8_t n) { + return (uint32_t(n & 0x1f) << 20) | (rs1 << 15) | (1u << 12) | (rd << 7) | 0x13; +} +static uint32_t encSrai(uint8_t rd, uint8_t rs1, uint8_t n) { + return (1u << 30) | (uint32_t(n & 0x1f) << 20) | (rs1 << 15) | (5u << 12) | (rd << 7) | 0x13; +} +// srli: srai without bit 30. The one bit between zero-filling and sign-filling. +static uint32_t encSrli(uint8_t rd, uint8_t rs1, uint8_t n) { + return (uint32_t(n & 0x1f) << 20) | (rs1 << 15) | (5u << 12) | (rd << 7) | 0x13; +} static uint32_t encSb(uint8_t rs2, uint8_t rs1, int32_t imm) { // sb rs2, imm(rs1) return (((uint32_t(imm) >> 5) & 0x7f) << 25) | (rs2 << 20) | (rs1 << 15) | (0 << 12) | ((uint32_t(imm) & 0x1f) << 7) | 0x23; @@ -183,6 +201,24 @@ void RiscvAssembler::addImm(Reg d, Reg a, int32_t imm) { emit32(encAddi(xr(d), x void RiscvAssembler::addReg(Reg d, Reg a, Reg b) { emit32(encAdd(xr(d), xr(a), xr(b))); } void RiscvAssembler::mulReg(Reg d, Reg a, Reg b) { emit32(encMul(xr(d), xr(a), xr(b))); } +void RiscvAssembler::mulhi(Reg d, Reg a, Reg b) { emit32(encMulh(xr(d), xr(a), xr(b))); } +void RiscvAssembler::shlImm(Reg d, Reg a, uint8_t n) { emit32(encSlli(xr(d), xr(a), n)); } +void RiscvAssembler::sarImm(Reg d, Reg a, uint8_t n) { emit32(encSrai(xr(d), xr(a), n)); } +void RiscvAssembler::shrImm(Reg d, Reg a, uint8_t n) { emit32(encSrli(xr(d), xr(a), n)); } +// The 4-byte slot access. encLw/encSw already existed for spills; these give them an arbitrary +// base and offset, which is what a member slot needs. +void RiscvAssembler::load32(Reg d, Reg base, int32_t imm) { emit32(encLw(xr(d), xr(base), imm)); } +void RiscvAssembler::store32(Reg base, int32_t imm, Reg val) { + emit32(encSw(xr(val), xr(base), imm)); +} +void RiscvAssembler::load32Idx(Reg d, Reg base, Reg off) { + emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off + emit32(encLw(xr(d), kScratchAddr, 0)); +} +void RiscvAssembler::store32Idx(Reg base, Reg off, Reg val) { + emit32(encAdd(kScratchAddr, xr(base), xr(off))); + emit32(encSw(xr(val), kScratchAddr, 0)); +} void RiscvAssembler::store8(Reg base, Reg off, Reg val) { emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off emit32(encSb(xr(val), kScratchAddr, 0)); // sb val, 0(t6) @@ -190,30 +226,12 @@ void RiscvAssembler::store8(Reg base, Reg off, Reg val) { void RiscvAssembler::load8(Reg d, Reg base, int32_t imm) { // lbu rDst, imm(rBase) β€” control read emit32(((uint32_t(imm) & 0xfff) << 20) | (xr(base) << 15) | (4 << 12) | (xr(d) << 7) | 0x03); } -void RiscvAssembler::store16(Reg base, Reg off, Reg val) { - emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off - // sh val, 0(t6): the S-type store, funct3 = 1 for a halfword where sb uses 0. - emit32((uint32_t(xr(val)) << 20) | (uint32_t(kScratchAddr) << 15) | (1u << 12) | 0x23u); -} -// lhu rDst, imm(rBase): funct3 = 5 where lbu uses 4. The immediate is in BYTES and unscaled, so -// unlike arm64 no even-offset rule is forced by the encoding here. -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) { emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off emit32((uint32_t(kScratchAddr) << 15) | (4 << 12) | (xr(d) << 7) | 0x03); // lbu d, 0(t6) } -void RiscvAssembler::load16Idx(Reg d, Reg base, Reg off) { - emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off - emit32((uint32_t(kScratchAddr) << 15) | (5 << 12) | (xr(d) << 7) | 0x03); // lhu d, 0(t6) -} void RiscvAssembler::branchIfZero(Reg a, Label l) { // a == 0 ⇔ bgeu x0, a (unsigned 0 >= a) addFixup(len_, l); emit32(encBranch(0, xr(a), 7, 0)); // bgeu x0, a, l (patched) diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index 23bc1c86..7049dbfa 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -86,13 +86,17 @@ class RiscvAssembler { void addImm(Reg d, Reg a, int32_t imm); // addi rd, ra, imm void addReg(Reg d, Reg a, Reg b); // add rd, ra, rb void mulReg(Reg d, Reg a, Reg b); // mul rd, ra, rb + void mulhi(Reg d, Reg a, Reg b); // mulh rd, ra, rb β€” the SIGNED high 32 bits + void shlImm(Reg d, Reg a, uint8_t n);// slli rd, ra, #n + void sarImm(Reg d, Reg a, uint8_t n);// srai rd, ra, #n β€” arithmetic, sign-filling + void shrImm(Reg d, Reg a, uint8_t n);// srli rd, ra, #n β€” logical, zero-filling void store8(Reg base, Reg off, Reg val); // add tmp,base,off ; sb val,0(tmp) 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 load32(Reg d, Reg base, int32_t imm); // lw rDst, imm(rBase) β€” a whole 4-byte slot + void store32(Reg base, int32_t imm, Reg val);// sw rVal, imm(rBase) (offset IMMEDIATE) + void load32Idx(Reg d, Reg base, Reg off); // add tmp,base,off ; lw d,0(tmp) + void store32Idx(Reg base, Reg off, Reg val);// add tmp,base,off ; sw val,0(tmp) 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 diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 9c40ea35..c1037ed0 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -206,7 +206,14 @@ void XtensaAssembler::movImm(Reg d, int32_t imm) { // A negative below the 12-bit field's reach has no encoding here, and falling through to the // unsigned path below would materialise a different number in silence β€” the failure mode that // cost this backend a long debugging session. Fail the compile instead. - if (imm < -2048) { overflow_ = true; return; } + // Outside every short encoding below, build the full 32-bit value the way movPtr does: the + // same byte-at-a-time chain, absolute so it survives the block's copy to its final address. + // The old positive path MASKED to 16 bits silently β€” invisible while the language capped + // literals at 65535, and the first thing a Q16.16 literal (2.0 is 131072) stepped on. + if (imm < -2048 || imm > 0xffff) { + movPtr(d, reinterpret_cast<const void*>(static_cast<uintptr_t>(static_cast<uint32_t>(imm)))); + return; + } if (imm < 0) { const uint32_t f = static_cast<uint32_t>(imm) & 0xfff; const uint8_t b[3] = {uint8_t((dr << 4) | 0x2), @@ -215,7 +222,7 @@ void XtensaAssembler::movImm(Reg d, int32_t imm) { emit(b, 3); // movi aD, #imm12 return; } - const uint32_t v = static_cast<uint32_t>(imm) & 0xffff; + const uint32_t v = static_cast<uint32_t>(imm); if (v <= 0xff) { const uint8_t b[3] = {uint8_t((dr << 4) | 0x2), 0xa0, uint8_t(v)}; emit(b, 3); @@ -290,11 +297,76 @@ void XtensaAssembler::addImm(Reg d, Reg a, int32_t imm) { void XtensaAssembler::mulReg(Reg d, Reg a, Reg b) { emit3(0x820000u | (uint32_t(ar(d)) << 12) | (uint32_t(ar(a)) << 8) | (uint32_t(ar(b)) << 4)); } +// mulsh aD, aA, aB β€” the SIGNED high 32 bits of the product; with mull it gives the Q16.16 +// multiply its middle 32 bits. MUL32_HIGH is present on LX6 and LX7. +// +// Every encoding here is an emit3 WORD, the same shape mull above uses. A first version built the +// memory bytes by hand from an objdump listing β€” and the two toolchains print differently: +// xtensa-esp32-elf-objdump shows the 24-bit word, xtensa-esp32s3-elf-objdump shows memory byte +// order. Reading word-hex as memory bytes reversed every instruction, and the reversed slli +// decoded as `l32r a1` β€” a stack-pointer clobber that hung the board hard enough for the system +// watchdog. The host tests can never execute these bytes; only a device shows it. +void XtensaAssembler::mulhi(Reg d, Reg a, Reg b) { + emit3(0xb20000u | (uint32_t(ar(d)) << 12) | (uint32_t(ar(a)) << 8) | (uint32_t(ar(b)) << 4)); +} +// slli aD, aA, #n : the field holds 32-n, split across bits 20-23 (high bit) and 4-7 (low +// nibble). n==0 is unencodable and the lowering never asks. +void XtensaAssembler::shlImm(Reg d, Reg a, uint8_t n) { + const uint32_t k = 32u - n; + emit3(((k >> 4) << 20) | 0x010000u | (uint32_t(ar(d)) << 12) | (uint32_t(ar(a)) << 8) | + ((k & 0x0fu) << 4)); +} +// srai aD, aA, #n : arithmetic, sign-filling. The amount rides bits 8-11 (low nibble) and bit 20 +// (high bit, folded into the 0x2/0x3 opcode nibble). +void XtensaAssembler::sarImm(Reg d, Reg a, uint8_t n) { + emit3(((0x2u | (uint32_t(n) >> 4)) << 20) | 0x010000u | (uint32_t(ar(d)) << 12) | + ((uint32_t(n) & 0x0fu) << 8) | (uint32_t(ar(a)) << 4)); +} +// The LOGICAL right shift. srli only encodes 1..15; a shift of 16 is spelled extui aD, aA, 16, 16, +// which extracts the top 16 bits β€” between them they cover every shift the front end emits. +void XtensaAssembler::shrImm(Reg d, Reg a, uint8_t n) { + if (n >= 1 && n <= 15) { + emit3(0x410000u | (uint32_t(ar(d)) << 12) | (uint32_t(n) << 8) | (uint32_t(ar(a)) << 4)); + return; + } + // extui's width field caps at 16, so 16 is the only wide shift it can express. Anything else + // has NO encoding here, and falling through to a shift-by-16 would emit a silently wrong + // constant β€” the failure mode movImm above was just fixed for. Fail the compile instead. + if (n != 16) { overflow_ = true; return; } + emit3(0xf50000u | (uint32_t(ar(d)) << 12) | (uint32_t(ar(a)) << 4)); // extui aD, aA, 16, 16 +} +// a12: the dedicated address scratch, OUTSIDE the R0..R9 -> a2..a11 vreg map, so computing an +// address into it can never clobber a live virtual register. Shared by every indexed access. +static constexpr uint8_t kAddrScratch = 12; // a12 + +// The 4-byte slot access, in the NARROW forms: l32i.n / s32i.n are 2 bytes where l16ui was 3, +// and they cover offsets 0..60 in steps of 4 β€” every arena offset, since the arena is 64 bytes. +// RRRN format: imm/4 in the top nibble, then base, then the value/destination, then 0x8 (load) +// or 0x9 (store). +void XtensaAssembler::load32(Reg d, Reg base, int32_t imm) { + emit2(uint16_t(((uint32_t(imm) / 4) << 12) | (uint32_t(ar(base)) << 8) | + (uint32_t(ar(d)) << 4) | 0x8)); +} +void XtensaAssembler::store32(Reg base, int32_t imm, Reg val) { + emit2(uint16_t(((uint32_t(imm) / 4) << 12) | (uint32_t(ar(base)) << 8) | + (uint32_t(ar(val)) << 4) | 0x9)); +} +// The indexed forms compute the address into a12 first, the same dedicated scratch the byte path +// uses: it sits outside the R0..R9 vreg map, so it never clobbers a live vreg. +void XtensaAssembler::load32Idx(Reg d, Reg base, Reg off) { + emit2(uint16_t((kAddrScratch << 12) | (uint32_t(ar(base)) << 8) | + (uint32_t(ar(off)) << 4) | 0xa)); // add.n a12, base, off + emit2(uint16_t((uint32_t(kAddrScratch) << 8) | (uint32_t(ar(d)) << 4) | 0x8)); +} +void XtensaAssembler::store32Idx(Reg base, Reg off, Reg val) { + emit2(uint16_t((kAddrScratch << 12) | (uint32_t(ar(base)) << 8) | + (uint32_t(ar(off)) << 4) | 0xa)); // add.n a12, base, off + emit2(uint16_t((uint32_t(kAddrScratch) << 8) | (uint32_t(ar(val)) << 4) | 0x9)); +} // Xtensa s8i only offsets a base by an immediate (no register-offset store), so compute the // address into a dedicated scratch a12 β€” OUTSIDE the R0..R9 β†’ a2..a11 vreg map, so it never // clobbers a live virtual register β€” then s8i aVal, a12, 0. // add.n a12, aBase, aOff : (12<<12)|(base<<8)|(off<<4)|0xa ; s8i aVal, a12, 0 : [(val<<4)|2, 0x40|12, 0] -static constexpr uint8_t kAddrScratch = 12; // a12 void XtensaAssembler::store8(Reg base, Reg off, Reg val) { emit2(uint16_t((kAddrScratch << 12) | (ar(base) << 8) | (ar(off) << 4) | 0xa)); // add.n a12, base, off const uint8_t b[3] = {uint8_t((ar(val) << 4) | 0x2), uint8_t(0x40 | kAddrScratch), 0x00}; @@ -306,29 +378,6 @@ void XtensaAssembler::load8(Reg d, Reg base, int32_t imm) { emit(b, 3); } -void XtensaAssembler::store16(Reg base, Reg off, Reg val) { - emit2(uint16_t((kAddrScratch << 12) | (ar(base) << 8) | (ar(off) << 4) | 0xa)); // add.n a12, base, off - // s16i aVal, a12, 0: RRI8 with r = 5 where s8i uses 4. - const uint8_t b[3] = {uint8_t((ar(val) << 4) | 0x2), uint8_t(0x50 | kAddrScratch), 0x00}; - emit(b, 3); -} -// l16ui aDst, aBase, #imm : bytes [ (dst<<4)|2, 0x10|base, imm/2 ]. The RRI8 immediate is SCALED -// by 2 for a halfword access, so the field holds imm/2 and an odd offset is not encodable: a -// halfword member sits on an even byte, which the arena cursor guarantees. -void XtensaAssembler::load16(Reg d, Reg base, int32_t imm) { - const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), uint8_t(0x10 | ar(base)), - 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. @@ -337,11 +386,6 @@ void XtensaAssembler::load8Idx(Reg d, Reg base, Reg off) { const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), kAddrScratch, 0x00}; // l8ui d, a12, 0 emit(b, 3); } -void XtensaAssembler::load16Idx(Reg d, Reg base, Reg off) { - emit2(uint16_t((kAddrScratch << 12) | (ar(base) << 8) | (ar(off) << 4) | 0xa)); // add.n a12, base, off - const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), uint8_t(0x10 | kAddrScratch), 0x00}; // l16ui d, a12, 0 - emit(b, 3); -} // branchIfZero(a, l): synthesised as `movi a13,0; bgeu a13, a, l`. Unsigned 0 >= a is true // IFF a == 0, so this branches exactly when a is zero β€” using only the verified bgeu 8-bit diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index a4370270..c71d0dec 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -89,13 +89,17 @@ class XtensaAssembler { void addImm(Reg d, Reg a, int32_t imm); // addi.n aD, aA, #imm (1..15) void addReg(Reg d, Reg a, Reg b); // add.n aD, aA, aB void mulReg(Reg d, Reg a, Reg b); // mull aD, aA, aB + void mulhi(Reg d, Reg a, Reg b); // mulsh aD, aA, aB β€” the SIGNED high 32 bits + void shlImm(Reg d, Reg a, uint8_t n);// slli aD, aA, #n (1..31) + void sarImm(Reg d, Reg a, uint8_t n);// srai aD, aA, #n (0..31), arithmetic + void shrImm(Reg d, Reg a, uint8_t n);// LOGICAL right shift (srli / extui) void store8(Reg base, Reg off, Reg val); // s8i via computed address (add then s8i,0) 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 load32(Reg d, Reg base, int32_t imm); // l32i.n aDst, aBase, #imm β€” a whole 4-byte slot + void store32(Reg base, int32_t imm, Reg val);// s32i.n aVal, aBase, #imm (offset IMMEDIATE) + void load32Idx(Reg d, Reg base, Reg off); // add.n tmp,base,off ; l32i.n d,tmp,0 + void store32Idx(Reg base, Reg off, Reg val);// add.n tmp,base,off ; s32i.n val,tmp,0 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) diff --git a/src/ui/app.js b/src/ui/app.js index e4a6f6a3..8828aa31 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -52,7 +52,7 @@ const dragTs = {}; // per-control last-touched timestamp (ms) β€” // (display/display-int/time/progress) and the composite `list` are absent on // purpose: they always reflect the latest push. const EDITABLE_CONTROL_TYPES = new Set( - ["uint8", "uint16", "int16", "pin", "bool", "text", "textarea", "filepath", "password", "select", + ["uint8", "uint16", "int16", "int32", "pin", "bool", "text", "textarea", "filepath", "password", "select", "palette", "ipv4"]); const TIMING_MODES = ["fps", "ms"]; @@ -1555,7 +1555,8 @@ function createControl(moduleName, moduleType, ctrl) { // integer is a discrete identity, not a magnitude β€” a PHY/I2C address, a channel). Render a plain // number input, same shape as the `pin` case, whatever the underlying numeric type. The WS-patch path // (updateModuleControls) reads the input by [data-mid][data-key] the same way, so no extra patch case. - const isNumericType = ctrl.type === "uint8" || ctrl.type === "uint16" || ctrl.type === "int16"; + const isNumericType = ctrl.type === "uint8" || ctrl.type === "uint16" || ctrl.type === "int16" || + ctrl.type === "int32"; if (ctrl.numberField && isNumericType) { const nMin = Number(ctrl.min ?? 0); const nMax = Number(ctrl.max ?? 65535); @@ -1726,14 +1727,17 @@ function createControl(moduleName, moduleType, ctrl) { appendResetButton(row, moduleName, ctrl, def, () => { input.value = def; }); break; } + case "int32": case "int16": { - // ctrl.min/ctrl.max are always present (server sends them). Sentinel - // values INT16_MIN (-32768) / INT16_MAX (32767) mean "unbounded" β€” - // fall back to a Β±percentage range. - const rawMin = Number(ctrl.min ?? -32768); - const rawMax = Number(ctrl.max ?? 32767); - const min = rawMin <= -32768 ? -100 : rawMin; - const max = rawMax >= 32767 ? 200 : rawMax; + // ctrl.min/ctrl.max are always present (server sends them). A min/max at the + // type's own limit means "unbounded" β€” fall back to a Β±percentage range, since + // a slider spanning the full type is useless to drag. + const lo = ctrl.type === "int32" ? -2147483648 : -32768; + const hi = ctrl.type === "int32" ? 2147483647 : 32767; + const rawMin = Number(ctrl.min ?? lo); + const rawMax = Number(ctrl.max ?? hi); + const min = rawMin <= lo ? -100 : rawMin; + const max = rawMax >= hi ? 200 : rawMax; const raw = Number(ctrl.value ?? 0); const clamped = Math.max(min, Math.min(max, raw)); const input = document.createElement("input"); @@ -3470,6 +3474,7 @@ function updateModuleControls(mod) { case "uint8": case "uint16": case "int16": + case "int32": case "pin": { // pin is a plain number input (no slider sibling); patches the same way const input = document.querySelector(`input[data-mid="${mid}"][data-key="${k}"]`); // While the demo sweep animates a control, leave it alone: the sweep restores the diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index efd700b0..a8529572 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(mm_tests unit/core/unit_MqttModule.cpp unit/core/unit_FileManagerModule.cpp unit/core/unit_Control_apply_absent_key.cpp + unit/core/unit_Control_int32.cpp unit/core/unit_Control_filepath.cpp unit/core/unit_Control_list.cpp unit/core/unit_DeviceIdentify.cpp diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json index df750c84..e9b7bd40 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_controls.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_controls.json @@ -14,7 +14,7 @@ "Drivers", "NetworkSendDriver" ], - "description": "Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a member and surfaces it (`addUint8(\"speed\", speed, 0, 15)` in defineControls) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β€” must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate.", + "description": "Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a member and surfaces it (`addControl(\"speed\", speed, 0, 15)` in defineControls) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β€” must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate.", "fixture": [ { "name": "fix-layouts", @@ -136,7 +136,7 @@ "description": "Save the script file: editing a script in place is what re-derives its controls.", "op": "write_file", "path": "/moonlive/sc-ctrl.mle", - "value": "class SpeedEffect {\n uint8_t speed = 7;\n defineControls() { addUint8(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n" + "value": "class SpeedEffect {\n byte speed = 7;\n defineControls() { addControl(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n" }, { "name": "set-source-with-control", @@ -273,7 +273,7 @@ "description": "Save the script file: editing a script in place is what re-derives its controls.", "op": "write_file", "path": "/moonlive/sc-ctrl.mle", - "value": "class SpeedEffect {\n uint8_t speed = 7;\n uint8_t hue = 128;\n defineControls() { addUint8(\"speed\", speed, 0, 15); addUint8(\"hue\", hue, 0, 255); }\n tick() { setRGB(speed, hue, 0, 255); }\n}\n" + "value": "class SpeedEffect {\n byte speed = 7;\n byte hue = 128;\n defineControls() { addControl(\"speed\", speed, 0, 15); addControl(\"hue\", hue, 0, 255); }\n tick() { setRGB(speed, hue, 0, 255); }\n}\n" }, { "name": "edit-source-two-controls", @@ -345,7 +345,7 @@ "description": "Save the script file: editing a script in place is what re-derives its controls.", "op": "write_file", "path": "/moonlive/sc-ctrl.mle", - "value": "class SpeedEffect {\n uint8_t speed = 7;\n defineControls() { addUint8(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n" + "value": "class SpeedEffect {\n byte speed = 7;\n defineControls() { addControl(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n" }, { "name": "edit-source-shrink-to-one-control", @@ -417,7 +417,7 @@ "description": "Save the script file: editing a script in place is what re-derives its controls.", "op": "write_file", "path": "/moonlive/sc-ctrl.mle", - "value": "class Broken {\n uint8_t speed = ;\n tick() { setRGB(0,0,0,0); }\n}\n" + "value": "class Broken {\n byte speed = ;\n tick() { setRGB(0,0,0,0); }\n}\n" }, { "name": "edit-source-broken", @@ -489,7 +489,7 @@ "description": "Save the script file: editing a script in place is what re-derives its controls.", "op": "write_file", "path": "/moonlive/sc-ctrl.mle", - "value": "class BrightEffect {\n uint8_t bright = 200;\n defineControls() { addUint8(\"bright\", bright, 0, 255); }\n tick() { fill(0, 0, bright); }\n}\n" + "value": "class BrightEffect {\n byte bright = 200;\n defineControls() { addControl(\"bright\", bright, 0, 255); }\n tick() { fill(0, 0, bright); }\n}\n" }, { "name": "edit-source-recover", diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 8bcab7e5..91089471 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -779,8 +779,8 @@ "observed": { "desktop-macos": { "tick_us": [ - 5, - 28 + 3, + 30 ], "free_heap": [ 0, @@ -792,7 +792,7 @@ ], "at": [ "2026-08-19", - "2026-08-22" + "2026-08-23" ] } } diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index 4b20f699..c7ca4a0f 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -330,7 +330,7 @@ "observed": { "desktop-macos": { "tick_us": [ - 5, + 2, 83 ], "free_heap": [ @@ -343,7 +343,7 @@ ], "at": [ "2026-08-09", - "2026-08-19" + "2026-08-23" ] }, "esp32s3-n16r8": { @@ -465,7 +465,7 @@ "observed": { "desktop-macos": { "tick_us": [ - 5, + 2, 34 ], "free_heap": [ @@ -478,7 +478,7 @@ ], "at": [ "2026-08-09", - "2026-08-19" + "2026-08-23" ] }, "esp32s3-n16r8": { @@ -594,7 +594,7 @@ "observed": { "desktop-macos": { "tick_us": [ - 5, + 2, 34 ], "free_heap": [ @@ -607,7 +607,7 @@ ], "at": [ "2026-08-09", - "2026-08-20" + "2026-08-23" ] }, "esp32s3-n16r8": { @@ -972,7 +972,7 @@ "observed": { "desktop-macos": { "tick_us": [ - 4, + 3, 41 ], "free_heap": [ @@ -985,7 +985,7 @@ ], "at": [ "2026-08-09", - "2026-08-19" + "2026-08-23" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 4df034f2..4cee39f5 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -152,7 +152,7 @@ "desktop-macos": { "tick_us": [ 4, - 71 + 159 ], "free_heap": [ 0, @@ -164,7 +164,7 @@ ], "at": [ "2026-06-07", - "2026-08-06" + "2026-08-23" ] }, "esp32-eth": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index a9013f76..352ce492 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -444,7 +444,7 @@ "desktop-macos": { "tick_us": [ 271, - 1427 + 2083 ], "free_heap": [ 0, @@ -456,7 +456,7 @@ ], "at": [ "2026-07-26", - "2026-08-13" + "2026-08-23" ] }, "desktop-windows": { @@ -849,7 +849,7 @@ "desktop-macos": { "tick_us": [ 270, - 1357 + 1600 ], "free_heap": [ 0, @@ -861,7 +861,7 @@ ], "at": [ "2026-07-26", - "2026-08-13" + "2026-08-23" ] }, "desktop-windows": { diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index 7e1927fe..d2dbce0c 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -44,8 +44,8 @@ namespace { // every S3, so the one worth pinning hardest. const char* kGridLayout = "class GridLayout {\n" - " uint8_t cols = 16;\n" - " uint8_t rows = 16;\n" + " byte cols = 16;\n" + " byte rows = 16;\n" " tick() {\n" " for (y = 0; y < rows; y = y + 1) {\n" " for (x = 0; x < cols; x = x + 1) {\n" diff --git a/test/unit/core/moonlive_script_wrap.h b/test/unit/core/moonlive_script_wrap.h index f6ee9a39..38698c7d 100644 --- a/test/unit/core/moonlive_script_wrap.h +++ b/test/unit/core/moonlive_script_wrap.h @@ -43,12 +43,26 @@ inline const char* mmScriptAs(const char* entry, const char* body) { const char* declEnd = body; while (true) { while (*p == ' ' || *p == '\t' || *p == '\n') p++; - // 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 + // Every member type the language has, not just one: a test declaring `fixed d = -1.0;` + // means a member exactly as `byte speed = 7;` does, and recognising only some 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; + // + // The keyword must be followed by a NON-IDENTIFIER character, or a body opening with a + // variable called `intensity` would be read as an `int` declaration and swallowed. + auto atType = [](const char* q) { + static const struct { const char* kw; size_t len; } kTypes[] = { + {"int", 3}, {"byte", 4}, {"bool", 4}, {"fixed", 5}, {"string", 6}}; + for (const auto& t : kTypes) { + if (std::strncmp(q, t.kw, t.len) == 0) { + const char c = q[t.len]; + const bool identChar = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_'; + if (!identChar) return true; + } + } + return false; + }; + if (!atType(p)) break; const char* semi = std::strchr(p, ';'); if (!semi) break; const char* eol = std::strchr(semi, '\n'); diff --git a/test/unit/core/moonlive_structural.inc b/test/unit/core/moonlive_structural.inc index c4333e0b..c7cefd7a 100644 --- a/test/unit/core/moonlive_structural.inc +++ b/test/unit/core/moonlive_structural.inc @@ -177,7 +177,7 @@ TEST_CASE("emitted " MM_ISA_NAME " code reads no register a call destroyed") { {"sysvar bound + call in body", mmScript("for (x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1}, {"member bound + call in body", - mmScript("uint8_t n = 8;\n" + mmScript("byte n = 8;\n" "for (x = 0; x < n; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1}, {"sysvar read inside the body, with a call", mmScript("for (x = 0; x < 4; x = x + 1) { setRGB(x, width, random16(256), 0); }\n"), 1}, diff --git a/test/unit/core/unit_Control_int32.cpp b/test/unit/core/unit_Control_int32.cpp new file mode 100644 index 00000000..419812bc --- /dev/null +++ b/test/unit/core/unit_Control_int32.cpp @@ -0,0 +1,75 @@ +// @module Control + +// Int32 is the control type for a value that genuinely exceeds 16 bits. It exists because a +// MoonLive script scalar occupies a uniform 4-byte slot, so an `int` member has no narrower +// type that can hold it without wrapping β€” the failure Uint16/Int16 would produce silently. + +#include "doctest.h" +#include "core/Control.h" +#include "core/JsonSink.h" + +#include <cstdint> +#include <cstring> + +TEST_CASE("an int32 control carries a value no 16-bit control could hold") { + mm::ControlList controls; + int32_t big = 0; + controls.addInt32("offset", big, -1000000, 1000000); + + const mm::ControlDescriptor& c = controls[0]; + CHECK(c.type == mm::ControlType::Int32); + CHECK(std::strcmp(mm::controlTypeName(c.type), "int32") == 0); + + // 100000 wraps to -31072 in an int16 and is simply unrepresentable in a uint16. + auto r = mm::applyControlValue(c, "{\"offset\":100000}", "offset", mm::ApplyPolicy::Clamp); + CHECK(r == mm::ApplyResult::Ok); + CHECK(big == 100000); + + char buf[64]; + mm::JsonSink sink(buf, sizeof(buf)); + mm::writeControlValue(sink, c); + CHECK(std::strcmp(buf, "100000") == 0); +} + +TEST_CASE("an int32 control round-trips a negative value") { + mm::ControlList controls; + int32_t v = 0; + controls.addInt32("delta", v, -2000000, 2000000); + + auto r = mm::applyControlValue(controls[0], "{\"delta\":-1500000}", "delta", + mm::ApplyPolicy::Clamp); + CHECK(r == mm::ApplyResult::Ok); + CHECK(v == -1500000); + + char buf[64]; + mm::JsonSink sink(buf, sizeof(buf)); + mm::writeControlValue(sink, controls[0]); + CHECK(std::strcmp(buf, "-1500000") == 0); +} + +TEST_CASE("an int32 control clamps a write past its range and refuses it under Strict") { + mm::ControlList controls; + int32_t v = 0; + controls.addInt32("bounded", v, 0, 1000); + + CHECK(mm::applyControlValue(controls[0], "{\"bounded\":5000}", "bounded", + mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok); + CHECK(v == 1000); + + v = 500; + CHECK(mm::applyControlValue(controls[0], "{\"bounded\":5000}", "bounded", + mm::ApplyPolicy::Strict) == mm::ApplyResult::OutOfRange); + CHECK(v == 500); // a refused write leaves the value alone +} + +TEST_CASE("an int32 control publishes its range to the UI") { + mm::ControlList controls; + int32_t v = 0; + controls.addInt32("span", v, -70000, 70000); + + char buf[128]; + mm::JsonSink sink(buf, sizeof(buf)); + mm::writeControlMetadata(sink, controls[0]); + CHECK(std::strstr(buf, "\"min\":-70000") != nullptr); + CHECK(std::strstr(buf, "\"max\":70000") != nullptr); +} diff --git a/test/unit/core/unit_moonlive_codegen_arm64.cpp b/test/unit/core/unit_moonlive_codegen_arm64.cpp index 43ce6d4c..b4668955 100644 --- a/test/unit/core/unit_moonlive_codegen_arm64.cpp +++ b/test/unit/core/unit_moonlive_codegen_arm64.cpp @@ -6,7 +6,8 @@ // 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 +// Scoped to what this file adds (branchGeS, the Q16.16 primitives, 32-bit slot access): +// the other arm64 encodings are // covered by every compile-through-run test on this host, which executes them for real. #include "doctest.h" @@ -26,18 +27,6 @@ uint32_t word(const HostAssembler& a, size_t i) { } } // 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. @@ -52,6 +41,51 @@ TEST_CASE("arm64: branchGeS branches on GE where branchGeU branches on HS") { CHECK((word(s, 4) & 0xFF00000Fu) == 0x5400000Au); // b.ge, SIGNED } + + +// The Q16.16 primitives. smull+lsr is the arm64 spelling of "the signed high 32 bits": a 32-bit +// vreg pair widened to 64, then the top word taken. Checked against clang's own encodings. +TEST_CASE("arm64: mulhi widens to 64 bits before taking the high word") { + HostAssembler a; a.mulhi(R0, R1, R2); a.finalize(); + REQUIRE(a.size() == 8); + CHECK(word(a, 0) == 0x9b227c20u); // smull x0, w1, w2 + CHECK(word(a, 4) == 0xd360fc00u); // lsr x0, x0, #32 +} + +// asr fills from the sign bit and lsl does not: the pair is what int <-> fixed conversion is, +// and using the logical shift for the down-conversion would turn every negative coordinate into +// a large positive one. +TEST_CASE("arm64: shlImm and sarImm emit lsl and the ARITHMETIC asr") { + HostAssembler l; l.shlImm(R3, R4, 16); l.finalize(); + HostAssembler r; r.sarImm(R3, R4, 16); r.finalize(); + REQUIRE(l.size() == 4); + REQUIRE(r.size() == 4); + CHECK(word(l, 0) == 0x53103c83u); // lsl w3, w4, #16 + CHECK(word(r, 0) == 0x13107c83u); // asr w3, w4, #16 +} + +// The 4-byte slot access every scalar now uses. The immediate is scaled by 4, so the encoded +// field is offset/4 β€” reading it as a raw byte offset would address four times too far and walk +// off the 64-byte arena into the system variables. +TEST_CASE("arm64: load32 and store32 scale their immediate by four") { + HostAssembler l; l.load32(R0, R1, 16); l.finalize(); + HostAssembler s; s.store32(R1, 16, R0); s.finalize(); + REQUIRE(l.size() == 4); + REQUIRE(s.size() == 4); + CHECK(word(l, 0) == 0xb9401020u); // ldr w0, [x1, #16] + CHECK(word(s, 0) == 0xb9001020u); // str w0, [x1, #16] + CHECK(((word(l, 0) >> 10) & 0xfffu) == 4u); // 16 bytes = element 4 +} + +// The indexed forms, which is how an int[] or fixed[] element is reached once the lowering has +// scaled the index. +TEST_CASE("arm64: the indexed 32-bit forms address base plus a register offset") { + HostAssembler l; l.load32Idx(R0, R1, R2); l.finalize(); + HostAssembler s; s.store32Idx(R1, R2, R0); s.finalize(); + CHECK(word(l, 0) == 0xb8626820u); // ldr w0, [x1, x2] + CHECK(word(s, 0) == 0xb8226820u); // str w0, [x1, x2] +} + #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 b9d0a5fb..0719cea1 100644 --- a/test/unit/core/unit_moonlive_codegen_riscv.cpp +++ b/test/unit/core/unit_moonlive_codegen_riscv.cpp @@ -57,28 +57,43 @@ namespace mm { using namespace ::mm; using namespace ::mm::moonlive; -// 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") { + +// The Q16.16 primitives. mulh is mul with funct3 = 1 β€” a single bit apart from the multiply the +// engine already emits, which is exactly why it is worth pinning: the wrong funct3 silently +// returns the LOW word, so a fixed multiply would be off by a factor of 65536 rather than fail. +TEST_CASE("RISC-V mulhi emits mulh, one funct3 from mul") { + using Asm = mm_riscv_backend::mm::moonlive::RiscvAssembler; + using mm_riscv_backend::mm::moonlive::R0; + using mm_riscv_backend::mm::moonlive::R1; + using mm_riscv_backend::mm::moonlive::R2; + Asm m(64); m.mulReg(R0, R1, R2); + Asm h(64); h.mulhi(R0, R1, R2); + REQUIRE(m.size() == 4); + REQUIRE(h.size() == 4); + const uint32_t wm = uint32_t(m.bytes()[0]) | (uint32_t(m.bytes()[1]) << 8) + | (uint32_t(m.bytes()[2]) << 16) | (uint32_t(m.bytes()[3]) << 24); + const uint32_t wh = uint32_t(h.bytes()[0]) | (uint32_t(h.bytes()[1]) << 8) + | (uint32_t(h.bytes()[2]) << 16) | (uint32_t(h.bytes()[3]) << 24); + CHECK(((wm >> 12) & 7u) == 0u); // mul: funct3 0 + CHECK(((wh >> 12) & 7u) == 1u); // mulh: funct3 1 + CHECK((wm & 0xfe00707fu) != (wh & 0xfe00707fu)); +} + +// srai sets bit 30 of the immediate field; without it the shift is srli and a negative fixed +// value converts to a huge positive int instead of the number the script wrote. +TEST_CASE("RISC-V sarImm sets the arithmetic-shift bit that srli lacks") { 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 + Asm l(64); l.shlImm(R0, R1, 16); + Asm r(64); r.sarImm(R0, R1, 16); + const uint32_t wl = uint32_t(l.bytes()[0]) | (uint32_t(l.bytes()[1]) << 8) + | (uint32_t(l.bytes()[2]) << 16) | (uint32_t(l.bytes()[3]) << 24); + const uint32_t wr = uint32_t(r.bytes()[0]) | (uint32_t(r.bytes()[1]) << 8) + | (uint32_t(r.bytes()[2]) << 16) | (uint32_t(r.bytes()[3]) << 24); + CHECK((wl & 0x7fu) == 0x13u); // OP-IMM + CHECK(((wl >> 12) & 7u) == 1u); // slli funct3 1 + CHECK(((wr >> 12) & 7u) == 5u); // srai/srli funct3 5 + CHECK((wr & (1u << 30)) != 0u); // the bit that makes it ARITHMETIC + CHECK(((wr >> 20) & 0x1fu) == 16u); // the shift amount } diff --git a/test/unit/core/unit_moonlive_codegen_xtensa.cpp b/test/unit/core/unit_moonlive_codegen_xtensa.cpp index c75b2939..6ff00281 100644 --- a/test/unit/core/unit_moonlive_codegen_xtensa.cpp +++ b/test/unit/core/unit_moonlive_codegen_xtensa.cpp @@ -48,7 +48,7 @@ namespace mm { using namespace ::mm; using namespace ::mm::moonlive; #define MM_ISA_NAME "Xtensa" // Golden values, recorded from this backend. See the .inc for what they are and are not. -#define MM_GOLD_GRID_LEN 227u +#define MM_GOLD_GRID_LEN 225u #define MM_GOLD_FX_LEN 105u #define MM_GOLD_FILLLOOP_LEN 254u // fits now: the host arguments left the register file #define MM_GOLD_FXLOOP_LEN 190u @@ -230,23 +230,6 @@ TEST_CASE("Xtensa addImm never encodes an add of zero as the narrow form") { } } -// 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 @@ -262,3 +245,98 @@ TEST_CASE("Xtensa branchGeS inverts to blt where branchGeU inverts to bltu") { CHECK((u.bytes()[1] >> 4) == 0x3); // bltu CHECK((s.bytes()[1] >> 4) == 0x2); // blt: the SIGNED inversion } + +// The Q16.16 primitives, pinned against the ESP-IDF assembler's own output (xtensa-esp32-elf-as +// emitted every byte below). The shift immediates are the reason these are pinned: slli encodes +// 32-n and srai encodes n, in fields that are easy to place plausibly and wrongly, and a wrong +// placement is an illegal instruction on the board while every host test stays green. +TEST_CASE("Xtensa mulhi emits mulsh, the signed high half") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + using mm_xtensa_backend::mm::moonlive::R2; + Asm a(64); a.mulhi(R0, R1, R2); + REQUIRE(a.size() == 3); + // The WORD is 0xb22340 (mulsh a2, a3, a4); in memory, little-endian, the opcode byte is LAST. + CHECK(a.bytes()[2] == 0xb2); // mulsh, against mull's 0x82 + CHECK(a.bytes()[0] == 0x40); + CHECK(a.bytes()[1] == 0x23); +} + +TEST_CASE("Xtensa shlImm encodes 32-n where sarImm encodes n") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + Asm l(64); l.shlImm(R0, R1, 16); + Asm r(64); r.sarImm(R0, R1, 16); + REQUIRE(l.size() == 3); + REQUIRE(r.size() == 3); + // WORDS: slli a2, a3, 16 = 0x112300; srai a2, a3, 16 = 0x312030. Little-endian memory puts + // the low byte first β€” a first version wrote these bytes REVERSED (an objdump listing read in + // the wrong convention), and the reversed slli decoded as `l32r a1`: a stack-pointer clobber + // that hung the board. These constants are the word order the S3's own objdump confirms. + CHECK(l.bytes()[0] == 0x00); + CHECK(l.bytes()[1] == 0x23); + CHECK(l.bytes()[2] == 0x11); + CHECK(r.bytes()[0] == 0x30); + CHECK(r.bytes()[1] == 0x20); + CHECK(r.bytes()[2] == 0x31); +} + +// The 4-byte slot access, in the NARROW forms: l32i.n/s32i.n are 2 bytes where the halfword +// forms are 3, so every scalar access got SMALLER as well as wider. The offset field holds +// offset/4, which is the encoding that would silently address four times too far if read as a +// byte offset β€” pinned against the ESP-IDF assembler's own bytes. +TEST_CASE("Xtensa load32 emits the two-byte l32i.n with a scaled offset") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + Asm l(64); l.load32(R0, R1, 4); + Asm s(64); s.store32(R1, 16, R0); + REQUIRE(l.size() == 2); // narrower than l16ui's 3 bytes + REQUIRE(s.size() == 2); + // WORDS: l32i.n a2, a3, 4 = 0x1328 ; s32i.n a2, a3, 16 = 0x4329 β€” low byte first in memory. + CHECK(l.bytes()[0] == 0x28); + CHECK(l.bytes()[1] == 0x13); + CHECK(s.bytes()[0] == 0x29); + CHECK(s.bytes()[1] == 0x43); +} + +// The indexed forms compute into a12, the scratch outside the vreg map, so an array element +// access can never clobber a live virtual register. +TEST_CASE("Xtensa the indexed 32-bit forms go through the a12 address scratch") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + using mm_xtensa_backend::mm::moonlive::R2; + Asm l(64); l.load32Idx(R0, R1, R2); + REQUIRE(l.size() == 4); // add.n (2) + l32i.n (2) + // add.n a12, a3, a4 = word 0xc34a; l32i.n a2, a12, 0 = word 0x0c28 β€” low byte first. + CHECK(l.bytes()[0] == 0x4a); + CHECK(l.bytes()[1] == 0xc3); // add.n writes a12 + CHECK(l.bytes()[2] == 0x28); + CHECK(l.bytes()[3] == 0x0c); // then reads through it at offset 0 +} + +// A fixed multiply must reach the DEVICE backend, not just the host one. The three-instruction +// sequence (mulsh for the high half, mull for the low, then the shifts that join them) is what +// makes Q16.16 arithmetic work on an ESP32, and a host-only test would never notice its absence: +// every render test on this machine executes the arm64 backend. +TEST_CASE("Xtensa: a fixed multiply emits mulsh beside mull") { + bool ok = false; + auto bytes = emitBytes("class T {\n" + " fixed a = 0.5;\n" + " fixed b = 2.0;\n" + " fixed c = 0.0;\n" + " tick() { c = a * b; setRGB(0, toInt(c), 0, 0); }\n" + "}\n", + mm::moonlive::modifierSysVars(), ok); + REQUIRE(ok); + REQUIRE(!bytes.empty()); + // mulsh's opcode byte in its RRR position. A coarse probe, but mulsh is the only b2-leading + // 24-bit op the engine can emit, so its absence is the failure this test exists to catch. + bool sawMulsh = false; + for (size_t i = 0; i + 2 < bytes.size(); i++) + if (bytes[i] == 0xb2) { sawMulsh = true; break; } + CHECK(sawMulsh); +} diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 21077415..89f33836 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -1,6 +1,9 @@ // @module MoonLive #include "doctest.h" +#include <sstream> +#include <fstream> +#include <filesystem> #include "moonlive_script_wrap.h" #include "core/moonlive/MoonLiveCompiler.h" #include "core/moonlive/MoonLive.h" @@ -72,7 +75,7 @@ TEST_CASE("compileSource: setRGB(index, r,g,b) writes one pixel") { TEST_CASE("a function the script calls can light pixels and read the script's controls") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t level = 200;\n" + " byte level = 200;\n" " paint() { setRGB(1, level, 0, 0); }\n" " tick() { setRGB(0, 7, 8, 9); paint(); }\n" "}\n", kTable, kSys)); @@ -183,14 +186,15 @@ TEST_CASE("compileSource: random16 works in any argument slot") { } } -// REMARK #2: a literal / random16 bound may be a uint16 (0..65535), not capped at 255. -TEST_CASE("compileSource: random16 accepts a uint16 bound (>255)") { +// A literal spans the whole signed 32-bit range, because a member does: the old 0..65535 cap was +// the widest member of the day, which left a literal and the member it was assigned to disagreeing +// about what a number could be. +TEST_CASE("compileSource: a literal may be any value an int member can hold") { moonlive::MoonLive eng; - CHECK(eng.compile(mmScript("setRGB(random16(65535), 0, 0, 255);"), kTable, kSys)); // 65535 accepted - CHECK(eng.compile(mmScript("setRGB(1000, 0, 0, 255);"), kTable, kSys)); // literal index > 255 ok - uint8_t out[256]; - auto r = moonlive::compileSource(mmScript("setRGB(70000, 0, 0, 0);"), kTable, kSys, out, sizeof(out)); - CHECK_FALSE(r.ok); // 70000 > 65535 β†’ rejected + CHECK(eng.compile(mmScript("setRGB(random16(65535), 0, 0, 255);"), kTable, kSys)); + CHECK(eng.compile(mmScript("setRGB(1000, 0, 0, 255);"), kTable, kSys)); + CHECK(eng.compile(mmScript("int big = 70000;\nsetRGB(big / 1000, 0, 0, 255);"), kTable, kSys)); + eng.free(); } TEST_CASE("compileSource: out-of-range index is bounds-rejected at runtime") { @@ -288,12 +292,12 @@ TEST_CASE("MoonLive recompiling swaps the program live (fill <-> setRGB)") { // // Engine-level rather than compileSource-level, because a control now exists because a function // RAN: compileSource emits the code, and runDefineControls executes it. -TEST_CASE("a control is declared by calling addUint8, and a plain member is not") { +TEST_CASE("a control is declared by calling addControl, and a plain member is not") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t speed = 50;\n" - " uint8_t hidden = 7;\n" - " defineControls() { addUint8(\"speed\", speed, 0, 99); }\n" + " byte speed = 50;\n" + " byte hidden = 7;\n" + " defineControls() { addControl(\"speed\", speed, 0, 99); }\n" " tick() { setRGB(0, speed, hidden, 255); }\n" "}\n", kTable, kSys)); moonlive::runDefineControls(eng); @@ -304,7 +308,7 @@ TEST_CASE("a control is declared by calling addUint8, and a plain member is not" CHECK(std::strcmp(c[0].name, "speed") == 0); CHECK(c[0].min == 0); CHECK(c[0].max == 99); CHECK(c[0].def == 50); // from the member's initializer - CHECK(c[0].type == moonlive::CtrlType::Uint8); + CHECK(c[0].type == moonlive::CtrlType::Byte); // Both members hold their declared values, whether or not a control surfaces them: the // initializer seeds the arena, which is what makes a member state rather than a constant. @@ -320,9 +324,9 @@ TEST_CASE("a control is declared by calling addUint8, and a plain member is not" TEST_CASE("a control's range can be computed, not just written as a literal") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t base = 10;\n" - " uint8_t speed = 20;\n" - " defineControls() { addUint8(\"speed\", speed, base, base * 4 + 5); }\n" + " byte base = 10;\n" + " byte speed = 20;\n" + " defineControls() { addControl(\"speed\", speed, base, base * 4 + 5); }\n" " tick() { setRGB(0, speed, 0, 0); }\n" "}\n", kTable, kSys)); moonlive::runDefineControls(eng); @@ -344,8 +348,8 @@ TEST_CASE("a script cannot declare a name the engine already defines") { uint8_t out[512]; struct Case { const char* src; const char* what; }; const Case refused[] = { - {mmScript("uint8_t width = 16;\nsetRGB(0, 0, 0, 0);"), "a control named width"}, - {mmScript("uint8_t t = 5;\nsetRGB(0, 0, 0, 0);"), "a control named t"}, + {mmScript("byte width = 16;\nsetRGB(0, 0, 0, 0);"), "a control named width"}, + {mmScript("byte t = 5;\nsetRGB(0, 0, 0, 0);"), "a control named t"}, {mmScript("for (xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"), "a loop variable named xPos"}, {mmScript("for (height = 0; height < 4; height = height + 1) { setRGB(0, 0, 0, 0); }"), @@ -366,7 +370,7 @@ TEST_CASE("a script cannot declare a name the engine already defines") { CHECK((ok.ok || std::string(ok.error) == moonlive::kCodegenFailed)); // parses; no backend here #endif // A name the host did NOT register is an ordinary control, not a reserved word. - auto own = moonlive::compileSource(mmScript("uint8_t cols = 16;\nsetRGB(cols, 0, 0, 0);"), kTable, kSys, + auto own = moonlive::compileSource(mmScript("byte cols = 16;\nsetRGB(cols, 0, 0, 0);"), kTable, kSys, out, sizeof(out)); #if MM_MOONLIVE_HAS_HOST_JIT CHECK(own.ok); @@ -478,18 +482,18 @@ TEST_CASE("a long script compiles or refuses, but never spins") { TEST_CASE("compileSource: malformed control declarations fail with a diagnostic, never crash") { uint8_t out[768]; const char* bad[] = { - mmScript("uint8_t speed 50; setRGB(0,0,0,0);"), // missing '=' - mmScript("uint8_t speed = 300; setRGB(0,0,0,0);"), // default > 255 + mmScript("byte speed 50; setRGB(0,0,0,0);"), // missing '=' + mmScript("byte speed = 300; setRGB(0,0,0,0);"), // default > 255 // The range cases moved to defineControls, where a range now lives. A comment cannot be // malformed any more, because a comment no longer declares anything. - "class T {\n uint8_t s = 5;\n defineControls() { addUint8(\"s\", nope, 0, 9); }\n" + "class T {\n byte s = 5;\n defineControls() { addControl(\"s\", nope, 0, 9); }\n" " tick() { setRGB(0,0,0,0); }\n}\n", // binds an undeclared member - "class T {\n uint8_t s = 5;\n defineControls() { addUint8(s, s, 0, 9); }\n" + "class T {\n byte s = 5;\n defineControls() { addControl(s, s, 0, 9); }\n" " tick() { setRGB(0,0,0,0); }\n}\n", // name is not a string - mmScript("uint8_t random16 = 5; setRGB(0,0,0,0);"), // name shadows a builtin - "uint8_t speed = 50;", // not even a class - mmScript("uint8_t = 50; setRGB(0,0,0,0);"), // no name - mmScript("uint8_t s = 1; uint8_t s = 2; setRGB(0,0,0,0);"), // duplicate member name + mmScript("byte random16 = 5; setRGB(0,0,0,0);"), // name shadows a builtin + "byte speed = 50;", // not even a class + mmScript("byte = 50; setRGB(0,0,0,0);"), // no name + mmScript("byte s = 1; byte s = 2; setRGB(0,0,0,0);"), // duplicate member name }; for (auto s : bad) { auto r = moonlive::compileSource(s, kTable, kSys, out, sizeof(out)); @@ -565,10 +569,21 @@ TEST_CASE("parentheses group an expression ahead of division") { } // A script must degrade, never fault. Dividing by zero is the one input the hardware would trap -// on, and it reaches the host helper as an ordinary value. -TEST_CASE("dividing by zero yields zero rather than faulting") { - CHECK(render(mmScript("setRGB(0, 100 / 0, 100 % 0, 0);"), 1)[0] == 0); +// on, and it reaches the host helper as an ordinary value. The masked result SATURATES with the +// numerator's sign β€” IEEE's Β±infinity mapped onto an int, and the visually right value: k / dist +// at dist == 0 is the center of a ripple, where max is the peak the eye expects and 0 punched a +// dark hole exactly there. So no zero-check is ever needed before a divide. The remainder stays +// 0: there is no "infinite remainder". +TEST_CASE("dividing by zero saturates toward the numerator's sign rather than faulting") { + // INT32_MAX through a truncating channel store reads 255: full bright, not a hole. + CHECK(render(mmScript("setRGB(0, 100 / 0, 100 % 0, 0);"), 1)[0] == 255); CHECK(render(mmScript("setRGB(0, 100 / 0, 100 % 0, 0);"), 1)[1] == 0); + // The sign carries: a negative numerator saturates DOWN, so the comparison sees a negative. + CHECK(render(mmScript("int n = -5;\n" + "if (n / 0 < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + // 0/0 has no direction to saturate toward. + CHECK(render(mmScript("setRGB(0, 0 / 0, 0, 0);"), 1)[0] == 0); } // A subtraction that goes below zero is the ordinary way to ask "which of these is bigger", and @@ -600,22 +615,30 @@ TEST_CASE("dividing the most negative value by minus one saturates rather than f 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") { +// An int ARRAY holds full 32-bit elements, negatives included: element access lowers through the +// 4-byte indexed load, which has no sign to lose. This is what the old int16_t-array refusal +// existed to stand in for β€” the language now has the load it was missing. +TEST_CASE("an int array element round-trips a value no byte could hold") { + CHECK(render(mmScript("int buf[2];\n" + "buf[0] = 1000;\n" + "setRGB(0, buf[0] - 900, 0, 0);"), 1)[0] == 100); +} + +// A STRING array is refused: a string is a reference into the compiled program's pool, so an +// array of them would be an array of references with no way to fill it β€” there is no runtime +// string. A refusal names the gap; a wrong number would not. +TEST_CASE("a string 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); } }", + CHECK_FALSE(eng.compile("class T { string names[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. +// A coordinate far outside the plane must escape immediately, not overflow: the value below is +// past the |8.0| input clamp, and without that clamp its square alone reaches 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); + CHECK(render(mmScript("fixed far = 30000.0;\n" + "setRGB(0, escape(far, far, 0.0, 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 @@ -623,16 +646,17 @@ TEST_CASE("escape treats an absurdly distant coordinate as escaped rather than o // 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); + CHECK(render(mmScript("setRGB(0, escape(0.0, 0.0, 0.0, 0.0, 40), 7, 0);"), 1)[0] == 0); + CHECK(render(mmScript("setRGB(0, escape(0.0, 0.0, 0.0, 0.0, 40), 7, 0);"), 1)[1] == 7); + CHECK(render(mmScript("setRGB(0, escape(2.0, 2.0, 0.0, 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); + // graded banding every rendering of the set is made of. Written as the numbers themselves, + // which is what `fixed` bought: the Q13 spelling was -9830 and 8192. + auto near_px = render(mmScript("setRGB(0, escape(-1.2, 0.3, 0.0, 0.0, 40), 0, 0);"), 1); + auto far_px = render(mmScript("setRGB(0, escape(1.0, 1.0, 0.0, 0.0, 40), 0, 0);"), 1); CHECK(near_px[0] > far_px[0]); } @@ -640,15 +664,15 @@ 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); + CHECK(render(mmScript("setRGB(0, escape(0.0, 0.0, 0.0, 0.0, 40), 7, 0);"), 1)[0] == 0); + CHECK(render(mmScript("setRGB(0, escape(0.0, 0.0, -0.4, 0.6, 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; " +TEST_CASE("an int member written negative reads back negative") { + CHECK(render(mmScript("int neg = -100; " "if (neg < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); } @@ -656,20 +680,20 @@ TEST_CASE("an int16_t member written negative reads back negative") { // 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; " +TEST_CASE("an int member assigned a negative in tick reads back negative") { + CHECK(render(mmScript("int 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; " + CHECK(render(mmScript("int 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; " +TEST_CASE("a int member holds the same bits as a large positive") { + CHECK(render(mmScript("int pos = 65436; " "if (pos < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); } @@ -700,4 +724,432 @@ TEST_CASE("a loop over a count still runs every step after comparisons became si CHECK(px[0] == 9); CHECK(px[3 * 3] == 9); } -#endif + +// --- the five types ----------------------------------------------------------------------------- + +// A type is a SEMANTIC, not a storage width. These pin what each one promises, which is the whole +// reason the language stopped making a script spell uint8_t/uint16_t/int16_t for itself. + +// An int holds what its name says: the full signed 32-bit range, negatives included. The old +// language had no such member β€” uint16_t wrapped at 65536 and int16_t at 32768 β€” so a script +// needing a big number had to know which width to reach for and got a silently wrong value when +// it guessed wrong. +TEST_CASE("an int member holds a value far outside any 16-bit range") { + CHECK(render(mmScript("int big = 1000000;\n" + "setRGB(0, big / 10000, 0, 0);"), 1)[0] == 100); +} + +TEST_CASE("an int member written negative reads back negative") { + CHECK(render(mmScript("int neg = -100;\n" + "if (neg < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), + 1)[0] == 7); +} + +// Assigning past a byte's range TRUNCATES rather than wrapping the slot: the store writes one +// byte, so the member keeps 0..255 and the three bytes above it stay zero. That zero is what lets +// a byte control's descriptor point at the slot's low byte and still read the member's value. +TEST_CASE("a byte member assigned past its range keeps only its own byte") { + CHECK(render(mmScript("byte n = 0;\nn = 300;\nsetRGB(0, n, 0, 0);"), 1)[0] == 44); +} + +// A bool is a flag, and its initializer is 0 or 1 β€” anything else is a declaration error, so a +// script cannot quietly seed a flag with a number it will later compare against. +TEST_CASE("a bool member takes 0 or 1 and refuses anything else") { + CHECK(render(mmScript("bool on = 1;\n" + "if (on != 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { bool on = 7; tick() { setRGB(0, on, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A bool is written the way it reads. `bool on = 0;` was a C-ism the old language forced, and the +// literals cost nothing: they are 1 and 0, so every comparison and arithmetic path takes them +// unchanged. +TEST_CASE("a bool member is initialized and compared with true and false") { + CHECK(render(mmScript("bool on = true;\n" + "if (on != false) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + CHECK(render(mmScript("bool off = false;\n" + "if (off != false) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 1); +} + +// --- fixed (Q16.16) ----------------------------------------------------------------------------- + +// A script writes the number it means. `fixed half = 0.5;` is the Q16.16 word 32768, and toInt +// brings it back to a whole number β€” the pair is what makes fractional arithmetic expressible +// without a float anywhere in the engine. +TEST_CASE("a fixed member holds a fractional value written as a decimal") { + // 2.5 * 100 = 250, and toInt of that is 250. Anything that lost the fraction would give 200. + CHECK(render(mmScript("fixed v = 2.5;\n" + "setRGB(0, toInt(v * toFixed(100)), 0, 0);"), 1)[0] == 250); +} + +// The multiply RESCALES: two Q16.16 values have 32 fraction bits between them, so the product has +// to come back down by 16. Without that, 0.5 * 0.5 would be 0.25 scaled wrong by 65536 β€” either 0 +// or an enormous number, depending which way the shift went missing. +TEST_CASE("multiplying two fixed values rescales the product") { + // 0.5 * 0.5 = 0.25; * 400 = 100. + CHECK(render(mmScript("fixed a = 0.5;\n" + "fixed b = 0.5;\n" + "setRGB(0, toInt(a * b * toFixed(400)), 0, 0);"), 1)[0] == 100); +} + +// A NEGATIVE fixed value survives the multiply. The low word of the 64-bit product is unsigned +// while the high word is signed, so joining them with the wrong shift turns -0.5 into a large +// positive: the logical/arithmetic distinction is the whole reason both shifts exist. +TEST_CASE("a fixed multiply keeps the sign of a negative operand") { + CHECK(render(mmScript("fixed neg = -0.5;\n" + "fixed two = 2.0;\n" + "if (neg * two < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + // -0.5 * 2.0 = -1.0, so adding 1.5 gives 0.5, and 0.5 * 200 = 100. + CHECK(render(mmScript("fixed neg = -0.5;\n" + "fixed two = 2.0;\n" + "fixed off = 1.5;\n" + "setRGB(0, toInt((neg * two + off) * toFixed(200)), 0, 0);"), 1)[0] == 100); +} + +// Division rescales the other way: the numerator is pre-shifted so the quotient lands back in +// Q16.16 rather than collapsing to a whole number. +TEST_CASE("dividing two fixed values keeps the fraction") { + // 1.0 / 4.0 = 0.25, * 400 = 100. + CHECK(render(mmScript("fixed one = 1.0;\n" + "fixed four = 4.0;\n" + "setRGB(0, toInt(one / four * toFixed(400)), 0, 0);"), 1)[0] == 100); +} + +// A whole number seeding a fixed member is converted at COMPILE time: `fixed z = 2;` means 2.0, +// and no runtime shift is spent on a constant. +TEST_CASE("a whole number initializing a fixed member means its whole value") { + CHECK(render(mmScript("fixed two = 2;\n" + "setRGB(0, toInt(two * toFixed(50)), 0, 0);"), 1)[0] == 100); +} + +// An integer LITERAL meeting a fixed value converts at compile time β€” its Const is patched to the +// same number in Q16.16, free at run time β€” so `v * 2` and `if (v < 0)` read naturally. A +// VARIABLE never adopts: its scaling is not visible at the site, so it keeps the explicit rule. +TEST_CASE("an integer literal adopts fixed at a meet point, a variable does not") { + // 1.5 * 2 = 3.0; * 50 = 150. + CHECK(render(mmScript("fixed v = 1.5;\n" + "setRGB(0, toInt(v * 2 * toFixed(50)), 0, 0);"), 1)[0] == 150); + // The comparison idiom: a fixed value against a bare 0. + CHECK(render(mmScript("fixed neg = -0.5;\n" + "if (neg < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + // Assignment: `c = 5;` on a fixed member means 5.0. + CHECK(render(mmScript("fixed c = 0.0;\n" + "c = 5;\n" + "setRGB(0, toInt(c * toFixed(20)), 0, 0);"), 1)[0] == 100); + // An int VARIABLE stays refused: nothing at the site says which scaling it carries. + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("fixed v = 1.5;\nint n = 2;\nsetRGB(0, toInt(v * n), 0, 0);"), + kTable, kSys)); + eng.free(); +} + +// The fixed divide is exact over the WHOLE range, because the widening happens in int64 in the +// host (fdiv), not by shifting a 32-bit register. A first implementation split the shift around +// an integer divide and silently wrapped for any |value| past 128.0 β€” which froze two shipped +// shaders whose animation flowed through exactly such a divide, while every small-value test +// stayed green. +TEST_CASE("a fixed divide is exact for values far past 128") { + // 32000.0 / 100.0 = 320.0; * 0.5 = 160.0. The wrapped version returned garbage near zero. + CHECK(render(mmScript("fixed big = 32000.0;\n" + "fixed d = 100.0;\n" + "fixed half = 0.5;\n" + "setRGB(0, toInt(big / d * half), 0, 0);"), 1)[0] == 160); + // And the sign survives. + CHECK(render(mmScript("fixed big = -32000.0;\n" + "fixed d = 100.0;\n" + "if (big / d < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); +} + +// Dividing a fixed value by fixed zero saturates exactly as the integer divide does: the same +// policy, stated once per representation because they are different host calls. +TEST_CASE("a fixed divide by zero saturates toward the numerator's sign") { + // Saturated positive: the quotient is INT32_MAX, so a comparison against any ordinary value + // sees it as larger. Asserted by comparison rather than by scaling it down β€” the previous + // version divided by toFixed(20000000), a literal that WRAPPED, so it passed through a number + // nobody wrote. + CHECK(render(mmScript("fixed a = 5.0;\n" + "fixed z = 0.0;\n" + "fixed big = 30000.0;\n" + "if (a / z > big) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + CHECK(render(mmScript("fixed a = -5.0;\n" + "fixed z = 0.0;\n" + "if (a / z < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); +} +#endif // MM_MOONLIVE_HAS_HOST_JIT + +// Compile-only from here down: these assert DIAGNOSTICS, which the front end produces +// with or without a backend, so they are exactly what a --no-jit build should still +// check. Everything above needs render(), which needs emitted code to execute. + + +// A byte is exactly a hardware channel: 0..255, and an initializer outside that is a COMPILE +// ERROR naming the member rather than an arbitrary in-range number. `byte n = 300;` used to +// become 44 with nothing reporting it. +TEST_CASE("a byte member outside 0..255 is refused at the declaration") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte n = 300; tick() { setRGB(0, n, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// Every scalar occupies the SAME 4-byte slot, so a byte and an int cost the same arena and the +// member after either one sits at the same offset. The old rule β€” a byte packed beside its +// neighbour, a wide member skipped to an even byte β€” is what this replaces. +TEST_CASE("a byte member and an int member occupy the same sized slot") { + moonlive::MoonLive a; + REQUIRE(a.compile("class T {\n byte first = 1;\n byte second = 2;\n" + " defineControls() { addControl(\"second\", second, 0, 9); }\n" + " tick() { setRGB(0, first, second, 0); }\n}\n", kTable, kSys)); + moonlive::runDefineControls(a); + uint8_t na = 0; + const auto* da = a.declaredControls(na); + REQUIRE(na == 1); + const uint8_t afterByte = da[0].offset; + + moonlive::MoonLive b; + REQUIRE(b.compile("class T {\n int first = 1;\n byte second = 2;\n" + " defineControls() { addControl(\"second\", second, 0, 9); }\n" + " tick() { setRGB(0, first, second, 0); }\n}\n", kTable, kSys)); + moonlive::runDefineControls(b); + uint8_t nb = 0; + const auto* db = b.declaredControls(nb); + REQUIRE(nb == 1); + CHECK(db[0].offset == afterByte); // the type of `first` did not move `second` + a.free(); b.free(); +} + +// An array still PACKS at its element width β€” that is where the width question survives, because +// a byte[] heat map costs a quarter of an int[] one and the classic ESP32 has no PSRAM to absorb +// the difference. Two arrays of the same length, different element types, different extents. +TEST_CASE("a byte array packs one byte per element where an int array takes four") { + moonlive::MoonLive small; + REQUIRE(small.compile("class T {\n byte heat[8];\n byte after = 3;\n" + " defineControls() { addControl(\"after\", after, 0, 9); }\n" + " tick() { setRGB(0, heat[0], after, 0); }\n}\n", kTable, kSys)); + moonlive::runDefineControls(small); + uint8_t ns = 0; + const auto* ds = small.declaredControls(ns); + REQUIRE(ns == 1); + + moonlive::MoonLive wide; + REQUIRE(wide.compile("class T {\n int heat[8];\n byte after = 3;\n" + " defineControls() { addControl(\"after\", after, 0, 9); }\n" + " tick() { setRGB(0, heat[0], after, 0); }\n}\n", kTable, kSys)); + moonlive::runDefineControls(wide); + uint8_t nw = 0; + const auto* dw = wide.declaredControls(nw); + REQUIRE(nw == 1); + + // Eight elements: 8 bytes against 32. The member after the array is 24 bytes further along. + CHECK(dw[0].offset - ds[0].offset == 24); + small.free(); wide.free(); +} + +// A control binds a member whose type the UI has a widget for. A fixed member has no widget yet, +// and a slider writing a Q16.16 word is worse than a diagnostic saying so. +TEST_CASE("a control refuses a member the UI has no widget for") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T {\n fixed scale = 1;\n" + " defineControls() { addControl(\"scale\", scale, 0, 9); }\n" + " tick() { setRGB(0, 1, 0, 0); }\n}\n", kTable, kSys)); + eng.free(); +} + +// true and false say bool, so seeding another type with one is a diagnostic rather than a silent 1. +TEST_CASE("true and false initialize a bool and nothing else") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte n = true; tick() { setRGB(0, n, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// THE WALL: mixing the two representations is a COMPILE ERROR naming the conversion, because at +// run time they are the same 32 bits and a silent mix is a number 65,536 times off with nothing +// reporting it. This is the diagnostic the whole type-tracking exists to produce. +TEST_CASE("mixing a whole number and a fixed value is refused with the conversion named") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("fixed v = 1.5;\nsetRGB(0, v + 2, 0, 0);"), kTable, kSys)); + eng.free(); + moonlive::MoonLive eng2; + CHECK_FALSE(eng2.compile(mmScript("fixed v = 1.5;\nint n = 2;\nsetRGB(0, v * n, 0, 0);"), + kTable, kSys)); + eng2.free(); +} + +// The conversions are explicit in BOTH directions, and each refuses a value already of its target +// type: toFixed on a fixed value is a mistake worth naming, not a no-op to absorb. +TEST_CASE("a conversion refuses a value already of its target type") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("fixed v = 1.5;\nsetRGB(0, toInt(toFixed(v)), 0, 0);"), + kTable, kSys)); + eng.free(); +} + +// A fixed member outside the representable range is refused at the declaration rather than +// wrapping: 40000.0 does not fit Q16.16's Β±32767.99998. +TEST_CASE("a fixed member outside its range is refused at the declaration") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { fixed v = 40000.0; tick() { setRGB(0, 1, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A literal too big for Q16.16 cannot adopt: patching 40000 to 40000.0 would wrap the word, so +// the meet refuses it rather than producing a number nobody wrote. +TEST_CASE("a literal outside the fixed range does not adopt") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("fixed v = 1.5;\nsetRGB(0, toInt(v * 40000), 0, 0);"), + kTable, kSys)); + eng.free(); +} + +// The boundary to a built-in stays whole-numbered: a fixed value crossing unconverted would be +// read 65,536 times off, so the conversion is written where the call is. +TEST_CASE("a fixed value passed to a built-in names the conversion") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("fixed v = 1.5;\nsetRGB(0, v, 0, 0);"), kTable, kSys)); + eng.free(); +} + +// --- the type wall, at every boundary ----------------------------------------------------------- + +// An ARRAY INDEX counts elements, so it is a whole number wherever it appears. A fixed index +// would address by the raw Q16.16 word β€” 1.5 reading element 98304, clamped to the last one. +TEST_CASE("an array index is refused as a fixed value") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte h[4]; fixed f = 1.5;\n" + " tick() { setRGB(0, h[f], 0, 0); } }", kTable, kSys)); + eng.free(); + moonlive::MoonLive eng2; + CHECK_FALSE(eng2.compile("class T { byte h[4]; fixed f = 1.5;\n" + " tick() { h[f] = 1; setRGB(0, 1, 0, 0); } }", kTable, kSys)); + eng2.free(); +} + +// An array ELEMENT reports the ARRAY's type, not whatever the index expression left behind. A +// literal index in a fixed context used to adopt the INDEX β€” patching `heat[3]`'s 3 into 196608, +// clamping to the last element, and reading a byte as though it were Q16.16. +TEST_CASE("an array element carries its array's type, not its index's") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte h[4]; fixed f = 0.0;\n" + " tick() { f = h[3] * 0.5; setRGB(0, toInt(f), 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// An element STORE takes what the element type holds, the same wall a scalar store enforces. +TEST_CASE("an array element refuses a value of the wrong type") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte h[4]; tick() { h[0] = 1.5; setRGB(0, h[0], 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A LOOP counts. A fixed limit would run the body ~65,536 times β€” a multi-second stall on the +// render thread rather than a diagnostic, which is the robustness rule's whole point. +TEST_CASE("a loop header refuses a fixed value in any of its three clauses") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { fixed f = 3.0;\n" + " tick() { for (i = 0; i < f; i = i + 1) { setRGB(0, 1, 0, 0); } } }", + kTable, kSys)); + eng.free(); +} + +// A fixed remainder keeps the fixed scale β€” (a*2^16) mod (b*2^16) is (a mod b)*2^16 β€” which is +// what makes the fractional-part idiom work. Typed int, `toInt` on it would be refused. +TEST_CASE("the remainder of two fixed values is itself fixed") { + moonlive::MoonLive eng; + CHECK(eng.compile("class T { fixed a = 1.5; fixed b = 1.0;\n" + " tick() { setRGB(0, toInt(a % b * toFixed(100)), 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A member may not take a name the expression parser resolves first, or it could be declared and +// then never read. Same stance the language already takes for a builtin's name. +TEST_CASE("a member may not be named after a conversion or a boolean literal") { + for (const char* src : {"class T { byte toFixed = 5; tick() { setRGB(0, 1, 0, 0); } }", + "class T { byte toInt = 5; tick() { setRGB(0, 1, 0, 0); } }", + "class T { byte true = 5; tick() { setRGB(0, 1, 0, 0); } }"}) { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(src, kTable, kSys)); + eng.free(); + } +} + +// A fixed literal cannot seed a whole-number member: `byte b = 0.0;` says two different things +// about what b is. +TEST_CASE("a whole-number member refuses a fixed initializer") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { byte b = 0.0; tick() { setRGB(0, b, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// A fixed ARRAY is refused rather than half-working: an element's type has to reach both the +// expression that reads it and the value that writes it, which scalars get from their declaration. +TEST_CASE("a fixed array is refused with a diagnostic") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { fixed w[4]; tick() { setRGB(0, 1, 0, 0); } }", + kTable, kSys)); + eng.free(); +} + +// toFixed of a literal past the representable range is a compile error, matching what adoption +// already refuses at a meet point. It used to shift and wrap into a number nobody wrote. +TEST_CASE("toFixed refuses a literal outside the fixed range") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("setRGB(0, toInt(toFixed(40000)), 0, 0);"), kTable, kSys)); + eng.free(); +} + +// Every script this project SHIPS compiles on the host. +// +// The device codegen tests already sweep the same folder for Xtensa and RISC-V, but nothing did it +// for the host backend β€” the one every desktop runs and every other test in this file uses. A +// language change that a shipped script no longer parses would otherwise reach a board before it +// reached a test. Read from disk deliberately, so the check cannot drift from what ships. +TEST_CASE("every shipped script compiles") { + struct Role { const char* dir; const moonlive::SysVarTable& (*sys)(); }; + // A layout places lights, a modifier transforms coordinates, an effect draws: three different + // sets of system variables, so each folder compiles against its own. + const moonlive::SysVarTable& layout = moonlive::layoutSysVars(); + const moonlive::SysVarTable& effect = moonlive::effectSysVars(); + const moonlive::SysVarTable& modifier = moonlive::modifierSysVars(); + const struct { const char* dir; const moonlive::SysVarTable* sys; } kRoles[] = { + {"layouts", &layout}, + {"effects", &effect}, + {"modifiers", &modifier}, + }; + // Located from __FILE__, not the working directory: the gate runs this binary from build/, + // where a relative path finds nothing and the test would pass while checking zero scripts. + const std::filesystem::path repo = + std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path(); + int checked = 0; + for (const auto& role : kRoles) { + for (const auto& entry : std::filesystem::directory_iterator(repo / "moonlive" / role.dir)) { + if (!entry.is_regular_file()) continue; + const std::string path = entry.path().string(); + if (path.find(".ml") == std::string::npos) continue; + std::ifstream in(path); + std::stringstream ss; ss << in.rdbuf(); + const std::string src = ss.str(); + moonlive::MoonLive eng; + INFO("script: ", path); + CHECK(eng.compile(src.c_str(), kTable, *role.sys)); + eng.free(); + checked++; + } + } + CHECK(checked > 0); // an empty folder would pass the loop vacuously +} diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index a0567b90..5147ec9c 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -158,7 +158,7 @@ static moonlive::SysVarTable kSys = moonlive::modifierSysVars(); TEST_CASE("a loop counter survives a call in the body") { moonlive::MoonLive eng; // random16 is a Call; `i` and the limit `w` are both live around it. - REQUIRE(eng.compile(mmScript("uint8_t w = 8;\nfor (i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"), + REQUIRE(eng.compile(mmScript("byte w = 8;\nfor (i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"), kCtrlTable, kSys)); uint8_t buf[8 * 3] = {}; eng.run(buf, 8, 3, 0); @@ -203,8 +203,8 @@ TEST_CASE("a compiled script reports its size, and its tightest budget only when CHECK(buf[0] == '\0'); // An ordinary script is nowhere near a wall, so it reports only its size. - REQUIRE(eng.compile("class T {\n uint8_t bpm = 30;\n" - " defineControls() { addUint8(\"bpm\", bpm, 1, 240); }\n" + REQUIRE(eng.compile("class T {\n byte bpm = 30;\n" + " defineControls() { addControl(\"bpm\", bpm, 1, 240); }\n" " tick() { setRGB(0, bpm, 0, 0); }\n}\n", kCtrlTable, kSys)); moonlive::runDefineControls(eng); eng.describe(buf, sizeof(buf)); @@ -215,11 +215,11 @@ TEST_CASE("a compiled script reports its size, and its tightest budget only when // A script using every control slot is one edit from failing, so the card says which wall. REQUIRE(eng.compile("class T {\n" - " uint8_t a=1; uint8_t b=1; uint8_t c=1; uint8_t d=1;\n" - " uint8_t e=1; uint8_t f=1; uint8_t g=1; uint8_t h=1;\n" - " defineControls() { addUint8(\"a\",a,0,9); addUint8(\"b\",b,0,9);\n" - " addUint8(\"c\",c,0,9); addUint8(\"d\",d,0,9); addUint8(\"e\",e,0,9);\n" - " addUint8(\"f\",f,0,9); addUint8(\"g\",g,0,9); addUint8(\"h\",h,0,9); }\n" + " byte a=1; byte b=1; byte c=1; byte d=1;\n" + " byte e=1; byte f=1; byte g=1; byte h=1;\n" + " defineControls() { addControl(\"a\",a,0,9); addControl(\"b\",b,0,9);\n" + " addControl(\"c\",c,0,9); addControl(\"d\",d,0,9); addControl(\"e\",e,0,9);\n" + " addControl(\"f\",f,0,9); addControl(\"g\",g,0,9); addControl(\"h\",h,0,9); }\n" " tick() { setRGB(0, a, b, c); }\n}\n", kCtrlTable, kSys)); moonlive::runDefineControls(eng); eng.describe(buf, sizeof(buf)); @@ -237,8 +237,8 @@ TEST_CASE("a compiled script reports its size, and its tightest budget only when TEST_CASE("a broken script drops its controls instead of blanking their names") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t bpm = 30;\n" - " defineControls() { addUint8(\"bpm\", bpm, 1, 240); }\n" + " byte bpm = 30;\n" + " defineControls() { addControl(\"bpm\", bpm, 1, 240); }\n" " tick() { setRGB(0, bpm, 0, 0); }\n" "}\n", kCtrlTable, kSys)); moonlive::runDefineControls(eng); @@ -247,7 +247,7 @@ TEST_CASE("a broken script drops its controls instead of blanking their names") REQUIRE(n == 1); CHECK(std::strcmp(dc[0].name, "bpm") == 0); - CHECK_FALSE(eng.compile("class T { uint8_t = ; }", kCtrlTable, kSys)); + CHECK_FALSE(eng.compile("class T { byte = ; }", kCtrlTable, kSys)); dc = eng.declaredControls(n); CHECK(n == 0); // dropped, so nothing points into a pool the next compile reuses eng.free(); @@ -256,8 +256,8 @@ TEST_CASE("a broken script drops its controls instead of blanking their names") TEST_CASE("MoonLive controls: declaredControls + controlSlot seeded from the default") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t speed = 42;\n" - " defineControls() { addUint8(\"speed\", speed, 0, 99); }\n" + " byte speed = 42;\n" + " defineControls() { addControl(\"speed\", speed, 0, 99); }\n" " tick() { setRGB(speed, 0, 0, 255); }\n" "}\n", kCtrlTable, kSys)); // A control exists because defineControls() RAN, the way a compiled module's does. This is @@ -283,11 +283,11 @@ TEST_CASE("MoonLive controls: declaredControls + controlSlot seeded from the def TEST_CASE("a control declared with min above max is refused, not published as unsettable") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t ok = 5;\n" - " uint8_t bad = 7;\n" + " byte ok = 5;\n" + " byte bad = 7;\n" " defineControls() {\n" - " addUint8(\"ok\", ok, 0, 99);\n" - " addUint8(\"bad\", bad, 90, 10);\n" + " addControl(\"ok\", ok, 0, 99);\n" + " addControl(\"bad\", bad, 90, 10);\n" " }\n" " tick() { setRGB(ok, 0, 0, 255); }\n" "}\n", kCtrlTable, kSys)); @@ -300,22 +300,22 @@ TEST_CASE("a control declared with min above max is refused, not published as un TEST_CASE("MoonLive controls: arena address is STABLE across a recompile and the slot value survives") { moonlive::MoonLive eng; - REQUIRE(eng.compile(mmScript("uint8_t speed = 7;\nsetRGB(speed, 0, 0, 255);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("byte speed = 7;\nsetRGB(speed, 0, 0, 255);"), kCtrlTable, kSys)); uint8_t* before = eng.controlSlot(0); REQUIRE(before != nullptr); *before = 12; // a "slider move" β€” write the live value // Edit the source (recompile) but KEEP the control. The grow-only arena must not move, and the // live value must survive (a kept control keeps its slider position across a source edit). - REQUIRE(eng.compile(mmScript("uint8_t speed = 7;\nsetRGB(speed, 255, 0, 0);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("byte speed = 7;\nsetRGB(speed, 255, 0, 0);"), kCtrlTable, kSys)); uint8_t* after = eng.controlSlot(0); CHECK(after == before); // STABLE address β€” no dangling bound pointer CHECK(*after == 12); // value preserved across the recompile // Adding a SECOND control keeps the first's value and seeds the new slot from its default. - REQUIRE(eng.compile(mmScript("uint8_t speed = 7;\nuint8_t hue = 200;\nsetRGB(speed, hue, 0, 255);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("byte speed = 7;\nbyte hue = 200;\nsetRGB(speed, hue, 0, 255);"), kCtrlTable, kSys)); CHECK(*eng.controlSlot(0) == 12); // speed kept its live value - CHECK(*eng.controlSlot(1) == 200); // hue seeded from its default + CHECK(*eng.controlSlot(4) == 200); // hue seeded from its default, one slot on } // A member keeps its live value across a recompile because its name and offset still match. But @@ -328,10 +328,10 @@ TEST_CASE("MoonLive controls: widening or growing a member reseeds its whole ext // Spelled out rather than via mmScript: that helper only hoists `uint8_t` declarations to class // scope, so a uint16_t member written through it would become a local instead. - REQUIRE(eng.compile("class T {\n uint8_t level = 3;\n tick() { setRGB(0, level, 0, 0); }\n}\n", + REQUIRE(eng.compile("class T {\n byte level = 3;\n tick() { setRGB(0, level, 0, 0); }\n}\n", kCtrlTable, kSys)); *eng.controlSlot(0) = 0xEE; // a "slider move" the widened member must not inherit - REQUIRE(eng.compile("class T {\n uint16_t level = 900;\n tick() { setRGB(0, level - 900, 0, 0); }\n}\n", + REQUIRE(eng.compile("class T {\n int level = 900;\n tick() { setRGB(0, level - 900, 0, 0); }\n}\n", kCtrlTable, kSys)); const uint8_t* wide = eng.controlSlot(0); REQUIRE(wide != nullptr); @@ -344,13 +344,13 @@ TEST_CASE("MoonLive controls: widening or growing a member reseeds its whole ext // The same rule for an array that grows: the new elements carry the declared default, not // whatever the previous program left at those addresses. moonlive::MoonLive eng2; - REQUIRE(eng2.compile("class T {\n uint8_t bank[2];\n tick() { setRGB(0, bank[0], 0, 0); }\n}\n", + REQUIRE(eng2.compile("class T {\n byte bank[2];\n tick() { setRGB(0, bank[0], 0, 0); }\n}\n", kCtrlTable, kSys)); uint8_t* slot = eng2.controlSlot(0); REQUIRE(slot != nullptr); slot[2] = 0x77; // beyond the old end: stale bytes to inherit slot[3] = 0x77; - REQUIRE(eng2.compile("class T {\n uint8_t bank[4];\n tick() { setRGB(0, bank[3], 0, 0); }\n}\n", + REQUIRE(eng2.compile("class T {\n byte bank[4];\n tick() { setRGB(0, bank[3], 0, 0); }\n}\n", kCtrlTable, kSys)); const uint8_t* grown = eng2.controlSlot(0); // An array with no initializer starts at zero, so the grown elements must read 0 rather than @@ -361,13 +361,13 @@ TEST_CASE("MoonLive controls: widening or growing a member reseeds its whole ext TEST_CASE("MoonLive controls: free() releases the arena (no stale slot after release)") { moonlive::MoonLive eng; - REQUIRE(eng.compile(mmScript("uint8_t a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("byte a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); REQUIRE(eng.controlSlot(0) != nullptr); eng.free(); CHECK_FALSE(eng.ok()); CHECK(eng.controlSlot(0) == nullptr); // arena gone β€” no dangling pointer handed out // Recompiling after a full free re-acquires cleanly (add/remove robustness). - REQUIRE(eng.compile(mmScript("uint8_t a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("byte a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); REQUIRE(eng.controlSlot(0) != nullptr); CHECK(*eng.controlSlot(0) == 5); // re-seeded from default } @@ -547,7 +547,7 @@ TEST_CASE("calling a function no one declared is a compile error") { TEST_CASE("a member written by one tick is read by the next") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t level = 0;\n" + " byte level = 0;\n" " tick() {\n" " level = level + 10;\n" " setRGB(0, level, 0, 0);\n" @@ -568,7 +568,7 @@ TEST_CASE("a member written by one tick is read by the next") { TEST_CASE("a member written by one function is read by another") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t shared = 0;\n" + " byte shared = 0;\n" " stash() { shared = 7; }\n" " tick() { stash(); setRGB(0, shared * 3, 0, 0); }\n" "}\n", kCtrlTable, kSys)); @@ -687,7 +687,7 @@ TEST_CASE("an if inside a for runs the body every iteration") { TEST_CASE("an if condition may be an expression on both sides") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t base = 3;\n" + " byte base = 3;\n" " tick() { if (base * 2 >= base + 2) { setRGB(0, 42, 0, 0); } }\n" "}\n", kCtrlTable, kSys)); uint8_t px[3] = {}; @@ -701,7 +701,7 @@ TEST_CASE("an if condition may be an expression on both sides") { TEST_CASE("a member decides which branch a tick takes") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t phase = 0;\n" + " byte phase = 0;\n" " tick() {\n" " if (phase == 0) { setRGB(0, 7, 0, 0); phase = 1; }\n" " else { setRGB(0, 9, 0, 0); phase = 0; }\n" @@ -722,20 +722,20 @@ TEST_CASE("== is one token, not two assignments") { eng.free(); } -// A member's arena offset is a BYTE CURSOR, not its declaration index. While every member was one -// byte the two were the same number, and the difference is invisible until a member is wider than a -// byte or is an array. Pinned now, because everything downstream keys on the offset: the bindings -// cache arena slot pointers, persistence uses it, and addUint8 passes it by reference. -TEST_CASE("member offsets are byte cursors assigned in declaration order") { +// A member's arena offset is a BYTE CURSOR, not its declaration index, and every SCALAR advances +// it by a whole 4-byte slot whatever the member's type. Pinned because everything downstream keys +// on the offset: the bindings cache arena slot pointers, persistence uses it, and addControl +// passes it by reference. +TEST_CASE("member offsets advance by a whole slot in declaration order") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t a = 1;\n" - " uint8_t b = 2;\n" - " uint8_t c = 3;\n" + " byte a = 1;\n" + " byte b = 2;\n" + " byte c = 3;\n" " defineControls() {\n" - " addUint8(\"a\", a, 0, 9);\n" - " addUint8(\"b\", b, 0, 9);\n" - " addUint8(\"c\", c, 0, 9);\n" + " addControl(\"a\", a, 0, 9);\n" + " addControl(\"b\", b, 0, 9);\n" + " addControl(\"c\", c, 0, 9);\n" " }\n" " tick() { setRGB(0, a, b, c); }\n" "}\n", kCtrlTable, kSys)); @@ -746,8 +746,8 @@ TEST_CASE("member offsets are byte cursors assigned in declaration order") { // Distinct, ascending, and each one addressing its own live byte: three members must never // share a slot, which is what a cursor that failed to advance would produce. CHECK(dc[0].offset == 0); - CHECK(dc[1].offset == 1); - CHECK(dc[2].offset == 2); + CHECK(dc[1].offset == 4); + CHECK(dc[2].offset == 8); uint8_t px[3] = {}; eng.run(px, 1, 3, 0, "tick"); CHECK(px[0] == 1); @@ -766,7 +766,7 @@ TEST_CASE("a class declaring more member data than the arena holds is refused") // cannot silently turn this into a test of the other limit. char src[512]; std::snprintf(src, sizeof(src), - "class T { uint8_t a[%d]; uint8_t b[%d]; tick() { a[0] = 1; } }", + "class T { byte a[%d]; byte b[%d]; tick() { a[0] = 1; } }", moonlive::kCtrlBytes, moonlive::kCtrlBytes); moonlive::MoonLive eng; CHECK_FALSE(eng.compile(src, kCtrlTable, kSys)); @@ -776,10 +776,10 @@ TEST_CASE("a class declaring more member data than the arena holds is refused") // A uint16_t member holds a value a byte cannot. This is the correctness wall on a 256-wide wall: // every arena slot was 8-bit, so a coordinate clamped at 255 and a modifier could not walk a light // off a large grid. The round trip is what matters: seeded wide, read wide, written wide. -TEST_CASE("a uint16_t member holds a value above 255") { +TEST_CASE("a int member holds a value above 255") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint16_t big = 1000;\n" + " int big = 1000;\n" " tick() {\n" " big = big + 300;\n" " setRGB(0, big - 1300, 0, 0);\n" // 1300 - 1300 = 0 on the first tick @@ -796,10 +796,10 @@ TEST_CASE("a uint16_t member holds a value above 255") { // The high byte must survive being stored and reloaded. A store that wrote only the low half would // pass the test above on the first tick and lose the value on the second, so the boundary at 256 is // checked directly: 255 -> 256 is exactly where a byte member wraps to 0 and a halfword does not. -TEST_CASE("a uint16_t member crosses the 255 boundary without wrapping") { +TEST_CASE("a int member crosses the 255 boundary without wrapping") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint16_t n = 255;\n" + " int n = 255;\n" " tick() { n = n + 1; if (n == 256) { setRGB(0, 77, 0, 0); } }\n" "}\n", kCtrlTable, kSys)); uint8_t px[3] = {}; @@ -808,18 +808,20 @@ TEST_CASE("a uint16_t member crosses the 255 boundary without wrapping") { eng.free(); } -// A wide member is placed on an EVEN byte, because two of the three backends scale a halfword -// load's immediate by the access size and cannot encode an odd offset at all. A byte member -// declared first is what forces the padding, so the arena cursor is what this pins. -TEST_CASE("a uint16_t member is aligned to an even arena offset") { +// EVERY scalar takes a whole 4-byte slot, whatever its type: a byte does not pack in beside its +// neighbour, so a mixed declaration order costs the same as a uniform one and no member ever +// straddles a boundary. That uniformity is what removed the per-width alignment rule this +// replaces, where a wide member had to skip to an even byte because two backends scale a halfword +// load's immediate and cannot encode an odd offset at all. +TEST_CASE("every scalar member takes a whole slot whatever its type") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t small = 1;\n" // takes byte 0, leaving the cursor odd - " uint16_t wide = 900;\n" // must skip byte 1 and land on byte 2 - " uint8_t after = 2;\n" // lands after the wide member + " byte small = 1;\n" // takes byte 0, leaving the cursor odd + " int wide = 900;\n" // must skip byte 1 and land on byte 2 + " byte after = 2;\n" // lands after the wide member " defineControls() {\n" - " addUint8(\"small\", small, 0, 9);\n" - " addUint8(\"after\", after, 0, 9);\n" + " addControl(\"small\", small, 0, 9);\n" + " addControl(\"after\", after, 0, 9);\n" " }\n" " tick() { setRGB(0, small + wide, 0, 0); }\n" "}\n", kCtrlTable, kSys)); @@ -828,54 +830,63 @@ TEST_CASE("a uint16_t member is aligned to an even arena offset") { const moonlive::DeclaredControl* dc = eng.declaredControls(n); REQUIRE(n == 2); CHECK(dc[0].offset == 0); - // `after` sits at byte 4: `small` took 0, byte 1 is padding, and the uint16 occupies 2-3. Its - // offset is what proves the wide member was placed on the even byte and given both of them. - // (The controls bind the two BYTE members: a control drives a single arena byte, so binding - // the uint16 itself is refused by the compiler.) - CHECK(dc[1].offset == 4); + // `after` sits at byte 8: `small` took slot 0, `wide` slot 4. A byte member costs a whole slot + // exactly as an int does, which is the storage rule stated in one number. + CHECK(dc[1].offset == 8); eng.free(); } -// A control declares a WIDTH, and it has to be the member's own. addUint8 on a uint16_t member -// would drive only its low half (leaving the high byte holding whatever it had, so the number the -// script reads is one nobody chose) and addUint16 on a uint8_t member would write past it. Each -// mismatch names the call to use instead, rather than compiling into a silently wrong value. -TEST_CASE("a control must be declared at its member's own width") { +// A control no longer declares a width to keep in step with its member: ONE call surfaces any +// member and reads the widget from the member's own type, so the pair that could disagree is gone. +// What survives is the range check β€” a range past what the member's type holds is refused rather +// than truncated, because a slider whose top silently wraps is worse than one that never appears. +TEST_CASE("a control takes any scalar member, but not a range its type cannot hold") { moonlive::MoonLive eng; - CHECK_FALSE(eng.compile("class T {\n" - " uint16_t wide = 5;\n" - " defineControls() { addUint8(\"wide\", wide, 0, 9); }\n" - " tick() { setRGB(0, wide, 0, 0); }\n" - "}\n", kCtrlTable, kSys)); + // An int member surfaces with a range no byte could hold. + REQUIRE(eng.compile("class T {\n" + " int wide = 5;\n" + " defineControls() { addControl(\"wide\", wide, 0, 900); }\n" + " tick() { setRGB(0, wide, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t n = 0; + eng.declaredControls(n); + CHECK(n == 1); eng.free(); + // The same range on a BYTE member is refused: 900 does not fit, and the control is absent + // rather than published with a top the member cannot reach. moonlive::MoonLive engNarrow; - CHECK_FALSE(engNarrow.compile("class T {\n" - " uint8_t small = 5;\n" - " defineControls() { addUint16(\"small\", small, 0, 900); }\n" - " tick() { setRGB(0, small, 0, 0); }\n" - "}\n", kCtrlTable, kSys)); + REQUIRE(engNarrow.compile("class T {\n" + " byte small = 5;\n" + " defineControls() { addControl(\"small\", small, 0, 900); }\n" + " tick() { setRGB(0, small, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(engNarrow); + uint8_t nn = 0; + engNarrow.declaredControls(nn); + CHECK(nn == 0); engNarrow.free(); // An array is not a control at either width: binding one would move element 0 and leave the // rest, with nothing on screen saying so. moonlive::MoonLive eng2; CHECK_FALSE(eng2.compile("class T {\n" - " uint8_t bank[4];\n" - " defineControls() { addUint8(\"bank\", bank, 0, 9); }\n" + " byte bank[4];\n" + " defineControls() { addControl(\"bank\", bank, 0, 9); }\n" " tick() { setRGB(0, bank[0], 0, 0); }\n" "}\n", kCtrlTable, kSys)); eng2.free(); } -// The point of addUint16: a script exposes a value a byte cannot hold β€” a dwell time, a 0..1000 -// scale β€” as ONE control, instead of packing it into two byte sliders. The declaration reaches the -// binding with its full range intact, and the live value spans both arena bytes. -TEST_CASE("a uint16_t member is published as a control spanning its full range") { +// The point of an `int` member: a script exposes a value a byte cannot hold β€” a dwell time, a +// 0..1000 scale β€” as ONE control, instead of packing it into two byte sliders. The declaration +// reaches the binding with its full range intact, and the live value spans the member's whole slot. +TEST_CASE("an int member is published as a control spanning its full range") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint16_t dwell = 900;\n" - " defineControls() { addUint16(\"dwell\", dwell, 0, 1000); }\n" + " int dwell = 900;\n" + " defineControls() { addControl(\"dwell\", dwell, 0, 1000); }\n" " tick() { setRGB(0, dwell - 900, 0, 0); }\n" "}\n", kCtrlTable, kSys)); moonlive::runDefineControls(eng); @@ -883,18 +894,20 @@ TEST_CASE("a uint16_t member is published as a control spanning its full range") uint8_t n = 0; const moonlive::DeclaredControl* dc = eng.declaredControls(n); REQUIRE(n == 1); - CHECK(dc[0].type == moonlive::CtrlType::Uint16); - CHECK(dc[0].max == 1000); // a range past 255 survives: the record is 16-bit wide + CHECK(dc[0].type == moonlive::CtrlType::Int); + CHECK(dc[0].max == 1000); // a range past 255 survives: the record is 32-bit wide CHECK(dc[0].def == 900); // seeded from the member's own initializer, not its low byte - // The live value occupies BOTH bytes, little-endian, which is what the UI writes through and - // the emitted code reads back. + // The live value occupies the whole 4-byte SLOT, little-endian, which is what the UI writes + // through and the emitted code reads back. uint8_t* slot = eng.controlSlot(dc[0].offset); REQUIRE(slot != nullptr); CHECK(slot[0] == (900 & 0xff)); CHECK(slot[1] == (900 >> 8)); + CHECK(slot[2] == 0); + CHECK(slot[3] == 0); - // A "slider move" writes BOTH bytes, the way the UI does, and the record keeps the full value. + // A "slider move" writes the slot the way the UI does, and the record keeps the full value. slot[0] = static_cast<uint8_t>(1000 & 0xff); slot[1] = static_cast<uint8_t>(1000 >> 8); CHECK((slot[0] | (slot[1] << 8)) == 1000); @@ -912,24 +925,29 @@ TEST_CASE("a uint16_t member is published as a control spanning its full range") // `cycle` to 2000 and back). } -// A range a uint16_t cannot hold is refused rather than truncated. A LITERAL past the width is -// caught by the compiler (every number is bounded at 0..65535), so the script never runs; a range -// COMPUTED at run time is caught by the declaration, which then publishes no control. Both are -// visible failures rather than a slider whose top silently wrapped to a small number. -TEST_CASE("a control range past its width is refused, not truncated") { +// A range the MEMBER'S TYPE cannot hold is refused rather than truncated, so a slider's top can +// never silently wrap to a small number. An int member takes any 32-bit range; a byte member does +// not, and the declaration is dropped rather than published with a top it cannot reach. +TEST_CASE("a control range past its type is refused, not truncated") { moonlive::MoonLive eng; - CHECK_FALSE(eng.compile("class T {\n" - " uint16_t wide = 5;\n" - " defineControls() { addUint16(\"wide\", wide, 0, 70000); }\n" - " tick() { setRGB(0, wide, 0, 0); }\n" - "}\n", kCtrlTable, kSys)); + // An int member reaches 70000 quite legitimately: the control is published. + REQUIRE(eng.compile("class T {\n" + " int wide = 5;\n" + " defineControls() { addControl(\"wide\", wide, 0, 70000); }\n" + " tick() { setRGB(0, wide, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t nWide = 0; + eng.declaredControls(nWide); + CHECK(nWide == 1); eng.free(); - // Computed past the width: 1000 * 100 is in range as a literal pair but not as a bound. + // Computed past what the TYPE holds: a byte member cannot reach 100000, so the range is + // refused rather than truncated to something the slider could never drive. moonlive::MoonLive eng2; REQUIRE(eng2.compile("class T {\n" - " uint16_t wide = 5;\n" - " defineControls() { addUint16(\"wide\", wide, 0, 1000 * 100); }\n" + " byte wide = 5;\n" + " defineControls() { addControl(\"wide\", wide, 0, 1000 * 100); }\n" " tick() { setRGB(0, wide, 0, 0); }\n" "}\n", kCtrlTable, kSys)); moonlive::runDefineControls(eng2); @@ -941,16 +959,16 @@ TEST_CASE("a control range past its width is refused, not truncated") { // The initializer is checked against the DECLARED type, so a value a uint8_t cannot hold is a // compile error rather than a member that silently starts at a different number than it says. -TEST_CASE("a uint8_t member cannot be initialized above 255") { +TEST_CASE("a byte member cannot be initialized above 255") { moonlive::MoonLive eng; - CHECK_FALSE(eng.compile("class T { uint8_t x = 300; tick() { setRGB(0,x,0,0); } }", kCtrlTable, kSys)); + CHECK_FALSE(eng.compile("class T { byte x = 300; tick() { setRGB(0,x,0,0); } }", kCtrlTable, kSys)); eng.free(); } // The same value is legal once the member is declared wide enough to hold it. -TEST_CASE("a uint16_t member accepts an initializer a byte could not hold") { +TEST_CASE("a int member accepts an initializer a byte could not hold") { moonlive::MoonLive eng; - CHECK(eng.compile("class T { uint16_t x = 300; tick() { setRGB(0, x - 300, 0, 0); } }", kCtrlTable, kSys)); + CHECK(eng.compile("class T { int x = 300; tick() { setRGB(0, x - 300, 0, 0); } }", kCtrlTable, kSys)); eng.free(); } @@ -959,7 +977,7 @@ TEST_CASE("a uint16_t member accepts an initializer a byte could not hold") { TEST_CASE("an array element written in one loop is read in the next") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t heat[8];\n" + " byte heat[8];\n" " tick() {\n" " for (i = 0; i < 8; i = i + 1) { heat[i] = i * 10; }\n" " for (j = 0; j < 8; j = j + 1) { setRGB(j, heat[j], 0, 0); }\n" @@ -976,7 +994,7 @@ TEST_CASE("an array element written in one loop is read in the next") { TEST_CASE("array contents survive from one tick to the next") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t acc[4];\n" + " byte acc[4];\n" " tick() {\n" " for (i = 0; i < 4; i = i + 1) { acc[i] = acc[i] + 5; setRGB(i, acc[i], 0, 0); }\n" " }\n" @@ -999,7 +1017,7 @@ TEST_CASE("array contents survive from one tick to the next") { TEST_CASE("an out-of-range array index is clamped, not written past the end") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t a[4];\n" + " byte a[4];\n" " tick() {\n" " for (i = 0; i < 4; i = i + 1) { a[i] = 1; }\n" " a[9] = 200;\n" // far past the end @@ -1020,7 +1038,7 @@ TEST_CASE("an out-of-range array index is clamped, not written past the end") { TEST_CASE("an out-of-range array read is clamped and leaves system variables intact") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t a[4];\n" + " byte a[4];\n" " tick() {\n" " a[3] = 42;\n" " setRGB(0, a[200], 0, 0);\n" // clamps to a[3] @@ -1042,8 +1060,8 @@ TEST_CASE("an out-of-range array read is clamped and leaves system variables int TEST_CASE("an array index may be an expression") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint8_t base = 1;\n" - " uint8_t a[8];\n" + " byte base = 1;\n" + " byte a[8];\n" " tick() {\n" " a[base * 2 + 1] = 88;\n" // a[3] " setRGB(0, a[3], 0, 0);\n" @@ -1057,10 +1075,10 @@ TEST_CASE("an array index may be an expression") { // An array of a wide type: the element scaling and the halfword access have to agree, which is the // case where an index multiplied by the wrong width silently reads a neighbour's byte. -TEST_CASE("a uint16_t array holds per-element values above 255") { +TEST_CASE("a int array holds per-element values above 255") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint16_t v[4];\n" + " int v[4];\n" " tick() {\n" " for (i = 0; i < 4; i = i + 1) { v[i] = 300 + i; }\n" " if (v[0] == 300) { setRGB(0, 1, 0, 0); }\n" @@ -1078,14 +1096,14 @@ TEST_CASE("a uint16_t array holds per-element values above 255") { // does work, rather than silently writing its first element. TEST_CASE("a whole array cannot be assigned in one statement") { moonlive::MoonLive eng; - CHECK_FALSE(eng.compile("class T { uint8_t a[4]; tick() { a = 5; } }", kCtrlTable, kSys)); + CHECK_FALSE(eng.compile("class T { byte a[4]; tick() { a = 5; } }", kCtrlTable, kSys)); eng.free(); } // And the reverse: a scalar indexed as though it were an array is a typo worth catching. TEST_CASE("a scalar member cannot be indexed") { moonlive::MoonLive eng; - CHECK_FALSE(eng.compile("class T { uint8_t x = 1; tick() { setRGB(0, x[0], 0, 0); } }", kCtrlTable, kSys)); + CHECK_FALSE(eng.compile("class T { byte x = 1; tick() { setRGB(0, x[0], 0, 0); } }", kCtrlTable, kSys)); eng.free(); } @@ -1094,7 +1112,7 @@ TEST_CASE("a scalar member cannot be indexed") { // not got and find out at run time. TEST_CASE("an array larger than the arena is refused at compile time") { char src[128]; - std::snprintf(src, sizeof(src), "class T { uint8_t a[%d]; tick() { a[0] = 1; } }", + std::snprintf(src, sizeof(src), "class T { byte a[%d]; tick() { a[0] = 1; } }", moonlive::kCtrlBytes + 1); moonlive::MoonLive eng; CHECK_FALSE(eng.compile(src, kCtrlTable, kSys)); @@ -1142,10 +1160,10 @@ TEST_CASE("setRGB still names the light it writes") { // `uint16_t phase = 1000;` started at 232 (1000 & 0xff). Every existing test observed through // setRGB, which truncates to a byte, and the error is always a multiple of 256: invisible. // Observed here through a COMPARISON instead, which the byte channel cannot hide. -TEST_CASE("a uint16_t member starts at the value it was initialized to") { +TEST_CASE("a int member starts at the value it was initialized to") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " uint16_t phase = 1000;\n" + " int phase = 1000;\n" " tick() { if (phase == 1000) { setRGB(0, 55, 0, 0); } }\n" "}\n", kCtrlTable, kSys)); uint8_t px[3] = {}; @@ -1248,10 +1266,15 @@ TEST_CASE("a circle drawn through uv stays circular on a wide panel") { moonlive::MoonLive eng; // Light every cell within a fixed uv radius of the center, on a grid four times wider than // it is tall. The lit region must be as tall as it is wide, in PIXELS. + // + // uv is Q16.16 and polarR takes whole numbers, so the coordinate is scaled UP before the + // conversion: toInt() alone discards the fraction, which on this grid rounds every cell to + // the same handful of integers and lights the lot. 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), uvY(y, 32, 8)) < 6000) {" + " if (polarR(toInt(uvX(x, 32, 8) * 1024), " + " toInt(uvY(y, 32, 8) * 1024)) < 650) {" " setRGB(y * 32 + x, 255, 0, 0);" " } } } } }", kCtrlTable, kSys)); uint8_t px[32 * 8 * 3] = {}; @@ -1287,7 +1310,7 @@ TEST_CASE("uv places the grid center at the origin, with the left half negative" // visible difference the blend control sells, stated as a test. TEST_CASE("blending two shapes with smin produces one surface, not two") { // Two circles far enough apart that a plain union leaves a gap between them. - const char* src = "class T { uint16_t k = 0; tick() {" + const char* src = "class T { int k = 0; tick() {" " for (x = 0; x < 16; x = x + 1) {" " if (smin(polarR(x - 4, 0) - 2, polarR(x - 11, 0) - 2, k) < 0) {" " setRGB(x, 255, 0, 0); } } } }"; diff --git a/test/unit/core/unit_moonlive_ir.cpp b/test/unit/core/unit_moonlive_ir.cpp index 23d6dbf2..1c47ca53 100644 --- a/test/unit/core/unit_moonlive_ir.cpp +++ b/test/unit/core/unit_moonlive_ir.cpp @@ -135,7 +135,7 @@ int firstLit(const std::vector<uint8_t>& b) { TEST_CASE("MoonLive control: a declared control reads the arena live (no recompile on value change)") { uint8_t code[768]; auto r = moonlive::compileSource( - mmScript("uint8_t speed = 50;\nsetRGB(speed, 0, 0, 255);"), kT, kSys, code, sizeof(code)); + mmScript("byte speed = 50;\nsetRGB(speed, 0, 0, 255);"), kT, kSys, code, sizeof(code)); REQUIRE(r.ok); REQUIRE(r.memberCount == 1); // the declaration is a member; a control needs defineControls void* blk = platform::allocExec(r.len); @@ -144,12 +144,14 @@ TEST_CASE("MoonLive control: a declared control reads the arena live (no recompi auto fn = reinterpret_cast<CtrlFn>(blk); std::vector<uint8_t> buf(16 * 3, 0); - uint8_t arena[1]; + // A member occupies a whole 4-byte SLOT and is read with a 32-bit load, so the arena has to + // hold all four bytes: a one-byte array would have the load reading three bytes past its end. + uint8_t arena[4] = {0, 0, 0, 0}; arena[0] = 5; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena); CHECK(firstLit(buf) == 5); // control value selects the pixel arena[0] = 9; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena); - CHECK(firstLit(buf) == 9); // changed the arena byte only β€” NO recompile + CHECK(firstLit(buf) == 9); // changed the arena slot only β€” NO recompile arena[0] = 0; std::fill(buf.begin(), buf.end(), 0); fn(buf.data(), 16, 3, 0, arena); CHECK(firstLit(buf) == 0); platform::freeExec(blk, r.len); @@ -160,7 +162,7 @@ TEST_CASE("MoonLive control survives a host call (kArg4 live across random16)") // scratch pool β€” pins that the call() save-set protects kArg4 (the arena pointer). uint8_t code[768]; auto r = moonlive::compileSource( - mmScript("uint8_t idx = 0;\nsetRGB(idx, random16(256), 0, 255);"), kT, kSys, code, sizeof(code)); + mmScript("byte idx = 0;\nsetRGB(idx, random16(256), 0, 255);"), kT, kSys, code, sizeof(code)); REQUIRE(r.ok); void* blk = platform::allocExec(r.len); REQUIRE(blk != nullptr); diff --git a/test/unit/core/unit_moonlive_spill.cpp b/test/unit/core/unit_moonlive_spill.cpp index b06db19f..7a39142f 100644 --- a/test/unit/core/unit_moonlive_spill.cpp +++ b/test/unit/core/unit_moonlive_spill.cpp @@ -122,7 +122,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards" // `keep` is defined before the call and used after it, so it must be live ACROSS random16 β€” // and at a squeezed budget it is one of the values that has nowhere to live but a slot. const char* src = - mmScript("uint8_t idx = 5;\n" + mmScript("byte idx = 5;\n" "for (i = 0; i < 3; i = i + 1) {\n" " setRGB(idx + i, random16(1) + 111, i + 1, 222);\n" "}\n"); @@ -148,7 +148,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards" // If it did, a control read after a spill would load from a register holding something else. TEST_CASE("a declared control still reads live at a squeezed budget") { const char* src = - mmScript("uint8_t pos = 0;\n" + mmScript("byte pos = 0;\n" "for (i = 0; i < 2; i = i + 1) {\n" " setRGB(pos + i, 10, 20, 30);\n" "}\n"); diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index f1ab1c75..f5fabaf7 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -58,8 +58,8 @@ std::vector<Coord3D> place(const char* script) { TEST_CASE("the default script lays out a grid, one light per cell") { // The shape almost every panel is, and the script that ships: a nested loop calling addLight. const std::vector<Coord3D> p = place( - mmScriptAs("placeLights", "uint8_t cols = 4;\n" - "uint8_t rows = 2;\n" + mmScriptAs("placeLights", "byte cols = 4;\n" + "byte rows = 2;\n" "for (yy = 0; yy < rows; yy = yy + 1) {" " for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")); REQUIRE(p.size() == 8); @@ -74,8 +74,8 @@ TEST_CASE("the light count is known before any coordinate is asked for") { // placeLights. A count that came from the walk would arrive too late to be useful. MoonLiveLayout l; l.defineControls(); - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 5;\n" - "uint8_t rows = 3;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 5;\n" + "byte rows = 3;\n" "for (yy = 0; yy < rows; yy = yy + 1) {" " for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"))); l.prepare(); @@ -112,7 +112,7 @@ TEST_CASE("a scripted layout allocates nothing, like every other layout") { TEST_CASE("a script places lights wherever it likes, which is the point of scripting one") { // A strand that runs right to left: one line here, a new C++ class otherwise. const std::vector<Coord3D> p = place( - mmScriptAs("placeLights", "uint8_t cols = 4;\n" + mmScriptAs("placeLights", "byte cols = 4;\n" "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }")); REQUIRE(p.size() == 4); CHECK(p[0] == Coord3D{3, 0, 0}); @@ -157,18 +157,18 @@ TEST_CASE("editing the script changes the fixture") { TEST_CASE("the scripts the documentation shows all compile") { const char* fromDocs[] = { // the default - mmScriptAs("placeLights", "uint8_t cols = 16;\n" - "uint8_t rows = 16;\n" + mmScriptAs("placeLights", "byte cols = 16;\n" + "byte rows = 16;\n" "for (yy = 0; yy < rows; yy = yy + 1) {" " for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"), // right to left - mmScriptAs("placeLights", "uint8_t cols = 8;\n" + mmScriptAs("placeLights", "byte cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"), // a diagonal - mmScriptAs("placeLights", "uint8_t cols = 8;\n" + mmScriptAs("placeLights", "byte cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"), // two rows, stacked - mmScriptAs("placeLights", "uint8_t cols = 8;\n" + mmScriptAs("placeLights", "byte cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }"), // print wrapping an argument mmScriptAs("placeLights", "for (i = 0; i < 2; i = i + 1) { addLight(print(i), 0, 0); }"), @@ -227,7 +227,7 @@ TEST_CASE("a subtraction feeding a loop bound produces the whole value") { CHECK(l.lightCount() == 6); // And a subtraction inside the placement, where the coordinate is the observable. - std::vector<Coord3D> p = place(mmScriptAs("placeLights", "uint8_t cols = 4;\n" + std::vector<Coord3D> p = place(mmScriptAs("placeLights", "byte cols = 4;\n" "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }")); REQUIRE(p.size() == 4); CHECK(p[0] == Coord3D{3, 0, 0}); // 4 - 1 - 0 @@ -246,13 +246,13 @@ TEST_CASE("a subtraction feeding a loop bound produces the whole value") { TEST_CASE("a scripted control keeps its live value when the script is edited") { MoonLiveLayout l; l.defineControls(); - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 16;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 16;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }"))); l.prepare(); CHECK(l.lightCount() == 16); // A second script declaring cols at the same offset inherits the live 16, not its own 8. - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 8;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }"))); l.prepare(); CHECK(l.lightCount() == 16); @@ -260,15 +260,15 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") { // A member INSERTED ABOVE cols shifts cols to the next arena byte, so the byte cols used to // own now belongs to `pad`. Identity is the name at an offset, not the declaration position: // pad must take its own 4 rather than inherit the 16 the user had dialed into cols. - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t pad = 4;\n" - "uint8_t cols = 7;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte pad = 4;\n" + "byte cols = 7;\n" "for (i = 0; i < pad; i = i + 1) { addLight(i, 2, 0); }"))); l.prepare(); CHECK(l.lightCount() == 4); // A script whose first control is a NEW slot gets its own initialiser: nothing to inherit. - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 16;\n" - "uint8_t rows = 3;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 16;\n" + "byte rows = 3;\n" "for (yy = 0; yy < rows; yy = yy + 1) {" " for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"))); l.prepare(); @@ -394,7 +394,7 @@ TEST_CASE("a disabled scripted layout stops reporting the memory it freed") { TEST_CASE("a scripted layout reports every heap byte it holds, compiled or not") { MoonLiveLayout l; l.defineControls(); - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 4;\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 4;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }"))); l.prepare(); const size_t compiled = l.dynamicBytes(); @@ -424,8 +424,8 @@ TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") { // layout to resize. A member alone would not appear on the module, so this one is surfaced. layout.setScript(mmWriteScript( "class GrowLayout {\n" - " uint8_t cols = 4;\n" - " defineControls() { addUint8(\"cols\", cols, 1, 64); }\n" + " byte cols = 4;\n" + " defineControls() { addControl(\"cols\", cols, 1, 64); }\n" " placeLights() { for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } }\n" "}\n")); layout.prepare(); @@ -641,9 +641,9 @@ TEST_CASE("a serpentine layout places every light exactly once") { MoonLiveLayout l; l.defineControls(); l.setScript(mmWriteScript(mmScriptAs("placeLights", - "uint8_t cols = 4;\n" - "uint8_t rows = 3;\n" - "uint8_t odd = 0;\n" + "byte cols = 4;\n" + "byte rows = 3;\n" + "byte odd = 0;\n" "for (y = 0; y < rows; y = y + 1) {\n" " for (x = 0; x < cols; x = x + 1) {\n" " if (odd == 0) { addLight(x, y, 0); }\n" @@ -771,8 +771,8 @@ TEST_CASE("a disabled scripted module publishes no controls bound to freed memor // A script with its OWN control, which is what binds a pointer into the engine's arena. l.setScript(mmWriteScript( "class T {\n" - " uint8_t cols = 7;\n" - " defineControls() { addUint8(\"cols\", cols, 1, 64); }\n" + " byte cols = 7;\n" + " defineControls() { addControl(\"cols\", cols, 1, 64); }\n" " placeLights() { for (x = 0; x < cols; x = x + 1) { addLight(x, 0, 0); } }\n" "}\n")); l.prepare(); @@ -976,9 +976,9 @@ TEST_CASE("a serpentine layout places every light exactly once") { MoonLiveLayout l; l.defineControls(); l.setScript(mmWriteScript(mmScriptAs("placeLights", - "uint8_t cols = 4;\n" - "uint8_t rows = 3;\n" - "uint8_t odd = 0;\n" + "byte cols = 4;\n" + "byte rows = 3;\n" + "byte odd = 0;\n" "for (y = 0; y < rows; y = y + 1) {\n" " for (x = 0; x < cols; x = x + 1) {\n" " if (odd == 0) { addLight(x, y, 0); }\n" diff --git a/test/unit/light/unit_MoonLiveParticles.cpp b/test/unit/light/unit_MoonLiveParticles.cpp index 558124b3..1c3f714b 100644 --- a/test/unit/light/unit_MoonLiveParticles.cpp +++ b/test/unit/light/unit_MoonLiveParticles.cpp @@ -56,7 +56,7 @@ TEST_CASE("a script sizes its own particle pool and is told what it got") { // The SAME script with and without the pool call, so the difference is the buffers alone and // not the compiled program, which varies with the source text. Scene without, with_; - without.run("class T { defineControls() { addUint8(\"n\", n, 0, 9); } uint8_t n = 0; tick() { } }"); + without.run("class T { defineControls() { addControl(\"n\", n, 0, 9); } byte n = 0; tick() { } }"); with_.run("class T { defineControls() { pool(64); } tick() { } }"); CHECK(with_.effect.dynamicBytes() > without.effect.dynamicBytes() + 1000); // ~1216 of buffers } @@ -121,7 +121,7 @@ TEST_CASE("a spark thrown upward comes back down") { // Straight up (angle16 49152 = three quarter turn = -y), fast, long-lived, no drag. // A member counter, so the spark is thrown once and then only physics runs. s.run("class T {" - " uint8_t fired = 0;" + " byte fired = 0;" " defineControls() { pool(8); }" " tick() { fill(0, 0, 0);" " if (fired == 0) { emit(8, 15, 49152, 260, 4, 600, 40); fired = 1; }" diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp index 0a8e06e0..7705ff03 100644 --- a/test/unit/light/unit_MoonLiveScripts.cpp +++ b/test/unit/light/unit_MoonLiveScripts.cpp @@ -128,9 +128,9 @@ TEST_CASE("every script reads the same system-variable vocabulary") { {mmScript("setRGB(xPos, 0, 0, 0);"), true, "reading a coordinate outside a modifier is legal and reads 0: no binding writes " "it, so there is nothing to disagree with"}, - {mmScript("uint8_t width = 16;\nsetRGB(0, 0, 0, 0);"), + {mmScript("byte width = 16;\nsetRGB(0, 0, 0, 0);"), false, "declaring one is still refused, in every role: that is what keeps a read meaningful"}, - {mmScript("uint8_t xPos = 3;\nsetRGB(0, 0, 0, 0);"), + {mmScript("byte xPos = 3;\nsetRGB(0, 0, 0, 0);"), false, "the coordinate names are reserved too, so a modifier cannot shadow what it is handed"}, }; uint8_t out[2048]; @@ -173,7 +173,7 @@ TEST_CASE("a comment is whitespace, wherever it appears") { "a comment inside a loop body"}, {mmScript("// @control 1..64 is just text now\naddLight(1, 2, 3);"), true, "the old annotation is an ordinary comment"}, - {mmScript("uint8_t n = 4; // anything at all !!\nfor (i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"), + {mmScript("byte n = 4; // anything at all !!\nfor (i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"), true, "a comment after a member declaration"}, }; for (const Case& c : cases) { @@ -289,7 +289,7 @@ TEST_CASE("noise is smooth across neighbouring points, and varies across the fie TEST_CASE("mod wraps a sweep, so an animation repeats instead of running off the end") { uint8_t code[4096]; auto r = moonlive::compileSource( - mmScript("uint8_t w = 16;\n" + mmScript("byte w = 16;\n" "for (yy = 0; yy < w; yy = yy + 1) { setRGB(yy * w + mod(t, w), 255, 0, 0); }"), moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code)); REQUIRE(r.ok); @@ -323,7 +323,7 @@ TEST_CASE("sequential loops reuse the same register, so a script is not billed p uint8_t code[8192]; // Four loops, each with a call in the body β€” comfortably over budget if counters accumulate. auto r = moonlive::compileSource( - mmScript("uint8_t w = 16;\n" + mmScript("byte w = 16;\n" "for (a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n" "for (b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n" "for (c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n" From 579564fe2487d306113ab367dd32366f37d5b00e Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Mon, 24 Aug 2026 09:01:14 +0200 Subject: [PATCH 2/3] Offer the Windows installer, and close the type wall's remaining gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web installer now hands a Windows user the setup.exe that was already in every release: the picker's asset pattern could not match a name with a suffix after the version, so it offered the bare zip instead. The MoonLive type checks close the boundaries a review found open, and the desktop install page documents the installer rather than the zip. Performance: desktop 186us / 5,376 FPS, esp32 2,151us / 464 FPS (unchanged; no tick-path code moved). **UI** - install-picker matches `-setup.exe` and prefers it the way it already prefers a .dmg or a .deb. macOS was never affected: a stable release simply has no .dmg to offer, which is what a report of "no installer on macOS" turned out to be - an int32 control keeps the range its script declared. Narrowing an unbounded one to -100..200 hid every value outside that window: a control declared 0..1000 sitting at 900 rendered pinned to its top with the real value unreachable **Core** - the lexer accumulates in int64 and checks BEFORE each multiply. `long` is 32 bits on both ESP32 targets and a script compiles ON THE DEVICE, so the old guard could never fire there β€” the multiply wrapped first (UB) and the script got a number nobody wrote, while every host test stayed green - `int lo = -2147483648;` compiles. The lexer saw the magnitude before the minus was applied, so the most negative int was unwritable - the five type keywords are reserved: `int int = 5;` declared a member the class-body loop then read as another declaration - ctrlMasksOnStore and ctrlBytes deleted β€” both unused, and ctrlBytes' scalar arithmetic contradicted the 4-byte slot rule **Platform** - x86-64 mulhi reads both sources before writing its destination. It borrowed rax, which IS a vreg here (R13): with d == rax the pop restored the stale value over the result, and with b == rax the movsxd destroyed b before it was read. Silently wrong answers, not crashes **Tests** - x86-64 gained encoding tests for the first time β€” arm64, Xtensa and RISC-V had them while the backend whose mulhi was alias-unsafe had none. Bytes checked against clang's own assembly, alias cases included - the new ops under register pressure, so sourcesOf/writesDst are pinned for programs big enough to spill - the shipped-script sweep requires is_directory and matches exact extensions, rather than skipping a moved folder silently and compiling anything containing ".ml" - lexer boundary and overflow cases; a duplicate negative-read test removed **Docs** - installing-to-desktop.md leads with the installer and keeps the zip as the folder/USB alternative; the "from the next release" note is gone, that release shipped - moonlive/README int range corrected from 0..65535 to the signed 32-bit span - the plan is archived in docs/history/plans, including what the design did not anticipate - repo-health.json rewritten by the KPI gate **Reviews** - πŸ‘Ύ Reviewer, 15 findings: 13 fixed (above, plus stale wordings and a missing REQUIRE in the RISC-V test). SKIPPED: tracking control defaults with a flag rather than the INT32_MIN sentinel β€” real, but pre-existing and shared with addInt16/addUint16, and nothing declares a default 2.1 billion below any control's range. Recorded where the sentinel is defined Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- ...60823 - Five types for MoonLive scripts.md | 102 ++++++++++++++++++ docs/metrics/repo-health.json | 36 +++---- docs/metrics/repo-health.md | 24 ++--- docs/moonmodules/light/MoonLiveEffect.md | 3 - docs/tutorials/installing-to-desktop.md | 36 +++---- moonlive/README.md | 4 +- src/core/Control.h | 5 + src/core/moonlive/MoonLiveBuiltins.h | 5 - src/core/moonlive/MoonLiveCompiler.cpp | 43 +++++--- src/core/moonlive/MoonLiveIr.h | 4 - src/platform/desktop/moonlive_asm_x86_64.cpp | 47 +++++--- src/ui/app.js | 21 ++-- src/ui/install-picker.js | 15 ++- test/js/installer-desktop-download.test.mjs | 34 +++++- .../scenario_MoonLiveEffect_controls.json | 2 +- .../unit/core/unit_moonlive_codegen_riscv.cpp | 2 + .../core/unit_moonlive_codegen_x86_64.cpp | 40 +++++++ test/unit/core/unit_moonlive_compiler.cpp | 86 ++++++++++++--- test/unit/core/unit_moonlive_fill.cpp | 6 +- 19 files changed, 391 insertions(+), 124 deletions(-) create mode 100644 docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md diff --git a/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md b/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md new file mode 100644 index 00000000..6ec44076 --- /dev/null +++ b/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md @@ -0,0 +1,102 @@ +# Plan, five types for MoonLive scripts + +## Context + +MoonLive scripts spelled C storage widths: `uint8_t`, `uint16_t`, `int16_t`. That convention produced +four shipped bugs of one family, each presenting as "the effect renders nothing" or "the effect +renders wrong" and never as an error: + +- `sin(a) - 32768` wrapped the sine's negative half to ~4.29 billion. +- `(uvX(...) - 32768) * zoom` did the same on the left half of a grid, tearing the plane into blocks. +- A `d = 60000` sentinel read back as -5536 through a 16-bit window, so every light stayed black. +- A one-byte store into a two-byte member collapsed a whole shader to one flat colour. + +Not one was a mistake in a script. Each was a script author choosing a storage width and the engine +silently disagreeing. The product owner and the agent settled the replacement in +[moonlive-language-roadmap.md](../../backlog/moonlive-language-roadmap.md): **five types β€” `int`, +`byte`, `bool`, `fixed`, `string` β€” each usable as scalar or array. Every scalar occupies one uniform +4-byte slot; arrays pack by element.** A type becomes a semantic rather than a width, which deletes +the machinery instead of patching it a fifth time. + +The timing was the argument: MoonLive is unlaunched, `MIGRATING.md:9` exempts it explicitly, and the +26 shipped scripts were ours to rewrite. After launch it becomes a compatibility program forever. + +Approved scope: all five types in one branch, including `fixed` and `string`. + +## Approach + +### Storage: one slot, whatever the type + +| Type | Scalar | Array element | Control | +|---|---|---|---| +| `int` | 4 bytes | 4 bytes | `Int32` (new) | +| `byte` | 4-byte slot, narrowed by the store | 1 byte | `Uint8` | +| `bool` | 4-byte slot, narrowed by the store | 1 byte | `Bool` | +| `fixed` | 4 bytes, Q16.16 | (refused, see below) | none | +| `string` | 4 bytes (pool offset) | not allowed | none | + +The width question survives only in arrays, where it pays for itself: a `byte[]` heat map costs a +quarter of an `int[]` one, and the classic ESP32 has no PSRAM to absorb the difference. + +### Typing without an AST + +The compiler is single-pass with no tree, so a type rides alongside the value: every parse function +sets `exprIsFixed` before returning, and the places where two values meet compare it. `byte` and +`bool` decay to `int` on read β€” they are semantics on storage, not on arithmetic β€” so the question is +only ever "is this Q16.16 or a plain integer". + +Mixing is a compile error naming the conversion, because at run time the two are the same 32 bits. +The exception is an integer **literal**, which adopts the fixed side by patching its own already +emitted `Const` (free at run time), so `v * 2`, `if (v < 0)` and `c = 5;` read naturally while a +*variable* keeps the explicit rule: a literal's meaning is visible at the site, a variable's is not. + +### `fixed` gets real instructions + +The JIT had no shift primitive and no multiply-high β€” `mulReg` is a plain 32-bit multiply and `/` was +already a host call. Routing `fixed` through host builtins would have put a call in every per-pixel +multiply and made `int`↔`fixed` conversion β€” one shift β€” a function call. So four primitives went +into all four backends: `mulhi`, `shlImm`, `shrImm`, `sarImm`, plus 32-bit slot access. + +A fixed multiply is `Mulhi + Mul + two shifts`. A fixed divide goes through a **host** call (`fdiv`) +that widens in int64: any 32-bit pre-shift wraps past |128.0|, which is exactly the range shaders use. + +### One control declaration + +`addUint8`/`addUint16` collapse into `addControl(name, member, min, max)`, which reads the widget from +the member's declared type β€” so a call and a declaration can no longer disagree. A `byte` control's +descriptor points at its slot's low byte, which is only sound because the store narrows: the upper +three bytes are always zero. + +## Verification + +- 1456 unit tests, 20 scenario tests, all 11 pre-commit gates. +- Every shipped script compiles, on the host backend and on both device ISAs. +- `disasm.py` on all four backends, reading the emitted sequences by eye. +- On hardware: desktop and an ESP32-S3 (shiffy), with the product owner's eyes on metal, fractal, + ripples, plasma and ember. + +## What the design did not anticipate + +Recorded because the plan was wrong about them, and the next reader should not re-derive them: + +- **`fixed` needed four assembler primitives, not zero.** The design assumed the shifts existed. +- **Literal adoption had to be added.** The design said any mix is an error; that made `v * 2` + unwritable, and the language would have been unusable for shaders. +- **Two latent bugs surfaced that predate the branch**: `movImm` silently masked constants to 16 bits + on arm64 and Xtensa (invisible until a Q16.16 literal rode one), and arena seeding wrote a single + byte (so `int neg = -100` seeded as 156). +- **`fixed[]` is refused, not shipped.** Element type-tracking needs the array's type to reach both + the read and the write; scalars get that from their declaration, elements would need it per array. + Parity with `string[]`, deferred until a script needs it. +- **`bool` truncates rather than normalizing** (`flag = 256` reads false): normalizing needs a + compare-and-select the IR has no op for. +- **`string` is declared but inert** β€” a string member cannot be initialized yet, and says so. + +## The lesson that cost the most + +Every hand-built Xtensa encoding was byte-reversed. The two ESP toolchain objdumps print different +conventions β€” `esp32-elf` shows the 24-bit word, `esp32s3-elf` shows memory bytes β€” so verifying +against the wrong one "matched" while emitting every instruction backwards. The reversed `slli` +decoded as `l32r a1`, a stack-pointer clobber that hung the board with no panic text while all 1400+ +host tests stayed green. Encoders now build words and emit through `emit3`/`emit2`, as the +pre-existing ones always did. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 2d32f3eb..32b9dea4 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,7 +1,7 @@ { - "commit": "a3f1cd54", + "commit": "f985a366", "flash": { - "esp32s3-n16r8": 1820064, + "esp32s3-n16r8": 1820752, "desktop": 1231192, "esp32": 1764416, "esp32p4rev1-eth": 1653696, @@ -16,8 +16,8 @@ }, "perf": { "desktop": { - "tick_us": 186, - "fps": 5376 + "tick_us": 259, + "fps": 3861 }, "esp32": { "tick_us": 2151, @@ -25,16 +25,16 @@ } }, "loc": { - "core": 20106, + "core": 20117, "light": 25873, - "platform": 15109, - "ui": 7033, - "test": 46696, + "platform": 15128, + "ui": 7047, + "test": 46792, "moondeck": 21847 }, "comments": { "core": { - "lines": 7914, + "lines": 7929, "ratio": 0.426 }, "light": { @@ -42,15 +42,15 @@ "ratio": 0.438 }, "platform": { - "lines": 5369, + "lines": 5379, "ratio": 0.39 }, "ui": { - "lines": 1861, - "ratio": 0.281 + "lines": 1874, + "ratio": 0.282 }, "test": { - "lines": 8628, + "lines": 8658, "ratio": 0.212 }, "moondeck": { @@ -59,19 +59,19 @@ } }, "tests": { - "cases": 1561, + "cases": 1565, "scenarios": 23 }, "docs": { - "md_files": 191, - "md_lines": 27981, - "plans_files": 97, + "md_files": 192, + "md_lines": 28089, + "plans_files": 98, "backlog_lines": 4451, "lessons_lines": 592, "claude_md_lines": 136 }, "complexity": { - "functions": 2747, + "functions": 2745, "over_threshold": 168, "worst_ccn": 108 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 7c88e83f..66f32fe5 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `a3f1cd54`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `f985a366`. 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. @@ -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,777 KB | +| esp32s3-n16r8 | 1,778 KB (+1 KB) ⚠ | | esp32s3-n8r8 | 1,712 KB | | esp32s31 | 2,031 KB | | qemu | 1,287 KB | @@ -25,32 +25,32 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 186 Β΅s | 5,376 | +| desktop | 259 Β΅s (+73 Β΅s) ⚠ | 3,861 (βˆ’1,515) ⚠ | | esp32 | 2,151 Β΅s | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 20,106 | 7,914 | 42.6 % | +| core | 20,117 (+11) ⚠ | 7,929 | 42.6 % | | light | 25,873 | 10,271 | 43.8 % | -| platform | 15,109 | 5,369 | 39.0 % | -| ui | 7,033 | 1,861 | 28.1 % | -| test | 46,696 | 8,628 | 21.2 % | +| platform | 15,128 (+19) ⚠ | 5,379 | 39.0 % | +| ui | 7,047 (+14) ⚠ | 1,874 | 28.2 % (+0.1 %) ⚠ | +| test | 46,792 (+96) ⚠ | 8,658 | 21.2 % | | moondeck | 21,847 | 3,529 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,561 | +| unit cases | 1,565 (+4) βœ“ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,747 | +| functions | 2,745 (βˆ’2) ⚠ | | over threshold | 168 | | worst CCN | 108 | @@ -58,9 +58,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| -| markdown files | 191 | -| markdown lines | 27,981 | -| plan files | 97 | +| markdown files | 192 (+1) ⚠ | +| markdown lines | 28,089 (+108) ⚠ | +| plan files | 98 (+1) ⚠ | | backlog lines | 4,451 | | lessons lines | 592 | | CLAUDE.md lines | 136 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 694f7a3b..f122073c 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -58,9 +58,6 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 UI shows one is the separate question `defineControls()` answers. A member no control names is simply the script's own state. - The compiled form is the same call with a receiver: `controls_.addUint8("speed", speed, 1, 255)` - β€” a compiled module still names the C++ width it binds, because there the member really is a - `uint8_t`. A script does not: its `addControl` reads the widget from how the member was declared. The member is named by identifier rather than by repeating the string, so a typo is a compile error here as it is there, and the quoted name is the UI label, free to differ from the member's name. The **default** comes from the member's initializer, so there is one home for the starting value. The range arguments are ordinary expressions, like every other argument in the language: `addControl("speed", speed, base, base * 4 + 5)` is valid. `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. diff --git a/docs/tutorials/installing-to-desktop.md b/docs/tutorials/installing-to-desktop.md index c0f060d6..6a82e661 100644 --- a/docs/tutorials/installing-to-desktop.md +++ b/docs/tutorials/installing-to-desktop.md @@ -8,25 +8,19 @@ This page covers **Windows**. For macOS and Linux, the [README](https://github.c --- -## 1. Download it +## 1. Download the installer Open the [web installer](https://moonmodules.org/projectMM/install/) and set **Install to** to `This computer (Windows x64)`. The Release picker offers stable releases and `latest`, a build published on every merge to main; pick a stable one unless you want the newest unreleased changes. -![The web installer with Windows x64 selected, and the downloaded zip in the browser's Downloads panel](../assets/tutorials/windows-01-download.png) +![The web installer with Windows x64 selected, and the download in the browser's Downloads panel](../assets/tutorials/windows-01-download.png) -**Download** gives you `projectMM-windows-x64-vX.Y.Z.zip`. There is nothing to sign up for and nothing else to install: the zip holds the application and a README, and that is all it needs. +**Download** gives you `projectMM-windows-x64-vX.Y.Z-setup.exe`. There is nothing to sign up for and nothing else to install. -## 2. Extract it, do not run it from inside the zip +## 2. Run it -Open the zip and Windows offers you **Extract all** or **Run**. +The installer installs for your user, so there is no administrator prompt. It adds a **Start-menu entry with an icon** and a proper uninstaller, and starts projectMM when it finishes. -![Windows offering Extract all or Run when opening the executable inside the zip](../assets/tutorials/windows-02-extract.png) - -**Choose Extract all.** Running straight from a zip makes Windows unpack the application into a temporary folder that it may clear at any time, so you end up running a copy that quietly disappears later. Extract it somewhere you would keep a program, then run it from there. - -## 3. Run it, and get past SmartScreen - -Double-click `projectMM.exe`. The first time, Windows stops you: +Windows stops you the first time: ![Microsoft Defender SmartScreen warning that it prevented an unrecognized app from starting](../assets/tutorials/windows-03-smartscreen.png) @@ -34,7 +28,7 @@ This is expected. SmartScreen warns about any application it has not seen signed Click **More info**, then **Run anyway**. You only have to do this once for a given copy. -## 4. That is it +## 3. That is it A console window opens showing what projectMM is doing, and your browser opens the interface at `http://localhost:8080/`. @@ -46,7 +40,7 @@ Out of the box you get a 16x16 grid and a running effect, which is enough to con Two options worth knowing: `--no-browser` stops it opening a browser (for a headless machine), and `--port <n>` serves somewhere other than 8080. -## 5. Where your settings live +## 4. Where your settings live Everything you change is saved automatically, in a folder that belongs to **your Windows user** rather than to the application: @@ -56,17 +50,21 @@ Everything you change is saved automatically, in a folder that belongs to **your This applies from the release that introduced it. On an older build, settings sat in a `build\.config` folder beside the executable instead, and the log said `write failed` for each save when that folder could not be created. -The location is deliberate, and it has a consequence worth knowing: **your settings are not in the folder you extracted to**. Move the application, replace it with a newer version, or delete the extracted folder entirely, and your configuration is still there. To start completely fresh, delete that folder. +The location is deliberate, and it has a consequence worth knowing: **your settings are not where the application is**. Move it, install a newer version over it, uninstall it, or delete the folder you extracted, and your configuration is still there. To start completely fresh, delete that folder. Paste `%LOCALAPPDATA%\projectMM` into the Explorer address bar to open it. -## 6. Or use the installer +## 5. Or use the zip -From the next release there is also `projectMM-windows-x64-vX.Y.Z-setup.exe`. It does the same thing as the steps above, with less clicking: it installs for your user, so there is no administrator prompt, and adds a **Start-menu entry with an icon** and a proper uninstaller. +Every release also ships `projectMM-windows-x64-vX.Y.Z.zip`, which is the right choice if you want to keep projectMM in a folder of your own, or run it from a USB stick. It holds the application and a README, and nothing else is needed. Download it from the [release page](https://github.com/MoonModules/projectMM/releases) rather than the web installer, which offers the installer for this platform. + +**Extract it before running.** Opening the zip, Windows offers **Extract all** or **Run**: + +![Windows offering Extract all or Run when opening the executable inside the zip](../assets/tutorials/windows-02-extract.png) -The zip stays available and is the right choice if you want to keep projectMM in a folder of your own, or run it from a USB stick. +Choose **Extract all**. Running straight from a zip makes Windows unpack the application into a temporary folder that it may clear at any time, so you end up running a copy that quietly disappears later. Extract it somewhere you would keep a program, then run `projectMM.exe` from there β€” SmartScreen warns the same way, and the settings location is the same. -Both are unsigned, so SmartScreen warns for either. Installing a new version over an old one keeps your settings, because the program and the settings live in different places; uninstalling removes the program and leaves your settings behind. +Both forms are unsigned, so SmartScreen warns for either. Installing a new version over an old one keeps your settings, because the program and the settings live in different places; uninstalling removes the program and leaves your settings behind. --- diff --git a/moonlive/README.md b/moonlive/README.md index 37a7aaa1..82805284 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -53,8 +53,8 @@ else { setRGB(i, 0, 0, 0); } ``` **Members can be wider than a byte, and can be arrays.** `byte` spans 0..255; `int` spans -0..65535, which is what a position on a wall wider than 255 needs. An array is declared with a -literal length and starts at zero: +-2,147,483,648..2,147,483,647, which is what a position on a wall wider than 255 needs. An array is +declared with a literal length and starts at zero: ```c int phase = 900; // a value a byte cannot hold diff --git a/src/core/Control.h b/src/core/Control.h index 1503e446..e026f67c 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -294,6 +294,11 @@ struct ControlDescriptor { // 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. + // + // The cost of a sentinel rather than a flag: a control whose default IS INT32_MIN cannot say + // so, and is serialized as having none. Nothing declares one β€” the value is 2.1 billion below + // any range a control here carries β€” and the alternative is a bool on every descriptor for a + // case that has never occurred. Revisit if one ever does. static constexpr int32_t kNoDefault = INT32_MIN; int32_t def = kNoDefault; bool hidden = false; // UI visibility flag. Set via ControlList::setHidden() after addX(). diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 5ff64ec8..b3880d83 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -48,11 +48,6 @@ constexpr uint8_t ctrlWidth(CtrlType t) { /// for a width. constexpr uint8_t ctrlSlotBytes(CtrlType) { return 4; } -/// Does a value of this type need masking on the way into its slot? Byte keeps its slot's upper -/// bytes zero, which is what lets a byte control's descriptor point at the slot's low byte. -constexpr bool ctrlMasksOnStore(CtrlType t) { - return t == CtrlType::Byte || t == CtrlType::Bool; -} // 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 5f2d2886..8e5fd887 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -17,7 +17,7 @@ enum class Tok { Ident, Number, String, Assign, LParen, RParen, LBrace, RBrace, struct Lexer { const char* p; Tok kind = Tok::Error; - long number = 0; + int64_t number = 0; // Whether the literal just lexed carried a decimal point, i.e. it is a Q16.16 value. bool numberIsFixed = false; const char* identBeg = nullptr; @@ -43,25 +43,37 @@ struct Lexer { // // Overflow FAILS rather than truncating: the old cap stopped consuming digits at 1000000 and // left the value silently wrong, so `99999999` became a number nobody wrote. - bool readNumber(long& v, bool& isFixed, bool& overflowed) { + bool readNumber(int64_t& v, bool& isFixed, bool& overflowed) { if (!isDigit(*p)) return false; v = 0; isFixed = false; overflowed = false; + // INT64 THROUGHOUT, and every check made BEFORE the multiply that could overflow. + // + // `long` is 32 bits on both ESP32 targets, and a script COMPILES ON THE DEVICE: the guard + // `v > INT32_MAX` after `v = v*10 + d` could never fire there, because the multiply had + // already wrapped (signed overflow, UB). `0.99999` was worse β€” num*65536 needs 33 bits. + // Both produced a number nobody wrote, on hardware, while every host test stayed green. while (isDigit(*p)) { + if (v > (INT64_MAX - 9) / 10) { overflowed = true; return true; } v = v * 10 + (*p - '0'); p++; - if (v > 2147483647L) { overflowed = true; return true; } + // The MAGNITUDE, not the value: a leading minus is a separate token, so 2147483648 + // is legal here and becomes INT32_MIN. The signed range is checked where the sign is + // known β€” parsePrimary for an expression, parseDecl for an initializer. + if (v > -static_cast<int64_t>(INT32_MIN)) { overflowed = true; return true; } } if (*p == '.' && isDigit(p[1])) { isFixed = true; p++; - long num = 0, den = 1; + int64_t num = 0, den = 1; while (isDigit(*p)) { - if (den <= 100000000L) { num = num * 10 + (*p - '0'); den *= 10; } - p++; // digits past the useful precision are read - } // and dropped, rather than shifting the value - if (v > 32767L) { overflowed = true; return true; } - // The integer part scales by 65536; the fraction is num/den of that. - v = (v << 16) + (num * 65536L + den / 2) / den; + // Bounded so den * 65536 and num * 65536 both stay well inside int64; digits past + // that precision are consumed and dropped rather than shifting the value. + if (den <= 100000000LL) { num = num * 10 + (*p - '0'); den *= 10; } + p++; + } + if (v > 32767) { overflowed = true; return true; } + // The integer part scales by 65536; the fraction is num/den of that, rounded. + v = (v << 16) + (num * 65536 + den / 2) / den; } return true; } @@ -122,7 +134,7 @@ struct Lexer { kind = Tok::String; return; } if (isDigit(c)) { - long v = 0; bool fx = false, over = false; + int64_t v = 0; bool fx = false, over = false; readNumber(v, fx, over); if (over) { err = "number out of range"; kind = Tok::Error; return; } number = v; numberIsFixed = fx; kind = Tok::Number; return; @@ -873,7 +885,7 @@ struct Parser { // silently truncate. `byte n = 300;` is refused here rather than becoming 44 at run time, // which is the class of bug this type system exists to remove: the old widths turned an // out-of-range initializer into an arbitrary in-range one with nothing reporting it. - long defMin = INT32_MIN, defMax = INT32_MAX; + int64_t defMin = INT32_MIN, defMax = INT32_MAX; const char* rangeErr = nullptr; switch (type) { case CtrlType::Byte: defMin = 0; defMax = 255; @@ -904,7 +916,7 @@ struct Parser { break; } if (lex.number < defMin || lex.number > defMax) { fail(rangeErr); return; } - long def = lex.number; + int64_t def = lex.number; lex.advance(); if (!expect(Tok::Semicolon, "expected ';' after the member declaration")) return; // A lexer error carries a specific message; surface it rather than letting it fall through @@ -939,7 +951,10 @@ struct Parser { /// and the two boolean literals. A member may not take one of these names. static bool isReservedWord(const char* n, size_t len) { static const struct { const char* w; size_t len; } kWords[] = { - {"toFixed", 7}, {"toInt", 5}, {"true", 4}, {"false", 5}}; + {"toFixed", 7}, {"toInt", 5}, {"true", 4}, {"false", 5}, + // The type keywords too: `int int = 5;` parsed, declaring a member whose name the + // class-body loop reads as the start of another declaration. + {"int", 3}, {"byte", 4}, {"bool", 4}, {"fixed", 5}, {"string", 6}}; for (const auto& k : kWords) if (len == k.len && std::strncmp(n, k.w, k.len) == 0) return true; return false; diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 7190a9f1..c9b2bcd9 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -211,10 +211,6 @@ constexpr uint8_t idxBase(int32_t p) { return uint8_t(p & 0xff); } constexpr uint8_t idxWidth(int32_t p) { return uint8_t((p >> 8) & 0xff); } constexpr uint8_t idxCount(int32_t p) { return uint8_t((p >> 16) & 0xff); } -/// Bytes a whole member occupies (its elements, at its width). -constexpr uint16_t ctrlBytes(const DeclaredControl& d) { - return uint16_t(d.count) * ctrlWidth(d.type); -} /// Branch targets one IR program may use. Two per `for` (entry guard + back edge), and the counter /// runs for the whole program rather than per scope β€” a label is never reused once a loop closes β€” diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp index 4d697b5d..ed4954d4 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -509,21 +509,40 @@ void HostAssembler::emitIndexed(const uint8_t* opcode, size_t opLen, bool prefix // already relies on saving it before borrowing it. Every OTHER volatile register (r9/r10/r11) // is inside the vreg pool, so using one would clobber a live virtual register. void HostAssembler::mulhi(Reg d, Reg a, Reg b) { + // BOTH sources are read into scratch BEFORE anything is written, so d may alias a, b, or the + // scratch itself. An earlier version borrowed rax around a push/pop and wrote d in the middle: + // with d == rax (which IS a vreg here β€” rax is R13, see the static_assert above) the pop then + // restored the old value over the result, and with b == rax the movsxd destroyed b before it + // was read. Neither shows on any current program, because the lowering reserves the scratch + // range; relying on that is exactly the kind of unstated precondition that breaks later. + // + // r10/r11 are pushed and popped around the sequence, so no vreg is disturbed whichever + // registers d, a and b turn out to be. const uint8_t dst = xr(d), ra = xr(a), rb = xr(b); - uint8_t save[2] = {0x50 | (x64::RAX & 7), 0x00}; // push rax - emitBytes(save, 1); - uint8_t ext_b[3] = {rex_(true, x64::RAX >= 8, false, rb >= 8), 0x63, - modrm_(0b11, x64::RAX & 7, rb & 7)}; - emitBytes(ext_b, 3); // movsxd rax, bD - uint8_t ext_a[3] = {rex_(true, dst >= 8, false, ra >= 8), 0x63, modrm_(0b11, dst & 7, ra & 7)}; - emitBytes(ext_a, 3); // movsxd rD, aD - uint8_t mul[4] = {rex_(true, dst >= 8, false, x64::RAX >= 8), 0x0F, 0xAF, - modrm_(0b11, dst & 7, x64::RAX & 7)}; - emitBytes(mul, 4); // imul rD, rax - uint8_t sar[4] = {rex_(true, false, false, dst >= 8), 0xC1, modrm_(0b11, 7, dst & 7), 32}; - emitBytes(sar, 4); // sar rD, 32 - uint8_t rest[1] = {uint8_t(0x58 | (x64::RAX & 7))}; // pop rax - emitBytes(rest, 1); + const uint8_t s1 = x64::R10, s2 = x64::R11; + + uint8_t push1[2] = {rex_(false, false, false, true), uint8_t(0x50 | (s1 & 7))}; + emitBytes(push1, 2); // push r10 + uint8_t push2[2] = {rex_(false, false, false, true), uint8_t(0x50 | (s2 & 7))}; + emitBytes(push2, 2); // push r11 + + uint8_t ext_a[3] = {rex_(true, s1 >= 8, false, ra >= 8), 0x63, modrm_(0b11, s1 & 7, ra & 7)}; + emitBytes(ext_a, 3); // movsxd r10, aD + uint8_t ext_b[3] = {rex_(true, s2 >= 8, false, rb >= 8), 0x63, modrm_(0b11, s2 & 7, rb & 7)}; + emitBytes(ext_b, 3); // movsxd r11, bD + uint8_t mul[4] = {rex_(true, s1 >= 8, false, s2 >= 8), 0x0F, 0xAF, + modrm_(0b11, s1 & 7, s2 & 7)}; + emitBytes(mul, 4); // imul r10, r11 + uint8_t sar[4] = {rex_(true, false, false, s1 >= 8), 0xC1, modrm_(0b11, 7, s1 & 7), 32}; + emitBytes(sar, 4); // sar r10, 32 + // The result lands in d only now, after every source has been consumed. + uint8_t mov[3] = {rex_(true, s1 >= 8, false, dst >= 8), 0x89, modrm_(0b11, s1 & 7, dst & 7)}; + emitBytes(mov, 3); // mov dD, r10 + + uint8_t pop2[2] = {rex_(false, false, false, true), uint8_t(0x58 | (s2 & 7))}; + emitBytes(pop2, 2); // pop r11 + uint8_t pop1[2] = {rex_(false, false, false, true), uint8_t(0x58 | (s1 & 7))}; + emitBytes(pop1, 2); // pop r10 } // 32-bit shifts: C1 /4 ib is shl, C1 /7 ib is sar. No REX.W β€” a vreg is 32 bits, and the // arithmetic shift must fill from bit 31, not bit 63. diff --git a/src/ui/app.js b/src/ui/app.js index 8828aa31..cf90675b 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1729,15 +1729,22 @@ function createControl(moduleName, moduleType, ctrl) { } case "int32": case "int16": { - // ctrl.min/ctrl.max are always present (server sends them). A min/max at the - // type's own limit means "unbounded" β€” fall back to a Β±percentage range, since - // a slider spanning the full type is useless to drag. - const lo = ctrl.type === "int32" ? -2147483648 : -32768; - const hi = ctrl.type === "int32" ? 2147483647 : 32767; + // ctrl.min/ctrl.max are always present (server sends them). An int16 at the type's + // own limit means "unbounded" and falls back to a +-percentage range, since a slider + // spanning the full type is useless to drag. + // + // An int32 does NOT: its range is what the script declared, and narrowing an + // unbounded one to -100..200 hid every value outside that window β€” a control + // declared 0..1000 sitting at 900 rendered as a slider pinned to its top with the + // real value unreachable. A bounded int32 keeps its bounds; an unbounded one spans + // the type, which the number input beside the slider makes usable. + const isI32 = ctrl.type === "int32"; + const lo = isI32 ? -2147483648 : -32768; + const hi = isI32 ? 2147483647 : 32767; const rawMin = Number(ctrl.min ?? lo); const rawMax = Number(ctrl.max ?? hi); - const min = rawMin <= lo ? -100 : rawMin; - const max = rawMax >= hi ? 200 : rawMax; + const min = (!isI32 && rawMin <= lo) ? -100 : rawMin; + const max = (!isI32 && rawMax >= hi) ? 200 : rawMax; const raw = Number(ctrl.value ?? 0); const clamped = Math.max(min, Math.min(max, raw)); const input = document.createElement("input"); diff --git a/src/ui/install-picker.js b/src/ui/install-picker.js index bf21ad7c..1445ee9c 100644 --- a/src/ui/install-picker.js +++ b/src/ui/install-picker.js @@ -197,7 +197,12 @@ function parseFirmwaresFromAssets(assets, tag) { // arm64" rather than a filename. // The version carries dots, so the platform group has to be anchored on the -v rather than on // "everything up to a dot". A .deb names its arch, not the platform, so it maps to linux-x64. - const desktopRe = /^projectMM-(macos-arm64|windows-x64|linux-x64)-v.+\.(dmg|tar\.gz|zip)$/; + // + // The Windows INSTALLER carries a suffix AFTER the version β€” projectMM-windows-x64-v1.2.3 + // -setup.exe β€” so the extension alternation alone could not match it and the installer never + // reached the dropdown: a Windows user was offered the bare .zip while setup.exe sat in the + // release. `.+` before the extension covers both shapes. + const desktopRe = /^projectMM-(macos-arm64|windows-x64|linux-x64)-v.+\.(dmg|tar\.gz|zip|exe)$/; const debRe = /^projectmm_.+_amd64\.deb$/; for (const a of assets) { const d = desktopRe.exec(a.name); @@ -208,9 +213,11 @@ function parseFirmwaresFromAssets(assets, tag) { isDesktop: true, assets: [] }; entry.isDesktop = true; (entry.assets = entry.assets || []).push({ name: a.name, url: a.browser_download_url }); - // Prefer the friendliest form when a platform ships more than one: a .dmg to drag, or a - // .deb apt can install, over the tarball that is there for scripting. - const friendly = /\.(dmg|deb)$/.test(a.name); + // Prefer the friendliest form when a platform ships more than one: a .dmg to drag, a + // setup.exe that installs, or a .deb apt can install, over the archive that is there for + // scripting. An installer is what a person double-clicks, which is the whole point of + // offering a download rather than a flash. + const friendly = /(\.dmg|\.deb|-setup\.exe)$/.test(a.name); if (!entry.binaryUrl || friendly) entry.binaryUrl = a.browser_download_url; firmwares.set(key, entry); } diff --git a/test/js/installer-desktop-download.test.mjs b/test/js/installer-desktop-download.test.mjs index b64d0b15..8728bd27 100644 --- a/test/js/installer-desktop-download.test.mjs +++ b/test/js/installer-desktop-download.test.mjs @@ -23,6 +23,7 @@ const RELEASE = [ asset("projectMM-macos-arm64-v3.0.0.tar.gz"), asset("projectMM-macos-arm64-v3.0.0.dmg"), asset("projectMM-windows-x64-v3.0.0.zip"), + asset("projectMM-windows-x64-v3.0.0-setup.exe"), asset("projectMM-linux-x64-v3.0.0.tar.gz"), asset("projectmm_3.0.0_amd64.deb"), ]; @@ -44,13 +45,28 @@ test("each desktop platform is offered once, however many archives it ships", () }); test("a platform shipping both an installer and a tarball offers the installer", () => { - // macOS ships a .dmg to drag and a .tar.gz for scripting; Linux ships a .deb and a - // tarball. The user clicking Download wants the one their OS knows how to open. + // macOS ships a .dmg to drag, Windows a setup.exe, Linux a .deb β€” each beside a plain + // archive for scripting. The user clicking Download wants the one their OS knows how to + // open. ALL THREE are asserted: this test covered only macOS and Linux while Windows was + // shipping a setup.exe the picker could not even see, so the one platform with a broken + // download was the one nothing checked. const got = parseFirmwaresFromAssets(RELEASE, "v3.0.0"); assert.match(got.find(f => f.firmware === "desktop-macos-arm64").binaryUrl, /\.dmg$/); + assert.match(got.find(f => f.firmware === "desktop-windows-x64").binaryUrl, /-setup\.exe$/); assert.match(got.find(f => f.firmware === "desktop-linux-x64").binaryUrl, /\.deb$/); }); +test("a windows installer whose name carries a suffix after the version is still matched", () => { + // projectMM-windows-x64-v3.0.0-setup.exe puts `-setup` AFTER the version, where every other + // asset ends at its extension. A pattern anchored on "version then extension" silently + // dropped it, and the release page had an installer the install page never offered. + const got = parseFirmwaresFromAssets(RELEASE, "v3.0.0"); + const win = got.find(f => f.firmware === "desktop-windows-x64"); + assert.ok(win, "windows must be offered at all"); + assert.ok(win.assets.some(a => /-setup\.exe$/.test(a.name)), + "the installer must appear among the platform's assets"); +}); + test("a version with dots does not break the platform match", () => { // The platform group is anchored on the "-v", not on "everything up to a dot": the // version's own dots would otherwise end the match and the archive would vanish. @@ -92,3 +108,17 @@ test("an ESP32 firmware and its ethernet variant remain mutually flashable", () assert.ok(isCompatible("esp32-eth-wifi", "esp32")); assert.equal(isCompatible("esp32", "esp32s3-n16r8"), false); }); + +// An OLDER release may ship only an archive where the newest ships an installer: v3.0.0 has a +// .tar.gz for macOS and no .dmg at all. The picker offers what exists, which is right β€” but a +// user who selected a stable release and received a .tar.gz had no way to see why, because the +// option said only "macOS arm64". The form now rides the label. +test("a release with only an archive still offers it, for every platform it has", () => { + const OLD_RELEASE = [ + asset("projectMM-macos-arm64-v3.0.0.tar.gz"), + asset("projectMM-windows-x64-v3.0.0.zip"), + ]; + const got = parseFirmwaresFromAssets(OLD_RELEASE, "v3.0.0"); + assert.match(got.find(f => f.firmware === "desktop-macos-arm64").binaryUrl, /\.tar\.gz$/); + assert.match(got.find(f => f.firmware === "desktop-windows-x64").binaryUrl, /\.zip$/); +}); diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json index e9b7bd40..46033af9 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_controls.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_controls.json @@ -14,7 +14,7 @@ "Drivers", "NetworkSendDriver" ], - "description": "Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a member and surfaces it (`addControl(\"speed\", speed, 0, 15)` in defineControls) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real uint8 MoonModule control bound to the live control-values arena slot. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β€” must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate.", + "description": "Exercise MoonLive Stage-1 CONTROLS end-to-end as a wired module. A script declares a member and surfaces it (`addControl(\"speed\", speed, 0, 15)` in defineControls) and uses it (`setRGB(speed, ...)`); the engine surfaces the control, the binding creates a real MoonModule control of the widget its member's TYPE calls for, bound to the member's 4-byte slot in the live control-values arena. The scenario: add the effect with a control script (the control appears, renders), change the CONTROL value live (a slider move β€” must NOT recompile; the arena byte updates and the next tick reads it), edit the SOURCE to add a second control (recompile re-derives the set, existing slider value preserved by the stable-address grow-only arena), edit the source to remove a control (the orphaned value drops), push a broken script (compile fails, renders dark, status shows the diagnostic, no crash), recover, and remove + re-add (resource teardown + re-acquire). A crash in the LoadCtrl codegen, a dangling arena pointer across a recompile, or a value change that wrongly triggers a recompile all show up as a failed measure or a tick spike. The codegen + live-read contract is pinned by unit_moonlive_ir / unit_moonlive_compiler; this is the wired-module gate.", "fixture": [ { "name": "fix-layouts", diff --git a/test/unit/core/unit_moonlive_codegen_riscv.cpp b/test/unit/core/unit_moonlive_codegen_riscv.cpp index 0719cea1..c0fe0d10 100644 --- a/test/unit/core/unit_moonlive_codegen_riscv.cpp +++ b/test/unit/core/unit_moonlive_codegen_riscv.cpp @@ -89,10 +89,12 @@ TEST_CASE("RISC-V sarImm sets the arithmetic-shift bit that srli lacks") { Asm r(64); r.sarImm(R0, R1, 16); const uint32_t wl = uint32_t(l.bytes()[0]) | (uint32_t(l.bytes()[1]) << 8) | (uint32_t(l.bytes()[2]) << 16) | (uint32_t(l.bytes()[3]) << 24); + REQUIRE(r.size() == 4); // read as four bytes below, so say so first const uint32_t wr = uint32_t(r.bytes()[0]) | (uint32_t(r.bytes()[1]) << 8) | (uint32_t(r.bytes()[2]) << 16) | (uint32_t(r.bytes()[3]) << 24); CHECK((wl & 0x7fu) == 0x13u); // OP-IMM CHECK(((wl >> 12) & 7u) == 1u); // slli funct3 1 + CHECK((wr & 0x7fu) == 0x13u); // OP-IMM for the shift too, not just the shl CHECK(((wr >> 12) & 7u) == 5u); // srai/srli funct3 5 CHECK((wr & (1u << 30)) != 0u); // the bit that makes it ARITHMETIC CHECK(((wr >> 20) & 0x1fu) == 16u); // the shift amount diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp index 3d3c3e66..f6d6cda6 100644 --- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp +++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp @@ -17,6 +17,7 @@ // Intel SDM Vol. 2 encoding tables and marked with the human-readable assembly they represent. #include "doctest.h" +#include <array> #if (defined(__x86_64__) || defined(_M_X64)) && !defined(MM_MOONLIVE_FORCE_NO_HOST_JIT) @@ -574,6 +575,45 @@ TEST_CASE("x86_64: a class with a script-to-script call compiles") { CHECK(r.entryCount == 2); } +// The Q16.16 multiply, whose sequence must survive d aliasing a or b. +// +// This backend had NO encoding tests for the new primitives while arm64, Xtensa and RISC-V all +// gained them β€” and its mulhi borrowed rax, which IS a vreg here (R13). With d == rax the old +// pop restored the stale value over the result; with b == rax the movsxd destroyed b before it +// was read. Both are silently wrong answers, not crashes. Bytes checked against clang's own +// assembly of the same sequence. +TEST_CASE("x86_64: mulhi reads both sources before it writes its destination") { + HostAssembler a; a.mulhi(R0, R1, R2); a.finalize(); + const uint8_t* b = a.bytes(); + REQUIRE(a.size() >= 12); + // push r10 / push r11 open the sequence: the scratch pair is saved, so no vreg is disturbed + // whichever registers the three operands turn out to be. + CHECK(b[0] == 0x41); CHECK(b[1] == 0x52); // push r10 + CHECK(b[2] == 0x41); CHECK(b[3] == 0x53); // push r11 + // ...and pop restores them at the end, AFTER the result has been moved into d. + CHECK(b[a.size() - 4] == 0x41); CHECK(b[a.size() - 3] == 0x5b); // pop r11 + CHECK(b[a.size() - 2] == 0x41); CHECK(b[a.size() - 1] == 0x5a); // pop r10 +} + +// The same sequence with the destination aliasing each source in turn, and with R13 (rax) in +// every position. None may produce a different shape: the result is computed in scratch and only +// then written, so aliasing cannot destroy an operand that has still to be read. +TEST_CASE("x86_64: mulhi emits the same shape however its operands alias") { + const size_t base = [] { HostAssembler a; a.mulhi(R0, R1, R2); a.finalize(); return a.size(); }(); + for (const auto& regs : {std::array<Reg, 3>{R0, R0, R1}, // d aliases a + std::array<Reg, 3>{R0, R1, R0}, // d aliases b + std::array<Reg, 3>{R0, R0, R0}, // all three + std::array<Reg, 3>{R13, R1, R2}, // d is rax + std::array<Reg, 3>{R0, R13, R2}, // a is rax + std::array<Reg, 3>{R0, R1, R13}}) // b is rax + { + HostAssembler a; a.mulhi(regs[0], regs[1], regs[2]); a.finalize(); + CHECK(a.size() == base); + CHECK(a.bytes()[0] == 0x41); // still opens by saving the scratch + CHECK(a.bytes()[a.size() - 1] == 0x5a); // still closes by restoring it + } +} + } // namespace #else // not x86_64 host diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 89f33836..3d7fc34d 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -194,7 +194,27 @@ TEST_CASE("compileSource: a literal may be any value an int member can hold") { CHECK(eng.compile(mmScript("setRGB(random16(65535), 0, 0, 255);"), kTable, kSys)); CHECK(eng.compile(mmScript("setRGB(1000, 0, 0, 255);"), kTable, kSys)); CHECK(eng.compile(mmScript("int big = 70000;\nsetRGB(big / 1000, 0, 0, 255);"), kTable, kSys)); + // The signed 32-bit boundaries themselves, which is where the lexer's overflow guard lives. + CHECK(eng.compile(mmScript("int hi = 2147483647;\nsetRGB(hi / 100000000, 0, 0, 255);"), + kTable, kSys)); + CHECK(eng.compile(mmScript("int lo = -2147483648;\nsetRGB(0 - lo / 100000000, 0, 0, 255);"), + kTable, kSys)); + eng.free(); +} + +// The lexer accumulates in int64 and checks BEFORE each multiply. `long` is 32 bits on both ESP32 +// targets and a script COMPILES ON THE DEVICE, so the old guard could never fire there: the +// multiply wrapped first (signed overflow, UB) and the script got a number nobody wrote, while +// every host test stayed green. These are the two shapes that broke. +TEST_CASE("a number too large for an int is refused rather than wrapped") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(mmScript("int huge = 3000000000;\nsetRGB(0, huge, 0, 0);"), + kTable, kSys)); eng.free(); + moonlive::MoonLive eng2; + CHECK_FALSE(eng2.compile(mmScript("int huge = 99999999999;\nsetRGB(0, huge, 0, 0);"), + kTable, kSys)); + eng2.free(); } TEST_CASE("compileSource: out-of-range index is bounds-rejected at runtime") { @@ -668,18 +688,10 @@ TEST_CASE("a nonzero seed selects the Julia set rather than the Mandelbrot set") CHECK(render(mmScript("setRGB(0, escape(0.0, 0.0, -0.4, 0.6, 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 int member written negative reads back negative") { - CHECK(render(mmScript("int 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. +// ops, and the bug this pins wrote only part of the member, so the load read a stale 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. The 4-byte slot removes the class. TEST_CASE("an int member assigned a negative in tick reads back negative") { CHECK(render(mmScript("int v = 0; " "v = 100 - 11000; " @@ -691,8 +703,10 @@ TEST_CASE("an int member assigned a negative in tick reads back negative") { "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 int member holds the same bits as a large positive") { +// 65436 is simply a positive number to an int. It is here because it USED to be the bit pattern a +// uint16_t member held for -100, so the two were indistinguishable in storage; with a 4-byte +// signed slot they are different values and the comparison says so. +TEST_CASE("an int member holds a large positive") { CHECK(render(mmScript("int pos = 65436; " "if (pos < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 3); } @@ -885,6 +899,39 @@ TEST_CASE("a fixed divide by zero saturates toward the numerator's sign") { "if (a / z < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), 1)[0] == 9); } +// The new ops under REGISTER PRESSURE. sourcesOf/writesDst tell the allocator which vregs an op +// reads and whether it defines one; get either wrong for a new op and the allocator spills the +// wrong value or keeps a dead one, which shows up as an arithmetic answer that is wrong only in +// the programs big enough to spill. A long chain of live fixed values forces that state. +TEST_CASE("the fixed ops survive being spilled") { + // Eight members live at once, each read after the chain has moved on, so the allocator has to + // park and reload values across Mulhi/Shl/Shr/Sar and the 32-bit slot access. + CHECK(render(mmScript("fixed a = 1.5;\n" + "fixed b = 2.0;\n" + "fixed c = 0.5;\n" + "fixed d = 4.0;\n" + "fixed e = 0.25;\n" + "fixed f = 8.0;\n" + "fixed g = 0.125;\n" + "fixed h = 0.0;\n" + "h = a * b + c * d + e * f + g;\n" + // 1.5*2 + 0.5*4 + 0.25*8 + 0.125 = 3 + 2 + 2 + 0.125 = 7.125 + "setRGB(0, toInt(h * toFixed(16)), 0, 0);"), 1)[0] == 114); +} + +// The same for the whole-number ops the slot access shares: a value stored to a member, read back +// after other work has claimed every register, and compared. +TEST_CASE("a member survives a spill across the 32-bit slot access") { + CHECK(render(mmScript("int a = 1000;\n" + "int b = 2000;\n" + "int c = 3000;\n" + "int d = 4000;\n" + "int e = 5000;\n" + "int f = 6000;\n" + "int g = 0;\n" + "g = a + b + c + d + e + f;\n" + "setRGB(0, g / 100, 0, 0);"), 1)[0] == 210); +} #endif // MM_MOONLIVE_HAS_HOST_JIT // Compile-only from here down: these assert DIAGNOSTICS, which the front end produces @@ -1120,7 +1167,6 @@ TEST_CASE("toFixed refuses a literal outside the fixed range") { // language change that a shipped script no longer parses would otherwise reach a board before it // reached a test. Read from disk deliberately, so the check cannot drift from what ships. TEST_CASE("every shipped script compiles") { - struct Role { const char* dir; const moonlive::SysVarTable& (*sys)(); }; // A layout places lights, a modifier transforms coordinates, an effect draws: three different // sets of system variables, so each folder compiles against its own. const moonlive::SysVarTable& layout = moonlive::layoutSysVars(); @@ -1137,10 +1183,18 @@ TEST_CASE("every shipped script compiles") { std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path(); int checked = 0; for (const auto& role : kRoles) { - for (const auto& entry : std::filesystem::directory_iterator(repo / "moonlive" / role.dir)) { + const std::filesystem::path dir = repo / "moonlive" / role.dir; + // REPORTED, not skipped: a folder that moved would otherwise make this test pass while + // checking nothing, which is the failure mode it exists to prevent. + INFO("script folder: ", dir.string()); + REQUIRE(std::filesystem::is_directory(dir)); + for (const auto& entry : std::filesystem::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; + // The exact extensions, not a substring: `.ml` also matches an editor backup or a + // note file that has no business being compiled. + const std::string ext = entry.path().extension().string(); + if (ext != ".mle" && ext != ".mll" && ext != ".mlm") continue; const std::string path = entry.path().string(); - if (path.find(".ml") == std::string::npos) continue; std::ifstream in(path); std::stringstream ss; ss << in.rdbuf(); const std::string src = ss.str(); diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 5147ec9c..a5866099 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -816,9 +816,9 @@ TEST_CASE("a int member crosses the 255 boundary without wrapping") { TEST_CASE("every scalar member takes a whole slot whatever its type") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" - " byte small = 1;\n" // takes byte 0, leaving the cursor odd - " int wide = 900;\n" // must skip byte 1 and land on byte 2 - " byte after = 2;\n" // lands after the wide member + " byte small = 1;\n" // slot 0 + " int wide = 900;\n" // slot 4: a byte costs a whole slot too + " byte after = 2;\n" // slot 8 " defineControls() {\n" " addControl(\"small\", small, 0, 9);\n" " addControl(\"after\", after, 0, 9);\n" From 321b402271bf609bd9d853e07fd1bf6faad7518c Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Mon, 24 Aug 2026 09:59:12 +0200 Subject: [PATCH 3/3] Fix the fixed multiply on x86-64, and write the boundary values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Q16.16 multiply returned garbage on x86-64: the emitted sequence borrowed r10/r11, which are the first registers the allocator hands out, so Windows and Linux desktop rendering of the fixed shaders was silently wrong. The most negative int and fixed values are now writable in an expression, and a sign on true/false is refused rather than ignored. Performance: desktop 132us idle. The 259us in the previous commit was measured against my own leftover projectMM process pinned at 100% CPU, not a tick-path change. **Platform** - mulhi borrows NO allocatable register: the intermediate lives on the stack, only rax is touched, and it is saved and restored around the sequence. The operand that might BE rax is widened first, and when the destination IS rax the saved word is discarded rather than popped back over the result. Three attempts, because the first two each picked a register that turned out to be allocatable (rax is vreg R13; r10/r11 are R5/R6). Bytes verified against clang - Xtensa shlImm/sarImm and all three RISC-V shifts had NO range guard: n >= 32 emitted a wrong instruction rather than refusing. The lowering now routes an unencodable shift to an encode the assembler rejects, instead of silently turning it into a move **Core** - `-32768.0` and `-2147483648` compile in an expression. Both magnitudes are one past their type's positive limit, so judging the number before the sign made the most negative value of each type unwritable β€” the same shape twice, once for int and once for fixed - `bool b = -true;` is refused. The minus was consumed and then never consulted, so the member seeded to 1 as though nothing had been written - the compiler-side member record no longer carries a 0..255 nobody reads: the range arrives with addControl at run time, and an int member claiming max 255 is noise the next reader would trust - a bool byte is normalized before the UI binding reads it through a `bool*`, which a script-written 7 would otherwise make undefined behaviour **Tests** - the fixed multiply is exercised by EXECUTION with its destination aliasing a source, and through a chain long enough to recycle registers β€” the byte shape is what hid the bug twice, so shape assertions were the wrong instrument - both boundary literals, in expressions; a signed bool initializer; one past the most negative int - a one-byte arena in unit_moonlive_ir.cpp and an unmigrated type keyword in an x86-guarded test, both found only under Rosetta **Docs** - lessons.md: an ISA-guarded test is not run by the machine that wrote it, and on Apple Silicon that blind spot is one `cmake -B build/x86 -DCMAKE_OSX_ARCHITECTURES=x86_64` wide - MoonLiveModifier.md no longer documents `uint16_t` as a member type; comments naming deleted API corrected **Reviews** - πŸ‘Ύ Reviewer over the branch diff, 8 findings: 7 fixed, 1 stale (it described a mulhi version already replaced). Its critical finding was the x86-64 multiply, which CI then confirmed independently - πŸ‡ 6 inline findings: 4 fixed, 1 already correct (-2147483649 was refused, only the diagnostic was unclear), 1 stale Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/history/lessons.md | 14 +++ ...60823 - Five types for MoonLive scripts.md | 11 ++- docs/moonmodules/light/MoonLiveModifier.md | 2 +- src/core/moonlive/MoonLiveCompiler.cpp | 39 +++++++- src/core/moonlive/MoonLiveIr.h | 7 +- src/core/moonlive/moonlive_lower.h | 18 +++- src/light/moonlive/MoonLiveBuiltins_light.h | 2 +- src/light/moonlive/MoonLiveScript.h | 7 ++ src/platform/desktop/moonlive_asm_x86_64.cpp | 96 +++++++++++-------- src/platform/esp32/moonlive_asm_riscv.cpp | 17 +++- src/platform/esp32/moonlive_asm_xtensa.cpp | 6 +- .../core/unit_moonlive_codegen_x86_64.cpp | 65 ++++++++----- test/unit/core/unit_moonlive_compiler.cpp | 50 ++++++++++ test/unit/core/unit_moonlive_fill.cpp | 8 +- test/unit/core/unit_moonlive_ir.cpp | 5 +- 15 files changed, 258 insertions(+), 89 deletions(-) diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 95d5ed1a..c38a1339 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -590,3 +590,17 @@ evidence which *looks* most authoritative here is the evidence that lies. next one reporting a stale runner, a self-inflicted loop that reads exactly like a real staleness failure. **Generated build metadata is not source**, and a guard that cannot tell them apart teaches people to ignore it. + +- **An ISA-guarded test is not run by the machine that wrote it, and a JIT's bytes are only true + where they execute.** The MoonLive backends are `#if`-guarded per architecture, so an arm64 bench + compiles neither the x86-64 encoder tests nor the x86-64 emitted code β€” a whole backend can be + wrong while the local suite is green and confident. A Q16.16 multiply that borrowed a register the + allocator hands out returned garbage on every x86-64 desktop; 1458 local tests passed, and CI is + where it surfaced. Two smaller defects hid in the same blind spot: a one-byte arena where the code + now does a 32-bit load, and an unmigrated type keyword in a test file arm64 never compiles. + + **On an Apple Silicon machine that blind spot is one command wide:** `cmake -B build/x86 + -DCMAKE_OSX_ARCHITECTURES=x86_64` then `arch -x86_64 ./build/x86/test/mm_tests`. Rosetta runs the + emitted x86-64 instructions for real, so the tests that only exist on that host actually execute. + Worth doing on any change to a backend, an encoder, or the register allocator β€” it is minutes, + and it is the difference between finding these locally and finding them in CI. diff --git a/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md b/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md index 6ec44076..0e2c0155 100644 --- a/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md +++ b/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md @@ -30,7 +30,7 @@ Approved scope: all five types in one branch, including `fixed` and `string`. | Type | Scalar | Array element | Control | |---|---|---|---| | `int` | 4 bytes | 4 bytes | `Int32` (new) | -| `byte` | 4-byte slot, narrowed by the store | 1 byte | `Uint8` | +| `byte` | 4-byte slot, narrowed by the store | 1 byte | `Uint8` (a 0..255 slider) | | `bool` | 4-byte slot, narrowed by the store | 1 byte | `Bool` | | `fixed` | 4 bytes, Q16.16 | (refused, see below) | none | | `string` | 4 bytes (pool offset) | not allowed | none | @@ -69,7 +69,8 @@ three bytes are always zero. ## Verification -- 1456 unit tests, 20 scenario tests, all 11 pre-commit gates. +- The unit suite and 20 scenario tests, all 11 pre-commit gates, on arm64 AND on x86-64 + under Rosetta β€” the x86-only tests no arm64 run compiles are where two defects hid. - Every shipped script compiles, on the host backend and on both device ISAs. - `disasm.py` on all four backends, reading the emitted sequences by eye. - On hardware: desktop and an ESP32-S3 (shiffy), with the product owner's eyes on metal, fractal, @@ -88,8 +89,10 @@ Recorded because the plan was wrong about them, and the next reader should not r - **`fixed[]` is refused, not shipped.** Element type-tracking needs the array's type to reach both the read and the write; scalars get that from their declaration, elements would need it per array. Parity with `string[]`, deferred until a script needs it. -- **`bool` truncates rather than normalizing** (`flag = 256` reads false): normalizing needs a - compare-and-select the IR has no op for. +- **`bool` truncates on store rather than normalizing** (`flag = 256` reads false): normalizing in + the emitted code needs a compare-and-select the IR has no op for, and a branch would spend two of + the script's sixteen labels. The byte IS normalized where it matters β€” at publish time, before + the UI binding reads it through a `bool*`, which would otherwise be undefined behaviour. - **`string` is declared but inert** β€” a string member cannot be initialized yet, and says so. ## The lesson that cost the most diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index 90398090..f807468a 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -41,7 +41,7 @@ It is for debugging and comes back out again β€” [what print costs](writing-scri ## Limits -**A coordinate is a byte, so an axis spans 0..255.** A position handed TO a script outside that range is passed through untransformed rather than wrapped. A position a script COMPUTES past 255 keeps its low byte, so `(width - 1 - x) * 2` on a grid wider than 128 lands somewhere unintended, so keep a computed result inside the box. A script's own MEMBERS may be `uint16_t`, so intermediate arithmetic can exceed 255 even where the coordinate handed back cannot. +**A coordinate is a byte, so an axis spans 0..255.** A position handed TO a script outside that range is passed through untransformed rather than wrapped. A position a script COMPUTES past 255 keeps its low byte, so `(width - 1 - x) * 2` on a grid wider than 128 lands somewhere unintended, so keep a computed result inside the box. A script's own MEMBERS may be `int`, so intermediate arithmetic can exceed 255 even where the coordinate handed back cannot. **A script cannot resize the logical box.** A modifier has two hooks: one reshapes the box once per rebuild, one folds each coordinate. A script drives only the second, so transforms that keep the box the same size work, and ones that halve it (the way the built-in [Mirror](modifiers.md#mirror) does) need the compiled modifier. diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 8e5fd887..43b01add 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -71,7 +71,10 @@ struct Lexer { if (den <= 100000000LL) { num = num * 10 + (*p - '0'); den *= 10; } p++; } - if (v > 32767) { overflowed = true; return true; } + // The MAGNITUDE: 32768.0 is legal with a leading minus (the most negative Q16.16 + // value) and refused without one, which is a judgement only the sign-aware caller can + // make. Same rule the integer path follows for 2147483648. + if (v > 32768) { overflowed = true; return true; } // The integer part scales by 65536; the fraction is num/den of that, rounded. v = (v << 16) + (num * 65536 + den / 2) / den; } @@ -483,6 +486,23 @@ struct Parser { } if (lex.kind == Tok::Minus) { // unary minus: 0 - v, as (v * -1) lex.advance(); + // A NEGATED LITERAL folds here, before the positive form is range-checked: the + // magnitude 2147483648 is legal only with the sign attached, so parsing the number + // first made INT32_MIN unwritable in an expression (`v = -2147483648;` was refused + // while the identical initializer compiled). Folding also spares a Const and a Mul. + if (lex.kind == Tok::Number) { + const int64_t neg = -lex.number; + if (neg < INT32_MIN || neg > INT32_MAX) { fail("number out of range"); return 0; } + VReg v = alloc(); + emit({IrOp::Const, v, 0,0,0,0, static_cast<int32_t>(neg), nullptr, {}}); + // A FIXED literal folds here too: `-32768.0` is the most negative Q16.16 value and + // its magnitude is one past the positive limit, so parsing the number first and + // negating after made it unwritable β€” the same shape as INT32_MIN. + exprIsFixed = lex.numberIsFixed; + exprLitConst = lex.numberIsFixed ? -1 : int(ir.count) - 1; + lex.advance(); + return v; + } VReg v = parsePrimary(); if (failed) return 0; VReg m = alloc(); @@ -507,6 +527,10 @@ struct Parser { // and the member it was assigned to disagree about what a number could be. if (lex.number < INT32_MIN || lex.number > INT32_MAX) { fail("number out of range"); return 0; } + // A positive fixed literal stops at 32767.99998; the magnitude 32768.0 the lexer now + // admits is legal only with the minus the branch above folds. + if (lex.numberIsFixed && lex.number > INT32_MAX) // 32767.99998 in Q16.16 + { fail("number out of range for a fixed value"); return 0; } VReg v = alloc(); emit({IrOp::Const, v, 0,0,0,0, static_cast<int32_t>(lex.number), nullptr, {}}); // A decimal point made it a fixed value at the lexer; the word is already scaled. @@ -843,7 +867,10 @@ struct Parser { // Zero, not a written initializer: an element-wise initializer list would be a second // syntax for what a `for` in the script already expresses, and every element seeding to // the same value is what a decay or particle buffer starts from anyway. - members[memberCount] = {name, 0, 255, 0, static_cast<uint8_t>(nameLen), type, + // The RANGE is not the compiler's to state: it arrives with the addControl call at + // run time, through addDeclaredControl. Zeroed here rather than carrying a 0..255 + // that an int or fixed member would flatly contradict. + members[memberCount] = {name, 0, 0, 0, static_cast<uint8_t>(nameLen), type, static_cast<uint8_t>(at), count}; memberBytes = static_cast<uint8_t>(at + need); memberCount++; @@ -859,6 +886,10 @@ struct Parser { // `true`/`false` seed a bool the way a script writes one. if (lex.kind == Tok::Ident && (atKeyword("true", 4) || atKeyword("false", 5))) { if (type != CtrlType::Bool) { fail("true and false initialize a bool member"); return; } + // `-true` parsed: the minus was consumed above and then never consulted, so the member + // seeded to 1 as though the sign had not been written. A sign has no meaning on a + // boolean, so say so rather than silently discarding it. + if (negated) { fail("true and false take no sign"); return; } const long b = atKeyword("true", 4) ? 1 : 0; lex.advance(); if (!expect(Tok::Semicolon, "expected ';' after the member declaration")) return; @@ -940,7 +971,9 @@ struct Parser { // def is int32_t on the record so a member's whole initializer survives; casting it // narrower here truncated `uint16_t phase = 1000;` to 232. Invisible to a test that // observes through setRGB, because the error is always a multiple of 256. - members[memberCount] = {name, 0, 255, static_cast<int32_t>(def), + // Range zeroed for the reason the array path gives: the control's range comes from + // addControl at run time, not from the declaration. + members[memberCount] = {name, 0, 0, static_cast<int32_t>(def), static_cast<uint8_t>(nameLen), type, static_cast<uint8_t>(at), 1}; memberBytes = static_cast<uint8_t>(at + need); diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index c9b2bcd9..5292ab43 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -191,10 +191,9 @@ struct DeclaredControl { uint8_t nameLen = 0; // length (the source is not NUL-terminated per token) CtrlType type = CtrlType::Int; // Byte offset into the controls arena, assigned as a running CURSOR in declaration order. Not - // the declaration index: a scalar costs a whole 4-byte slot and an array costs count * -// element width, so the - // n-th member is no longer at byte n. Everything downstream (the bindings' cached slot - // pointers, persistence, addUint8's by-reference argument) already keys on this offset, which + // the declaration index: a scalar costs a whole 4-byte slot and an array costs count * element + // width, so the n-th member is no longer at byte n. Everything downstream (the bindings' + // cached slot pointers, persistence, addControl's by-reference argument) keys on this offset, // is why widening a member does not reach any of them. uint8_t offset = 0; // Elements: 1 for a scalar, the length for an array. Total bytes is count * ctrlWidth(type). diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index 84c5e981..6d8eb81f 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -240,16 +240,28 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee // The shift amount is an immediate 1..31. A zero shift is a no-op the front end never // emits (Xtensa cannot even encode it: slli's field holds 32-n). case IrOp::Shl: + // A shift of 0 is a move; anything outside 1..31 has no encoding and must REFUSE + // rather than silently become one, which is the stance Xtensa's shrImm takes for + // the same reason: a wrong constant that still runs is the worst outcome. if (op.imm > 0 && op.imm < 32) a.shlImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); - else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + else if (op.imm == 0) { if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); } + else a.shlImm(reg(op.dst), reg(op.a), 32); // no encoding: the assembler refuses break; case IrOp::Shr: + // A shift of 0 is a move; anything outside 1..31 has no encoding and must REFUSE + // rather than silently become one, which is the stance Xtensa's shrImm takes for + // the same reason: a wrong constant that still runs is the worst outcome. if (op.imm > 0 && op.imm < 32) a.shrImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); - else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + else if (op.imm == 0) { if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); } + else a.shrImm(reg(op.dst), reg(op.a), 32); // no encoding: the assembler refuses break; case IrOp::Sar: + // A shift of 0 is a move; anything outside 1..31 has no encoding and must REFUSE + // rather than silently become one, which is the stance Xtensa's shrImm takes for + // the same reason: a wrong constant that still runs is the worst outcome. if (op.imm > 0 && op.imm < 32) a.sarImm(reg(op.dst), reg(op.a), uint8_t(op.imm)); - else if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); + else if (op.imm == 0) { if (op.dst != op.a) a.movReg(reg(op.dst), reg(op.a)); } + else a.sarImm(reg(op.dst), reg(op.a), 32); // no encoding: the assembler refuses break; // A real register move, NOT add-immediate-zero: Xtensa's addi.n cannot encode 0, since // the ISA reuses that slot for -1, so `dst = a + 0` silently computed a - 1. A loop diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index dc0fa563..103f897a 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -469,7 +469,7 @@ using AddLightFn = void (*)(void* ctx, uint16_t x, uint16_t y, uint16_t z); /// and a third would mean a genuinely new concurrency story rather than a bigger table. struct AddLightSink { AddLightFn fn = nullptr; void* ctx = nullptr; }; -/// Where a running `defineControls()` sends each `addUint8` / `addUint16`. Same shape and same +/// Where a running `defineControls()` sends each `addControl`. Same shape and same /// reason as the addLight sink: a builtin has no receiver, so the binding installs one for the /// duration of the run and the call reaches the engine through it. /// diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index 5ba6c315..1ad6e9ee 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -165,6 +165,13 @@ class MoonLiveScript { // the low byte would be at offset+3 β€” no supported target is one. switch (decls[i].type) { case moonlive::CtrlType::Bool: + // NORMALIZED before the byte is ever read as a `bool`. A script's store + // truncates rather than normalizing, so a bool member can legally hold 7 + // (`flag = 7;` is ordinary arithmetic to the language), and a C++ bool object + // holding anything but 0 or 1 is undefined behaviour the moment it is read. + // One write at publish time settles it; every later write comes through + // applyControlValue's parseBool, which yields 0 or 1 by construction. + *slot = (*slot != 0) ? 1 : 0; controls.addBool(decls[i].name, *reinterpret_cast<bool*>(slot)); break; case moonlive::CtrlType::Byte: diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp index ed4954d4..693819d0 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -502,47 +502,67 @@ void HostAssembler::emitIndexed(const uint8_t* opcode, size_t opLen, bool prefix emitBytes(b, n); } -// The signed high 32 bits of a * b. A vreg holds a 32-bit value, so both operands are -// sign-extended to 64 bits before a 64-bit imul; the arithmetic shift then takes the high word. +// The signed high 32 bits of a * b, for the Q16.16 multiply. // -// rax is the scratch, exactly as call() uses it: it is the last vreg (R13) and the assembler -// already relies on saving it before borrowing it. Every OTHER volatile register (r9/r10/r11) -// is inside the vreg pool, so using one would clobber a live virtual register. +// ALIAS-SAFE BY CONSTRUCTION: both sources are read before the destination is written, and the +// only register touched is rax, saved and restored around the sequence. The intermediate lives on +// the STACK rather than in a borrowed register, because there is no register here that is safe to +// borrow: rax is vreg R13, and r10/r11 are R5/R6 β€” the FIRST temps the allocator hands out, so +// borrowing them is worse than borrowing rax, not better. Two earlier attempts each picked a +// register that turned out to be allocatable, and each produced the same failure: `pop` restoring +// a stale value over the result when the destination aliased the scratch, or a source destroyed +// before it was read. A silently wrong number, not a crash. +// +// movsxd rax, aD ; push rax β€” a widened, parked +// movsxd rax, bD ; imul rax, [rsp] β€” b widened, then the 64-bit product +// sar rax, 32 ; mov dD, rax β€” the high word, into d only now +// add rsp, 8 β€” discard, without writing any register void HostAssembler::mulhi(Reg d, Reg a, Reg b) { - // BOTH sources are read into scratch BEFORE anything is written, so d may alias a, b, or the - // scratch itself. An earlier version borrowed rax around a push/pop and wrote d in the middle: - // with d == rax (which IS a vreg here β€” rax is R13, see the static_assert above) the pop then - // restored the old value over the result, and with b == rax the movsxd destroyed b before it - // was read. Neither shows on any current program, because the lowering reserves the scratch - // range; relying on that is exactly the kind of unstated precondition that breaks later. - // - // r10/r11 are pushed and popped around the sequence, so no vreg is disturbed whichever - // registers d, a and b turn out to be. const uint8_t dst = xr(d), ra = xr(a), rb = xr(b); - const uint8_t s1 = x64::R10, s2 = x64::R11; - - uint8_t push1[2] = {rex_(false, false, false, true), uint8_t(0x50 | (s1 & 7))}; - emitBytes(push1, 2); // push r10 - uint8_t push2[2] = {rex_(false, false, false, true), uint8_t(0x50 | (s2 & 7))}; - emitBytes(push2, 2); // push r11 - - uint8_t ext_a[3] = {rex_(true, s1 >= 8, false, ra >= 8), 0x63, modrm_(0b11, s1 & 7, ra & 7)}; - emitBytes(ext_a, 3); // movsxd r10, aD - uint8_t ext_b[3] = {rex_(true, s2 >= 8, false, rb >= 8), 0x63, modrm_(0b11, s2 & 7, rb & 7)}; - emitBytes(ext_b, 3); // movsxd r11, bD - uint8_t mul[4] = {rex_(true, s1 >= 8, false, s2 >= 8), 0x0F, 0xAF, - modrm_(0b11, s1 & 7, s2 & 7)}; - emitBytes(mul, 4); // imul r10, r11 - uint8_t sar[4] = {rex_(true, false, false, s1 >= 8), 0xC1, modrm_(0b11, 7, s1 & 7), 32}; - emitBytes(sar, 4); // sar r10, 32 - // The result lands in d only now, after every source has been consumed. - uint8_t mov[3] = {rex_(true, s1 >= 8, false, dst >= 8), 0x89, modrm_(0b11, s1 & 7, dst & 7)}; - emitBytes(mov, 3); // mov dD, r10 - - uint8_t pop2[2] = {rex_(false, false, false, true), uint8_t(0x58 | (s2 & 7))}; - emitBytes(pop2, 2); // pop r11 - uint8_t pop1[2] = {rex_(false, false, false, true), uint8_t(0x58 | (s1 & 7))}; - emitBytes(pop1, 2); // pop r10 + const uint8_t RAX = x64::RAX; + + uint8_t save[1] = {uint8_t(0x50 | (RAX & 7))}; + emitBytes(save, 1); // push rax (save the vreg) + + // The operand that might BE rax is widened FIRST, because the other widening overwrites rax. + // Reading a first when b == rax destroyed b before it was ever read β€” the third shape of the + // same mistake, and the reason both operands are now ordered rather than assumed independent. + const uint8_t first = (rb == RAX) ? rb : ra; + const uint8_t second = (rb == RAX) ? ra : rb; + + uint8_t ext1[3] = {rex_(true, RAX >= 8, false, first >= 8), 0x63, + modrm_(0b11, RAX & 7, first & 7)}; + emitBytes(ext1, 3); // movsxd rax, <first> + uint8_t park[1] = {uint8_t(0x50 | (RAX & 7))}; + emitBytes(park, 1); // push rax (park it) + + uint8_t ext2[3] = {rex_(true, RAX >= 8, false, second >= 8), 0x63, + modrm_(0b11, RAX & 7, second & 7)}; + emitBytes(ext2, 3); // movsxd rax, <second> + // imul rax, [rsp] β€” the parked a. modrm mod=00 rm=100 selects a SIB byte; the SIB names rsp + // as base with no index, which is how [rsp] is addressed. + uint8_t mul[5] = {rex_(true, RAX >= 8, false, false), 0x0F, 0xAF, + modrm_(0b00, RAX & 7, 0b100), sib_(0, 0b100, x64::RSP & 7)}; + emitBytes(mul, 5); + uint8_t sar[4] = {rex_(true, false, false, RAX >= 8), 0xC1, modrm_(0b11, 7, RAX & 7), 32}; + emitBytes(sar, 4); // sar rax, 32 + + // Both sources are spent; only now does d take the result. + uint8_t mov[3] = {rex_(true, RAX >= 8, false, dst >= 8), 0x89, modrm_(0b11, RAX & 7, dst & 7)}; + emitBytes(mov, 3); // mov dD, rax + + uint8_t drop[4] = {rex_(true, false, false, false), 0x83, modrm_(0b11, 0, x64::RSP & 7), 8}; + emitBytes(drop, 4); // add rsp, 8 (discard a) + // The saved rax is restored LAST, and into rax only β€” if d IS rax the mov above already put + // the result there, so this would overwrite it. Pop into rax is therefore skipped in that + // case and the stack adjusted instead. + if (dst == RAX) { + uint8_t skip[4] = {rex_(true, false, false, false), 0x83, modrm_(0b11, 0, x64::RSP & 7), 8}; + emitBytes(skip, 4); // add rsp, 8 + } else { + uint8_t rest[1] = {uint8_t(0x58 | (RAX & 7))}; + emitBytes(rest, 1); // pop rax + } } // 32-bit shifts: C1 /4 ib is shl, C1 /7 ib is sar. No REX.W β€” a vreg is 32 bits, and the // arithmetic shift must fill from bit 31, not bit 63. diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index 845bf91c..d969ea34 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -202,9 +202,18 @@ void RiscvAssembler::addReg(Reg d, Reg a, Reg b) { emit32(encAdd(xr(d), xr(a), x void RiscvAssembler::mulReg(Reg d, Reg a, Reg b) { emit32(encMul(xr(d), xr(a), xr(b))); } void RiscvAssembler::mulhi(Reg d, Reg a, Reg b) { emit32(encMulh(xr(d), xr(a), xr(b))); } -void RiscvAssembler::shlImm(Reg d, Reg a, uint8_t n) { emit32(encSlli(xr(d), xr(a), n)); } -void RiscvAssembler::sarImm(Reg d, Reg a, uint8_t n) { emit32(encSrai(xr(d), xr(a), n)); } -void RiscvAssembler::shrImm(Reg d, Reg a, uint8_t n) { emit32(encSrli(xr(d), xr(a), n)); } +void RiscvAssembler::shlImm(Reg d, Reg a, uint8_t n) { + if (n >= 32) { overflow_ = true; return; } // shamt is five bits + emit32(encSlli(xr(d), xr(a), n)); +} +void RiscvAssembler::sarImm(Reg d, Reg a, uint8_t n) { + if (n >= 32) { overflow_ = true; return; } // shamt is five bits + emit32(encSrai(xr(d), xr(a), n)); +} +void RiscvAssembler::shrImm(Reg d, Reg a, uint8_t n) { + if (n >= 32) { overflow_ = true; return; } // shamt is five bits + emit32(encSrli(xr(d), xr(a), n)); +} // The 4-byte slot access. encLw/encSw already existed for spills; these give them an arbitrary // base and offset, which is what a member slot needs. void RiscvAssembler::load32(Reg d, Reg base, int32_t imm) { emit32(encLw(xr(d), xr(base), imm)); } @@ -227,7 +236,7 @@ void RiscvAssembler::load8(Reg d, Reg base, int32_t imm) { // lbu rDst, imm(rB emit32(((uint32_t(imm) & 0xfff) << 20) | (xr(base) << 15) | (4 << 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. +// store8 and store32, which is why they share kScratchAddr. void RiscvAssembler::load8Idx(Reg d, Reg base, Reg off) { emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off emit32((uint32_t(kScratchAddr) << 15) | (4 << 12) | (xr(d) << 7) | 0x03); // lbu d, 0(t6) diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index c1037ed0..74cd00c0 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -312,6 +312,9 @@ void XtensaAssembler::mulhi(Reg d, Reg a, Reg b) { // slli aD, aA, #n : the field holds 32-n, split across bits 20-23 (high bit) and 4-7 (low // nibble). n==0 is unencodable and the lowering never asks. void XtensaAssembler::shlImm(Reg d, Reg a, uint8_t n) { + // 1..31 only: the field holds 32-n, so n==0 and n>=32 have no encoding and would emit a + // shift by some other amount. Refuse, the way shrImm below does. + if (n == 0 || n >= 32) { overflow_ = true; return; } const uint32_t k = 32u - n; emit3(((k >> 4) << 20) | 0x010000u | (uint32_t(ar(d)) << 12) | (uint32_t(ar(a)) << 8) | ((k & 0x0fu) << 4)); @@ -319,6 +322,7 @@ void XtensaAssembler::shlImm(Reg d, Reg a, uint8_t n) { // srai aD, aA, #n : arithmetic, sign-filling. The amount rides bits 8-11 (low nibble) and bit 20 // (high bit, folded into the 0x2/0x3 opcode nibble). void XtensaAssembler::sarImm(Reg d, Reg a, uint8_t n) { + if (n >= 32) { overflow_ = true; return; } // the amount field is five bits emit3(((0x2u | (uint32_t(n) >> 4)) << 20) | 0x010000u | (uint32_t(ar(d)) << 12) | ((uint32_t(n) & 0x0fu) << 8) | (uint32_t(ar(a)) << 4)); } @@ -380,7 +384,7 @@ void XtensaAssembler::load8(Reg d, Reg base, int32_t imm) { // 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. +// same temp store8 uses, and the RRI8 offset is 0 so the offset scaling never applies. void XtensaAssembler::load8Idx(Reg d, Reg base, Reg off) { emit2(uint16_t((kAddrScratch << 12) | (ar(base) << 8) | (ar(off) << 4) | 0xa)); // add.n a12, base, off const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), kAddrScratch, 0x00}; // l8ui d, a12, 0 diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp index f6d6cda6..42b1c9cb 100644 --- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp +++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp @@ -560,7 +560,7 @@ TEST_CASE("x86_64: two sequential call-bearing loops stay under the density boun TEST_CASE("x86_64: a class with a script-to-script call compiles") { const char* src = "class T {\n" - " uint8_t level = 200;\n" + " byte level = 200;\n" " paint() { setRGB(1, level, 0, 0); }\n" " tick() { setRGB(0, 7, 8, 9); paint(); }\n" "}\n"; @@ -575,42 +575,57 @@ TEST_CASE("x86_64: a class with a script-to-script call compiles") { CHECK(r.entryCount == 2); } -// The Q16.16 multiply, whose sequence must survive d aliasing a or b. +// The Q16.16 multiply. The sequence must survive d aliasing a or b, AND must not borrow any +// register the allocator can hand out. // -// This backend had NO encoding tests for the new primitives while arm64, Xtensa and RISC-V all -// gained them β€” and its mulhi borrowed rax, which IS a vreg here (R13). With d == rax the old -// pop restored the stale value over the result; with b == rax the movsxd destroyed b before it -// was read. Both are silently wrong answers, not crashes. Bytes checked against clang's own -// assembly of the same sequence. -TEST_CASE("x86_64: mulhi reads both sources before it writes its destination") { +// Two earlier versions failed that second rule: the first borrowed rax (vreg R13), the second +// r10/r11 β€” which are R5/R6, the FIRST temps the allocator assigns, so it was strictly worse. +// Both produced a silently wrong number: `pop` restoring a stale value over the result when d +// aliased the scratch, or a source destroyed before it was read. The intermediate now lives on +// the STACK and only rax is touched, saved and restored around the whole sequence. +// +// Bytes verified against clang's assembly of the same instruction sequence. +TEST_CASE("x86_64: mulhi borrows no allocatable register") { HostAssembler a; a.mulhi(R0, R1, R2); a.finalize(); const uint8_t* b = a.bytes(); - REQUIRE(a.size() >= 12); - // push r10 / push r11 open the sequence: the scratch pair is saved, so no vreg is disturbed - // whichever registers the three operands turn out to be. - CHECK(b[0] == 0x41); CHECK(b[1] == 0x52); // push r10 - CHECK(b[2] == 0x41); CHECK(b[3] == 0x53); // push r11 - // ...and pop restores them at the end, AFTER the result has been moved into d. - CHECK(b[a.size() - 4] == 0x41); CHECK(b[a.size() - 3] == 0x5b); // pop r11 - CHECK(b[a.size() - 2] == 0x41); CHECK(b[a.size() - 1] == 0x5a); // pop r10 + REQUIRE(a.size() >= 24); + // No push/pop of r10 or r11 anywhere: those encode as 41 52 / 41 53 / 41 5a / 41 5b, and a + // 0x41 REX.B prefix on a push is the tell. Their absence is the property under test. + for (size_t i = 0; i + 1 < a.size(); i++) { + const bool pushPopR8plus = (b[i] == 0x41) && + ((b[i + 1] & 0xf8) == 0x50 || (b[i + 1] & 0xf8) == 0x58); + CHECK_FALSE(pushPopR8plus); + } + CHECK(b[0] == 0x50); // opens by saving rax + // The parked operand is discarded with `add rsp, 8` β€” a stack adjust, never a pop into some + // register. The sequence then ends either with `pop rax` (d is not rax) or a second adjust. + bool sawAdjust = false; + for (size_t i = 0; i + 3 < a.size(); i++) + if (b[i] == 0x48 && b[i + 1] == 0x83 && b[i + 2] == 0xc4 && b[i + 3] == 0x08) + sawAdjust = true; + CHECK(sawAdjust); } -// The same sequence with the destination aliasing each source in turn, and with R13 (rax) in -// every position. None may produce a different shape: the result is computed in scratch and only -// then written, so aliasing cannot destroy an operand that has still to be read. -TEST_CASE("x86_64: mulhi emits the same shape however its operands alias") { - const size_t base = [] { HostAssembler a; a.mulhi(R0, R1, R2); a.finalize(); return a.size(); }(); +// The destination aliasing each source, and rax itself. None may lose an operand or its result: +// with d == rax the saved value must NOT be popped back over the answer. +TEST_CASE("x86_64: mulhi handles every aliasing of its operands") { for (const auto& regs : {std::array<Reg, 3>{R0, R0, R1}, // d aliases a std::array<Reg, 3>{R0, R1, R0}, // d aliases b std::array<Reg, 3>{R0, R0, R0}, // all three - std::array<Reg, 3>{R13, R1, R2}, // d is rax + std::array<Reg, 3>{R5, R1, R2}, // d is a low temp (r10/r9) + std::array<Reg, 3>{R0, R5, R6}, // both sources are low temps + std::array<Reg, 3>{R13, R1, R2}, // d IS rax std::array<Reg, 3>{R0, R13, R2}, // a is rax std::array<Reg, 3>{R0, R1, R13}}) // b is rax { HostAssembler a; a.mulhi(regs[0], regs[1], regs[2]); a.finalize(); - CHECK(a.size() == base); - CHECK(a.bytes()[0] == 0x41); // still opens by saving the scratch - CHECK(a.bytes()[a.size() - 1] == 0x5a); // still closes by restoring it + CHECK(a.size() >= 24); + CHECK(a.bytes()[0] == 0x50); // always saves rax first + // The stack is always balanced: one save-push, one park-push, and two 8-byte adjustments + // (or one adjustment and one pop when the destination is not rax). + int pushes = 0; + for (size_t i = 0; i < a.size(); i++) if (a.bytes()[i] == 0x50) pushes++; + CHECK(pushes >= 2); } } diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 3d7fc34d..c611aabd 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -211,6 +211,12 @@ TEST_CASE("a number too large for an int is refused rather than wrapped") { CHECK_FALSE(eng.compile(mmScript("int huge = 3000000000;\nsetRGB(0, huge, 0, 0);"), kTable, kSys)); eng.free(); + // One PAST the most negative int: the lexer admits the magnitude 2147483648 so that + // -2147483648 can be written, so the sign-aware site is what has to catch this. + moonlive::MoonLive engLow; + CHECK_FALSE(engLow.compile(mmScript("int low = -2147483649;\nsetRGB(0, low, 0, 0);"), + kTable, kSys)); + engLow.free(); moonlive::MoonLive eng2; CHECK_FALSE(eng2.compile(mmScript("int huge = 99999999999;\nsetRGB(0, huge, 0, 0);"), kTable, kSys)); @@ -932,6 +938,41 @@ TEST_CASE("a member survives a spill across the 32-bit slot access") { "g = a + b + c + d + e + f;\n" "setRGB(0, g / 100, 0, 0);"), 1)[0] == 210); } + +// The two BOUNDARY literals, in an expression rather than an initializer. Both have a magnitude +// one past their type's positive limit, so a lexer that judged the number before the sign made +// them unwritable: -2147483648 is the most negative int and -32768.0 the most negative fixed. +TEST_CASE("the most negative int and fixed values can be written in an expression") { + CHECK(render(mmScript("int n = 0;\n" + "n = -2147483648;\n" + "if (n < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); + CHECK(render(mmScript("fixed f = 0.0;\n" + "f = -32768.0;\n" + "if (f < 0) { setRGB(0, 9, 0, 0); } else { setRGB(0, 1, 0, 0); }"), + 1)[0] == 9); +} + +// A fixed multiply where the destination is also a source, and where a long chain forces the +// allocator to reuse registers. On x86-64 the emitted sequence borrows a scratch register and +// writes its destination last; an ordering mistake there returns a*a, or a stale value, rather +// than the product. Run rather than decoded, because the byte shape is what hid the bug twice. +TEST_CASE("a fixed multiply is correct when its destination aliases a source") { + // f = f * two: destination and first source are the same member. + CHECK(render(mmScript("fixed f = 1.5;\n" + "fixed two = 2.0;\n" + "f = f * two;\n" + "setRGB(0, toInt(f * toFixed(50)), 0, 0);"), 1)[0] == 150); + // A chain long enough that the allocator recycles registers between the multiplies. + CHECK(render(mmScript("fixed a = 1.5;\n" + "fixed b = 2.0;\n" + "fixed c = 0.5;\n" + "fixed d = 4.0;\n" + "fixed r = 0.0;\n" + "r = a * b * c * d;\n" // 1.5*2*0.5*4 = 6.0 + "setRGB(0, toInt(r * toFixed(20)), 0, 0);"), 1)[0] == 120); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT // Compile-only from here down: these assert DIAGNOSTICS, which the front end produces @@ -1207,3 +1248,12 @@ TEST_CASE("every shipped script compiles") { } CHECK(checked > 0); // an empty folder would pass the loop vacuously } + +// A sign has no meaning on a boolean. `-true` consumed the minus and then ignored it, seeding the +// member to 1 as though nothing had been written. +TEST_CASE("a bool initializer takes no sign") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { bool b = -true; tick() { setRGB(0, 1, 0, 0); } }", + kTable, kSys)); + eng.free(); +} diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index a5866099..4ff259f4 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -776,7 +776,7 @@ TEST_CASE("a class declaring more member data than the arena holds is refused") // A uint16_t member holds a value a byte cannot. This is the correctness wall on a 256-wide wall: // every arena slot was 8-bit, so a coordinate clamped at 255 and a modifier could not walk a light // off a large grid. The round trip is what matters: seeded wide, read wide, written wide. -TEST_CASE("a int member holds a value above 255") { +TEST_CASE("an int member holds a value above 255") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" " int big = 1000;\n" @@ -796,7 +796,7 @@ TEST_CASE("a int member holds a value above 255") { // The high byte must survive being stored and reloaded. A store that wrote only the low half would // pass the test above on the first tick and lose the value on the second, so the boundary at 256 is // checked directly: 255 -> 256 is exactly where a byte member wraps to 0 and a halfword does not. -TEST_CASE("a int member crosses the 255 boundary without wrapping") { +TEST_CASE("an int member crosses the 255 boundary without wrapping") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" " int n = 255;\n" @@ -966,7 +966,7 @@ TEST_CASE("a byte member cannot be initialized above 255") { } // The same value is legal once the member is declared wide enough to hold it. -TEST_CASE("a int member accepts an initializer a byte could not hold") { +TEST_CASE("an int member accepts an initializer a byte could not hold") { moonlive::MoonLive eng; CHECK(eng.compile("class T { int x = 300; tick() { setRGB(0, x - 300, 0, 0); } }", kCtrlTable, kSys)); eng.free(); @@ -1160,7 +1160,7 @@ TEST_CASE("setRGB still names the light it writes") { // `uint16_t phase = 1000;` started at 232 (1000 & 0xff). Every existing test observed through // setRGB, which truncates to a byte, and the error is always a multiple of 256: invisible. // Observed here through a COMPARISON instead, which the byte channel cannot hide. -TEST_CASE("a int member starts at the value it was initialized to") { +TEST_CASE("an int member starts at the value it was initialized to") { moonlive::MoonLive eng; REQUIRE(eng.compile("class T {\n" " int phase = 1000;\n" diff --git a/test/unit/core/unit_moonlive_ir.cpp b/test/unit/core/unit_moonlive_ir.cpp index 1c47ca53..1a41f126 100644 --- a/test/unit/core/unit_moonlive_ir.cpp +++ b/test/unit/core/unit_moonlive_ir.cpp @@ -169,7 +169,10 @@ TEST_CASE("MoonLive control survives a host call (kArg4 live across random16)") platform::writeExec(blk, code, r.len); auto fn = reinterpret_cast<CtrlFn>(blk); - uint8_t arena[1] = {7}; + // A member occupies a whole 4-byte SLOT and is read with a 32-bit load, so the arena has to + // hold all four bytes: a one-byte array would have the load reading past its end, and the + // index would come back as whatever followed it on the stack. + uint8_t arena[4] = {7, 0, 0, 0}; std::vector<uint8_t> buf(16 * 3, 0); fn(buf.data(), 16, 3, 0, arena); // pixel 7 is lit (its blue channel is 255), and ONLY pixel 7 (the control index held across the call)