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 - .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.
-
+
-**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.
-
-
-**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:

@@ -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 ` 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**:
+
+
-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(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(*static_cast(c.ptr)));
+ return;
case ControlType::Pin: // int8_t storage; serialized as a plain integer
sink.appendf("%d", *static_cast(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(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(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(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(decls[i].def & 0xff);
- if (w == 2) ctrlArena_[at + 1] = static_cast(decls[i].def >> 8);
+ for (uint8_t b = 0; b < w; b++)
+ ctrlArena_[at + b] = static_cast((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(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(def & 0xff);
- if (ctrlWidth(type) == 2) ctrlArena_[offset + 1] = static_cast(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((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(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(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(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(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(nameLen), type,
static_cast(at), count};
memberBytes = static_cast(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(b),
+ static_cast(nameLen), type,
+ static_cast(bat), 1};
+ memberBytes = static_cast(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(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(def),
static_cast(nameLen), type,
static_cast(at), 1};
memberBytes = static_cast(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(uint32_t(args[0]));
const int32_t b = static_cast(uint32_t(args[1]));
+ if (b == 0)
+ return static_cast(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(INT32_MAX);
return static_cast(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(uint32_t(args[0]));
+ const int32_t b = static_cast(uint32_t(args[1]));
+ if (b == 0)
+ return static_cast(a > 0 ? INT32_MAX : a < 0 ? INT32_MIN : 0);
+ const int64_t q = (static_cast(a) << 16) / b;
+ if (q > INT32_MAX) return static_cast(INT32_MAX);
+ if (q < INT32_MIN) return static_cast(INT32_MIN);
+ return static_cast(static_cast(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(static_cast(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(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(args[1]),
- static_cast(args[2]), static_cast(args[3]), type);
+ if (lo > hi) return 0;
+ s.fn(s.ctx, name, static_cast(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((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(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(slot),
- decls[i].min, decls[i].max);
- } else {
- controls.addUint8(decls[i].name, *slot,
- static_cast(decls[i].min),
- static_cast(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(slot));
+ break;
+ case moonlive::CtrlType::Byte:
+ controls.addUint8(decls[i].name, *slot,
+ static_cast(decls[i].min),
+ static_cast(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(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,
+ 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,
+ // 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(static_cast(static_cast(imm))));
+ return;
+ }
if (imm < 0) {
const uint32_t f = static_cast(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(imm) & 0xffff;
+ const uint32_t v = static_cast(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
+#include
+
+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
#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{R0, R0, R1}, // d aliases a
+ std::array{R0, R1, R0}, // d aliases b
+ std::array{R0, R0, R0}, // all three
+ std::array{R5, R1, R2}, // d is a low temp (r10/r9)
+ std::array{R0, R5, R6}, // both sources are low temps
+ std::array{R13, R1, R2}, // d IS rax
+ std::array{R0, R13, R2}, // a is rax
+ std::array{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
+#include
+#include
#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(1000 & 0xff);
slot[1] = static_cast(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& 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(blk);
std::vector 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(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 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 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 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 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 p = place(mmScriptAs("placeLights", "uint8_t cols = 4;\n"
+ std::vector 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"