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/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 new file mode 100644 index 00000000..0e2c0155 --- /dev/null +++ b/docs/history/plans/Plan-20260823 - Five types for MoonLive scripts.md @@ -0,0 +1,105 @@ +# 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` (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 | + +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 + +- 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, + 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 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 + +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 0ed7dc1b..32b9dea4 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,40 +1,23 @@ { -<<<<<<< HEAD - "commit": "4c91f700", -======= - "commit": "ab508402", ->>>>>>> main + "commit": "f985a366", "flash": { + "esp32s3-n16r8": 1820752, + "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": 259, + "fps": 3861 }, "esp32": { "tick_us": 2151, @@ -42,96 +25,54 @@ } }, "loc": { -<<<<<<< HEAD - "core": 19600, - "light": 25712, - "platform": 14900, - "ui": 7023, - "test": 45720, - "moondeck": 21801 + "core": 20117, + "light": 25873, + "platform": 15128, + "ui": 7047, + "test": 46792, + "moondeck": 21847 }, "comments": { "core": { - "lines": 7693, - "ratio": 0.425 -======= - "core": 19684, - "light": 25824, - "platform": 14891, - "ui": 7028, - "test": 45902, - "moondeck": 21626 - }, - "comments": { - "core": { - "lines": 7746, + "lines": 7929, "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": 5379, + "ratio": 0.39 }, "ui": { - "lines": 1861, - "ratio": 0.281 + "lines": 1874, + "ratio": 0.282 }, "test": { - "lines": 8438, - "ratio": 0.211 + "lines": 8658, + "ratio": 0.212 }, "moondeck": { - "lines": 3504, + "lines": 3529, "ratio": 0.185 } }, "tests": { - "cases": 1511, + "cases": 1565, "scenarios": 23 }, "docs": { - "md_files": 189, - "md_lines": 27631, - "plans_files": 96, - "backlog_lines": 4341, ->>>>>>> main - "lessons_lines": 576, + "md_files": 192, + "md_lines": 28089, + "plans_files": 98, + "backlog_lines": 4451, + "lessons_lines": 592, "claude_md_lines": 136 }, "complexity": { - "functions": 2716, - "over_threshold": 165, + "functions": 2745, + "over_threshold": 168, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index ad03a694..66f32fe5 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 `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. @@ -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,778 KB (+1 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 | 259 µs (+73 µs) ⚠ | 3,861 (−1,515) ⚠ | | 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,117 (+11) ⚠ | 7,929 | 42.6 % | +| light | 25,873 | 10,271 | 43.8 % | +| 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 | |---|---:| -<<<<<<< HEAD -| unit cases | 1,493 (+1) ✓ | -======= -| unit cases | 1,511 (+7) ✓ | ->>>>>>> main +| unit cases | 1,565 (+4) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,716 | -| over threshold | 165 | +| functions | 2,745 (−2) ⚠ | +| 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 | 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 01dc0a31..f122073c 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); } @@ -58,15 +58,40 @@ 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)` - (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. + 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 +131,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 +161,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 +180,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 +212,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..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. @@ -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/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 7c6408ed..82805284 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 -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: +**Members can be wider than a byte, and can be arrays.** `byte` spans 0..255; `int` spans +-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 -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..e026f67c 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. @@ -290,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(). @@ -395,6 +404,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..b3880d83 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -23,17 +23,31 @@ 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; } -constexpr bool ctrlIsSigned(CtrlType t) { return t == CtrlType::Int16; } + +/// 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; } + // Neutral inline opcodes — "store shapes a backend can emit", not "LED operations". A host maps @@ -92,11 +106,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..43b01add 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -17,7 +17,9 @@ 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; size_t identLen = 0; const char* tokBeg = nullptr; @@ -32,11 +34,50 @@ 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(int64_t& 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; + // 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++; + // 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++; + int64_t num = 0, den = 1; + while (isDigit(*p)) { + // 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++; + } + // 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; + } return true; } @@ -96,8 +137,10 @@ struct Lexer { kind = Tok::String; return; } if (isDigit(c)) { - long v = 0; readNumber(v); - number = v; kind = Tok::Number; return; + 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; } if (isIdentStart(c)) { identBeg = p; @@ -160,6 +203,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 +302,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 +389,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 +446,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(); @@ -330,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(); @@ -339,10 +512,32 @@ 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; } + // 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. + 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 +581,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 +599,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 +718,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 +829,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,15 +856,21 @@ 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 // 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++; @@ -648,24 +883,71 @@ 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; } + // `-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; + 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. + int64_t 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; } - long def = lex.number; + if (lex.number < defMin || lex.number > defMax) { fail(rangeErr); return; } + 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 @@ -679,40 +961,58 @@ 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), + // 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); 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}, + // 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; + } + // 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 +1058,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 +1088,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 +1145,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 +1207,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 +1252,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 +1338,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..5292ab43 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,17 +182,18 @@ 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 - // 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). @@ -204,10 +210,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/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..6d8eb81f 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,33 @@ 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: + // 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.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.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.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 // counter initialized through Mov therefore started at -1, the unsigned loop guard saw @@ -258,8 +285,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 +319,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..103f897a 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) { @@ -437,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. /// @@ -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..1ad6e9ee 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -144,28 +144,47 @@ 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: + // 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: + 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..693819d0 100644 --- a/src/platform/desktop/moonlive_asm_x86_64.cpp +++ b/src/platform/desktop/moonlive_asm_x86_64.cpp @@ -502,56 +502,143 @@ 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, for the Q16.16 multiply. +// +// 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) { + const uint8_t dst = xr(d), ra = xr(a), rb = xr(b); + 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 + } } -// 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) { +// 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); +} +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 +650,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..d969ea34 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,33 @@ 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) { + 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)); } +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 +235,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. +// 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) } -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..74cd00c0 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,80 @@ 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) { + // 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)); +} +// 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)); +} +// 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,42 +382,14 @@ 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. +// 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 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..cf90675b 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,24 @@ 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). 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 = (!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"); @@ -3470,6 +3481,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/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/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/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 df750c84..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 (`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 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", @@ -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..c0fe0d10 100644 --- a/test/unit/core/unit_moonlive_codegen_riscv.cpp +++ b/test/unit/core/unit_moonlive_codegen_riscv.cpp @@ -57,28 +57,45 @@ 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); + 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..42b1c9cb 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) @@ -559,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"; @@ -574,6 +575,60 @@ TEST_CASE("x86_64: a class with a script-to-script call compiles") { CHECK(r.entryCount == 2); } +// The Q16.16 multiply. The sequence must survive d aliasing a or b, AND must not borrow any +// register the allocator can hand out. +// +// 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() >= 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 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>{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() >= 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); + } +} + } // namespace #else // not x86_64 host 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..c611aabd 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,41 @@ 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)); + // 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(); + // 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)); + eng2.free(); } TEST_CASE("compileSource: out-of-range index is bounds-rejected at runtime") { @@ -288,12 +318,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 +334,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 +350,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 +374,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 +396,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 +508,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 +595,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 +641,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 +672,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,36 +690,30 @@ TEST_CASE("a nonzero seed selects the Julia set rather than the Mandelbrot set") // The SAME pixel answers differently under the two modes, which is the whole point of the // seed: the origin is inside the Mandelbrot set (0 forever), but under Julia seed // (-0.4, 0.6) it iterates z = z*z + c from z = 0+0i and escapes with a graded count. - CHECK(render(mmScript("setRGB(0, escape(0, 0, 0, 0, 40), 7, 0);"), 1)[0] == 0); - CHECK(render(mmScript("setRGB(0, escape(0, 0, 0 - 3277, 4915, 40), 7, 0);"), 1)[0] > 0); -} - -// An int16_t member is how a script holds a value that goes below zero: a velocity, a delta, a -// distance from a center. Stored in the arena as two bytes and read back SIGN-EXTENDED, where a -// uint16_t member would return 65436 for -100. -TEST_CASE("an int16_t member written negative reads back negative") { - CHECK(render(mmScript("int16_t neg = -100; " - "if (neg < 0) { setRGB(0, 7, 0, 0); } else { setRGB(0, 3, 0, 0); }"), 1)[0] == 7); + 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); } // ASSIGNED in tick(), not just seeded by the initializer: the store and the load are different -// ops, and the bug this pins wrote only ONE byte of the two-byte member, so the sign-extending -// load read a stale high byte and every stored coordinate collapsed to 0..255. A whole shader -// rendered one flat color, and the initializer-only test above stayed green throughout. -TEST_CASE("an int16_t member assigned a negative in tick reads back negative") { - CHECK(render(mmScript("int16_t v = 0; " +// 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; " "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; " +// 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); } @@ -700,4 +744,516 @@ 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); +} +// 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); +} + +// 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 +// 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") { + // 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) { + 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(); + 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 +} + +// 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 a0567b90..4ff259f4 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("an 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("an 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" // slot 0 + " int wide = 900;\n" // slot 4: a byte costs a whole slot too + " byte after = 2;\n" // slot 8 " 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("an 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("an 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..1a41f126 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,14 +162,17 @@ 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); 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) 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"