From 426d5cc4a914e07f052ab90294119da253ff5df0 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 18 Aug 2026 16:20:52 +0200 Subject: [PATCH 1/4] A MoonLive script declares its controls by calling addUint8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control was a COMMENT that changed behaviour (`uint8_t bpm = 30; // @control 1..240`), which is not C and does not resemble the compiled module a script stands in for. It is a call now, the same one a compiled module makes: `defineControls() { addUint8("bpm", bpm, 1, 240); }`. Every class-scope declaration is a member, and a control is a member the script chose to surface. Perf: no per-tick cost. defineControls runs once after a compile and emits nothing per frame. Flash +1888 bytes on the classic ESP32 (+0.11%), +1856 S3, +1488 S31/P4, +672 desktop. Net -192 bytes RAM per engine (three per pipeline) from deleting a name pool the run-time path made redundant. Core - Declarations are MEMBERS: arena-resident, seeded once from their initializer, visible in every function, surviving every call. A member no addUint8 names is the script's own state, which is what a stateful effect will need. - IrOp::ConstPtr and movPtr(Reg, const void*) on all three assemblers: a full-width address into a register. IrInst::imm is int32_t, so a pointer cannot ride an immediate. Each backend generalizes the address load its own call() already emits, rather than adding a mechanism. - An engine-owned string pool. Control::name is a HELD pointer the UI reads on every /api/state, and the script source is freed when the compile returns, so a label is interned into memory that outlives both. - byRef and byStr on the builtin descriptor: which argument is a member (passed as its arena offset, so the script reads as the reference a compiled module passes) and which must be a quoted name. Per-parameter attributes on the declaration, driving a generic parser, rather than name-checks inside it. - A FAILED recompile drops its controls instead of blanking their names. The string pool was cleared before the compile could fail, leaving published records pointing into it: every card came back named "", and a name is the key for both persistence and POST /api/control, so a typo silently unbound the user's own sliders. freeCode now owns the declared set, the way it already owns the entry table for the same reason. - addDeclaredControl clamps the LIVE ARENA BYTE when a range narrows, not just the record. The native code reads the byte every tick, so clamping the record alone left an out-of-range value driving the effect under a slider that could not reach it. - A second addUint8 on one member is refused: two cards would write one byte, each overwriting the other. - The compile-time control path is DELETED end to end. It was dead the moment a control became a run-time declaration, and it took ctrlNames_ with it. Light domain - addUint8 is an ordinary builtin in the same table as setRGB, reaching the engine through a thread-local sink exactly as addLight does. - runDefineControls: the binding calls the entry point after a successful compile and before rebuildControls, mirroring the Scheduler's setup -> defineControls -> prepare. - releaseIfEmpty sees all three sink halves. It checked the addLight sink and the canvas but not the control sink, so a slot could be released while live and hand this engine's context to the next claiming thread. Scripts/MoonDeck - All 16 shipped scripts converted. emit_isa.cpp supplies a string pool, without which disasm.py refused every converted script. Tests - A control declared by calling addUint8, with a member the UI never shows; a range built from an EXPRESSION (`addUint8("speed", speed, base, base * 4 + 5)`) rather than literals, which is what proves addUint8 is an ordinary call and not a special case; a broken script dropping its controls rather than blanking them. Each control-checked by reverting its fix. - The @control form is gone from every test. The wrapper briefly translated it, which was removed too: a test that writes a syntax the language does not have teaches the wrong thing. Docs/CI - The three catalog pages and moonlive/README describe the call form, with the member-versus-control split stated where a reader meets it first. - Plan-20260817: step 2 done, and step 3 corrected. Half of "typed members" arrived here, because a control turned out to BE a member the UI shows; what remains is an assignment statement and types wider than a byte. Reviews - πŸ‘Ύ Reviewer (Fable), on orthogonality, industry-standardness and cost. Judged orthogonal: byRef/byStr are the construct LLVM uses for per-parameter attributes, hot path unaffected, movPtr justified, and the l32r rejection sound because place() copies the block so PC-relative literals would need a relocation pass. All findings applied; the blanked-name bug above was its headline and was reproduced before fixing. Verified on all four boards: S3 and classic (Xtensa), S31 and P4 (RISC-V), each running a scripted layout and effect with the controls their addUint8 calls declare; the P4 additionally a modifier. NOTE: the P4 logged one Cache error panic during a script switch and has run clean since (134 fps, several windows). One observation is not enough to attribute or exonerate, so it is recorded here rather than called clean. Co-Authored-By: Claude Opus 5 (1M context) --- ...20260817 - MoonLive scripts are classes.md | 137 ++++++++++-- docs/metrics/repo-health.json | 50 ++--- docs/metrics/repo-health.md | 36 +-- docs/moonmodules/light/MoonLiveEffect.md | 26 ++- docs/moonmodules/light/MoonLiveLayout.md | 16 +- docs/moonmodules/light/MoonLiveModifier.md | 2 +- moondeck/moonlive/emit_isa.cpp | 7 +- moonlive/README.md | 13 +- moonlive/effects/crosshair.mlv | 6 +- moonlive/effects/lines.mlv | 6 +- moonlive/effects/plasma.mlv | 9 +- moonlive/effects/ripples.mlv | 9 +- moonlive/layouts/diagonal.mlv | 6 +- moonlive/layouts/grid.mlv | 9 +- moonlive/layouts/lattice.mlv | 12 +- moonlive/layouts/reversed-row.mlv | 6 +- moonlive/layouts/ring.mlv | 9 +- moonlive/layouts/rose.mlv | 9 +- moonlive/layouts/two-rows.mlv | 6 +- moonlive/modifiers/shift.mlv | 6 +- src/core/moonlive/MoonLive.cpp | 54 +++-- src/core/moonlive/MoonLive.h | 49 ++++- src/core/moonlive/MoonLiveBuiltins.h | 13 ++ src/core/moonlive/MoonLiveCompiler.cpp | 206 ++++++++++++------ src/core/moonlive/MoonLiveCompiler.h | 22 +- src/core/moonlive/MoonLiveIr.h | 12 +- src/core/moonlive/MoonLiveSpill.cpp | 1 + src/core/moonlive/moonlive_lower.h | 3 +- src/light/moonlive/MoonLiveBuiltins_light.h | 88 +++++++- src/light/moonlive/MoonLiveEffect.h | 6 +- src/light/moonlive/MoonLiveLayout.h | 6 +- src/light/moonlive/MoonLiveModifier.h | 4 + src/platform/desktop/moonlive_asm_host.cpp | 15 ++ src/platform/desktop/moonlive_asm_host.h | 1 + src/platform/esp32/moonlive_asm_riscv.cpp | 13 ++ src/platform/esp32/moonlive_asm_riscv.h | 1 + src/platform/esp32/moonlive_asm_xtensa.cpp | 26 +++ src/platform/esp32/moonlive_asm_xtensa.h | 1 + .../scenario_MoonLiveEffect_controls.json | 12 +- test/unit/core/moonlive_device_codegen.inc | 11 +- test/unit/core/moonlive_structural.inc | 2 +- test/unit/core/unit_JsonUtil_parse.cpp | 2 +- test/unit/core/unit_moonlive_compiler.cpp | 100 +++++---- test/unit/core/unit_moonlive_fill.cpp | 44 +++- test/unit/core/unit_moonlive_ir.cpp | 6 +- test/unit/core/unit_moonlive_spill.cpp | 15 +- test/unit/light/unit_MoonLiveLayout.cpp | 42 ++-- test/unit/light/unit_MoonLiveScripts.cpp | 24 +- 48 files changed, 866 insertions(+), 293 deletions(-) diff --git a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md index c946b2f2..73ff891f 100644 --- a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md +++ b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md @@ -298,19 +298,68 @@ than being retrofitted into a language still moving underneath it. registers and `bl` leaves them alone, so removing the fix fails no test there while crashing an S3. The boards are the only check for that class. -2. ⬜ **Typed script-level members**, per *Where script-level state lives* above: a variable declared inside the - class but outside any function lives in the arena, is visible in every function, is initialised - once and survives every call. Scalars first, with the storage designed so a struct and an array - can follow without moving anything. This is what makes a stateful effect (fire, trails, decay) - expressible at all, so it is worth landing on its own and measuring before anything is built on - it. - -3. ⬜ **`defineControls()`, replacing the `// @control` comment.** A control is declared by calling - `addUint8("bpm", 30, 1, 240)` inside a `defineControls()` the script defines, the same call a - compiled module makes. Today's form is a COMMENT that changes behaviour, which is not C and does - not resemble the thing it imitates; the lexer's `ControlAnno` token and its capture path go away - with it. Comes after step 1 because it IS a function, and after step 2 because the control it - declares is a member. The shipped scripts and the three docs move with it. +2. βœ… **`defineControls()`, replacing the `// @control` comment.** Done: a control is declared by + calling `addUint8("bpm", bpm, 1, 240)` inside a `defineControls()` the script defines, the same + call a compiled module makes. The `ControlAnno` token and its capture path are gone, all 16 + shipped scripts and the four docs moved with it, and both boards run the new form. + + **It is ORDINARY CODE, which took more than the syntax swap this step first looked like.** + `defineControls` is a function the binding CALLS after a successful compile, the way the + Scheduler calls a compiled module's; `addUint8` is a builtin in the same table as `setRGB`, + reaching the engine through a sink as `addLight` does. A compile-time reading of the arguments + was built first and rejected: it would have made `addUint8` the one call in the language whose + arguments must be literals, which is a special case wearing a disguise. `addUint8("speed", + speed, base, base * 4 + 5)` works, and a test pins it. + + That required three things the step did not anticipate: + + - **`IrOp::ConstPtr` and `movPtr` on all three backends.** A label is a pointer and `IrInst::imm` + is `int32_t`, so an address cannot ride an immediate. Each backend already materializes one for + a host call's target (arm64 movz + 3x movk, RISC-V lui + addi, Xtensa a byte at a time), so + this generalizes a proven sequence rather than adding a mechanism. + - **An engine-owned string pool.** `Control::name` is a HELD pointer the UI dereferences on every + `/api/state`, and the source buffer is freed when the compile returns, so a literal is interned + into memory that outlives both. In the engine rather than the exec block: that block is IRAM on + a device, which takes 32-bit stores only. + - **`byRef` and `byStr` on the builtin descriptor.** Which argument is a member (passed as its + arena offset, so the script reads as the reference a compiled module passes) and which must be + a quoted name. Stated per builtin rather than special-cased by name in the parser. `byStr` + came from a test: `addUint8(s, s, 0, 9)` compiled, reading the member's VALUE as the label and + handing the host a pointer built from a colour byte. + + **SWAPPED with typed members, which this plan originally put first.** The stated reason for the + old order was that "the control it declares is a member", so members had to exist to declare one + against. Building it showed the dependency runs the other way. Every class-scope declaration is a + member; whether the UI shows one is a separate question `defineControls()` answers. While + `@control` is still the marker, the member rule has to be written in terms of a comment that this + step deletes, so members built first would be built against a discriminator with no future, and + step 3 would spend its budget unpicking that rather than on itself. Starting a member's WIP + against the annotation is what surfaced this: the declaration rule kept wanting to ask a question + the next step abolishes. + +3. ⬜ **Typed script-level members**, per *Where script-level state lives* above. + + **Half of this arrived with step 2**, because a control turned out to BE a member the UI shows. + A variable declared in the class body already lives in the arena, is visible in every function, + is seeded once from its initializer and survives every call, and a member no `addUint8` names is + already private state. What is missing is that a script cannot WRITE one, which is what makes it + state rather than a constant. + + So what remains is: + + - **An assignment statement.** `x = expr;` is reachable only inside a `for` header today, so a + member can be declared and read and never written. `IrOp::StoreCtrl` and its lowering exist + (built during step 2's first attempt and set aside when the steps swapped); the grammar does + not. What may be assigned to is the rule worth stating: a member and a script-local may; a + control and a system variable may not, because the UI and the host own those and a script + store would be overwritten unpredictably. + - **Types wider than a byte, and aggregates.** Scalars work; a `Coord3D` or an array does not. + The storage decision is settled (scalars in the arena, per-light arrays as a `ScratchBuffer`), + so this is implementing it rather than deciding it. An element COUNT is already on the member + record, 1 for a scalar, so a wider type is a wider record rather than a second mechanism. + + This is what makes a stateful effect (fire, trails, decay) expressible at all, and it is the one + step where a hot-path regression is plausible, so `collect_kpi.py` runs against it. 3b. βœ… **A frame per FUNCTION, not per program.** Done: each function emits its own prologue and epilogue, the host arguments are parked per function (they were spilling into a frame that did @@ -430,6 +479,37 @@ mechanism or a language people build with. This is a correctness wall on exactly the installations worth demonstrating on, and it touches the same typed-storage decision as steps 2 and 8, so those three want to agree with each other. +### Strings: literals yes, a String TYPE not yet + +`IrOp::ConstPtr` gives a script string LITERALS as arguments, which is what `addUint8("bpm", bpm, +1, 120)` needs: the text is interned into the compiled program and the emitted code carries a +pointer that outlives the source buffer, the same lifetime answer the engine already gives control +and entry-point names. + +A String TYPE is deliberately NOT next, and the reason is what the light domain measures rather +than taste. Every one of the 52 compiled effects mentions `const char*`, and every use is +metadata: `name()`, `tags()`, a control label. Not one manipulates text while rendering. So +strings here are a declaration-time concern, which literals cover. + +**The first thing literals buy, beyond a control name, is a real debugger.** `print(v)` writes +`[script] 42` and nothing about which value that was, so debugging a script means printing several +numbers and inferring which line each came from. `printf("y=%d x=%d\n", y, x)` is one more builtin +on the same table and the same call path once a string can be an argument, and it makes the one +script-level debugging tool actually usable. + +It must be OUR formatter, not a `std::printf` passthrough. The format string comes from a script, +which is the textbook format-string vulnerability: `%s` against an integer argument dereferences a +wild pointer and `%n` writes memory. Walking the format ourselves and accepting `%d`/`%u`/`%x`/`%%` +against arguments that are known to be integers removes the class rather than documenting it. It +also keeps the existing print budget, which is what stops a serial write from sitting on the render +tick. + +What a mutable String would additionally need is the hard half: somewhere to put bytes a script +assigns at run time, a length convention, and comparison/concatenation as builtins. That is the +same storage-and-ceiling question arrays face in step 8, so the two want one answer rather than +two. The one concrete use case is a text overlay in a showcase effect, and that can go a long way +on literals plus the numeric vocabulary already present. + 10. ⬜ **The editing loop, which is the thing people will actually see.** Editing a script means the File Manager today: find the file, edit it, save it, then re-name it on the module. The demo is live authoring, and that wants an editor on the module's own card, saving to the same file the @@ -485,9 +565,10 @@ therefore needs a host test that proves the semantics and a bench run that prove 3. **A member written by one function and read by another**, and a member that survives across `tick()` calls (step 2). The second is what a stateful effect depends on and is not provable by inspection. -4. **The same script at the host's real budget and a squeezed one renders identical pixels.** The +4. βœ… **The same script at the host's real budget and a squeezed one renders identical pixels.** The predecessor plan's technique, still the only way the register work is testable off hardware, and - every new construct has to keep passing it. + every new construct has to keep passing it. Holds after `ConstPtr` joined the lowering, which is + the check that matters: a new op that disturbed the allocator would show up here first. 5. βœ… **Recursion depth degrades visibly** (step 1): a script that recurses without bound keeps the device rendering rather than resetting it. Pinned by `a script that recurses without end keeps rendering instead of resetting`, which also re-runs the script to prove the counter unwinds: a @@ -499,11 +580,27 @@ therefore needs a host test that proves the semantics and a bench run that prove channel from the emitted block back to the binding, which does not exist yet: worth having, and left for the step that gives scripts a diagnostic path. 6. **An arena ceiling reports a compile error** (step 8), not a failed allocation at run time. -7. **The bench, on all four boards**, after each step: S3 and classic (Xtensa), P4 and S31 (RISC-V), - a scripted layout and a scripted effect. Exec-block sizes compared against the previous step, since - an unexplained jump is the cheapest signal that codegen went wrong. -8. **`collect_kpi.py` after step 2**, because members change how EVERY variable is accessed. That is - the one step where a hot-path regression is plausible, so it is measured rather than assumed. +7. 🟑 **The bench, on all four boards**, after each step: S3 and classic (Xtensa), P4 and S31 + (RISC-V), a scripted layout and a scripted effect. Exec-block sizes compared against the previous + step, since an unexplained jump is the cheapest signal that codegen went wrong. + + After step 2: **S3 and S31 done**, one board per ISA, each running a scripted layout + (`grid.mlv`), effect (`plasma.mlv`) and modifier with the controls their `addUint8` calls + declare. The classic and the P4 are NOT done, so the step is verified per ISA rather than per + board. Note a device keeps its scripts across a flash, so a board tests the new syntax only once + the converted files are uploaded to it: the S31 was still running the annotated `grid.mlv` after + its firmware was current. + + Exec blocks at this step, for the next one to compare against: + + | script | Xtensa | RISC-V | + |---|---:|---:| + | `grid.mlv` | 499 B | 880 B | + | `plasma.mlv` | 1378 B | 2644 B | +8. **`collect_kpi.py` after typed members** (now step 3), because members change how EVERY variable + is accessed. That is the one step where a hot-path regression is plausible, so it is measured + rather than assumed. It moved with the step when 2 and 3 swapped: `defineControls()` runs once + after a compile and emits nothing per tick, so it has no hot path to regress. ## Deliberately not in this plan diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index ff4de606..c6ff9b54 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,13 +1,13 @@ { - "commit": "2f4c292f", + "commit": "8be2dfb9", "flash": { - "esp32": 1726144, - "esp32p4-eth": 1615872, + "esp32": 1728032, + "esp32p4-eth": 1617360, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1764928, + "esp32s3-n16r8": 1766784, "esp32s3-n8r8": 1753232, - "esp32s31": 2037728, - "desktop": 1157176, + "esp32s31": 2039216, + "desktop": 1157848, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, @@ -15,8 +15,8 @@ }, "perf": { "desktop": { - "tick_us": 133, - "fps": 7518 + "tick_us": 263, + "fps": 3802 }, "esp32": { "tick_us": 2151, @@ -24,24 +24,24 @@ } }, "loc": { - "core": 18392, - "light": 24653, - "platform": 13309, + "core": 18579, + "light": 24743, + "platform": 13369, "ui": 6468, - "test": 42534, - "moondeck": 20835 + "test": 42616, + "moondeck": 20945 }, "comments": { "core": { - "lines": 7099, - "ratio": 0.42 + "lines": 7237, + "ratio": 0.423 }, "light": { - "lines": 9613, + "lines": 9659, "ratio": 0.431 }, "platform": { - "lines": 4727, + "lines": 4750, "ratio": 0.392 }, "ui": { @@ -49,28 +49,28 @@ "ratio": 0.274 }, "test": { - "lines": 7569, - "ratio": 0.205 + "lines": 7598, + "ratio": 0.206 }, "moondeck": { - "lines": 3357, + "lines": 3373, "ratio": 0.184 } }, "tests": { - "cases": 1357, + "cases": 1360, "scenarios": 23 }, "docs": { "md_files": 179, - "md_lines": 25049, + "md_lines": 25225, "plans_files": 92, - "backlog_lines": 3685, - "lessons_lines": 503, + "backlog_lines": 3683, + "lessons_lines": 549, "claude_md_lines": 135 }, "complexity": { - "functions": 2538, + "functions": 2548, "over_threshold": 158, "worst_ccn": 108 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index f62cb1fb..b317f270 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `2f4c292f`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `8be2dfb9`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,59 +8,59 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,130 KB (+0 KB) ⚠ | -| esp32 | 1,686 KB (+3 KB) ⚠ | +| desktop | 1,131 KB (+1 KB) ⚠ | +| esp32 | 1,688 KB (+2 KB) ⚠ | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | | esp32-wrover | 1,724 KB | -| esp32p4-eth | 1,578 KB (+2 KB) ⚠ | +| esp32p4-eth | 1,579 KB (+1 KB) ⚠ | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,724 KB (+3 KB) ⚠ | +| esp32s3-n16r8 | 1,725 KB (+2 KB) ⚠ | | esp32s3-n8r8 | 1,712 KB | -| esp32s31 | 1,990 KB (+2 KB) ⚠ | +| esp32s31 | 1,991 KB (+1 KB) ⚠ | | qemu | 1,287 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 133 Β΅s (+1 Β΅s) ⚠ | 7,518 (βˆ’57) ⚠ | +| desktop | 263 Β΅s (+130 Β΅s) ⚠ | 3,802 (βˆ’3,716) ⚠ | | esp32 | 2,151 Β΅s | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 18,392 (+247) ⚠ | 7,099 | 42.0 % (+0.4 %) ⚠ | -| light | 24,653 | 9,613 | 43.1 % | -| platform | 13,309 (+173) ⚠ | 4,727 | 39.2 % (+0.4 %) ⚠ | +| core | 18,579 (+187) ⚠ | 7,237 | 42.3 % (+0.3 %) ⚠ | +| light | 24,743 (+90) ⚠ | 9,659 | 43.1 % | +| platform | 13,369 (+60) ⚠ | 4,750 | 39.2 % | | ui | 6,468 | 1,670 | 27.4 % | -| test | 42,534 (+113) ⚠ | 7,569 | 20.5 % | -| moondeck | 20,835 (+5) ⚠ | 3,357 | 18.4 % | +| test | 42,616 (+82) ⚠ | 7,598 | 20.6 % (+0.1 %) ⚠ | +| moondeck | 20,945 (+110) ⚠ | 3,373 | 18.4 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,357 (+5) βœ“ | +| unit cases | 1,360 (+3) βœ“ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,538 (+7) βœ“ | +| functions | 2,548 (+10) βœ“ | | over threshold | 158 | -| worst CCN | 108 (+3) ⚠ | +| worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| | markdown files | 179 | -| markdown lines | 25,049 (+77) ⚠ | +| markdown lines | 25,225 (+176) ⚠ | | plan files | 92 | -| backlog lines | 3,685 | -| lessons lines | 503 | +| backlog lines | 3,683 (βˆ’2) βœ“ | +| lessons lines | 549 (+46) ⚠ | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 42e43a2f..3ffe7729 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -26,21 +26,31 @@ The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random1 ## Controls - `script` β€” the file name under `/moonlive/`, e.g. `lines.mlv`. A fresh module has none: it reports `no script β€” set the script name` and renders nothing, rather than every new module compiling the same default. Naming one (or re-naming it after an edit) recompiles live: 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 fixed (the script-editor loop, robust + no reboot). The directory is created on demand. -- **Scripted controls** β€” a script declares a tunable variable with a range annotation, and the engine surfaces it as a real `uint8` 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 `addUint8` inside a `defineControls()`, the same call a compiled module makes. Each becomes a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c class SpeedyEffect { - uint8_t speed = 50; // @control 0..99 β†’ a "speed" slider, default 50, range 0..99 - uint8_t hue = 128; // @control 0..255 + uint8_t speed = 50; + uint8_t hue = 128; + uint8_t phase = 0; // a member, not a control: the UI never shows it - tick() { setRGB(speed, hue, 0, 255); } + defineControls() { + addUint8("speed", speed, 0, 99); + addUint8("hue", hue, 0, 255); + } + + tick() { setRGB(speed, hue, phase, 255); } } ``` - A declared variable sits in the class body, not inside a function: it is a member, which is what - lets the UI bind to it and what will let one function set a value another reads. + A declaration sits in the class body, not inside a function: it is a **member**, visible in every + function and surviving every call. That is the whole of what a declaration means, and whether the + 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)`. 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. - Declaring the variable is what **creates** the control: `uint8_t = ;` becomes a `` slider (default ``, range `0..255`). The trailing `// @control ..` only **adjusts that control's range**; it's optional. A declared name used in a statement reads the control's **current** value. 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 file 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. Stage 1 is `uint8` only. + `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. Stage 1 is `uint8` only. ### System variables β€” what the engine hands a script @@ -97,7 +107,7 @@ Two rules a script author meets: ### Wire contract β€” control declaration -The controls are **derived from the script** (one per declared `uint8` control; the optional `@control` annotation only refines a control's range), 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 `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`. ## Pieces diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index 23bd6b93..a7185c97 100644 --- a/docs/moonmodules/light/MoonLiveLayout.md +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -12,8 +12,13 @@ The script places every light itself, with a loop. That is the difference from a ```c class GridLayout { - uint8_t cols = 16; // @control 1..64 - uint8_t rows = 16; // @control 1..64 + uint8_t cols = 16; + uint8_t rows = 16; + + defineControls() { + addUint8("cols", cols, 1, 64); + addUint8("rows", rows, 1, 64); + } placeLights() { for (y = 0; y < rows; y = y + 1) { @@ -44,8 +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 -uint8_t count = 24; // @control 3..255 -uint8_t radius = 5; // @control 1..127 +// (`count` and `radius` are members, surfaced by addUint8 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); @@ -54,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; // @control 1..64` becomes a real slider in the UI, and the loop reads it β€” which is how a panel gets resized without editing code. +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 member no `addUint8` 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. @@ -83,7 +87,7 @@ So it runs twice. On the first pass `addLight` counts; on the second it emits ea |---|---| | `script` | the file name under `/moonlive/`; naming it (or re-naming it after an edit) recompiles and re-places the lights live | -Plus one control per `@control` the script declares. +Plus one control per `addUint8` in the script's `defineControls()`. Editing any of them rebuilds the pipeline, because every one can change where the lights are. A script that fails to compile leaves a fixture with no lights, shows the parse error on the module, and the device keeps running. diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index 3540e764..43394bed 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -53,7 +53,7 @@ It is for debugging and comes back out again β€” [what print costs](../../../moo |---|---| | `script` | the file name under `/moonlive/`; naming it (or re-naming it after an edit) recompiles and re-maps live | -Plus one control per `@control` the script declares β€” `uint8_t amount = 4; // @control 0..64` +Plus one control per `addUint8` in the script's `defineControls()`: `addUint8("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/moondeck/moonlive/emit_isa.cpp b/moondeck/moonlive/emit_isa.cpp index d9cadece..9b50dc45 100644 --- a/moondeck/moonlive/emit_isa.cpp +++ b/moondeck/moonlive/emit_isa.cpp @@ -63,7 +63,12 @@ int main(int argc, char** argv) { const auto sysvars = std::strcmp(binding, "modifier") == 0 ? moonlive::modifierSysVars() : std::strcmp(binding, "effect") == 0 ? moonlive::effectSysVars() : moonlive::layoutSysVars(); - auto r = moonlive::compileSource(src, moonlive::lightBuiltins(), sysvars, buf, sizeof(buf)); + // A string pool, as the engine supplies one: `addUint8("name", ...)` interns its label there + // and the emitted code carries a pointer to it. Static so the pointers stay valid while the + // bytes below are dumped. + static char strings[moonlive::CompileResult::kStringPool]; + auto r = moonlive::compileSource(src, moonlive::lightBuiltins(), sysvars, buf, sizeof(buf), + nullptr, nullptr, strings, sizeof(strings)); if (!r.ok) { printf("compile failed: %s\n", r.error); return 1; } printf("# %s\n# %zu bytes\n", src, r.len); for (size_t i = 0; i < r.len; i++) printf("%02x%s", buf[i], (i % 16 == 15) ? "\n" : " "); diff --git a/moonlive/README.md b/moonlive/README.md index e7deb56b..bd856dba 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -13,7 +13,9 @@ A class may also define functions of its own and **call them**, including callin ``` class CrosshairEffect { - uint8_t bpm = 30; // @control 1..240 + uint8_t bpm = 30; + + defineControls() { addUint8("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(); } @@ -25,6 +27,15 @@ 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.mlv` is the worked example. +**A declaration is a MEMBER; `defineControls()` decides what the UI shows.** `uint8_t 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, +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 +label, free to differ from the member's name. + **Declare a helper above the function that calls it.** Only functions already parsed are visible, so a call to one declared further down reports `unknown function`. A function can always call itself. diff --git a/moonlive/effects/crosshair.mlv b/moonlive/effects/crosshair.mlv index 2c85f271..7b7a14fd 100644 --- a/moonlive/effects/crosshair.mlv +++ b/moonlive/effects/crosshair.mlv @@ -9,7 +9,11 @@ // 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; // @control 1..240 + uint8_t bpm = 30; + + defineControls() { + addUint8("bpm", bpm, 1, 240); + } column() { for (y = 0; y < height; y = y + 1) { diff --git a/moonlive/effects/lines.mlv b/moonlive/effects/lines.mlv index cb1caaf4..0aea1ac0 100644 --- a/moonlive/effects/lines.mlv +++ b/moonlive/effects/lines.mlv @@ -6,7 +6,11 @@ // script used to spell out. class LinesEffect { - uint8_t bpm = 30; // @control 1..240 + uint8_t bpm = 30; + + defineControls() { + addUint8("bpm", bpm, 1, 240); + } tick() { fill(0, 0, 0); diff --git a/moonlive/effects/plasma.mlv b/moonlive/effects/plasma.mlv index 85e11cd0..ee15e748 100644 --- a/moonlive/effects/plasma.mlv +++ b/moonlive/effects/plasma.mlv @@ -7,8 +7,13 @@ // through the same path an effect always does. class PlasmaEffect { - uint8_t bpm = 12; // @control 1..120 - uint8_t zoom = 24; // @control 1..64 + uint8_t bpm = 12; + uint8_t zoom = 24; + + defineControls() { + addUint8("bpm", bpm, 1, 120); + addUint8("zoom", zoom, 1, 64); + } tick() { for (y = 0; y < height; y = y + 1) { diff --git a/moonlive/effects/ripples.mlv b/moonlive/effects/ripples.mlv index 71c87b8c..96fb8328 100644 --- a/moonlive/effects/ripples.mlv +++ b/moonlive/effects/ripples.mlv @@ -8,8 +8,13 @@ // the working stress test for the call path. class RipplesEffect { - uint8_t bpm = 10; // @control 1..120 - uint8_t rings = 8; // @control 1..32 + uint8_t bpm = 10; + uint8_t rings = 8; + + defineControls() { + addUint8("bpm", bpm, 1, 120); + addUint8("rings", rings, 1, 32); + } tick() { for (y = 0; y < height; y = y + 1) { diff --git a/moonlive/layouts/diagonal.mlv b/moonlive/layouts/diagonal.mlv index 42fba6a6..a5128188 100644 --- a/moonlive/layouts/diagonal.mlv +++ b/moonlive/layouts/diagonal.mlv @@ -1,7 +1,11 @@ // A diagonal run β€” light i at (i, i). The kind of fixture that otherwise needs its own class. class DiagonalLayout { - uint8_t count = 16; // @control 1..64 + uint8_t count = 16; + + defineControls() { + addUint8("count", count, 1, 64); + } placeLights() { for (i = 0; i < count; i = i + 1) { diff --git a/moonlive/layouts/grid.mlv b/moonlive/layouts/grid.mlv index 1b97b395..53c9d307 100644 --- a/moonlive/layouts/grid.mlv +++ b/moonlive/layouts/grid.mlv @@ -2,8 +2,13 @@ // `cols`/`rows` are this layout's own controls; the logical grid comes from what it places. class GridLayout { - uint8_t cols = 16; // @control 1..64 - uint8_t rows = 16; // @control 1..64 + uint8_t cols = 16; + uint8_t rows = 16; + + defineControls() { + addUint8("cols", cols, 1, 64); + addUint8("rows", rows, 1, 64); + } placeLights() { for (y = 0; y < rows; y = y + 1) { diff --git a/moonlive/layouts/lattice.mlv b/moonlive/layouts/lattice.mlv index 8356396e..2ae74bfb 100644 --- a/moonlive/layouts/lattice.mlv +++ b/moonlive/layouts/lattice.mlv @@ -4,9 +4,15 @@ // but not the S3; two loops (grid.mlv) fit everywhere. class LatticeLayout { - uint8_t cols = 4; // @control 1..32 - uint8_t rows = 3; // @control 1..32 - uint8_t layers = 5; // @control 1..32 + uint8_t cols = 4; + uint8_t rows = 3; + uint8_t layers = 5; + + defineControls() { + addUint8("cols", cols, 1, 32); + addUint8("rows", rows, 1, 32); + addUint8("layers", layers, 1, 32); + } placeLights() { for (z = 0; z < layers; z = z + 1) { diff --git a/moonlive/layouts/reversed-row.mlv b/moonlive/layouts/reversed-row.mlv index 3b451f64..f98f101f 100644 --- a/moonlive/layouts/reversed-row.mlv +++ b/moonlive/layouts/reversed-row.mlv @@ -1,7 +1,11 @@ // A strand wired right to left: light 0 sits at the far end. class ReversedRowLayout { - uint8_t cols = 16; // @control 1..64 + uint8_t cols = 16; + + defineControls() { + addUint8("cols", cols, 1, 64); + } placeLights() { for (i = 0; i < cols; i = i + 1) { diff --git a/moonlive/layouts/ring.mlv b/moonlive/layouts/ring.mlv index 73c63390..f1c5a930 100644 --- a/moonlive/layouts/ring.mlv +++ b/moonlive/layouts/ring.mlv @@ -3,8 +3,13 @@ // `cos`/`sin` run 0..65535 centred at 32768, so scaling by the DIAMETER lands the whole circle. class RingLayout { - uint8_t count = 24; // @control 3..255 - uint8_t radius = 5; // @control 1..127 + uint8_t count = 24; + uint8_t radius = 5; + + defineControls() { + addUint8("count", count, 3, 255); + addUint8("radius", radius, 1, 127); + } placeLights() { for (i = 0; i < count; i = i + 1) { diff --git a/moonlive/layouts/rose.mlv b/moonlive/layouts/rose.mlv index 00532d94..84b12650 100644 --- a/moonlive/layouts/rose.mlv +++ b/moonlive/layouts/rose.mlv @@ -8,8 +8,13 @@ // walk runs once per edit, so clarity beats the repeated call. class RoseLayout { - uint8_t petals = 2; // @control 1..8 - uint8_t radius = 15; // @control 4..30 + uint8_t petals = 2; + uint8_t radius = 15; + + defineControls() { + addUint8("petals", petals, 1, 8); + addUint8("radius", radius, 4, 30); + } placeLights() { for (i = 0; i < 256; i = i + 1) { diff --git a/moonlive/layouts/two-rows.mlv b/moonlive/layouts/two-rows.mlv index 7dadd22d..44fe7aff 100644 --- a/moonlive/layouts/two-rows.mlv +++ b/moonlive/layouts/two-rows.mlv @@ -2,7 +2,11 @@ // The return row counts x DOWN -- the strand turns around at the far end. class TwoRowsLayout { - uint8_t cols = 16; // @control 1..64 + uint8_t cols = 16; + + defineControls() { + addUint8("cols", cols, 1, 64); + } placeLights() { for (i = 0; i < cols; i = i + 1) { diff --git a/moonlive/modifiers/shift.mlv b/moonlive/modifiers/shift.mlv index e4f85276..5731f350 100644 --- a/moonlive/modifiers/shift.mlv +++ b/moonlive/modifiers/shift.mlv @@ -2,7 +2,11 @@ // 256: past that it wraps and the light reappears at the left edge. class ShiftModifier { - uint8_t amount = 4; // @control 0..64 + uint8_t amount = 4; + + defineControls() { + addUint8("amount", amount, 0, 64); + } modifyLogical() { setXYZ(0, xPos + amount, yPos, zPos); diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index 57f2209f..f9485954 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -20,6 +20,16 @@ void MoonLive::freeCode() { // The entry table describes code that no longer exists. Left behind, entry() would hand a // binding an address into a freed block: the same stale-state trap the control arena has. entryCount_ = 0; + // The declared controls go with it: a control record describes the program that just went + // away, and running its defineControls() is what will publish the next set. Dropping them here + // is also what makes a FAILED recompile safe, since the records would otherwise outlive the + // code and the pool their names point into. + // + // The string pool itself is NOT cleared here. freeCode runs mid-compile (place() calls it), so + // clearing would wipe the labels of the program still published while the next one is being + // built. It is reclaimed when the next compile interns into it from offset zero, which is the + // ordinary arena discipline: one program's strings at a time. + controlCount_ = 0; } // Copy `len` already-emitted bytes into a fresh exec block. writeExec hides the ISA quirks @@ -80,33 +90,25 @@ bool MoonLive::compile(uint8_t r, uint8_t g, uint8_t b) { bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysVarTable& sysvars) { Staging staging(codeCapFor(countTokens(source))); if (!staging) { freeCode(); error_ = "no memory to compile"; return false; } - CompileResult cr = compileSource(source, table, sysvars, staging.p, staging.n); + // strings_ is passed so a string literal is interned into memory that outlives the compile: + // the emitted code carries pointers into it, and the source buffer is freed the moment this + // returns. NOT cleared here: freeCode() owns that, because a control record published by the + // previous program still points into this pool. Zeroing before a compile that then FAILS left + // every published control named "" β€” name-keyed persistence and `POST /api/control` both go + // through that name, so a broken script silently unbound the user's own sliders. + CompileResult cr = compileSource(source, table, sysvars, staging.p, staging.n, + nullptr, nullptr, strings_, CompileResult::kStringPool); if (!cr.ok) { freeCode(); error_ = cr.error; return false; } // surface the parse diagnostic // Allocate the control arena (fixed address) and seed new slots, BEFORE publishing the control // set β€” ensureArena reads the previous controlCount_ to know which slots are new. - if (!ensureArena(cr.controls, cr.controlCount)) { freeCode(); error_ = "no control memory"; return false; } + // Seeded from the MEMBERS, not the controls: a member the UI never shows still has an + // initializer, and reading it before anything wrote would give 0 rather than what the script + // declared. A control is one of these members surfaced, so seeding members covers both. + if (!ensureArena(cr.members, cr.memberCount)) { freeCode(); error_ = "no control memory"; return false; } // Place the code. Only after it succeeds do we publish the new control set β€” a failed place() // must not leave declaredControls() advertising controls for code that isn't running. void* block = place(staging.p, cr.len); if (!block) return false; // controlCount_/controls_ unchanged - // Clamp any kept slot whose range shrank (e.g. @control 0..99 edited to 0..10) so a stale live - // value can't fall outside the new bounds before the native code reads it. - for (uint8_t i = 0; i < cr.controlCount && i < controlCount_; i++) { - uint8_t lo = static_cast(cr.controls[i].min), hi = static_cast(cr.controls[i].max); - if (ctrlArena_[i] < lo) ctrlArena_[i] = lo; - else if (ctrlArena_[i] > hi) ctrlArena_[i] = hi; - } - controlCount_ = cr.controlCount; - for (uint8_t i = 0; i < cr.controlCount; i++) { - controls_[i] = cr.controls[i]; - // Re-point `name` at our own copy: the parser's pointer is into the source text, which the - // caller may free as soon as this returns. - const uint8_t len = cr.controls[i].nameLen < kMaxControlName - 1 - ? cr.controls[i].nameLen : static_cast(kMaxControlName - 1); - for (uint8_t j = 0; j < len; j++) ctrlNames_[i][j] = cr.controls[i].name[j]; - ctrlNames_[i][len] = '\0'; - controls_[i].name = ctrlNames_[i]; - } // Copy the entry table, names included: a CompileResult's `name` points into the source text, // which the caller frees as soon as this returns. entryCount_ = cr.entryCount < kMaxEntryPoints ? cr.entryCount : kMaxEntryPoints; @@ -134,7 +136,13 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { if (!ctrlArena_) return false; for (uint8_t i = 0; i < kArenaBytes; i++) ctrlArena_[i] = 0; } - for (uint8_t i = controlCount_; i < count; i++) ctrlArena_[i] = static_cast(decls[i].def); + // A NEW slot takes its declared initializer; an EXISTING one keeps its live value, so a source + // edit that keeps a control does not snap its slider back to the default. Indexed by the + // declaration's own offset rather than by position, because a member and the control that + // surfaces it share one arena byte and only the offset knows which. + for (uint8_t i = memberCount_; i < count; i++) + ctrlArena_[decls[i].offset] = static_cast(decls[i].def); + memberCount_ = count; return true; } @@ -153,6 +161,10 @@ void MoonLive::free() { if (ctrlArena_) platform::free(ctrlArena_); // full release also releases the control arena ctrlArena_ = nullptr; controlCount_ = 0; + // The seeded-slot count goes with the arena it describes. Left behind, the next compile would + // treat every member as one it had already seeded and skip the initializers, so a script would + // start every value at zero instead of what it declared. + memberCount_ = 0; } } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index 484ebde0..e8b5e774 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -101,6 +101,45 @@ class MoonLive { else if (anim_) anim_(buf, nLights, cpl, t); // hand-encoded animated fill } + /// 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 + /// 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, uint8_t lo, uint8_t hi) { + if (controlCount_ >= kMaxCtrls || !name || offset >= kArenaBytes) return; + if (lo > hi) 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++) + if (controls_[i].offset == offset) return; + + // The DEFAULT is whatever the member already holds: its initializer seeded the arena + // before this ran, so the live byte is the declared value. + // + // CLAMPED IN THE ARENA, not just in the record. A script edit that narrows a range leaves + // a live value outside it, and the native code reads the arena byte every tick, not the + // 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. + uint8_t def = ctrlArena_ ? ctrlArena_[offset] : lo; + if (def < lo) def = lo; + else if (def > hi) def = hi; + if (ctrlArena_) ctrlArena_[offset] = def; + controls_[controlCount_] = {name, lo, hi, def, 0, CtrlType::Uint8, offset}; + // nameLen is what the binding reports; measured here rather than passed, so a caller + // cannot disagree with the string it handed over. + uint8_t n = 0; + while (name[n] && n < kMaxControlName - 1) n++; + controls_[controlCount_].nameLen = n; + controlCount_++; + } + + /// Forget the controls a previous defineControls() declared, so re-running it rebuilds rather + /// than appends. A compiled module's defineControls() is re-runnable for the same reason. + void clearDeclaredControls() { controlCount_ = 0; } + /// Does the script define this entry point? A binding asks before reporting "no tick() to run". bool hasEntry(const char* name) const { return entry(name) != nullptr; } @@ -153,6 +192,9 @@ class MoonLive { // recompile; preserves an existing slot's live value when the script is edited but the control // persists. Returns false on alloc failure (the caller degrades). bool ensureArena(const DeclaredControl* decls, uint8_t count); + // How many MEMBER slots have been seeded. A recompile seeds only the new ones, so a member + // that survives an edit keeps its live value rather than snapping back to its initializer. + uint8_t memberCount_ = 0; void* code_ = nullptr; // allocExec block holding the emitted machine code size_t codeCap_ = 0; // its capacity (for freeExec) @@ -177,7 +219,12 @@ class MoonLive { // from a file into a transient buffer. Copying the bytes here is what lets the engine outlive // the text it was built from; without it a binding reads freed memory when it publishes its // controls, which showed up as a control literally named "\x05". - char ctrlNames_[kMaxCtrls][kMaxControlName] = {}; + // Text a script wrote as a literal, copied out of the compile result so a pointer the emitted + // code carries stays valid. In the ENGINE rather than the exec block: the block is IRAM on a + // device, which takes 32-bit stores only and is instruction memory, so string bytes do not + // belong in it. This is a plain member for the same reason ctrlNames_ is, and it lives as long + // as the compiled program that points into it. + char strings_[CompileResult::kStringPool] = {}; }; } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 899abe13..4d1ea780 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -64,6 +64,19 @@ struct Builtin { BuiltinKind kind = BuiltinKind::Call; HostCallFn fn = nullptr; // Call: the host C function pointer InlineOp inlineOp{}; // Inline: the neutral opcode tag + // Which arguments are passed BY REFERENCE, as a bit per position (bit 0 = first argument). + // A script names a member and the compiler passes its arena offset, so `addUint8("bpm", bpm, + // 1, 120)` reads as the reference a compiled module passes rather than as bpm's value. Zero + // for every builtin that takes plain values, which is all of them but this one. + // + // A bitmask rather than a per-argument enum because the only question is by-value or + // by-reference, and `draw::line` already proves a builtin may take seven arguments. + uint8_t byRef = 0; + // Which arguments must be a STRING LITERAL, a bit per position. Without it a bare identifier + // in a name slot compiles: `addUint8(s, s, 0, 9)` read `s`'s VALUE as the label and handed the + // 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; }; // A fixed-capacity table the host fills and the compiler reads. No heap; a host registers a diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 6bda1e3a..e67c3a55 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -2,18 +2,16 @@ #include "core/moonlive/moonlive_emit.h" #include "core/moonlive/MoonLiveIr.h" -#include // std::strncmp (@control keyword match) +#include // std::strncmp (keyword matching) namespace mm::moonlive { namespace { // --- Lexer --------------------------------------------------------------------------- -// `ControlAnno` is a captured `// @control min..max` comment (a control's UI range). A plain -// `//` line comment is skipped like whitespace; only the @control form becomes a token, carrying -// its min/max in annoMin/annoMax. `Assign` is `=` (a control declaration's initializer). -enum class Tok { Ident, Number, Assign, LParen, RParen, LBrace, RBrace, Comma, Semicolon, - ControlAnno, Plus, Minus, Star, Less, End, Error }; +// A `//` line comment is whitespace. `Assign` is `=` (a member declaration's initializer). +enum class Tok { Ident, Number, String, Assign, LParen, RParen, LBrace, RBrace, Comma, Semicolon, + Plus, Minus, Star, Less, End, Error }; struct Lexer { const char* p; @@ -21,7 +19,6 @@ struct Lexer { long number = 0; const char* identBeg = nullptr; size_t identLen = 0; - long annoMin = 0, annoMax = 0; // ControlAnno: the captured min..max const char* tokBeg = nullptr; const char* srcBeg; const char* err = ""; @@ -45,25 +42,14 @@ struct Lexer { void advance() { for (;;) { while (isSpace(*p)) p++; - // Line comment: a plain `//…` is skipped; a `// @control min..max` is captured. + // A line comment is whitespace, with no exception. if (p[0] == '/' && p[1] == '/') { - const char* lineStart = p; p += 2; - while (*p == ' ' || *p == '\t') p++; - // Match `@control` only as a whole word β€” require a non-identifier - // char after it, so a comment like `// @controlled …` is a plain - // comment, not a malformed annotation. - if (p[0] == '@' && std::strncmp(p, "@control", 8) == 0 && !isIdentCont(p[8])) { - tokBeg = lineStart; - p += 8; - while (*p == ' ' || *p == '\t') p++; - long lo = 0, hi = 0; - if (!readNumber(lo) || !(p[0] == '.' && p[1] == '.')) { kind = Tok::Error; err = "malformed @control (expected min..max)"; return; } - p += 2; - if (!readNumber(hi)) { kind = Tok::Error; err = "malformed @control (expected max)"; return; } - annoMin = lo; annoMax = hi; kind = Tok::ControlAnno; return; - } - // plain comment β€” skip to end of line and re-loop (treated as whitespace) + // Every line comment is whitespace. A comment that CHANGED BEHAVIOR lived here: + // `// @control 1..120` declared a control's range, which is not C and does not + // resemble the compiled module a script stands in for. `defineControls()` calling + // `addUint8("bpm", bpm, 1, 120)` replaced it, so the token, its capture and the + // `lineStart` this needed are all gone. while (*p && *p != '\n') p++; continue; } @@ -88,6 +74,19 @@ struct Lexer { // both would lower to a host call β€” which the light domain already ships as `mod(a, b)` and // `turn(n)`, so the capability exists under a name instead of an operator. A script using // the character gets "unexpected character", which is the honest answer. Backlogged. + // A quoted string: a control's UI label. The span goes in identBeg/identLen, the same + // fields an identifier uses, because both are a run of source bytes the parser reads + // without copying. No escapes: a control name with a quote or a newline in it is not a + // thing anyone needs, and the absence is one less rule to document. + if (c == '"') { + p++; + identBeg = p; + while (*p && *p != '"' && *p != '\n') p++; + if (*p != '"') { kind = Tok::Error; err = "unterminated string"; return; } + identLen = static_cast(p - identBeg); + p++; // the closing quote + kind = Tok::String; return; + } if (isDigit(c)) { long v = 0; readNumber(v); number = v; kind = Tok::Number; return; @@ -140,19 +139,48 @@ struct Parser { uint8_t slotsUsed = 0; // PEAK slots β€” what the prologue reserves uint8_t nextLabel = 0; // IR label ids, handed out in source order - DeclaredControl controls[kMaxCtrls] = {}; // controls the script declared (decl lines) - uint8_t controlCount = 0; + // Every class-scope `uint8_t x = 0;` is a MEMBER: the class model, where a declaration inside + // the class is a member of it. Whether the UI shows one is a separate question the script + // answers by calling addUint8 in defineControls, so `controls` below is a VIEW of these rather + // than a second storage: a control's offset IS its member's arena byte. + // + // `DeclaredControl` carries both because they are the same record. A member that no control + // names simply never appears in the control list, and the binding creates no card for it. + char* strings = nullptr; // the caller's pool; see compileSource + uint16_t stringCap = 0; + uint16_t stringLen = 0; + DeclaredControl members[kMaxCtrls] = {}; + uint8_t memberCount = 0; + const char* error = ""; uint16_t errorCol = 0; bool failed = false; void fail(const char* msg) { if (!failed) { failed = true; error = msg; errorCol = lex.col(); } } - // Find a declared control by name; returns its index or -1. Names point into the source buffer - // (token spans, not NUL-terminated), so compare by length + bytes. - int findControl(const char* name, size_t len) const { - for (uint8_t i = 0; i < controlCount; i++) - if (controls[i].nameLen == len && std::strncmp(controls[i].name, name, len) == 0) + + /// Copy a token's text into the program's string pool and return a pointer to it. + /// + /// The source buffer is freed the moment the compile returns, so a pointer into it would + /// dangle before the emitted code ran. The pool travels with the compiled program instead, + /// which is the same lifetime answer the engine already gives control and entry-point names. + /// Null when the pool is full, which the caller turns into a diagnostic rather than a silent + /// truncation. + const char* internString(const char* text, size_t len) { + if (!strings || stringLen + len + 1 > stringCap) return nullptr; + char* at = strings + stringLen; + for (size_t i = 0; i < len; i++) at[i] = text[i]; + at[len] = '\0'; + stringLen = static_cast(stringLen + len + 1); + return at; + } + + + /// Find a declared MEMBER by name; its index, or -1. Names are token spans into the source + /// rather than NUL-terminated strings, so compare by length and bytes. + int findMember(const char* name, size_t len) const { + for (uint8_t i = 0; i < memberCount; i++) + if (members[i].nameLen == len && std::strncmp(members[i].name, name, len) == 0) return i; return -1; } @@ -289,10 +317,12 @@ struct Parser { return v; } } - int ci = findControl(lex.identBeg, lex.identLen); - if (ci >= 0) { // a declared control read + // A MEMBER read. A control is a member the UI shows, so this one lookup answers both: + // the arena byte is the same byte either way. + const int mi = findMember(lex.identBeg, lex.identLen); + if (mi >= 0) { VReg v = alloc(); - emit({IrOp::LoadCtrl, v, 0,0,0,0, controls[ci].offset, nullptr, {}}); + emit({IrOp::LoadCtrl, v, 0,0,0,0, members[mi].offset, nullptr, {}}); lex.advance(); return v; } @@ -359,7 +389,55 @@ struct Parser { if (lex.kind != Tok::RParen) { while (true) { if (n >= fn->argc) { fail("too many arguments"); return; } - const VReg v = parseExpr(); + // Two argument forms an ordinary expression cannot carry, both needed so a control + // is declared by the same call a compiled module makes: + // + // a STRING, for the UI label. A frame slot is a machine word, so the pointer + // into the source fits; the host reads it as a `const char*`. + // + // a MEMBER BY NAME, meaning its ADDRESS rather than its value. `addUint8("bpm", + // bpm, 1, 120)` reads as the reference a compiled module passes, and the compiler + // supplies the arena offset the host binds to. Only where the builtin asks for it + // (byRef), so `setRGB(bpm, …)` still reads bpm's value as it always did. + VReg v = 0; + const bool wantStr = (fn->byStr >> n) & 1u; + if (wantStr && lex.kind != Tok::String) { + fail("this argument must be a name in quotes"); return; + } + // And a string ONLY where one is wanted: `setRGB("red", 0, 0, 0)` would otherwise + // pass the low bits of a pointer as a color index. + if (!wantStr && lex.kind == Tok::String) { + fail("this argument is a number, not a name in quotes"); return; + } + if (lex.kind == Tok::String) { + // The label is recorded HERE, at compile time, rather than travelling through + // a frame slot: the source buffer is freed after the compile, so a pointer the + // emitted code carried would dangle by the time the host read it. The engine + // already copies control names into its own pool, which is the same lifetime + // problem solved once. The slot still gets a value so the argument count is + // honest; the host reads the name from the control record, not from the slot. + // INTERNED, so the pointer outlives the source. The text is freed the moment + // the compile returns, and this pointer travels in the emitted code to a host + // that reads it later, so it cannot point into the source. The pool is part of + // the compiled program, exactly as the entry-point and control names already + // are: the same lifetime problem, solved the same way. + // Interned into the caller's pool, which outlives the compile, so the address + // is final the moment it is made and the emitted code carries it directly. + const char* interned = internString(lex.identBeg, lex.identLen); + if (!interned) { fail("no room for this script's strings"); return; } + v = alloc(); + emit({IrOp::ConstPtr, v, 0,0,0,0, 0, nullptr, interned, {}}); + lex.advance(); + } else if (fn->byRef && (fn->byRef >> n) & 1u) { + 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; } + v = alloc(); + emit({IrOp::Const, v, 0,0,0,0, members[mi].offset, nullptr, {}}); + lex.advance(); + } else { + v = parseExpr(); + } if (failed) return; if (slotHighWater >= kMaxLocals) { fail("too many arguments to hold"); return; } emit({IrOp::Spill, 0, v, 0,0,0, slotHighWater++, nullptr, {}}); @@ -371,6 +449,7 @@ struct Parser { } if (slotHighWater > slotsUsed) slotsUsed = slotHighWater; if (n != fn->argc) { fail("wrong number of arguments"); return; } + if (!expect(Tok::RParen, "expected ')'")) return; // The IR Call op carries a single argument vreg, so a Call-kind builtin must be unary. @@ -406,7 +485,7 @@ struct Parser { *slot[i] = alloc(); emit({IrOp::Reload, *slot[i], 0,0,0,0, static_cast(argBase + i), nullptr, {}}); } - emit({IrOp::Inline, 0, a0, a1, a2, a3, 0, nullptr, fn->inlineOp}); + emit({IrOp::Inline, 0, a0, a1, a2, a3, 0, nullptr, nullptr, fn->inlineOp}); for (uint8_t i = 0; i < n && i < 4; i++) freeTemp(*slot[i]); } } @@ -419,43 +498,39 @@ struct Parser { slotHighWater = argBase; } - // A control declaration: `uint8_t ident = number ;` optionally followed by `// @control min..max`. + // A MEMBER declaration: `uint8_t ident = number ;`. Whether the UI shows it is a separate + // question the script answers by naming it in defineControls(). // The leading `uint8_t` keyword is already consumed by the caller. Records a DeclaredControl. void parseDecl() { - if (lex.kind != Tok::Ident) { fail("expected a control name after the type"); return; } + if (lex.kind != Tok::Ident) { fail("expected a member name after the type"); return; } const char* name = lex.identBeg; size_t nameLen = lex.identLen; - if (nameLen >= kMaxControlName) { fail("control name too long"); return; } // no silent truncation downstream + if (nameLen >= kMaxControlName) { fail("member name too long"); return; } // no silent truncation downstream if (sysvars.find(name, nameLen)) { fail("name is a system variable"); return; } - if (findControl(name, nameLen) >= 0) { fail("duplicate control name"); return; } + // Against MEMBERS, which is where a declaration now lands. Checked against the controls + // before, which stopped catching anything the moment a declaration became a member: two + // members of one name would both exist, and every read would resolve to the first while + // the second silently owned an arena byte nobody could reach. + if (findMember(name, nameLen) >= 0) { fail("duplicate member name"); return; } // 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("control name shadows a built-in function"); return; } - if (controlCount >= kMaxCtrls) { fail("too many controls"); return; } + if (table.find(name, nameLen)) { fail("member name shadows a built-in function"); return; } lex.advance(); - if (!expect(Tok::Assign, "expected '=' in a control declaration")) return; + if (!expect(Tok::Assign, "expected '=' in a member declaration")) return; if (lex.kind != Tok::Number) { fail("expected a default value (a number)"); return; } if (lex.number < 0 || lex.number > 255) { fail("uint8_t default out of range (0..255)"); return; } long def = lex.number; lex.advance(); - if (!expect(Tok::Semicolon, "expected ';' after the control declaration")) return; - // A malformed `// @control …` comment lexes to Tok::Error with a specific - // message (e.g. "malformed @control (expected min..max)"). Surface it here - // rather than letting it fall through to a generic later parse failure. + 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 + // to a generic later parse failure. if (lex.kind == Tok::Error) { fail(lex.err); return; } - // Optional range annotation; default 0..255 if absent. - long lo = 0, hi = 255; - if (lex.kind == Tok::ControlAnno) { - lo = lex.annoMin; hi = lex.annoMax; - if (lo < 0 || hi > 255 || lo > hi) { fail("@control range out of order or out of 0..255"); return; } - lex.advance(); - } - // The default must lie within the (possibly annotated) range β€” a slider can't start outside - // its own bounds. - if (def < lo || def > hi) { fail("control default is outside its @control range"); return; } - controls[controlCount] = {name, static_cast(lo), static_cast(hi), - static_cast(def), static_cast(nameLen), - CtrlType::Uint8, controlCount}; - controlCount++; + // A MEMBER, and only that. Whether the UI shows it is a separate question the script + // answers by naming it in `defineControls()`, so a declaration no longer carries a range: + // the range belongs to the control, and a member that no control surfaces has none. + if (memberCount >= kMaxCtrls) { fail("too many members"); return; } + members[memberCount] = {name, 0, 255, static_cast(def), + static_cast(nameLen), CtrlType::Uint8, memberCount}; + memberCount++; } // Is the current Ident this exact keyword? Keywords are matched by text rather than lexed as @@ -721,7 +796,8 @@ uint32_t countTokens(const char* source) { CompileResult compileSource(const char* source, const BuiltinTable& table, const SysVarTable& sysvars, uint8_t* out, size_t cap, - const RegBudget* squeeze, LowerFn lower) { + const RegBudget* squeeze, LowerFn lower, + char* strings, uint16_t stringCap) { CompileResult r; if (!source) { r.error = "no source"; return r; } if (!out || cap == 0) { r.error = "no code buffer"; return r; } @@ -744,6 +820,8 @@ CompileResult compileSource(const char* source, const BuiltinTable& table, } Lexer lex(source); Parser parser{lex, table, sysvars, ir, r.className}; + parser.strings = strings; // where string literals are interned; see compileSource + parser.stringCap = stringCap; if (!parser.parseProgram()) { r.error = parser.error; r.errorCol = parser.errorCol; return r; } // Hand the backend the frame the script's variables need. The register allocator numbers any // further slot from here up, so the two never overlap in the one frame they share. @@ -757,8 +835,8 @@ CompileResult compileSource(const char* source, const BuiltinTable& table, r.ok = true; r.len = len; // Surface the declared controls so the binding can create real MoonModule controls. - r.controlCount = parser.controlCount; - for (uint8_t i = 0; i < parser.controlCount; i++) r.controls[i] = parser.controls[i]; + r.memberCount = parser.memberCount; + for (uint8_t i = 0; i < parser.memberCount; i++) r.members[i] = parser.members[i]; // The functions the class defined, each with the byte its code starts at. The parser recorded // an IR index and the lowering converted it while emitting, so this is a real symbol table: a // binding asks for an entry by name and gets an address inside the one emitted block. diff --git a/src/core/moonlive/MoonLiveCompiler.h b/src/core/moonlive/MoonLiveCompiler.h index b8309c2d..03047ce8 100644 --- a/src/core/moonlive/MoonLiveCompiler.h +++ b/src/core/moonlive/MoonLiveCompiler.h @@ -57,10 +57,16 @@ struct CompileResult { const char* error = ""; uint16_t errorCol = 0; size_t len = 0; - // Controls the script declared (`uint8_t speed = 50; // @control 0..99`). The binding reads - // this list and creates a real MoonModule control per entry, bound to the run-time arena slot. - DeclaredControl controls[kMaxCtrls]; - uint8_t controlCount = 0; + // Every member the class declared (`uint8_t speed = 50;`). The engine seeds each one's arena + // byte with its initializer, which is what "a member is initialized once" means: a member the + // UI never shows still has to start at the value the script wrote. + DeclaredControl members[kMaxCtrls]; + uint8_t memberCount = 0; + // Text a script wrote as a string literal, NUL-separated. The source buffer is freed as soon + // as a compile returns, so a `const char*` the emitted code carries cannot point into it; the + // engine copies this pool alongside the code and the emitted pointers are rebased onto its + // copy. 128 bytes is a handful of control labels, which is all a string is used for today. + static constexpr uint16_t kStringPool = 128; // The name the script gave its class. What diagnostics and the module status report, so a // renamed FILE does not change what a user is told: the filename is what the engine loads, the // class name is what it is. Copied out of the source, which is freed after the compile. @@ -84,9 +90,15 @@ struct CompileResult { /// the front end is identical either way, so the seam is one pointer rather than a second compiler. using LowerFn = size_t (*)(IrProgram&, uint8_t*, size_t, const RegBudget*); +/// `strings` is where string literals are interned, supplied by the CALLER because the emitted +/// code carries pointers into it: the parser's own storage dies with the compile, so a pointer +/// made there would dangle before the program ran. The engine passes its own member, which lives +/// as long as the compiled program. Null is allowed for a caller with no string literals to +/// support (the codegen tests), and a script that uses one then fails with a diagnostic. CompileResult compileSource(const char* source, const BuiltinTable& table, const SysVarTable& sysvars, uint8_t* out, size_t cap, - const RegBudget* squeeze = nullptr, LowerFn lower = nullptr); + const RegBudget* squeeze = nullptr, LowerFn lower = nullptr, + char* strings = nullptr, uint16_t stringCap = 0); /// Tokens in `source`, the one measure both right-sized buffers derive from: the caller sizes its /// code buffer with `codeCapFor`, and compileSource sizes the IR op array from the same count. One diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 8259d9c4..baa34ec1 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -82,6 +82,15 @@ enum class IrOp : uint8_t { // and temporaries die within their statement, so this holds today and is why no // save-set is emitted. Xtensa's window rotation would hide a violation that // corrupts RISC-V, so it is stated here rather than left to be discovered. + ConstPtr, // dst = the pointer in `ptr`: a full-width address materialized into a register. + // Distinct from Const because `imm` is int32_t and a pointer is 64 bits on the + // desktop, so an address cannot ride an immediate. Every backend already builds one + // for a host call's target (arm64 movz + 3x movk, the devices a byte at a time), so + // this generalizes a proven sequence rather than adding a mechanism. + // + // What it carries is a string a script wrote. The source buffer is freed the moment + // the compile returns, so the text is interned into the compiled program and this + // 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 Mov, // dst = a β€” the assignment a loop variable needs (vregs are otherwise write-once) @@ -110,10 +119,11 @@ struct IrInst { VReg a = 0, b = 0, c = 0, d = 0; // source vregs (op-dependent) int32_t imm = 0; // immediate (Const) / addr offset HostCallFn callFn = nullptr; // Call: the host C function pointer (typed alias) + const void* ptr = nullptr; // ConstPtr: the address to materialize InlineOp inlineOp{}; // Inline: the neutral opcode tag }; -// A control a script declared (`uint8_t speed = 50; // @control 0..99`). Neutral: the core +// A control a script declared (`addUint8("speed", speed, 0, 99)`). Neutral: the core // knows {name, a neutral type, range, default, and the byte offset into the run-time controls // arena it lives at}. The light-domain binding turns this into a real MoonModule control bound to // the arena slot. `type` is a neutral kind β€” Uint8 only in Stage 1 β€” NOT a projectMM ControlType. diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index c5843976..7348aaf3 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -51,6 +51,7 @@ struct Loop { uint16_t header; uint16_t back; }; uint8_t sourcesOf(const IrInst& in, VReg* out) { switch (in.op) { case IrOp::Const: return 0; + case IrOp::ConstPtr: return 0; // an address, not a value case IrOp::Reload: return 0; case IrOp::Label: return 0; case IrOp::Mov: diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index 4fbdf8de..3c74390d 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -23,7 +23,7 @@ // 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, movReg, addImm, addReg, mulReg, store8, load8, +// movImm, movPtr, movReg, addImm, addReg, mulReg, store8, load8, // 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 @@ -231,6 +231,7 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee const IrInst& op = ir.ops[i]; switch (op.op) { case IrOp::Const: a.movImm(reg(op.dst), op.imm); break; + case IrOp::ConstPtr: a.movPtr(reg(op.dst), op.ptr); break; 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; diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index 0a0a854c..4e1e5dd1 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -3,6 +3,7 @@ #include #include "core/moonlive/MoonLiveBuiltins.h" +#include "core/moonlive/MoonLive.h" // runDefineControls drives the engine #include "core/moonlive/MoonLiveIr.h" // kArg3 β€” the register `t` is passed in #include @@ -187,6 +188,12 @@ 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`. 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. +using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, uint8_t lo, uint8_t hi); +struct AddControlSink { AddControlFn fn = nullptr; void* ctx = nullptr; }; + namespace detail { // `owner` is ATOMIC and claimed with compare_exchange: the claim used to be a load then a store, // so two threads could both see the same slot free and both take it β€” leaving them sharing one @@ -195,7 +202,8 @@ namespace detail { // The slot is the ONE per-thread home for everything a running script's built-ins reach: the // addLight sink (a layout run installs it) and the draw canvas (an effect run installs it). A // second table would repeat the claim/release machinery for the same lifetime. -struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; }; +struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; + AddControlSink controls; }; /// Two slots: the render task and whichever task edits a control are the two that ever run a script /// at once. A third concurrent runner gets the overflow slot, which holds no sink β€” so its addLight /// calls no-op instead of writing through someone else's context. @@ -227,10 +235,11 @@ inline SinkSlot* ownedSlot(bool claim) MM_NONBLOCKING { } return nullptr; } -/// Release only a fully empty slot: the sink and the canvas detach independently, and a release -/// while the other half is live would hand this thread's context to the next claimer. +/// Release only a fully empty slot: the three halves (addLight sink, draw canvas, control sink) +/// detach independently, and a release while any of them is live would hand this thread's context +/// to the next claimer, whose script would then reach a dead engine through it. inline void releaseIfEmpty(SinkSlot* s) MM_NONBLOCKING { - if (s && !s->sink.fn && !s->sink.ctx && !s->canvas.data) + if (s && !s->sink.fn && !s->sink.ctx && !s->canvas.data && !s->controls.fn) s->owner.store(0, std::memory_order_release); } } // namespace detail @@ -246,9 +255,25 @@ inline const AddLightSink& addLightSink() { return s ? s->sink : detail::sinkOverflow(); } +/// The control sink for this thread, or an empty one. Reading does not claim a slot, for the same +/// reason addLightSink() does not: a binding that installs nothing must not hold a slot for life. +inline const AddControlSink& addControlSink() { + detail::SinkSlot* s = detail::ownedSlot(false); + static constinit AddControlSink none{}; + return s ? s->controls : none; +} + +/// Point addUint8 at a consumer for the duration of one defineControls() run; nullptr to detach. +inline void setAddControlSink(AddControlFn fn, void* ctx) { + detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); + if (!s) return; + s->controls = {fn, ctx}; + if (!fn) detail::releaseIfEmpty(s); +} + /// Point addLight at a consumer for the duration of one run; pass nullptr to detach. /// -/// Detaching RELEASES this thread's slot (unless the canvas half is still live), so two slots are +/// Detaching RELEASES this thread's slot (unless another half is still live), so two slots are /// not exhausted by tasks that come and go: an HTTP request lands on whichever worker is free. inline void setAddLightSink(AddLightFn fn, void* ctx) { if (!fn && !ctx) { @@ -266,6 +291,25 @@ inline void setAddLightSink(AddLightFn fn, void* ctx) { if (s) s->sink = {fn, ctx}; } +// addUint8(name, memberOffset, min, max): the run-time half of declaring a control. +// +// The CONTROL RECORD is built by the compiler, which knows the name span and the member's offset, +// so nothing has to travel through a frame slot into a source buffer that is freed by the time +// 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. +extern "C" inline uint32_t mm_light_addUint8(const uintptr_t* args, uint32_t, const uint8_t*) { + // 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 + // compiler passed by reference. + 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 + s.fn(s.ctx, name, static_cast(args[1]), + static_cast(args[2]), static_cast(args[3])); + return 0; +} + extern "C" inline uint32_t mm_light_addLight(const uintptr_t* args, uint32_t, const uint8_t*) { const uint32_t x = uint32_t(args[0]), y = uint32_t(args[1]), z = uint32_t(args[2]); // Both halves checked: a sink is only ever installed as a pair, but a context of null with a live @@ -360,6 +404,9 @@ enum : uint8_t { /// entitled to do, and the cost of a name nothing calls is a function that does not run, which is /// visible immediately rather than silent. inline constexpr const char* kEntryTick = "tick"; // an effect, per frame +// The declaration moment, run once after a successful compile rather than per tick: the same +// place a compiled module's defineControls() sits in its lifecycle. +inline constexpr const char* kEntryDefineControls = "defineControls"; inline constexpr const char* kEntryPlaceLights = "placeLights"; // a layout, placing lights inline constexpr const char* kEntryModify = "modifyLogical"; // a modifier, folding one light @@ -435,7 +482,38 @@ 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}); return t; } +/// Run a script's `defineControls()`, so the controls it declares exist. +/// +/// A compiled module's controls exist because `defineControls()` RAN: the Scheduler calls it on +/// every module at setup, and again whenever a Select reshapes the visible set. A scripted one +/// works the same way. This calls the entry point, each `addUint8` inside it reaches the engine +/// through the control sink, and the binding's `rebuildControls()` then finds a populated list. +/// +/// Re-runnable, like its compiled counterpart: the list is cleared first, so calling it twice +/// rebuilds rather than appends. A script that defines no `defineControls()` declares no controls, +/// which is the honest answer for a script that wants no UI. +inline void runDefineControls(MoonLive& engine) { + // A script with no defineControls() declares no controls, which is the honest answer for one + // that wants no UI: there is nothing to clear and nothing to run. + if (!engine.hasEntry(kEntryDefineControls)) return; + engine.clearDeclaredControls(); // re-runnable: rebuild rather than append + setAddControlSink([](void* ctx, const char* n, uint8_t off, uint8_t lo, uint8_t hi) { + static_cast(ctx)->addDeclaredControl(n, off, lo, hi); + }, &engine); + // A one-light scratch buffer: this entry point writes no pixels, but `run` refuses a null or + // undersized one, and honoring that contract costs less than carving out an exception. + uint8_t scratch[3] = {}; + engine.run(scratch, 1, 3, 0, kEntryDefineControls); + setAddControlSink(nullptr, nullptr); +} + } // namespace mm::moonlive diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 0f3794a1..ebee4aaf 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -26,7 +26,7 @@ class MoonLiveEffect : public EffectBase { Dim dimensions() const override { return Dim::D2; } // The effect carries its script's NAME as an editable, persisted text control, plus a control - // for every variable the script DECLARED (`uint8_t speed = 50; // @control 0..99`). The + // for every control the script declared (`addUint8("speed", speed, 0, 99)`). The // engine exposes the declared list after a compile; each becomes a real uint8 control bound by // reference to the engine's live control-arena slot, so a slider write lands in the slot the // next render tick reads, with no recompile (the live-edit guarantee). Naming a different @@ -72,6 +72,10 @@ class MoonLiveEffect : public EffectBase { const char* err = nullptr; if (moonlive::compileScriptFile(engine_, script_, moonlive::lightBuiltins(), moonlive::effectSysVars(), err)) { + // Declare the controls the script asks for, the way a compiled module does: by + // RUNNING defineControls(). Before rebuildControls() below, which is what + // turns the declared list into UI cards. + moonlive::runDefineControls(engine_); clearStatus(); } else { setStatus(err, Severity::Error); diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h index 884560e5..1b8eac1e 100644 --- a/src/light/moonlive/MoonLiveLayout.h +++ b/src/light/moonlive/MoonLiveLayout.h @@ -47,7 +47,7 @@ class MoonLiveLayout : public LayoutBase { // RECEIVE a width: the pipeline derives its bounding box from the coordinates the layouts // actually place (Layouts::prepare, "max coordinate + 1 per axis"), so a width handed in // from outside would be a second, disagreeing source of truth. A script that wants one - // declares it (`uint8_t width = 16; // @control 1..64`) and it becomes a real slider. + // declares it (`addUint8("width", width, 1, 64)`) and it becomes a real slider. uint8_t n = 0; const moonlive::DeclaredControl* decls = engine_.declaredControls(n); for (uint8_t i = 0; i < n; i++) { @@ -137,6 +137,10 @@ class MoonLiveLayout : public LayoutBase { uint32_t hash = 0; if (moonlive::compileScriptFile(self->engine_, script_, moonlive::lightBuiltins(), moonlive::layoutSysVars(), err, &hash)) { + // Declare the controls the script asks for, the way a compiled module does: by + // RUNNING defineControls(). Before rebuildControls(), which turns the declared + // list into UI cards. + moonlive::runDefineControls(self->engine_); self->clearStatus(); self->compileFailed_ = false; } else { diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h index 43e055dc..45304d84 100644 --- a/src/light/moonlive/MoonLiveModifier.h +++ b/src/light/moonlive/MoonLiveModifier.h @@ -73,6 +73,10 @@ class MoonLiveModifier : public ModifierBase { uint32_t hash = 0; if (moonlive::compileScriptFile(engine_, script_, moonlive::lightBuiltins(), moonlive::modifierSysVars(), err, &hash)) { + // Declare the controls the script asks for, the way a compiled module does: by + // RUNNING defineControls(). Before rebuildControls(), which turns the declared + // list into UI cards. + moonlive::runDefineControls(engine_); clearStatus(); } else { setStatus(err, Severity::Error); diff --git a/src/platform/desktop/moonlive_asm_host.cpp b/src/platform/desktop/moonlive_asm_host.cpp index d59db4c5..e36a61c9 100644 --- a/src/platform/desktop/moonlive_asm_host.cpp +++ b/src/platform/desktop/moonlive_asm_host.cpp @@ -172,6 +172,21 @@ void HostAssembler::movReg(Reg d, Reg a) { addImm(d, a, 0); } // mov wD, wA ( void HostAssembler::branchGeU(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Hs, l); } void HostAssembler::branchNe(Reg a, Reg b, Label l) { cmp(a, b); branchIf(Cond::Ne, l); } +// movPtr: a full 64-bit address into a register, movz + three movk. +// +// The same four instructions call() emits for its target, parameterized on the destination. A +// pointer cannot ride an immediate (IrInst::imm is int32_t) and cannot be a PC-relative literal +// either, because the emitted block is copied to its final address after these bytes are built, +// so an absolute materialization is what stays correct across that move. +void HostAssembler::movPtr(Reg d, const void* p) { + const uint64_t addr = reinterpret_cast(p); + const uint8_t r = mr(d); + emit32(0xd2800000u | ((uint32_t(addr) & 0xffff) << 5) | r); // movz xD, #b0 + emit32(0xf2800000u | (1u << 21) | (((uint32_t(addr >> 16)) & 0xffff) << 5) | r); // movk xD,#b1,lsl16 + emit32(0xf2800000u | (2u << 21) | (((uint32_t(addr >> 32)) & 0xffff) << 5) | r); // movk xD,#b2,lsl32 + emit32(0xf2800000u | (3u << 21) | (((uint32_t(addr >> 48)) & 0xffff) << 5) | r); // movk xD,#b3,lsl48 +} + void HostAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { // Preserve EVERY register that may hold a live value across the call: the host args // (x0/x1/x2/x3), the link register x30 (blr overwrites it; our function is a leaf), and the diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h index 10e6ac43..d3d8c393 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -84,6 +84,7 @@ class HostAssembler { static constexpr uint8_t kMaxSpillSlots = kTotalSlots; // parser/allocator range + the parked host args // what the frame below can address // --- instructions (named, register/immediate operands) --- + void movPtr(Reg d, const void* p); // a full-width address into a register (ConstPtr) void movImm(Reg d, int32_t imm); // d = imm void addImm(Reg d, Reg a, int32_t imm); // d = a + imm void addReg(Reg d, Reg a, Reg b); // d = a + b diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index 5c26063f..0b209c52 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -206,6 +206,19 @@ void RiscvAssembler::branchNe(Reg a, Reg b, Label l) { // live across the call must be preserved β€” save the whole pool + ra + the host args around the // call (mirrors the host backend). The fn address is built with lui+addi (the hi/lo split, +1 // to the upper when the low 12 bits' sign bit is set). 64-byte frame, 16-byte aligned. +// movPtr: a 32-bit address into a register, lui + addi. +// +// The same pair call() builds for its target, parameterized on the destination. The +0x800 rounds +// for addi's SIGN EXTENSION: without it an address whose low half has bit 11 set lands one 4 KB +// page low, which is the classic RISC-V hi/lo bug and is silent until the pointer is dereferenced. +void RiscvAssembler::movPtr(Reg d, const void* p) { + const uint32_t addr = static_cast(reinterpret_cast(p)); + const uint32_t hi = (addr + 0x800) >> 12; + const int32_t lo = static_cast(addr) - static_cast(hi << 12); + emit32(encLui(xr(d), hi & 0xfffff)); + emit32(encAddi(xr(d), xr(d), lo)); +} + void RiscvAssembler::call(Reg d, Reg a, Reg b, Reg c, const void* fn) { // 80-byte frame, 16-byte aligned: 14 saved registers (56 bytes), three argument staging slots // (56/60/64), and ra at 76. Every register the map hands out is saved here, or a value live diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index 3c5745f9..5e3a4fa3 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -80,6 +80,7 @@ class RiscvAssembler { Label newLabel(); void bind(Label l); + void movPtr(Reg d, const void* p); // a full-width address into a register (ConstPtr) void movImm(Reg d, int32_t imm); // li rd, imm (addi rd, x0, imm) void movReg(Reg d, Reg a); // mv rd, ra (addi rd, ra, 0) void addImm(Reg d, Reg a, int32_t imm); // addi rd, ra, imm diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 0effd0ac..2c3ef487 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -229,6 +229,32 @@ void XtensaAssembler::movImm(Reg d, int32_t imm) { emit(lo, 3); // movi a13, lo8 emit2(uint16_t((dr << 12) | (dr << 8) | (kTmp << 4) | 0xa)); // add.n aD, aD, a13 } +// movPtr: a 32-bit address into a register, a byte at a time. +// +// The same shape movImm uses for a 16-bit constant and call() uses for its target, extended to +// four bytes: movi the top byte, then three times (slli 8, movi the next byte into the scratch, +// add). a13 is this assembler's reserved scratch, outside the vreg map, so nothing live is +// disturbed. +// +// Byte-at-a-time rather than an l32r literal because a literal needs a pool at a known +// PC-relative distance, and this block is COPIED to its final address after these bytes are +// built. An absolute materialization survives that move; a PC-relative one would have to be +// re-based. +void XtensaAssembler::movPtr(Reg d, const void* p) { + const uint32_t addr = static_cast(reinterpret_cast(p)); + const uint8_t dr = ar(d); + static constexpr uint8_t kTmp = 13; + const uint8_t top[3] = {uint8_t((dr << 4) | 0x2), 0xa0, uint8_t(addr >> 24)}; + emit(top, 3); // movi aD, b3 + for (int shift = 16; shift >= 0; shift -= 8) { + const uint8_t sl[3] = {0x80, uint8_t((dr << 4) | dr), 0x11}; + emit(sl, 3); // slli aD, aD, 8 + const uint8_t by[3] = {uint8_t((kTmp << 4) | 0x2), 0xa0, uint8_t((addr >> shift) & 0xff)}; + emit(by, 3); // movi a13, bN + emit2(uint16_t((dr << 12) | (dr << 8) | (kTmp << 4) | 0xa)); // add.n aD, aD, a13 + } +} + // add.n aD, aA, aB : word (d<<12)|(a<<8)|(b<<4)|0xa void XtensaAssembler::addReg(Reg d, Reg a, Reg b) { emit2(uint16_t((ar(d) << 12) | (ar(a) << 8) | (ar(b) << 4) | 0xa)); diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index 1a03d11b..be4dc9fc 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -83,6 +83,7 @@ class XtensaAssembler { Label newLabel(); void bind(Label l); + void movPtr(Reg d, const void* p); // a full-width address into a register (ConstPtr) void movImm(Reg d, int32_t imm); // movi aD, #imm (0..255) void movReg(Reg d, Reg a); // mov.n aD, aA void addImm(Reg d, Reg a, int32_t imm); // addi.n aD, aA, #imm (1..15) diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json index 0f695a81..4520577e 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 control (`uint8_t speed = 7; // @control 0..15`) 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 (`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.", "fixture": [ { "name": "fix-layouts", @@ -122,7 +122,7 @@ "op": "set_control", "id": "ML", "key": "source", - "value": "uint8_t speed = 7; // @control 0..15\nsetRGB(speed, 0, 0, 255);", + "value": "class SpeedEffect {\n uint8_t speed = 7;\n defineControls() { addUint8(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n", "measure": true, "observed": { "desktop-macos": { @@ -252,7 +252,7 @@ "op": "set_control", "id": "ML", "key": "source", - "value": "uint8_t speed = 7; // @control 0..15\nuint8_t hue = 128; // @control 0..255\nsetRGB(speed, hue, 0, 255);", + "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", "measure": true, "observed": { "desktop-macos": { @@ -317,7 +317,7 @@ "op": "set_control", "id": "ML", "key": "source", - "value": "uint8_t speed = 7; // @control 0..15\nsetRGB(speed, 0, 0, 255);", + "value": "class SpeedEffect {\n uint8_t speed = 7;\n defineControls() { addUint8(\"speed\", speed, 0, 15); }\n tick() { setRGB(speed, 0, 0, 255); }\n}\n", "measure": true, "observed": { "desktop-macos": { @@ -382,7 +382,7 @@ "op": "set_control", "id": "ML", "key": "source", - "value": "uint8_t speed = ;", + "value": "class Broken {\n uint8_t speed = ;\n tick() { setRGB(0,0,0,0); }\n}\n", "measure": true, "observed": { "desktop-macos": { @@ -447,7 +447,7 @@ "op": "set_control", "id": "ML", "key": "source", - "value": "uint8_t bright = 200; // @control 0..255\nfill(0, 0, bright);", + "value": "class BrightEffect {\n uint8_t bright = 200;\n defineControls() { addUint8(\"bright\", bright, 0, 255); }\n tick() { fill(0, 0, bright); }\n}\n", "measure": true, "observed": { "desktop-macos": { diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index 5d760d75..d0abbd72 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -34,8 +34,8 @@ namespace { // every S3, so the one worth pinning hardest. const char* kGridLayout = "class GridLayout {\n" - " uint8_t cols = 16; // @control 1..64\n" - " uint8_t rows = 16; // @control 1..64\n" + " uint8_t cols = 16;\n" + " uint8_t rows = 16;\n" " tick() {\n" " for (y = 0; y < rows; y = y + 1) {\n" " for (x = 0; x < cols; x = x + 1) {\n" @@ -62,8 +62,13 @@ std::vector emitBytes(const char* src, const mm::moonlive::SysVarTable& // Sized the way production sizes it, from the script's own token count β€” a test that always // allocated the sanity bound would pass while a right-sized caller overflowed. std::vector out(mm::moonlive::codeCapFor(mm::moonlive::countTokens(src))); + // A string pool, as the engine supplies one: a script with a literal (every `addUint8` name) + // interns into it, and the emitted code carries pointers there. Static so those pointers stay + // valid for as long as a test might look at the bytes. + static char strings[mm::moonlive::CompileResult::kStringPool]; auto r = mm::moonlive::compileSource(src, mm::moonlive::lightBuiltins(), sysvars, - out.data(), out.size(), nullptr, MM_ISA_LOWER); + out.data(), out.size(), nullptr, MM_ISA_LOWER, + strings, sizeof(strings)); ok = r.ok; out.resize(r.ok ? r.len : 0); return out; diff --git a/test/unit/core/moonlive_structural.inc b/test/unit/core/moonlive_structural.inc index fed9b042..fd89994b 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}, {"control bound + call in body", - mmScript("uint8_t n = 8; // @control 1..64\n" + mmScript("uint8_t 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_JsonUtil_parse.cpp b/test/unit/core/unit_JsonUtil_parse.cpp index 2f4245f1..54665228 100644 --- a/test/unit/core/unit_JsonUtil_parse.cpp +++ b/test/unit/core/unit_JsonUtil_parse.cpp @@ -191,7 +191,7 @@ TEST_CASE("parseString decodes the standard JSON string escapes (symmetric with CHECK(out[0] == 'x'); CHECK(out[1] == 0x01); CHECK(out[2] == 'y'); CHECK(out[3] == 0x1f); // a multi-line script value (the MoonLive Stage-1 case) - json::parseString("{\"source\":\"uint8_t s = 1; // @control 0..9\\nsetRGB(s,0,0,255);\"}", + json::parseString("{\"source\":\"uint8_t s = 1;\\nsetRGB(s,0,0,255);\"}", "source", out, sizeof(out)); CHECK(std::strchr(out, '\n') != nullptr); // real newline, so the // comment ends CHECK(std::strstr(out, "setRGB") != nullptr); // the statement survives on its own line diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 6f8cd6c5..7b7d2343 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -71,7 +71,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; // @control 0..255\n" + " uint8_t level = 200;\n" " paint() { setRGB(1, level, 0, 0); }\n" " tick() { setRGB(0, 7, 8, 9); paint(); }\n" "}\n", kTable, kSys)); @@ -280,38 +280,58 @@ TEST_CASE("MoonLive recompiling swaps the program live (fill <-> setRGB)") { CHECK(buf[1*3+0] == 255); CHECK(buf[0] == 0); } -// STAGE 1 CONTROLS β€” parse layer: a `uint8_t name = def; // @control min..max` declaration -// surfaces a DeclaredControl, and a declared name used in a statement resolves to it. -// The DeclaredControl tests also need lowerToBytes to return non-zero β€” r.ok gates on it. -TEST_CASE("compileSource: a control declaration surfaces a DeclaredControl") { - uint8_t out[768]; - auto r = moonlive::compileSource( - mmScript("uint8_t speed = 50; // @control 0..99\nsetRGB(speed, 0, 0, 255);"), kTable, kSys, out, sizeof(out)); - REQUIRE(r.ok); - REQUIRE(r.controlCount == 1); - const auto& c = r.controls[0]; - CHECK(std::strncmp(c.name, "speed", c.nameLen) == 0); - CHECK(c.nameLen == 5); - CHECK(c.min == 0); CHECK(c.max == 99); CHECK(c.def == 50); CHECK(c.offset == 0); - CHECK(c.type == moonlive::CtrlType::Uint8); - - // No annotation β†’ default 0..255; two controls get sequential offsets (each default in range). - auto r2 = moonlive::compileSource( - mmScript("uint8_t a = 10;\nuint8_t b = 5; // @control 1..7\nsetRGB(a, b, 0, 0);"), kTable, kSys, out, sizeof(out)); - REQUIRE(r2.ok); - REQUIRE(r2.controlCount == 2); - CHECK(r2.controls[0].max == 255); CHECK(r2.controls[0].offset == 0); // a: no anno - CHECK(r2.controls[1].min == 1); CHECK(r2.controls[1].max == 7); CHECK(r2.controls[1].def == 5); CHECK(r2.controls[1].offset == 1); - - // `@control` matches as a whole word: a comment whose first word merely STARTS - // with "@control" (e.g. "@controlled") is a plain comment, not a malformed - // annotation β€” it's skipped, the declaration takes the default 0..255 range. - auto r3 = moonlive::compileSource( - mmScript("uint8_t speed = 9; // @controlled by the user\nsetRGB(speed, 0, 0, 0);"), kTable, kSys, out, sizeof(out)); - REQUIRE(r3.ok); - REQUIRE(r3.controlCount == 1); - CHECK(r3.controls[0].min == 0); CHECK(r3.controls[0].max == 255); CHECK(r3.controls[0].def == 9); +// CONTROLS: a declaration is a member, and `addUint8("name", name, lo, hi)` in defineControls +// A control is declared by CALLING addUint8 inside defineControls, the same call a compiled module +// makes. The declaration alone is a member: state the script owns, which the UI never sees unless +// the script asks for it. That split is the whole point, so both halves are checked here. +// +// 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") { + 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" + " tick() { setRGB(0, speed, hidden, 255); }\n" + "}\n", kTable, kSys)); + moonlive::runDefineControls(eng); + + uint8_t n = 0; + const auto* c = eng.declaredControls(n); + REQUIRE(n == 1); // `hidden` is a member, not a control + 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); + + // 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. + uint8_t buf[3] = {}; + eng.run(buf, 1, 3, 0, "tick"); + CHECK(buf[0] == 50); // `speed`, which the UI also shows + CHECK(buf[1] == 7); // `hidden`, read by tick, never on the UI } + +// A control's range is an ORDINARY EXPRESSION, like every other argument in the language. Making +// addUint8 the one call whose arguments must be literals would be a special case wearing a +// disguise, so this pins that it is not one. +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" + " tick() { setRGB(0, speed, 0, 0); }\n" + "}\n", kTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t n = 0; + const auto* c = eng.declaredControls(n); + REQUIRE(n == 1); + CHECK(c[0].min == 10); // base + CHECK(c[0].max == 45); // base * 4 + 5 +} + #endif // MM_MOONLIVE_HAS_HOST_JIT // A system variable is a value the HOST hands the script β€” the layer's size, the light being @@ -323,7 +343,7 @@ 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; // @control 1..64\nsetRGB(0, 0, 0, 0);"), "a control named width"}, + {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("for (xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"), "a loop variable named xPos"}, @@ -454,14 +474,16 @@ TEST_CASE("compileSource: malformed control declarations fail with a diagnostic, 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("uint8_t speed = 50; // @control 99..0\nsetRGB(0,0,0,0);"), // reversed range - mmScript("uint8_t speed = 50; // @control 0..10\nsetRGB(0,0,0,0);"), // default outside @control range - mmScript("uint8_t speed = 50; // @control 5\nsetRGB(0,0,0,0);"), // lexer-level malformed: no `..max` (Tok::Error surfaced, not a generic fall-through) - mmScript("uint8_t speed = 50; // @control 5..\nsetRGB(0,0,0,0);"), // lexer-level malformed: missing max + // 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" + " 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" + " 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;", // no statement + "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 name + mmScript("uint8_t s = 1; uint8_t s = 2; setRGB(0,0,0,0);"), // duplicate member name }; for (auto s : bad) { auto r = moonlive::compileSource(s, kTable, kSys, out, sizeof(out)); diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 6403220e..e70f16e1 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -188,9 +188,41 @@ TEST_CASE("elapsed time survives a call that happens before it is read") { } #endif +// A FAILED recompile drops the declared controls rather than leaving them named "". +// +// The editor loop pushes broken text constantly: that is what editing is. A control's `name` is a +// pointer the UI dereferences on every /api/state, and it points into the engine's string pool, so +// a pool cleared while the records survived left every card named "" and unmatched by both +// name-keyed persistence and `POST /api/control`. The user's own sliders came unbound from a typo. +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" + " tick() { setRGB(0, bpm, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t n = 0; + const moonlive::DeclaredControl* dc = eng.declaredControls(n); + REQUIRE(n == 1); + CHECK(std::strcmp(dc[0].name, "bpm") == 0); + + CHECK_FALSE(eng.compile("class T { uint8_t = ; }", kCtrlTable, kSys)); + dc = eng.declaredControls(n); + CHECK(n == 0); // dropped, so nothing points into a pool the next compile reuses + eng.free(); +} + TEST_CASE("MoonLive controls: declaredControls + controlSlot seeded from the default") { moonlive::MoonLive eng; - REQUIRE(eng.compile(mmScript("uint8_t speed = 42; // @control 0..99\nsetRGB(speed, 0, 0, 255);"), kCtrlTable, kSys)); + REQUIRE(eng.compile("class T {\n" + " uint8_t speed = 42;\n" + " defineControls() { addUint8(\"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 + // the binding's half of that. + moonlive::runDefineControls(eng); uint8_t n = 0; const moonlive::DeclaredControl* dc = eng.declaredControls(n); REQUIRE(n == 1); @@ -205,33 +237,33 @@ TEST_CASE("MoonLive controls: declaredControls + controlSlot seeded from the def 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; // @control 0..15\nsetRGB(speed, 0, 0, 255);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("uint8_t 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; // @control 0..15\nsetRGB(speed, 255, 0, 0);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("uint8_t 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; // @control 0..15\nuint8_t hue = 200; // @control 0..255\nsetRGB(speed, hue, 0, 255);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("uint8_t speed = 7;\nuint8_t 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 } TEST_CASE("MoonLive controls: free() releases the arena (no stale slot after release)") { moonlive::MoonLive eng; - REQUIRE(eng.compile(mmScript("uint8_t a = 5; // @control 0..9\nfill(0, 0, a);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("uint8_t 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; // @control 0..9\nfill(0, 0, a);"), kCtrlTable, kSys)); + REQUIRE(eng.compile(mmScript("uint8_t a = 5;\nfill(0, 0, a);"), kCtrlTable, kSys)); REQUIRE(eng.controlSlot(0) != nullptr); CHECK(*eng.controlSlot(0) == 5); // re-seeded from default } diff --git a/test/unit/core/unit_moonlive_ir.cpp b/test/unit/core/unit_moonlive_ir.cpp index 3d58b873..23d6dbf2 100644 --- a/test/unit/core/unit_moonlive_ir.cpp +++ b/test/unit/core/unit_moonlive_ir.cpp @@ -135,9 +135,9 @@ 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; // @control 0..99\nsetRGB(speed, 0, 0, 255);"), kT, kSys, code, sizeof(code)); + mmScript("uint8_t speed = 50;\nsetRGB(speed, 0, 0, 255);"), kT, kSys, code, sizeof(code)); REQUIRE(r.ok); - REQUIRE(r.controlCount == 1); + REQUIRE(r.memberCount == 1); // the declaration is a member; a control needs defineControls void* blk = platform::allocExec(r.len); REQUIRE(blk != nullptr); platform::writeExec(blk, code, r.len); @@ -160,7 +160,7 @@ TEST_CASE("MoonLive control survives a host call (kArg4 live across random16)") // scratch pool β€” pins that the call() save-set protects kArg4 (the arena pointer). uint8_t code[768]; auto r = moonlive::compileSource( - mmScript("uint8_t idx = 0; // @control 0..15\nsetRGB(idx, random16(256), 0, 255);"), kT, kSys, code, sizeof(code)); + mmScript("uint8_t idx = 0;\nsetRGB(idx, random16(256), 0, 255);"), kT, kSys, code, sizeof(code)); REQUIRE(r.ok); void* blk = platform::allocExec(r.len); REQUIRE(blk != nullptr); diff --git a/test/unit/core/unit_moonlive_spill.cpp b/test/unit/core/unit_moonlive_spill.cpp index bd75e463..b06db19f 100644 --- a/test/unit/core/unit_moonlive_spill.cpp +++ b/test/unit/core/unit_moonlive_spill.cpp @@ -44,7 +44,10 @@ std::vector renderAt(const char* src, int nLights, const moonlive::RegB REQUIRE(blk != nullptr); platform::writeExec(blk, code, r.len); uint8_t arena[moonlive::kArenaBytes] = {}; - for (uint8_t i = 0; i < r.controlCount; i++) arena[r.controls[i].offset] = r.controls[i].def; + // Seeded from the MEMBERS, as the engine does: a declaration is a member, and its initializer + // is what the arena holds. A control is one of those members surfaced on the UI, so seeding + // members covers both, and a script with no defineControls still starts at its declared values. + for (uint8_t i = 0; i < r.memberCount; i++) arena[r.members[i].offset] = r.members[i].def; reinterpret_cast(blk)(buf.data(), static_cast(nLights), 3, t, arena); platform::freeExec(blk, r.len); return buf; @@ -119,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; // @control 0..15\n" + mmScript("uint8_t idx = 5;\n" "for (i = 0; i < 3; i = i + 1) {\n" " setRGB(idx + i, random16(1) + 111, i + 1, 222);\n" "}\n"); @@ -145,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; // @control 0..15\n" + mmScript("uint8_t pos = 0;\n" "for (i = 0; i < 2; i = i + 1) {\n" " setRGB(pos + i, 10, 20, 30);\n" "}\n"); @@ -153,7 +156,7 @@ TEST_CASE("a declared control still reads live at a squeezed budget") { const auto tightBudget = squeezed(11, 1); auto r = moonlive::compileSource(src, kT, kSys, code, sizeof(code), &tightBudget); REQUIRE(r.ok); - REQUIRE(r.controlCount == 1); + REQUIRE(r.memberCount == 1); // `pos` is a member; the arena read is what this pins void* blk = platform::allocExec(r.len); REQUIRE(blk != nullptr); platform::writeExec(blk, code, r.len); @@ -277,7 +280,7 @@ TEST_CASE("a value live across a loop keeps its storage for the whole loop") { // in the body. Bench-bisected, each ingredient alone is fine, and only the three together fail: // // loop, constant bound, call in body -> runs -// loop, @control bound, call in body -> runs +// loop, member-bound limit, call in body -> runs // `width` read, no loop -> runs // `width` loop, no call in body -> runs // `width` loop WITH a call in body -> LoadProhibited inside the emitted code @@ -311,7 +314,7 @@ TEST_CASE("a system variable read in a loop survives a host call in that loop") // The arena the binding would hand over: controls at their defaults, and `width` where // MoonLiveEffect::tick writes it. uint8_t arena[moonlive::kArenaBytes] = {}; - for (uint8_t i = 0; i < r.controlCount; i++) arena[r.controls[i].offset] = r.controls[i].def; + for (uint8_t i = 0; i < r.memberCount; i++) arena[r.members[i].offset] = r.members[i].def; const uint8_t kWidth = 8; arena[moonlive::kSysWidth] = kWidth; diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index b436e20c..aef5511b 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -57,8 +57,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; // @control 1..64\n" - "uint8_t rows = 2; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t cols = 4;\n" + "uint8_t 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); @@ -73,8 +73,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; // @control 1..64\n" - "uint8_t rows = 3; // @control 1..64\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 5;\n" + "uint8_t 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(); @@ -111,7 +111,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; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t 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}); @@ -156,18 +156,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; // @control 1..64\n" - "uint8_t rows = 16; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t cols = 16;\n" + "uint8_t 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; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t 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; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"), // two rows, stacked - mmScriptAs("placeLights", "uint8_t cols = 8; // @control 1..64\n" + mmScriptAs("placeLights", "uint8_t 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); }"), @@ -226,7 +226,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; // @control 1..64\n" + std::vector p = place(mmScriptAs("placeLights", "uint8_t 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 @@ -245,20 +245,20 @@ 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; // @control 1..64\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t 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; // @control 1..64\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 8;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }"))); l.prepare(); CHECK(l.lightCount() == 16); // 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; // @control 1..64\n" - "uint8_t rows = 3; // @control 1..64\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 16;\n" + "uint8_t 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(); @@ -371,7 +371,7 @@ TEST_CASE("two threads can run scripts at once without stealing each other's sin 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; // @control 1..64\n" + l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 4;\n" "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }"))); l.prepare(); const size_t compiled = l.dynamicBytes(); @@ -397,8 +397,14 @@ TEST_CASE("a scripted layout reports every heap byte it holds, compiled or not") TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") { MoonLiveLayout layout; layout.defineControls(); - layout.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 4; // @control 1..64\n" - "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }"))); + // `cols` is a CONTROL here, because the test drives it: the loop below sets it and expects the + // 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" + " placeLights() { for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } }\n" + "}\n")); layout.prepare(); // The script's own controls (`cols`) exist only once it has COMPILED, and a module starts with // no script now β€” so the control list has to be rebuilt after prepare() for setWidth to find it. diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp index ca464ea5..696951a8 100644 --- a/test/unit/light/unit_MoonLiveScripts.cpp +++ b/test/unit/light/unit_MoonLiveScripts.cpp @@ -89,7 +89,8 @@ TEST_CASE("every script in moonlive/ compiles") { // Comments are what makes a script in `moonlive/` readable, so the lexer has to treat a plain `//` // line as whitespace β€” anywhere, including between the statements of a loop body. The one exception -// is `// @control min..max`, which is not a comment at all but the declaration of a UI slider. +// was `// @control min..max`, a comment that declared a UI slider. defineControls() replaced it, +// so every comment is now genuinely a comment. // Each binding supplies the system variables it actually WRITES, and supplying a name is also what // reserves it. That split is what keeps `x` usable as a loop counter in a layout while still making // it mean "the light being folded" in a modifier β€” and what turns a layout reading `width` into an @@ -116,7 +117,7 @@ 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; // @control 1..64\nsetRGB(0, 0, 0, 0);"), + {mmScript("uint8_t 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);"), false, "the coordinate names are reserved too, so a modifier cannot shadow what it is handed"}, @@ -149,19 +150,20 @@ TEST_CASE("the three roles are handed the same table") { CHECK(mod.count == moonlive::lightSysVars().count); } -TEST_CASE("a script may be commented, and only @control carries meaning") { +// EVERY comment is whitespace, with no exception. There used to be one: `// @control 1..240` +// declared a control's range, so a comment changed behavior and a malformed one was a compile +// error. `defineControls()` replaced it, which means a comment can no longer be wrong. +TEST_CASE("a comment is whitespace, wherever it appears") { struct Case { const char* src; bool ok; const char* what; }; const Case cases[] = { {mmScript("// leading comment\naddLight(1, 2, 3);"), true, "a comment before the code"}, {mmScript("addLight(1, 2, 3); // trailing comment"), true, "a comment after the code"}, {mmScript("for (i = 0; i < 2; i = i + 1) {\n // inside the body\n addLight(i, 0, 0);\n}"), true, "a comment inside a loop body"}, - {mmScript("// @controlled is a word, not an annotation\naddLight(1, 2, 3);"), true, - "@control matched as a whole word only"}, - {mmScript("uint8_t n = 4; // @control 1..64\nfor (i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"), - true, "an @control declaration"}, - {mmScript("uint8_t n = 4; // @control oops\naddLight(1, 2, 3);"), false, - "a malformed @control is an error, not a comment"}, + {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); }"), + true, "a comment after a member declaration"}, }; for (const Case& c : cases) { moonlive::MoonLive engine; @@ -233,7 +235,7 @@ TEST_CASE("a script reads elapsed time, so it can animate") { 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; // @control 1..64\n" + mmScript("uint8_t 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); @@ -267,7 +269,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; // @control 1..64\n" + mmScript("uint8_t w = 16;\n" "for (a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n" "for (b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n" "for (c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n" From 762676fb5b87c44f076c74e1fa12e788774c504f Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 18 Aug 2026 22:26:04 +0200 Subject: [PATCH 2/4] A MoonLive script can hold state: assignment, if/else, arrays, uint16_t Four language features, and together they are the line between a script that evaluates a formula every frame and one that runs a simulation. A member can now be WRITTEN, so it survives the tick; `if`/`else` lets a script branch; an array holds a value per light; and a `uint16_t` counts past the 255 a byte stops at. `effects/ember.mlv` is the worked example: a heat buffer that decays and re-ignites, so what it draws depends on the frame before it. Perf: no per-tick cost. The growth is the PARSER, which runs once per compile on the cold path. Flash +7856 bytes on the classic ESP32 (+0.45%), measured against this branch's own baseline; MoonLive is 86% of that (+6746 bytes, 21% of its own size), and 3538 of it is Parser::parseStatement, which the compiler inlined parseIf and parseAssignment into. Core - An assignment statement. `x = expr;` was reachable only inside a `for` header, so a member could be declared and read and never written, which made every member a constant. One token of lookahead in parseStatement separates `name =` from `name(`. A system variable is refused: the engine rewrites it before every call, so the store would silently vanish. - A CONTROL is deliberately not refused, correcting what the plan said. Whether a member becomes a control is decided at RUN time by defineControls calling addUint8, so the parser cannot know. Writing one is also legitimate (an effect that ramps its own speed and lets the slider re-take it). - `if` / `else`, with `<`, `<=`, `>`, `>=`, `==`, `!=`. The six comparisons lower onto the TWO branch ops the loops already use, by negating the test and swapping the operands: no new IR op, no backend change, no allocator change. Only `>=` and `<=` need a second branch, neither being a single unsigned `>=`. - The allocator needed no change because an `if` jumps FORWARD around its block, and the spill pass identifies a loop as a BranchNe whose label was bound EARLIER. That property came from the emit shape, not from a guard added for it. - The lexer gained `<=`, `>=`, `==`, `!=` and `>`, matched before the one-character operators they contain: testing `=` first lexes `a == b` as two assignments. - A member's arena offset is a BYTE CURSOR, not its declaration index. The two were the same number while every member was a byte. Nothing downstream broke because the bindings, persistence and addUint8 already keyed on the offset. - kCtrlBytes (the arena's byte budget) split from kMaxCtrls (the record count): one question while a member was a byte, two questions afterwards. - uint16_t members via LoadCtrl16/StoreCtrl16 and load16/store16 on all three backends. Separate ops rather than a width field on the existing pair, because every backend switch is exhaustive over IrOp: a backend that forgot the width fails to COMPILE, where an ignored field would emit a byte access against a two-byte member and lose the high half at run time. - A wide member is placed on an EVEN arena byte. arm64 ldrh and Xtensa l16ui SCALE the immediate by the access size and cannot encode an odd offset at all, so the ISA's rule is honored once in the cursor rather than worked around in two assemblers. - Arrays: `uint8_t heat[16];`, an arbitrary expression as the index, via LoadIdx/StoreIdx plus a load8Idx/load16Idx pair (the stores already took a register offset; the loads did not). The length is a literal, since the arena is sized at compile time. - An out-of-range index is CLAMPED to the last element, one compare and one branch. Not refused and not allowed through: a script computes indices from live control values, so out of range is an ordinary run-time state, and the arena also holds the system variables and the recursion depth counter, so a stray write would corrupt the ENGINE rather than the picture. - A class asking for more member data than the arena holds is a COMPILE error naming the arena, not a failed allocation while a fixture runs. Three codegen defects, all found by flashing an S3 - All three compiled cleanly, emitted plausible lengths, and passed the whole host suite. arm64's register map absorbed the first two and the third is Xtensa-only, so no host test could see them. Reintroducing either of the first two leaves all 1313 tests green, which was control-checked rather than assumed. - width and count were parked in IrInst::c/d, which are VREG fields the spill pass renumbers. They ride `imm` now (idxPack/idxBase/idxWidth/idxCount), the one per-instruction field the allocator never rewrites. Call already documented this trap for its argument count. - sourcesOf reported kArg4 as an indexed op's FIRST source, and the rewriter writes sources back POSITIONALLY, so the index landed in the value's field and the value was dropped entirely. LoadCtrl gets away with it by having no other source and reading the pointer through host(kArg4); these ops do the same. - Xtensa addi.n encodes immediate 0 as MINUS ONE: its narrow field covers 1..15. Every caller passed a literal 1 until an array based at arena offset 0 asked for `+0`, which shifted every element access down a byte. addImm now emits nothing for 0 and the wide RRI8 form outside 1..15. Light domain - effects/ember.mlv: the first shipped script that carries state between frames. Plasma would look identical if every frame started from scratch; this one goes dark. 17 scripts now compile in the sweep. Tests - The assignment statement: a member read back across three ticks, a member written by one function and read by another, a loop variable assigned in the body, and both refusal cases. - if/else: a boundary table across all six comparisons at, above and below the compared value, which is the only place an off-by-one in the negation mapping shows. Plus an if inside a for, an expression on both sides, a member steering which branch a tick takes, and `==` lexing as one token. - Arrays and wide members: the 255 boundary, even alignment, both clamp directions including that a system variable survives an out-of-range access, and a uint16_t array proving the stride. - A SERPENTINE layout, which the docs listed as the standing example of what the language could not express. - Xtensa addImm is pinned by a test asserting the ENCODER, control-checked to fail on the bug. A difference-based test cannot catch it: wrong bytes still differ from other wrong bytes. - Five difference-based codegen tests and a register-liveness check were written and DELETED, each after a control run passed with the defect reintroduced. The gap for the other two defects is backlogged by name rather than papered over. - New tests were guarded on MM_MOONLIVE_HAS_HOST_JIT after the no-backend gate caught them referencing helpers that are compiled out on a host with no backend. Docs/CI - The plan records what the verification MISSED: 1313 tests, both clamp directions and disasm.py on all three ISAs all passed while three defects were live, and only the bench found them. - Backlog: catching operand-shift and vreg-renumbering defects on the host, and sizing the arena to the script (blocked on system variables sitting above the script region at compile-time constant offsets). - The layout and modifier pages drop the "if is not in the language yet" limit and gain the serpentine; moonlive/README documents assignment, if/else, arrays and wide members. Bench: classic, S3 and S31 all running ember.mlv. The two Xtensa targets emit byte-identical code (1396 B effect, 499 B layout), which is the cross-check that nothing target-specific leaked into codegen; S31 1620 B. Ticks 81 / 117 / 33 us. The P4 is not reflashed since these fixes: it is RISC-V like the S31, so the same codegen, but that is an assumption rather than a check. Co-Authored-By: Claude Opus 5 (1M context) --- docs/backlog/backlog-light.md | 29 + ...20260817 - MoonLive scripts are classes.md | 187 +++++-- docs/metrics/repo-health.json | 54 +- docs/metrics/repo-health.md | 36 +- docs/moonmodules/light/MoonLiveEffect.md | 43 ++ docs/moonmodules/light/MoonLiveLayout.md | 22 +- docs/moonmodules/light/MoonLiveModifier.md | 11 +- docs/performance.md | 14 + moondeck/moonlive/emit_isa.cpp | 8 +- moonlive/README.md | 38 ++ moonlive/effects/ember.mlv | 47 ++ src/core/moonlive/MoonLive.cpp | 48 +- src/core/moonlive/MoonLive.h | 75 ++- src/core/moonlive/MoonLiveBuiltins.h | 58 +- src/core/moonlive/MoonLiveCompiler.cpp | 329 +++++++++++- src/core/moonlive/MoonLiveCompiler.h | 9 +- src/core/moonlive/MoonLiveIr.h | 64 ++- src/core/moonlive/MoonLiveSpill.cpp | 16 + src/core/moonlive/moonlive_lower.h | 60 ++- src/light/moonlive/MoonLiveBuiltins_light.h | 35 +- src/light/moonlive/MoonLiveEffect.h | 9 +- src/light/moonlive/MoonLiveLayout.h | 13 +- src/light/moonlive/MoonLiveModifier.h | 9 +- src/platform/desktop/moonlive_asm_host.cpp | 20 + src/platform/desktop/moonlive_asm_host.h | 4 + src/platform/esp32/moonlive_asm_riscv.cpp | 20 + src/platform/esp32/moonlive_asm_riscv.h | 4 + src/platform/esp32/moonlive_asm_xtensa.cpp | 49 +- src/platform/esp32/moonlive_asm_xtensa.h | 4 + .../light/scenario_MoonLive_pipeline.json | 8 +- test/unit/core/moonlive_device_codegen.inc | 9 + test/unit/core/moonlive_structural.inc | 3 +- .../core/unit_moonlive_codegen_xtensa.cpp | 47 ++ test/unit/core/unit_moonlive_fill.cpp | 498 +++++++++++++++++- test/unit/light/unit_MoonLiveLayout.cpp | 33 ++ 35 files changed, 1738 insertions(+), 175 deletions(-) create mode 100644 moonlive/effects/ember.mlv diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index e98a6cea..4e5be9ef 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -317,6 +317,35 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on `disasm.py --isa x86_64` should land with it, since no test executes emitted bytes for any backend but the host's. +- **Catch device-backend operand defects on the host** (2026-08-18). Two array-codegen bugs shipped + to an S3 while all 1313 host tests stayed green, and both were control-checked: reintroducing + either one leaves the suite fully passing. `IrInst::c`/`d` are VREG fields the spill pass + renumbers, so a width parked there becomes a register number; and `sourcesOf` writes its sources + back POSITIONALLY, so reporting `kArg4` first shifts every real operand one place along. arm64's + register map absorbs both, which is exactly why the suite cannot see them. + + Three test shapes were tried and deleted for failing their control run: emitted-bytes difference + tests (wrong bytes still differ from other wrong bytes) and a register-liveness walk (the index + satisfies it whatever happens to the value). What DOES work is asserting an assembler primitive + directly, as `unit_moonlive_codegen_xtensa.cpp` now does for `addImm`. The general form is + probably an IR-level invariant check rather than a bytes-level one: assert that no op reports a + fixed ABI vreg among its positional sources, and that non-register operands never occupy c/d. + That is a property of the IR the host CAN evaluate, unlike the emitted code. + +- **Size the MoonLive control arena to the script** (2026-08-18). `kCtrlBytes` is a fixed 64 bytes + per engine (three engines per pipeline), so a script declaring one byte pays for 64 and one + wanting a 128-light array is refused. The arena is already `platform::alloc`'d, so the constant is + habit rather than necessity, and the member byte count is known at compile time. + + What blocks it: the system variables sit ABOVE the script region at compile-time constant offsets + (`kSysWidth = kCtrlBytes + 0`), baked into emitted code as `LoadCtrl` immediates and cached as + slot POINTERS by the bindings. A script-sized region moves every one of them. Closing it means + putting the system variables BELOW the script region so their addresses stop depending on it, + which touches the sysvar table, the bindings and every emitted immediate. The hard ceiling stays + 255 either way, since an arena offset is a `uint8_t` in the record, in `controlSlot` and in the + instruction. Until then, raising the constant is one edit and the failure is a clear compile + error naming the arena, so hitting it is visible rather than silent. + - **Drain MoonLive's `print()` through a queue** (2026-08-09). `print(v)` writes to serial directly, and an EFFECT script runs on the render tick β€” so a print inside one blocks the frame for as long as the UART takes. The burst cap bounds it (a handful of writes per compile, then a compare and a return), but bounded is not free, and `tick()` is annotated `MM_NONBLOCKING`. **What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for. diff --git a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md index 73ff891f..b37696b7 100644 --- a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md +++ b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md @@ -120,6 +120,21 @@ and each is a real decision rather than a detail: able to be a scalar, a STRUCT (`Coord3D`), an ARRAY, or an array of structs. Not on day one, but the storage has to be designed for it, which makes this a typed addressable region rather than a wider row of bytes. Everything else here is downstream of this decision. + + **Correction, found when step 3 reached it.** This plan later says the storage decision is settled + and that an element count is already on the member record. Neither is true, and building against + the claim would have started in the wrong place: + + - `DeclaredControl` has no count and no width. `CtrlType` has exactly one value, `Uint8`. + - The arena is `kArenaBytes` = 17 with a hardcoded three-way split, and a control's **offset IS + its declaration index**. An array or a 16-bit member breaks that identity, and the identity is + load-bearing outside the compiler: the bindings cache arena slot POINTERS, `addUint8` passes an + offset as a byte, and persistence is keyed on it. + - `LoadCtrl`/`StoreCtrl` lower to `load8`/`store8` on all three backends, so a wider member is a + new op pair per backend, not a wider record. + + So the decision above is a DIRECTION, not a design. Steps 8 and 9 carry the design, and they are + done together because a byte arena rebuilt for arrays would be rebuilt again for 16-bit values. - **Cost.** Every access becomes an arena load rather than a frame slot read. Cheap at today's script sizes, worth measuring before it is the default for every variable. @@ -337,7 +352,8 @@ than being retrofitted into a language still moving underneath it. against the annotation is what surfaced this: the declaration rule kept wanting to ask a question the next step abolishes. -3. ⬜ **Typed script-level members**, per *Where script-level state lives* above. +3. βœ… **Typed script-level members**, per *Where script-level state lives* above. Both halves are + now done: the assignment statement below, and the types in steps 8 and 9. **Half of this arrived with step 2**, because a control turned out to BE a member the UI shows. A variable declared in the class body already lives in the arena, is visible in every function, @@ -347,16 +363,20 @@ than being retrofitted into a language still moving underneath it. So what remains is: - - **An assignment statement.** `x = expr;` is reachable only inside a `for` header today, so a - member can be declared and read and never written. `IrOp::StoreCtrl` and its lowering exist - (built during step 2's first attempt and set aside when the steps swapped); the grammar does - not. What may be assigned to is the rule worth stating: a member and a script-local may; a - control and a system variable may not, because the UI and the host own those and a script - store would be overwritten unpredictably. - - **Types wider than a byte, and aggregates.** Scalars work; a `Coord3D` or an array does not. - The storage decision is settled (scalars in the arena, per-light arrays as a `ScratchBuffer`), - so this is implementing it rather than deciding it. An element COUNT is already on the member - record, 1 for a scalar, so a wider type is a wider record rather than a second mechanism. + - βœ… **An assignment statement.** Done. `x = expr;` was reachable only inside a `for` header, so a + member could be declared and read and never written. `IrOp::StoreCtrl` and its lowering were + recovered from step 2's first attempt; the grammar is new, one token of lookahead in + `parseStatement` separating `name =` from `name(`. A member and a script-local may be assigned + to; a SYSTEM VARIABLE may not, because the engine rewrites it before every call and the store + would silently vanish. + + A CONTROL is deliberately NOT refused, which is a correction to what this plan said. Whether a + member becomes a control is decided at RUN time, by `defineControls()` calling `addUint8` on + it, so the parser cannot know: the direct consequence of step 2 making a control an ordinary + call. Writing one is also legitimate (an effect that ramps its own speed and lets the slider + re-take it), and the outcome is visible rather than silent. + - ⬜ **Types wider than a byte, and aggregates.** Scalars work; a `Coord3D` or an array does not. + This step is now steps 8 and 9, because the storage is NOT settled: see the correction below. This is what makes a stateful effect (fire, trails, decay) expressible at all, and it is the one step where a hot-path regression is plausible, so `collect_kpi.py` runs against it. @@ -454,12 +474,27 @@ in a layout or a modifier later without a grammar change. The shape is finished at that point. What follows decides whether MoonLive is an impressive mechanism or a language people build with. -6. ⬜ **`if` / `else`.** The single largest gap between what MoonLive can express and what an effect - IS. Today the language does smooth arithmetic over a grid (plasma, ripples, a gradient) and - nothing that branches, so fire, sparkles, particles, a boundary test, "respawn this one if it - died" are all unreachable. The stack machine already made this cheap (the predecessor plan's - table: "a branch over a region; storage is untouched"), and the emitter already has the - conditional branches the loops use. This is where the language stops being a demo. +6. βœ… **`if` / `else`.** Done. The single largest gap between what MoonLive can express and what an + effect IS: the language did smooth arithmetic over a grid and nothing that branches. + + Cheaper than expected, and the reason is worth recording. The six comparisons lower onto the TWO + branch ops the loops already use, `BranchGe` (unsigned `>=`) and `BranchNe`, by negating the test + and swapping the operands: the emitted branch skips the then-block, so `a < b` emits + `BranchGe a, b`. Only `>=` and `<=` need a second branch, because neither is a single unsigned + `>=`. **No new IR op, no backend change, no allocator change.** + + The last of those is the one that could have gone wrong. The spill pass identifies a loop as a + `BranchNe` whose label was bound EARLIER, and an `if` always jumps FORWARD around its block, so a + conditional cannot be mistaken for a loop. That property came from the emit shape rather than + from a guard added for it. + + The lexer gained `<=`, `>=`, `==`, `!=` and `>`, matched before the one-character operators they + contain (maximal munch): testing `=` first would have lexed `a == b` as two assignments. + + Pinned by a boundary table across all six operators at, above and below the compared value, which + is the only place an off-by-one in the negation mapping is visible; plus an `if` inside a `for`, + a condition that is an expression on both sides, and a member steering which branch a tick takes. + Encodings confirmed on all three ISAs with `disasm.py`. 7. ⬜ **Reading a light back: `get(x, y)`.** One builtin, and an entire family of effects becomes expressible: fire, decay, trails, blur feedback all work by reading what was drawn and modifying @@ -468,16 +503,80 @@ mechanism or a language people build with. comes back: three builtins (`red`/`green`/`blue`) or bit operators, which is the same question the seven-argument `line()` answered for arguments and would answer once for both. -8. ⬜ **Arrays, and arrays of structs.** Step 2 designs the storage for it; this is where it works. - A particle array is the difference between an effect that draws a formula and one that simulates - something, and it is what most of the effects people ask for are built on. Includes the arena - ceiling and its diagnostic: an array lets a script ask for more memory than a classic ESP32 has, - and the answer must be a clear compile error rather than a failed allocation at run time. - -9. ⬜ **Wider values than a byte.** Coordinates, members and arena slots are 8-bit, so a script - cannot address a 256-wide wall correctly, and a modifier cannot walk a light off a large grid. - This is a correctness wall on exactly the installations worth demonstrating on, and it touches - the same typed-storage decision as steps 2 and 8, so those three want to agree with each other. +8. 🟑 **Arrays** (arrays of structs not yet) and **9. 🟑 Wider values than a byte.** Both built; + the ceiling NUMBER is the open item, see below. + + What shipped, in the order it had to be built: + + - **A member's offset is a BYTE CURSOR**, not its declaration index. The two were the same number + while every member was a byte, which is why nothing downstream had to change: the bindings' + cached slot pointers, persistence and `addUint8` all keyed on the offset already. + - **The arena's byte budget split from the record count** (`kCtrlBytes` and `kMaxCtrls`). They + answered one question while a member was a byte and two questions afterwards. + - **`uint16_t` members**, with `LoadCtrl16`/`StoreCtrl16` and `load16`/`store16` on all three + backends. Separate ops rather than a width field, because every backend switch is exhaustive + over `IrOp`: a backend that forgot the width fails to COMPILE, where an ignored field would + have emitted a byte access against a two-byte member and lost the high half at run time. + - **Even alignment for a wide member**, because arm64 `ldrh` and Xtensa `l16ui` SCALE the + immediate by the access size and cannot encode an odd offset at all. The ISA's rule, honored + once in the cursor rather than worked around in two assemblers. + - **Arrays**, with `LoadIdx`/`StoreIdx` and a `load8Idx`/`load16Idx` pair (the stores already + took a register offset; the loads did not). An index is an arbitrary expression. + - **An out-of-range index is CLAMPED to the last element**, one compare and one branch. Not + refused and not allowed through: a script computes indices from live control values, so out of + range is an ordinary run-time state, and the arena holds the system variables and the depth + counter, so a stray write would corrupt the ENGINE rather than the picture. + + A SERPENTINE layout now compiles, which the docs had listed as the standing example of what the + language could not express. + + **Verification, and what it missed.** 1313 unit tests, the clamp proven in both directions + (including that a system variable survives an out-of-range access), and the emitted instruction + sequence read on all three ISAs with `disasm.py`. All of that passed while THREE codegen defects + were live. The bench found them: + + - `width`/`count` were parked in `IrInst::c`/`d`, which are VREG fields the spill pass renumbers. + - `sourcesOf` reported `kArg4` as the first source, and sources are written back POSITIONALLY, so + the index landed in the value's field and the stored value was dropped. + - Xtensa `addi.n` encodes immediate 0 as MINUS ONE (its narrow field covers 1..15), so an array + based at arena offset 0 shifted every element access down a byte. + + arm64's register map absorbed the first two and the third is Xtensa-only, so the host suite could + not see any of them: reintroducing either of the first two leaves all 1313 tests passing, which + was control-checked rather than assumed. `disasm.py` showed the wrong code without the wrongness + being apparent, since a plausible-looking instruction sequence is what all three produced. + + Only flashing an S3 and looking at the fixture exposed them. This is the plan's own verification + item 9 doing the job it exists for, and the strongest evidence yet that the device backends are + verified by hardware, not by inspection. `addImm` is now pinned by a test asserting the ENCODER + (control-checked to fail on the bug); the other two are backlogged by name, because three + attempts at a host test each passed with the defect reintroduced. + + **Open: `kCtrlBytes` is a placeholder 16.** The compile error and its diagnostic are built and + tested; the NUMBER is a product-owner decision, since it trades what a script can hold against + RAM on the smallest target (25 bytes per engine today, three engines per pipeline). A particle + array wants more than 16; a classic ESP32 driving a large fixture is what bounds it. + + **8 and 9 were ONE step, done together.** Step 2 was expected to have designed this storage, and + it did not (see the correction under *Where script-level state lives*): the arena is a fixed row + of 17 bytes whose offset IS a declaration index. Both steps break that identity in the same + place, and a byte arena rebuilt for arrays would be rebuilt again for 16-bit values, so doing + them apart means paying for the migration twice and leaving the intermediate state on a device. + + What the two share, and therefore what the step actually decides: + + - **A member record with a width and an element count**, replacing an offset that means an index. + - **Offsets that survive a declaration changing shape**, because the bindings cache arena slot + POINTERS and persistence is keyed on the offset. Step 3's identity fix (a member is its name at + an offset, not its position) is the precedent this extends. + - **`LoadCtrl`/`StoreCtrl` widened**, which is a new op pair on all three backends: today both + lower to `load8`/`store8` unconditionally. + - **Where an array lives.** Per-light arrays as a `ScratchBuffer` was the direction; whether a + small fixed array can stay in the arena is part of the ceiling question rather than separate + from it. + - **The ceiling and its number.** A compile error needs a budget, and what a classic ESP32 can + spare is a product-owner decision, not one to derive from the largest script that happens to + exist today. ### Strings: literals yes, a String TYPE not yet @@ -528,7 +627,7 @@ is a step whose design is wrong. conditional branch, a typed member load/store). - `src/core/moonlive/moonlive_lower.h`: one arm per new IR op, and nothing else. Touching more than that means an ISA fact leaked into the language. -- `src/core/moonlive/MoonLive.{h,cpp}`: the arena becomes typed storage (step 2) and gains its +- `src/core/moonlive/MoonLive.{h,cpp}`: the arena becomes typed storage (step 3) and gains its ceiling (step 8); entry-point discovery lives here (step 1) for the bindings to consume. - `src/light/moonlive/MoonLive{Effect,Layout,Modifier}.h`: call an entry point instead of running the whole program (step 1), then collapse onto dispatch (step 5). @@ -562,9 +661,11 @@ therefore needs a host test that proves the semantics and a bench run that prove kWindowSaveReserve from the frame calculation, which fires the offset check as it should. The derived reserve resisted the first attempt to break it, which is the anti-drift design working: editing the static_assert alone changes nothing, because the value comes from the callx opcode. -3. **A member written by one function and read by another**, and a member that survives across - `tick()` calls (step 2). The second is what a stateful effect depends on and is not provable by - inspection. +3. βœ… **A member written by one function and read by another**, and a member that survives across + `tick()` calls (step 3: the write is the assignment statement, which step 2 did not need). Both + pinned by unit tests, and the second is the one a stateful effect depends on: three consecutive + ticks read back 10, 20, 30 from a member the previous tick wrote. Proven on hardware too, since + `ember.mlv` carries its heat array and phase across every frame on all three boards. 4. βœ… **The same script at the host's real budget and a squeezed one renders identical pixels.** The predecessor plan's technique, still the only way the register work is testable off hardware, and every new construct has to keep passing it. Holds after `ConstPtr` joined the lowering, which is @@ -579,7 +680,11 @@ therefore needs a host test that proves the semantics and a bench run that prove user sees is the picture being wrong where the recursion bottomed out. Reporting it needs a channel from the emitted block back to the binding, which does not exist yet: worth having, and left for the step that gives scripts a diagnostic path. -6. **An arena ceiling reports a compile error** (step 8), not a failed allocation at run time. +6. βœ… **An arena ceiling reports a compile error** (step 8), not a failed allocation at run time. + "the class declares more member data than the arena holds", pinned by a test that sizes its + source from the constants so raising either limit cannot turn it into a test of the other. The + ceiling proved itself immediately: the first realistic effect written against it (a 16-element + fire buffer) was refused at the placeholder 16 bytes, which is how `kCtrlBytes` came to be 64. 7. 🟑 **The bench, on all four boards**, after each step: S3 and classic (Xtensa), P4 and S31 (RISC-V), a scripted layout and a scripted effect. Exec-block sizes compared against the previous step, since an unexplained jump is the cheapest signal that codegen went wrong. @@ -591,12 +696,30 @@ therefore needs a host test that proves the semantics and a bench run that prove the converted files are uploaded to it: the S31 was still running the annotated `grid.mlv` after its firmware was current. + After steps 3a, 6, 8 and 9: **classic, S3 and S31 done**, all running `ember.mlv`, an effect + that uses every construct at once (a `uint16_t` counter past 255, a `uint8_t[16]` heat array read + and written by index, member assignment carrying state between ticks, and `if`/`else` on four + comparisons). The P4 is NOT reflashed since these fixes; it is RISC-V like the S31, so the same + codegen, but that is an assumption rather than a check. + + | board | ISA | ember | tick | layout | + |---|---|---:|---:|---:| + | classic ESP32 | Xtensa | 1396 B | 81 us | 499 B | + | S3 | Xtensa | 1396 B | 117 us | 499 B | + | S31 | RISC-V | 1620 B | 33 us | 880 B | + + The two Xtensa targets emit BYTE-IDENTICAL code, which is the cross-check that nothing + target-specific leaked into codegen. This is also the step where the bench earned its place in + this list: it found three defects that the whole host suite, both clamp directions and + `disasm.py` on all three ISAs had passed over (recorded under step 8/9 above). + Exec blocks at this step, for the next one to compare against: | script | Xtensa | RISC-V | |---|---:|---:| | `grid.mlv` | 499 B | 880 B | | `plasma.mlv` | 1378 B | 2644 B | + 8. **`collect_kpi.py` after typed members** (now step 3), because members change how EVERY variable is accessed. That is the one step where a hot-path regression is plausible, so it is measured rather than assumed. It moved with the step when 2 and 3 swapped: `defineControls()` runs once diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index c6ff9b54..f711a0dc 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,13 +1,13 @@ { - "commit": "8be2dfb9", + "commit": "426d5cc4", "flash": { - "esp32": 1728032, + "esp32": 1735888, "esp32p4-eth": 1617360, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1766784, + "esp32s3-n16r8": 1774656, "esp32s3-n8r8": 1753232, - "esp32s31": 2039216, - "desktop": 1157848, + "esp32s31": 2047152, + "desktop": 1175080, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, @@ -15,8 +15,8 @@ }, "perf": { "desktop": { - "tick_us": 263, - "fps": 3802 + "tick_us": 186, + "fps": 5376 }, "esp32": { "tick_us": 2151, @@ -24,54 +24,54 @@ } }, "loc": { - "core": 18579, - "light": 24743, - "platform": 13369, + "core": 19100, + "light": 24777, + "platform": 13466, "ui": 6468, - "test": 42616, - "moondeck": 20945 + "test": 43192, + "moondeck": 20949 }, "comments": { "core": { - "lines": 7237, - "ratio": 0.423 + "lines": 7460, + "ratio": 0.424 }, "light": { - "lines": 9659, - "ratio": 0.431 + "lines": 9685, + "ratio": 0.432 }, "platform": { - "lines": 4750, - "ratio": 0.392 + "lines": 4776, + "ratio": 0.391 }, "ui": { "lines": 1670, "ratio": 0.274 }, "test": { - "lines": 7598, - "ratio": 0.206 + "lines": 7699, + "ratio": 0.205 }, "moondeck": { - "lines": 3373, - "ratio": 0.184 + "lines": 3377, + "ratio": 0.185 } }, "tests": { - "cases": 1360, + "cases": 1390, "scenarios": 23 }, "docs": { "md_files": 179, - "md_lines": 25225, + "md_lines": 25461, "plans_files": 92, - "backlog_lines": 3683, + "backlog_lines": 3712, "lessons_lines": 549, "claude_md_lines": 135 }, "complexity": { - "functions": 2548, - "over_threshold": 158, + "functions": 2572, + "over_threshold": 161, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index b317f270..e15a9772 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `8be2dfb9`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `426d5cc4`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,49 +8,49 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,131 KB (+1 KB) ⚠ | -| esp32 | 1,688 KB (+2 KB) ⚠ | +| desktop | 1,148 KB (+17 KB) ⚠ | +| esp32 | 1,695 KB (+8 KB) ⚠ | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | | esp32-wrover | 1,724 KB | -| esp32p4-eth | 1,579 KB (+1 KB) ⚠ | +| esp32p4-eth | 1,579 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,725 KB (+2 KB) ⚠ | +| esp32s3-n16r8 | 1,733 KB (+8 KB) ⚠ | | esp32s3-n8r8 | 1,712 KB | -| esp32s31 | 1,991 KB (+1 KB) ⚠ | +| esp32s31 | 1,999 KB (+8 KB) ⚠ | | qemu | 1,287 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 263 Β΅s (+130 Β΅s) ⚠ | 3,802 (βˆ’3,716) ⚠ | +| desktop | 186 Β΅s (βˆ’77 Β΅s) βœ“ | 5,376 (+1,574) βœ“ | | esp32 | 2,151 Β΅s | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 18,579 (+187) ⚠ | 7,237 | 42.3 % (+0.3 %) ⚠ | -| light | 24,743 (+90) ⚠ | 9,659 | 43.1 % | -| platform | 13,369 (+60) ⚠ | 4,750 | 39.2 % | +| core | 19,100 (+521) ⚠ | 7,460 | 42.4 % (+0.1 %) ⚠ | +| light | 24,777 (+34) ⚠ | 9,685 | 43.2 % (+0.1 %) ⚠ | +| platform | 13,466 (+97) ⚠ | 4,776 | 39.1 % (βˆ’0.1 %) βœ“ | | ui | 6,468 | 1,670 | 27.4 % | -| test | 42,616 (+82) ⚠ | 7,598 | 20.6 % (+0.1 %) ⚠ | -| moondeck | 20,945 (+110) ⚠ | 3,373 | 18.4 % | +| test | 43,192 (+576) ⚠ | 7,699 | 20.5 % (βˆ’0.1 %) βœ“ | +| moondeck | 20,949 (+4) ⚠ | 3,377 | 18.5 % (+0.1 %) ⚠ | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,360 (+3) βœ“ | +| unit cases | 1,390 (+30) βœ“ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,548 (+10) βœ“ | -| over threshold | 158 | +| functions | 2,572 (+24) βœ“ | +| over threshold | 161 (+3) ⚠ | | worst CCN | 108 | ## Documentation @@ -58,9 +58,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| | markdown files | 179 | -| markdown lines | 25,225 (+176) ⚠ | +| markdown lines | 25,461 (+236) ⚠ | | plan files | 92 | -| backlog lines | 3,683 (βˆ’2) βœ“ | -| lessons lines | 549 (+46) ⚠ | +| backlog lines | 3,712 (+29) ⚠ | +| lessons lines | 549 | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 3ffe7729..907df068 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -109,6 +109,49 @@ Two rules a script author meets: 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`. +## What the card tells you: size, memory, and how close to a wall + +Three numbers, and they are not the same thing. + +**`status` is the size of the compiled program**: how many bytes of machine code the script became. +That is what a script author asks and what nothing else answers. + +**The memory figure (`696B + 1.4KB`) is what the module costs the device.** The first part is the +module's own `sizeof`, fixed whether or not a script is loaded. The second is its dynamic bytes: the +exec block holding the JIT'd code, plus the 17-byte control arena. So the status and the dynamic +figure describe the same bytes from two angles, one as the program and one as the allocation, which +is word-rounded and includes the arena. + +**`tickTimeUs` is the real per-tick cost** of running the compiled function, measured the way every +module's is. `defineControls()` is not in it: that runs once after a compile. + +**A third allocation exists and appears nowhere**, deliberately. Compiling needs a staging buffer, +sized from the script's token count before a byte is emitted, and it is freed the moment the compile +returns. It never reaches a card because by the time the UI reads anything it is gone. It also does +not accumulate: three scripted modules compiling in sequence each borrow and return it, so what +persists per module is only the exec block, sized to what was actually emitted rather than to the +estimate. + +### The walls, and which one the card warns about + +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` | +| members | 8 | shares the budget with controls | +| functions | 8 | merge two helpers | +| string bytes | 128 | shorter control labels | + +The other five (IR ops, virtual registers, frame slots, assembler labels and fixups) are derived +from code size or loop nesting, so a number for them is noise: nothing an author writes addresses +them directly. + +The card shows the **tightest** of the five, and only past half full: `1568 B, controls 8/8`. The +others by definition have more room, so showing all five would bury the one that matters. An +ordinary script reads its size and nothing else. + ## Pieces - **`MoonLive`** (`src/core/moonlive/MoonLive.h/.cpp`) β€” the **domain-neutral engine core**. Owns a block of executable memory; `compile(source, table)` runs the front-end against a host builtin table and places the emitted code, `run(buf, nLights, cpl, t)` calls it. Includes only ``, the compiler/emitter seams, and the platform seam β€” never `EffectBase`, `Buffer`, or any LED type. diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index a7185c97..45e45ad7 100644 --- a/docs/moonmodules/light/MoonLiveLayout.md +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -77,10 +77,30 @@ So it runs twice. On the first pass `addLight` counts; on the second it emits ea ## Limits -**The grammar is arithmetic, calls and `for`** β€” `+`, `-`, `*`, parentheses, nested loops. Division, `%` and `if` are not in the language yet, so a serpentine over an arbitrary number of rows (every other row reversed) is not expressible today. A fixed few rows can be written out as one loop per direction β€” `two-rows.mlv` does exactly that β€” but each row costs its own loop, so it does not scale to a panel. +**The grammar is arithmetic, calls, `for` and `if`**: `+`, `-`, `*`, parentheses, nested loops, and the six comparisons (`<`, `<=`, `>`, `>=`, `==`, `!=`). Division and `%` are not in the language, so where a script would divide it calls `mod(a, b)` or `turn(n)` instead. + +A serpentine (every other row reversed) is what `if` makes expressible, and it is the common panel wiring: + +```c +uint8_t 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); } + else { addLight(cols - 1 - x, y, 0); } + } + if (odd == 0) { odd = 1; } else { odd = 0; } +} +``` **A script runs twice per rebuild**, once to count and once to place, so it has to be deterministic. With `random16` in a loop bound or around an `addLight` call, the two passes disagree on the count; with `random16` in a coordinate, the count holds and only the positions move. +## What the card tells you + +`status` is the size of the compiled program; the memory figure is what the module costs the device +(its own `sizeof`, plus the exec block and control arena); `tickTimeUs` is the real per-tick cost. +Past half full, the status also names the tightest limit the script is approaching. Detail: +[MoonLive](MoonLiveEffect.md#what-the-card-tells-you-size-memory-and-how-close-to-a-wall). + ## Controls | control | what it does | diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index 43394bed..de49f7b9 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -41,11 +41,18 @@ It is for debugging and comes back out again β€” [what print costs](../../../moo ## 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 β€” keep a computed result inside the box. +**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 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. -**The grammar is arithmetic over calls** β€” `+`, `-`, `*`, parentheses, the usual precedence, and `for`. Division and `if` are not in the language yet. +**The grammar is arithmetic over calls**: `+`, `-`, `*`, parentheses, the usual precedence, `for`, and `if` with the six comparisons. Division and `%` are not operators; `mod(a, b)` and `turn(n)` are the calls that cover them. + +## What the card tells you + +`status` is the size of the compiled program; the memory figure is what the module costs the device +(its own `sizeof`, plus the exec block and control arena); `tickTimeUs` is the real per-tick cost. +Past half full, the status also names the tightest limit the script is approaching. Detail: +[MoonLive](MoonLiveEffect.md#what-the-card-tells-you-size-memory-and-how-close-to-a-wall). ## Controls diff --git a/docs/performance.md b/docs/performance.md index e78f9b07..4b441c8b 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -250,6 +250,20 @@ The **depth guard** is one arena byte, incremented on entry and decremented in t **Desktop tick across this cycle:** 150 β†’ 133 Β΅s (6666 β†’ 7518 fps), measured by `collect_kpi.py --commit` at each commit. The gain is not from MoonLive β€” it tracks the two heap-overrun fixes and the register-reuse work landing earlier in the branch. No scenario `contract` was renegotiated on this branch: all 20 scenarios pass inside their existing budgets, which is the assertion surface this page defers to. +**The compile-time staging buffer is sized from the script's tokens**, at 48 bytes per token plus a +256-byte floor, and freed when the compile returns. The constant is the worst case rather than the +average, because the buffer is allocated before a byte is emitted: measured across every shipped +script on all three backends, the densest is `random-pixel.mlv` at 28.5 B/token on RISC-V (one +statement, four nested `random16()` calls, each saving the whole register pool), so 48 is a ~1.7x +margin. Density FALLS as a script grows, so a short call-dense script sets the bound: `gradient.mlv` +is 5.9 and the longest shipped script is 15.3. + +It was 64, measured before host arguments moved into frame slots shrank what a call saves. At that +figure the two longest scripts asked for more than the 16 KB sanity bound and were served by the +clamp, so a script's buffer had stopped tracking its size. Re-measuring took 25% off the transient +allocation, which matters on a classic ESP32 where the compile shares a 12 KB task. A per-ISA test +pins that every shipped script still emits under two-thirds of its budget. + **Flash**, measured by building the classic at the branch point and again with the local-call work: 1723295 β†’ 1726027 bytes, +2732 (+0.16%). High per line of source (about 20 bytes for ~137 net lines of code) because nearly all of it is emitter code instantiated once per backend, so one line of the shared lowering becomes three copies of emitted-instruction sequences in the image. **The compile path's stack grew 640 bytes.** `kAsmLabels`/`kAsmFixups` went from 16/32 to 48/96 because a class allocates a label per function, so `lowerWith`'s frame went 480 β†’ 1120 bytes on the classic β€” the largest on the chain (144 + 288 + 576 + 1120 = 2128 nested, 17% of the 12 KB main task). It is a compile-path local, not a per-tick cost, but the compile runs on the render task. diff --git a/moondeck/moonlive/emit_isa.cpp b/moondeck/moonlive/emit_isa.cpp index 9b50dc45..2f164a07 100644 --- a/moondeck/moonlive/emit_isa.cpp +++ b/moondeck/moonlive/emit_isa.cpp @@ -53,7 +53,11 @@ using namespace mm; int main(int argc, char** argv) { const char* src = argc > 1 ? argv[1] : "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"; - uint8_t buf[4096]; + // Sized the way the ENGINE sizes it, from the script's own token count. A fixed 4 KB refused + // ripples.mlv and rose.mlv on RISC-V, which emits ~1.3x Xtensa: the tool reported "codegen + // failed (too large)" for scripts a device compiles without trouble, so the one place that + // measures emitted size was lying about the two largest scripts. + static uint8_t buf[moonlive::kCodeCap]; // Which BINDING to compile as, because the system-variable tables are different vocabularies and // not nested supersets: a modifier is handed `x`/`y`/`z`, and a LAYOUT deliberately is not, so it // may use those names as ordinary loop counters β€” which the shipped grid.mlv does. Compiling @@ -67,7 +71,7 @@ int main(int argc, char** argv) { // and the emitted code carries a pointer to it. Static so the pointers stay valid while the // bytes below are dumped. static char strings[moonlive::CompileResult::kStringPool]; - auto r = moonlive::compileSource(src, moonlive::lightBuiltins(), sysvars, buf, sizeof(buf), + auto r = moonlive::compileSource(src, moonlive::lightBuiltins(), sysvars, buf, moonlive::codeCapFor(moonlive::countTokens(src)), nullptr, nullptr, strings, sizeof(strings)); if (!r.ok) { printf("compile failed: %s\n", r.error); return 1; } printf("# %s\n# %zu bytes\n", src, r.len); diff --git a/moonlive/README.md b/moonlive/README.md index bd856dba..c4e269b2 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -36,6 +36,44 @@ 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 label, free to differ from the member's name. +**A member can be WRITTEN, which is what makes it state.** `level = level + 10;` assigns, and the +value is still there on the next tick, because a member lives in storage that outlives the call. A +loop variable can be assigned too. A [system variable](../docs/moonmodules/light/MoonLiveEffect.md) +(`width`, `t`, `xPos`) cannot: the engine rewrites it before every call, so the store would vanish. + +A control CAN be assigned, and the effect is visible rather than surprising: the value moves under +the slider until the user drags it again. Whether a member is a control is decided by +`defineControls()` at run time, so the language does not distinguish the two here. + +**`if` and `else`,** with `<`, `<=`, `>`, `>=`, `==` and `!=`. Both sides are ordinary expressions: + +```c +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: + +```c +uint16_t phase = 900; // a value a byte cannot hold +uint8_t 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 +**clamped to the last element** rather than refused or allowed through: a script computes indices +from live control values, so out of range is a normal run-time state, and the fixture shows a +repeated last light instead of crashing. + +All of a class's members share a small fixed budget (`kCtrlBytes`), so a class that declares more +than fits is a compile error naming the arena, not a failed allocation while a fixture runs. + +`effects/ember.mlv` is the worked example: a heat array that decays and re-ignites, so what it +draws this frame depends on the last one. That is the line between an effect that evaluates a +formula and one that runs a simulation, and it is the reason arrays exist. `plasma.mlv` would look +identical if every frame started from scratch; `ember.mlv` would go dark. + **Declare a helper above the function that calls it.** Only functions already parsed are visible, so a call to one declared further down reports `unknown function`. A function can always call itself. diff --git a/moonlive/effects/ember.mlv b/moonlive/effects/ember.mlv new file mode 100644 index 00000000..a6efbed1 --- /dev/null +++ b/moonlive/effects/ember.mlv @@ -0,0 +1,47 @@ +// Ember: a fire that SIMULATES rather than draws. Each cell holds a heat value that decays a +// little every frame and is re-lit at random, so what you see this frame depends on the last one. +// +// The first shipped script that carries state: `heat` is an array, and an array is what separates +// an effect that evaluates a formula from one that runs a simulation. Plasma computes each cell +// from the clock alone and would look identical if the previous frame were discarded; this one +// would go dark. +// +// Uses every construct the language grew for that: an array read and written by index, a member +// assigned (`heat[i] = ...`), `if`/`else` choosing a colour ramp, and a `uint16_t` counter that +// keeps counting past the 255 a byte stops at. + +class EmberEffect { + uint8_t cool = 30; // how fast a lit cell fades back to black + uint8_t spark = 60; // chance out of 256 that a cell re-ignites this frame + uint16_t phase = 0; // a free-running counter, wider than a byte on purpose + uint8_t heat[16]; // the simulation itself: one heat value per cell, kept between frames + + defineControls() { + addUint8("cool", cool, 1, 120); + addUint8("spark", spark, 0, 200); + } + + tick() { + // Wider than a byte, so it counts to 1000 rather than wrapping at 255. + phase = phase + 7; + if (phase >= 1000) { phase = 0; } + + // Cool: every cell loses `cool`, clamped at zero rather than wrapping to white. + for (i = 0; i < 16; i = i + 1) { + if (heat[i] > cool) { heat[i] = heat[i] - cool; } + else { heat[i] = 0; } + } + + // Spark: re-light a few cells. This is the only randomness, and it is what keeps the + // simulation from settling into a still image. + for (j = 0; j < 16; j = j + 1) { + if (random16(256) < spark) { heat[j] = 255; } + } + + // Paint: hot cells run red into yellow, cool ones stay in the dim reds. + for (k = 0; k < 16; k = k + 1) { + if (heat[k] > 128) { setRGB(k, 255, heat[k] - 128, 0); } + else { setRGB(k, heat[k] * 2, 0, 0); } + } + } +} diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index f9485954..24412a95 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -30,6 +30,7 @@ void MoonLive::freeCode() { // built. It is reclaimed when the next compile interns into it from offset zero, which is the // ordinary arena discipline: one program's strings at a time. controlCount_ = 0; + stringLen_ = 0; } // Copy `len` already-emitted bytes into a fresh exec block. writeExec hides the ISA quirks @@ -94,7 +95,7 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysV // the emitted code carries pointers into it, and the source buffer is freed the moment this // returns. NOT cleared here: freeCode() owns that, because a control record published by the // previous program still points into this pool. Zeroing before a compile that then FAILS left - // every published control named "" β€” name-keyed persistence and `POST /api/control` both go + // every published control named "": name-keyed persistence and `POST /api/control` both go // through that name, so a broken script silently unbound the user's own sliders. CompileResult cr = compileSource(source, table, sysvars, staging.p, staging.n, nullptr, nullptr, strings_, CompileResult::kStringPool); @@ -118,15 +119,16 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysV entryNames_[i][n] = '\0'; entries_[i] = {entryNames_[i], n, cr.entries[i].offset}; } + stringLen_ = cr.stringLen; ctrl_ = reinterpret_cast(block); return true; } // Ensure the control arena exists and seed newly-declared slots. The arena is allocated ONCE at -// full kMaxCtrls capacity and never reallocated, so its address β€” and every control pointer the +// full kArenaBytes capacity and never reallocated, so its address, and every control pointer the // binding bound to a slot β€” is fixed for the engine's lifetime (the stable-slot contract // controlSlot() promises; a recompile that adds a control must not move a pointer the previous -// defineControls already published). kMaxCtrls bytes is a handful; the up-front allocation is +// defineControls already published). kArenaBytes is a handful; the up-front allocation is // cheaper than the move-and-rebind it avoids. A NEW slot (beyond the previous count) is seeded // from its declared default; an EXISTING slot keeps its live value (a source edit that keeps the // control preserves the slider position). Returns false on alloc failure. @@ -136,13 +138,36 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { if (!ctrlArena_) return false; for (uint8_t i = 0; i < kArenaBytes; i++) ctrlArena_[i] = 0; } - // A NEW slot takes its declared initializer; an EXISTING one keeps its live value, so a source - // edit that keeps a control does not snap its slider back to the default. Indexed by the - // declaration's own offset rather than by position, because a member and the control that - // surfaces it share one arena byte and only the offset knows which. - for (uint8_t i = memberCount_; i < count; i++) - ctrlArena_[decls[i].offset] = static_cast(decls[i].def); - memberCount_ = count; + // A NEW slot takes its declared initializer; one holding the SAME member keeps its live value, + // so a source edit that keeps a control does not snap its slider back to the default. "Same" + // is offset AND name: a member inserted at the top of the class shifts every later declaration + // to a new offset, and each of those is a different member now occupying a seeded byte, so it + // must take its own initializer rather than inherit the previous occupant's value. + uint32_t seeding = 0; + for (uint8_t i = 0; i < count; i++) { + const uint8_t off = decls[i].offset; + if (off >= kArenaBytes) continue; // the parser bounds it; belt and braces + // The declared name is a SPAN of the source (nameLen, no terminator), so it is compared + // and stored length-bounded: strcmp would read past it into the rest of the script. + const uint8_t n = decls[i].nameLen < kSeedNameLen - 1 ? decls[i].nameLen + : uint8_t(kSeedNameLen - 1); + const bool same = ((seeded_ >> off) & 1u) && + std::strncmp(seededName_[off], decls[i].name, n) == 0 && + seededName_[off][n] == '\0'; + if (!same) { + // Seed the member's WHOLE width, little-endian to match every backend's halfword + // load: writing only the low byte would leave the high half holding whatever the + // previous program left there, so a fresh uint16_t member would start at a value its + // script never wrote. + ctrlArena_[off] = static_cast(decls[i].def & 0xff); + if (ctrlWidth(decls[i].type) == 2 && off + 1 < kArenaBytes) + ctrlArena_[off + 1] = static_cast(decls[i].def >> 8); + } + for (uint8_t c = 0; c < n; c++) seededName_[off][c] = decls[i].name[c]; + seededName_[off][n] = '\0'; + seeding |= 1u << off; + } + seeded_ = seeding; // a member the new script dropped is unseeded: its byte reseeds if it returns return true; } @@ -164,7 +189,8 @@ void MoonLive::free() { // The seeded-slot count goes with the arena it describes. Left behind, the next compile would // treat every member as one it had already seeded and skip the initializers, so a script would // start every value at zero instead of what it declared. - memberCount_ = 0; + seeded_ = 0; + for (uint8_t i = 0; i < kArenaBytes; i++) seededName_[i][0] = '\0'; } } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index e8b5e774..1830f4d7 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -1,6 +1,7 @@ #pragma once #include +#include // snprintf, for describe() #include #include "core/moonlive/moonlive_emit.h" #include "core/moonlive/MoonLiveBuiltins.h" @@ -154,20 +155,61 @@ class MoonLive { // every control from its declared default. void freeCode(); - // The emitted code length, for the golden-bytes test (0 until compiled). + /// How many bytes of machine code the current program IS, as opposed to what its allocation + /// cost. `heapBytes` answers "what does this module take from the heap"; this answers "how big + /// is my script", which is what a script author asks and what the card reports. 0 until + /// compiled. Also the golden-bytes test's subject. size_t codeLen() const { return codeLen_; } // The allocated exec-block size (word-rounded codeLen) β€” the actual heap held, for memory // accounting. 0 until compiled / after free(). size_t codeCap() const { return codeCap_; } - /// Every heap byte this engine holds β€” the exec block plus the control arena. + /// Every HEAP byte this engine holds: the exec block plus the control arena. /// - /// What a binding reports as its dynamicBytes: the card is supposed to show the memory the - /// module actually costs, and codeCap() alone missed the arena. The arena is small but it is a - /// real allocation with the module's lifetime, and "roughly right" is how a memory figure stops - /// being worth reading. + /// What a binding reports as its dynamicBytes. codeCap() alone missed the arena, which is small + /// but is a real allocation with the module's lifetime, and "roughly right" is how a memory + /// figure stops being worth reading. + /// + /// The string pool is NOT here, and that is not an omission: it is an inline member, so it is + /// already counted in the module's own sizeof rather than its dynamic bytes. Adding it would + /// report those bytes twice. size_t heapBytes() const { return codeCap_ + (ctrlArena_ ? kArenaBytes : 0); } + /// How much of the string pool the current program uses. Its ceiling is kStringPool. + uint16_t stringBytes() const { return stringLen_; } + + /// Write "1700 B - controls 2/8" into `out`: how big the compiled program is, and the ONE + /// budget it is closest to exhausting. + /// + /// Size first because it is what a script author asks and nothing could answer: the card's + /// memory figure is the ALLOCATION, which is word-rounded and says nothing about the program. + /// + /// One budget, not five. A script can hit ten ceilings, but five are derived (IR ops, vregs, + /// labels, fixups, locals all follow from code size or nesting) and reporting a number the + /// author cannot act on is noise. Of the five that are actionable, showing the tightest is what + /// answers "am I near a wall": the others by definition have more room. It appears only past + /// half full, so an ordinary script reads its size and nothing else. + void describe(char* out, size_t cap) const { + if (!out || !cap) return; + if (!codeLen_) { out[0] = '\0'; return; } + // Each actionable budget as a percentage, so the tightest is comparable across units. + struct Budget { const char* name; uint32_t used, max; }; + const Budget budgets[] = { + {"controls", controlCount_, kMaxCtrls}, + {"strings", stringLen_, CompileResult::kStringPool}, + {"code", static_cast(codeLen_), kCodeCap}, + {"entries", entryCount_, kMaxEntryPoints}, + }; + const Budget* worst = &budgets[0]; + for (const Budget& b : budgets) + if (b.used * uint64_t(worst->max) > worst->used * uint64_t(b.max)) worst = &b; + if (worst->used * 2 > worst->max) + std::snprintf(out, cap, "%u B, %s %u/%u", unsigned(codeLen_), + worst->name, unsigned(worst->used), unsigned(worst->max)); + else + std::snprintf(out, cap, "%u B", unsigned(codeLen_)); + } + // The controls the last compile() declared (empty if none / not a source compile). The binding // reads this to create real MoonModule controls bound to the arena slots. const DeclaredControl* declaredControls(uint8_t& count) const { count = controlCount_; return controls_; } @@ -175,7 +217,7 @@ class MoonLive { // reference here. nullptr if offset is out of range. The arena is allocated once at full // capacity (ensureArena) and never moves, so a bound control pointer stays valid for the // engine's lifetime, across every recompile (the stable-slot contract). - /// The live byte at an arena offset: a script-declared control (offset < kMaxCtrls) or a host + /// The live byte at an arena offset: a script-declared member (offset < kCtrlBytes) or a host /// system variable (above it). Bounded by the ARENA, not by controlCount_ β€” a system variable's /// slot exists whether or not the script declared any control, and the binding writes it every /// frame. Returns nullptr for an offset the arena does not hold. @@ -192,9 +234,20 @@ class MoonLive { // recompile; preserves an existing slot's live value when the script is edited but the control // persists. Returns false on alloc failure (the caller degrades). bool ensureArena(const DeclaredControl* decls, uint8_t count); - // How many MEMBER slots have been seeded. A recompile seeds only the new ones, so a member - // that survives an edit keeps its live value rather than snapping back to its initializer. - uint8_t memberCount_ = 0; + // WHICH arena bytes hold a seeded member, one bit per offset, and the name each one was + // seeded under. Identity is (offset, name), not declaration position: inserting a member at + // the top of a class shifts every later declaration down one, and a position-keyed check would + // then hand each member the value of the one that used to sit there. kArenaBytes is 17, so a + // uint32_t covers the whole arena and the mask costs less than the count it replaces. + // + // The name is COPIED rather than pointed at. The compiler's record names a span of the source + // buffer, which is freed the moment the compile returns, and the comparison happens on the + // NEXT compile: a pointer would be dangling by then. Truncated to a prefix, which is enough to + // tell two members apart in the only case that matters, and bounded so a long name cannot run + // off the end of a record that carries its length instead of a terminator. + static constexpr uint8_t kSeedNameLen = 12; + uint32_t seeded_ = 0; + char seededName_[kArenaBytes][kSeedNameLen] = {}; void* code_ = nullptr; // allocExec block holding the emitted machine code size_t codeCap_ = 0; // its capacity (for freeExec) @@ -225,6 +278,8 @@ class MoonLive { // belong in it. This is a plain member for the same reason ctrlNames_ is, and it lives as long // as the compiled program that points into it. char strings_[CompileResult::kStringPool] = {}; + // How much of the pool the current program uses, for the card's headroom readout. + uint16_t stringLen_ = 0; }; } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index 4d1ea780..962ef7e4 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -102,11 +102,28 @@ struct BuiltinTable { } }; -static constexpr uint8_t kMaxCtrls = 8; // a script declares a handful of controls; fixed, no heap +/// BYTES the script's own members may occupy, and separately how many members it may declare. +/// +/// These were one number while every member was a byte and its offset WAS its declaration index. +/// A `uint16_t` member costs two bytes and an array costs its length, so the two stopped being the +/// same question: a script may want six members costing sixteen bytes, or two members costing +/// twelve. The byte budget is what the arena allocates; the count is what the fixed record tables +/// hold. Both are fixed, so neither needs a heap. +// 64 was chosen against the first effect that wanted an array rather than in the abstract: a +// 16-element heat buffer with two byte controls and a uint16_t phase needs 20, and a per-light +// buffer for a small fixture wants more. 64 holds a uint8_t[64] or a uint16_t[32] alongside a few +// scalars, costs 48 bytes per engine over the old value (three engines per pipeline, so 144 on a +// device), and keeps the whole arena inside a byte offset, which is what LoadCtrl's immediate and +// the DeclaredControl record both carry. Raise it against a script that needs more, not on +// speculation: the failure is a clear compile error naming the arena, so hitting it is visible. +static constexpr uint8_t kCtrlBytes = 64; // arena bytes the script's members share +static constexpr uint8_t kMaxCtrls = 8; // records: how many members/controls may exist // The controls arena holds three kinds of byte, in one allocation with a fixed split: -// [0 .. kMaxCtrls) script-declared controls, offset == declaration index -// [kMaxCtrls .. kMaxCtrls+kMaxSysVars) host system variables (width/height/…), offset assigned +// [0 .. kCtrlBytes) script-declared members, offset == a BYTE CURSOR assigned +// in declaration order (NOT the declaration index: a member +// wider than a byte, or an array, consumes several) +// [kCtrlBytes .. kCtrlBytes+kMaxSysVars) host system variables (width/height/…), offset assigned // by the host and CONSTANT for the program's life // [kDepthSlot] the recursion depth counter, owned by the emitted code // System variables sit ABOVE the script's range so that adding or removing a control β€” which @@ -127,16 +144,29 @@ static constexpr size_t kCodeCap = 16384; /// that is freed when the compile ends; under-estimating fails a script that would have fit, so the /// direction of the error is deliberate β€” the same rule the IR's op estimate follows. /// -/// 64 bytes/token, measured across every shipped script on all three backends with `countTokens` +/// 48 bytes/token, measured across every shipped script on all three backends with `countTokens` /// (which skips comments, so a long header does not inflate the count). The densest is -/// `random-pixel.mlv` at 39.3 β€” one statement, four nested `random16()` calls, and on RISC-V each -/// call saves and restores the whole register pool β€” so this is a ~1.6x margin over the worst real -/// case. A SHORT call-dense script sets the bound, not a long one: a call lowers to a save/restore -/// while declarations and operators lower to a few instructions each, so bytes-per-token FALLS as a -/// script grows. The floor covers a tiny script's fixed prologue and epilogue, which no per-token -/// figure expresses. +/// `random-pixel.mlv` at 28.5 on RISC-V: one statement, four nested `random16()` calls, and each +/// call saves and restores the whole register pool. So this is a ~1.7x margin over the worst real +/// case. +/// +/// A SHORT call-dense script sets the bound, not a long one. A call lowers to a save/restore while +/// declarations and operators lower to a few instructions each, so bytes-per-token FALLS as a +/// script grows: `gradient.mlv` is 5.9 where `random-pixel.mlv` is 28.5, and the longest shipped +/// script (`ripples.mlv`, 280 tokens) is only 15.3. The margin is kept wide for that reason rather +/// than trimmed to the observed worst: a new short call-dense script could beat 28.5, while a long +/// one cannot. +/// +/// It was 64, from a measurement taken before host arguments moved into frame slots, which shrank +/// what a call saves. At 64 the two longest scripts asked for more than kCodeCap and were served by +/// the clamp, which works but means a script's buffer stopped tracking its size. Re-measuring took +/// 25% off the transient allocation, which matters on a classic ESP32 where the compile shares a +/// 12 KB task. +/// +/// The floor covers a tiny script's fixed prologue and epilogue, which no per-token figure +/// expresses. constexpr size_t codeCapFor(uint32_t tokens) { - const size_t want = size_t(tokens) * 64 + 256; + const size_t want = size_t(tokens) * 48 + 256; return want > kCodeCap ? kCodeCap : want; } @@ -151,7 +181,7 @@ static constexpr uint8_t kMaxSysVars = 8; /// The host zeroes it before each run rather than trusting the block to unwind cleanly: a script /// that hits the limit leaves the counter wherever the skipped call left it, and a stale value /// would shrink the budget of every later frame until nothing ran at all. -static constexpr uint8_t kDepthSlot = kMaxCtrls + kMaxSysVars; +static constexpr uint8_t kDepthSlot = kCtrlBytes + kMaxSysVars; /// The depth at which a call is REFUSED: an activation that would make the counter reach this /// number returns without running, so 31 activations execute, the entry function included. @@ -165,7 +195,7 @@ static constexpr uint8_t kDepthSlot = kMaxCtrls + kMaxSysVars; /// stack and the rest of the render path need. static constexpr uint8_t kMaxCallDepth = 32; -static constexpr uint8_t kArenaBytes = kMaxCtrls + kMaxSysVars + 1; // +1: kDepthSlot +static constexpr uint8_t kArenaBytes = kCtrlBytes + kMaxSysVars + 1; // +1: kDepthSlot /// A name the HOST defines and the script only reads: `width`, `height`, `depth`. Reserved β€” a /// script cannot declare one, so the name means the same thing in every script (the `t` rule, one @@ -203,7 +233,7 @@ struct SysVarTable { // controls, inside the arena); an Arg must name a real argument register. bool add(const SysVar& v) { if (count >= kMax || v.name == nullptr) return false; - if (v.kind == SysVarKind::Arena && (v.where < kMaxCtrls || v.where >= kArenaBytes)) + if (v.kind == SysVarKind::Arena && (v.where < kCtrlBytes || v.where >= kArenaBytes)) return false; // kArg4 is the last argument register (MoonLiveIr.h owns the enum, and includes THIS // header, so the bound is spelled here rather than referenced). diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index e67c3a55..bd083ed2 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -11,7 +11,8 @@ namespace { // --- Lexer --------------------------------------------------------------------------- // A `//` line comment is whitespace. `Assign` is `=` (a member declaration's initializer). enum class Tok { Ident, Number, String, Assign, LParen, RParen, LBrace, RBrace, Comma, Semicolon, - Plus, Minus, Star, Less, End, Error }; + Plus, Minus, Star, Less, LessEq, Greater, GreaterEq, EqEq, NotEq, + LBracket, RBracket, End, Error }; struct Lexer { const char* p; @@ -58,6 +59,12 @@ struct Lexer { tokBeg = p; char c = *p; if (c == 0) { kind = Tok::End; return; } + // Two-character operators first: '<' is a prefix of '<=' and '=' of '==', so testing a + // short form ahead of the long one would lex `a == b` as two assignments. Maximal munch. + if (c == '<' && p[1] == '=') { p += 2; kind = Tok::LessEq; return; } + if (c == '>' && p[1] == '=') { p += 2; kind = Tok::GreaterEq; return; } + if (c == '=' && p[1] == '=') { p += 2; kind = Tok::EqEq; return; } + if (c == '!' && p[1] == '=') { p += 2; kind = Tok::NotEq; return; } if (c == '=') { p++; kind = Tok::Assign; return; } if (c == '(') { p++; kind = Tok::LParen; return; } if (c == ')') { p++; kind = Tok::RParen; return; } @@ -68,7 +75,10 @@ struct Lexer { if (c == '*') { p++; kind = Tok::Star; return; } if (c == '{') { p++; kind = Tok::LBrace; return; } if (c == '}') { p++; kind = Tok::RBrace; return; } + if (c == '[') { p++; kind = Tok::LBracket; return; } + if (c == ']') { p++; kind = Tok::RBracket; return; } if (c == '<') { p++; kind = Tok::Less; return; } + if (c == '>') { p++; kind = Tok::Greater; return; } // '/' only reaches here when it is NOT the `//` a comment starts with (handled above). // '/' and '%' are deliberately NOT tokens yet. No ISA here has a cheap integer divide, so // both would lower to a host call β€” which the light domain already ships as `mod(a, b)` and @@ -150,6 +160,9 @@ struct Parser { uint16_t stringCap = 0; uint16_t stringLen = 0; 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. + uint8_t memberBytes = 0; uint8_t memberCount = 0; const char* error = ""; @@ -176,6 +189,15 @@ struct Parser { } + /// Find a script-local (a `for` counter or limit) by name; its index, or -1. Same span + /// comparison as findMember, for the same reason. + int findLocal(const char* name, size_t len) const { + for (uint8_t i = 0; i < localCount; i++) + if (locals[i].nameLen == len && std::strncmp(locals[i].name, name, len) == 0) + return i; + return -1; + } + /// Find a declared MEMBER by name; its index, or -1. Names are token spans into the source /// rather than NUL-terminated strings, so compare by length and bytes. int findMember(const char* name, size_t len) const { @@ -304,26 +326,42 @@ struct Parser { emit({IrOp::LoadCtrl, v, 0,0,0,0, sv->where, nullptr, {}}); return v; } - for (uint8_t li = 0; li < localCount; li++) { - if (locals[li].nameLen == lex.identLen && - std::strncmp(locals[li].name, lex.identBeg, lex.identLen) == 0) { - // Load the variable from its frame slot into a fresh temp. The temp is ordinary: - // the caller consumes it and frees it like any other expression value, so a - // variable occupies a register only for the instruction that reads it rather - // than for the whole of its scope. - lex.advance(); - VReg v = alloc(); - emit({IrOp::Reload, v, 0,0,0,0, locals[li].slot, nullptr, {}}); - return v; - } + const int li = findLocal(lex.identBeg, lex.identLen); + if (li >= 0) { + // Load the variable from its frame slot into a fresh temp. The temp is ordinary: + // the caller consumes it and frees it like any other expression value, so a + // variable occupies a register only for the instruction that reads it rather + // than for the whole of its scope. + lex.advance(); + VReg v = alloc(); + emit({IrOp::Reload, v, 0,0,0,0, locals[li].slot, nullptr, {}}); + return v; } // A MEMBER read. A control is a member the UI shows, so this one lookup answers both: // the arena byte is the same byte either way. const int mi = findMember(lex.identBeg, lex.identLen); if (mi >= 0) { - VReg v = alloc(); - emit({IrOp::LoadCtrl, v, 0,0,0,0, members[mi].offset, nullptr, {}}); lex.advance(); + // An ARRAY element: `heat[i]`, where the index is an arbitrary expression. The + // same orthogonality the rest of the language has, so an index may be a member, a + // loop counter or arithmetic over both. + if (lex.kind == Tok::LBracket) { + if (members[mi].count == 1) { fail("this member is not an array"); return 0; } + lex.advance(); + VReg idx = parseExpr(); + if (failed) return 0; + if (!expect(Tok::RBracket, "expected ']' to close an array index")) { freeTemp(idx); return 0; } + VReg v = alloc(); + emit({IrOp::LoadIdx, v, idx, 0, 0, 0, + idxPack(members[mi].offset, ctrlWidth(members[mi].type), + members[mi].count), nullptr, {}}); + freeTemp(idx); + return v; + } + if (members[mi].count > 1) { fail("an array needs an index: write name[i]"); return 0; } + VReg v = alloc(); + emit({members[mi].type == CtrlType::Uint16 ? IrOp::LoadCtrl16 : IrOp::LoadCtrl, + v, 0,0,0,0, members[mi].offset, nullptr, {}}); return v; } VReg out = 0; @@ -428,7 +466,7 @@ struct Parser { v = alloc(); emit({IrOp::ConstPtr, v, 0,0,0,0, 0, nullptr, interned, {}}); lex.advance(); - } else if (fn->byRef && (fn->byRef >> n) & 1u) { + } else if ((fn->byRef >> n) & 1u) { 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; } @@ -501,7 +539,7 @@ struct Parser { // A MEMBER declaration: `uint8_t ident = number ;`. Whether the UI shows it is a separate // question the script answers by naming it in defineControls(). // The leading `uint8_t` keyword is already consumed by the caller. Records a DeclaredControl. - void parseDecl() { + void parseDecl(CtrlType type) { if (lex.kind != Tok::Ident) { fail("expected a member name after the type"); return; } const char* name = lex.identBeg; size_t nameLen = lex.identLen; if (nameLen >= kMaxControlName) { fail("member name too long"); return; } // no silent truncation downstream @@ -515,9 +553,46 @@ struct Parser { // 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; } lex.advance(); + // An ARRAY: `uint8_t 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) { + 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; } + count = static_cast(lex.number); + lex.advance(); + if (!expect(Tok::RBracket, "expected ']' to close the array length")) return; + 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); + 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 + 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, + static_cast(at), count}; + memberBytes = static_cast(at + need); + memberCount++; + return; + } if (!expect(Tok::Assign, "expected '=' in a member declaration")) return; if (lex.kind != Tok::Number) { fail("expected a default value (a number)"); return; } - if (lex.number < 0 || lex.number > 255) { fail("uint8_t default out of range (0..255)"); return; } + // The initializer is range-checked against the DECLARED type, so a uint8_t member cannot be + // given a value it silently truncates. A uint16_t's own default is bounded below, once its + // arena slot is known: the DeclaredControl record carries a byte, so a wide default is the + // seeding path's concern rather than the parser's. + const long defMax = type == CtrlType::Uint16 ? 65535 : 255; + if (lex.number < 0 || lex.number > defMax) { + fail(type == CtrlType::Uint16 ? "uint16_t default out of range (0..65535)" + : "uint8_t default out of range (0..255)"); + return; + } long def = lex.number; lex.advance(); if (!expect(Tok::Semicolon, "expected ';' after the member declaration")) return; @@ -528,8 +603,23 @@ struct Parser { // answers by naming it in `defineControls()`, so a declaration no longer carries a range: // the range belongs to the control, and a member that no control surfaces has none. if (memberCount >= kMaxCtrls) { fail("too many members"); return; } + // The offset is a running BYTE CURSOR, not the declaration index: a member wider than a + // 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); + 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 + need > kCtrlBytes) { fail("the class declares more member data than the arena holds"); return; } members[memberCount] = {name, 0, 255, static_cast(def), - static_cast(nameLen), CtrlType::Uint8, memberCount}; + static_cast(nameLen), type, + static_cast(at), 1}; + memberBytes = static_cast(at + need); memberCount++; } @@ -540,7 +630,9 @@ struct Parser { return lex.kind == Tok::Ident && lex.identLen == len && std::strncmp(lex.identBeg, kw, len) == 0; } // Is the current Ident the `uint8_t` type keyword (the only declared type in Stage 1)? - bool atTypeKeyword() const { return atKeyword("uint8_t", 7); } + bool atTypeKeyword() const { return atKeyword("uint8_t", 7) || atKeyword("uint16_t", 8); } + /// The type the current keyword names. Only called when atTypeKeyword() is true. + CtrlType currentType() const { return atKeyword("uint16_t", 8) ? CtrlType::Uint16 : CtrlType::Uint8; } // program := { decl } { stmt }. Declarations (control vars) come first, then one-or-more // call statements. (Multi-statement now: a script has decl lines AND a statement line.) @@ -581,11 +673,7 @@ struct Parser { // the inner step then writes the register the outer back edge tests, and the emitted program // never terminates β€” a hang on the render task from a script a user can type. Refused for the // same reason a duplicate control name is. - for (uint8_t li = 0; li < localCount; li++) - if (locals[li].nameLen == varLen && - std::strncmp(locals[li].name, varName, varLen) == 0) { - fail("loop variable already in use"); return false; - } + if (findLocal(varName, varLen) >= 0) { fail("loop variable already in use"); return false; } lex.advance(); if (!expect(Tok::Assign, "expected '=' in the for's first clause")) return false; VReg init = parseExpr(); @@ -707,9 +795,195 @@ struct Parser { } /// One statement: a call, or a for. + /// `name = expr;` is the statement that makes a member STATE rather than a constant. + /// + /// What may be assigned to is the rule worth stating. A MEMBER may: it is the script's own + /// storage, and its arena byte surviving every call is exactly what a stateful effect (fire, + /// trails, decay) needs. A script-LOCAL may: a `for` counter already lives in a frame slot and + /// the step clause already writes it, so a body assignment is the same store the loop makes. + /// + /// A SYSTEM VARIABLE may not: the engine rewrites it before every call, so a store would + /// appear to work and then vanish, which is worse than being refused. + /// + /// A CONTROL is deliberately NOT refused, though the UI does own its value. Whether a member + /// becomes a control is decided at RUN time, by `defineControls()` calling `addUint8` on it, + /// so the parser cannot know: that is the direct consequence of a control being an ordinary + /// call rather than an annotation. Writing one is also legitimate (an effect that ramps its + /// own speed and lets the slider re-take it), and the outcome is visible rather than silent: + /// the value moves under the slider until the user drags it again. The name is already + /// consumed when this runs. + bool parseAssignment(const char* name, size_t nameLen) { + // An ARRAY element assignment: `heat[i] = expr;`. Resolved before the '=' because the + // index sits between the name and the operator. + if (lex.kind == Tok::LBracket) { + const int ai = findMember(name, nameLen); + if (ai < 0) { fail("no member of that name is declared in this class"); return false; } + if (members[ai].count == 1) { fail("this member is not an array"); return false; } + lex.advance(); + VReg idx = parseExpr(); + if (failed) 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; } + emit({IrOp::StoreIdx, 0, idx, v, 0, 0, + idxPack(members[ai].offset, ctrlWidth(members[ai].type), + members[ai].count), nullptr, {}}); + freeTemp(v); freeTemp(idx); + return expect(Tok::Semicolon, "expected ';' after an assignment"); + } + if (!expect(Tok::Assign, "expected '=' in an assignment")) return false; + + // Resolve the DESTINATION before parsing the value, so a refused target reports the name + // the script wrote rather than a diagnostic from somewhere inside the expression. + const int li = findLocal(name, nameLen); + const int mi = li >= 0 ? -1 : findMember(name, nameLen); + if (mi >= 0 && members[mi].count > 1) { + fail("an array is assigned one element at a time: write name[i] = value"); + return false; + } + if (li < 0 && mi < 0) { + if (sysvars.find(name, nameLen)) { + fail("a system variable is read-only: the engine writes it before every call"); + } else { + fail("no member or loop variable of that name: declare it in the class body"); + } + return false; + } + VReg v = parseExpr(); + if (failed) return false; + if (li >= 0) emit({IrOp::Spill, 0, v, 0,0,0, locals[li].slot, nullptr, {}}); + else emit({members[mi].type == CtrlType::Uint16 ? IrOp::StoreCtrl16 : IrOp::StoreCtrl, + 0, v, 0,0,0, members[mi].offset, nullptr, {}}); + freeTemp(v); + return expect(Tok::Semicolon, "expected ';' after an assignment"); + } + + /// `if (a OP b) { … }` with an optional `else { … }`. + /// + /// The six comparisons lower onto the TWO branch ops the loops already use. The emitted branch + /// skips the then-block, so each one emits the NEGATION of what the script wrote. `BranchGe` is + /// unsigned `a >= b` and `BranchNe` is `a != b`, which is all a byte language needs: + /// + /// written skip when emitted + /// a < b a >= b BranchGe a, b + /// a > b a <= b BranchGe b, a (b >= a is exactly a <= b) + /// a >= b a < b emitStrictLess(a, b) (strict, so not a single BranchGe) + /// a <= b a > b emitStrictLess(b, a) + /// a == b a != b BranchNe a, b + /// a != b a == b BranchNe over an always-taken skip (there is no BranchEq) + /// + /// The branch always jumps AROUND the taken block, which is the shape that makes an `if` emit + /// only FORWARD branches. That matters beyond tidiness: the spill pass identifies a loop as a + /// BranchNe whose label was bound EARLIER, so a forward-only construct cannot be mistaken for + /// one, and no change to the allocator is needed to support conditionals. + bool parseIf() { + lex.advance(); // `if` + if (!expect(Tok::LParen, "expected '(' after if")) return false; + VReg a = parseExpr(); + if (failed) return false; + const Tok cmp = lex.kind; + if (cmp != Tok::Less && cmp != Tok::LessEq && cmp != Tok::Greater && + cmp != Tok::GreaterEq && cmp != Tok::EqEq && cmp != Tok::NotEq) { + freeTemp(a); + fail("expected a comparison: <, <=, >, >=, == or !="); + return false; + } + lex.advance(); + VReg b = parseExpr(); + if (failed) { 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; } + + // Two labels at most: one to skip the then-block, one to skip the else-block. + if (nextLabel + 2 > kIrLabels) { fail("too many branches in one script"); return false; } + const uint8_t lElse = nextLabel++; + + // Emit the branch that SKIPS the then-block, which is the NEGATION of the written test. + switch (cmp) { + // !(a < b) is a >= b. + case Tok::Less: emit({IrOp::BranchGe, 0, a, b, 0,0, lElse, nullptr, {}}); break; + // !(a >= b) is a < b, which is b > a: BranchGe with the operands swapped tests b >= a, + // so the strict form needs the pair below. a >= b skips when a < b == b > a. + case Tok::GreaterEq: emitStrictLess(a, b, lElse); break; + // !(a > b) is a <= b, i.e. b >= a. + case Tok::Greater: emit({IrOp::BranchGe, 0, b, a, 0,0, lElse, nullptr, {}}); break; + // !(a <= b) is a > b, i.e. b < a. + case Tok::LessEq: emitStrictLess(b, a, lElse); break; + case Tok::EqEq: emit({IrOp::BranchNe, 0, a, b, 0,0, lElse, nullptr, {}}); break; + // !(a != b) is a == b. There is no BranchEq, so this is the one case that needs two: + // branch over the skip when they differ, then skip unconditionally. + case Tok::NotEq: { + if (nextLabel >= kIrLabels) { freeTemp(b); freeTemp(a); fail("too many branches in one script"); return false; } + const uint8_t lBody = nextLabel++; + emit({IrOp::BranchNe, 0, a, b, 0,0, lBody, nullptr, {}}); + VReg z = alloc(); + emit({IrOp::Const, z, 0,0,0,0, 0, nullptr, {}}); + emit({IrOp::BranchGe, 0, z, z, 0,0, lElse, nullptr, {}}); // z >= z: always taken + freeTemp(z); + emit({IrOp::Label, 0, 0,0,0,0, lBody, nullptr, {}}); + break; + } + default: break; + } + freeTemp(b); freeTemp(a); + + while (!failed && lex.kind != Tok::RBrace && lex.kind != Tok::End) + if (!parseStatement()) return false; + if (failed) return false; + if (!expect(Tok::RBrace, "expected '}' to close the if body")) return false; + + if (atKeyword("else", 4)) { + lex.advance(); + if (nextLabel >= kIrLabels) { fail("too many branches in one script"); return false; } + const uint8_t lEnd = nextLabel++; + // The then-block falls through to here, and must jump OVER the else-block. An + // unconditional jump is `x >= x`, which the assemblers already encode. + VReg z = alloc(); + emit({IrOp::Const, z, 0,0,0,0, 0, nullptr, {}}); + emit({IrOp::BranchGe, 0, z, z, 0,0, lEnd, nullptr, {}}); + freeTemp(z); + emit({IrOp::Label, 0, 0,0,0,0, lElse, nullptr, {}}); + if (!expect(Tok::LBrace, "expected '{': an else body is braced")) return false; + while (!failed && lex.kind != Tok::RBrace && lex.kind != Tok::End) + if (!parseStatement()) return false; + if (failed) return false; + if (!expect(Tok::RBrace, "expected '}' to close the else body")) return false; + emit({IrOp::Label, 0, 0,0,0,0, lEnd, nullptr, {}}); + } else { + emit({IrOp::Label, 0, 0,0,0,0, lElse, nullptr, {}}); + } + return true; + } + + /// Branch to `label` when `a < b`, STRICTLY. BranchGe gives `>=` only, so the strict form is + /// "not (a >= b)": branch over an unconditional jump. Two branches for the two comparisons a + /// single unsigned `>=` cannot express, rather than a third branch op every backend must grow. + void emitStrictLess(VReg a, VReg b, uint8_t label) { + if (nextLabel >= kIrLabels) { fail("too many branches in one script"); return; } + const uint8_t lSkip = nextLabel++; + emit({IrOp::BranchGe, 0, a, b, 0,0, lSkip, nullptr, {}}); // a >= b: do NOT take the skip + VReg z = alloc(); + emit({IrOp::Const, z, 0,0,0,0, 0, nullptr, {}}); + emit({IrOp::BranchGe, 0, z, z, 0,0, label, nullptr, {}}); // always taken + freeTemp(z); + emit({IrOp::Label, 0, 0,0,0,0, lSkip, nullptr, {}}); + } + bool parseStatement() { if (atKeyword("for", 3)) return parseFor(); - if (lex.kind != Tok::Ident) { fail("expected a function call"); return false; } + if (atKeyword("if", 2)) return parseIf(); + if (lex.kind != Tok::Ident) { fail("expected a function call or an assignment"); return false; } + // One token of lookahead separates `name = …` from `name(…)`. Both start with an + // identifier, and only the token AFTER it says which, so the name is saved and the lexer + // rewound rather than committing to either shape. + const char* name = lex.identBeg; + const size_t nameLen = lex.identLen; + Lexer save = lex; + lex.advance(); + // `name =` and `name[` both start an assignment; `name(` is a call. + if (lex.kind == Tok::Assign || lex.kind == Tok::LBracket) return parseAssignment(name, nameLen); + lex = save; parseCall(nullptr); if (failed) return false; return expect(Tok::Semicolon, "expected ';'"); @@ -748,7 +1022,7 @@ struct Parser { if (!expect(Tok::LBrace, "expected '{' to open the class body")) return false; // Declarations first (the controls), then the functions. Both live inside the braces now. - while (!failed && atTypeKeyword()) { lex.advance(); parseDecl(); } + while (!failed && atTypeKeyword()) { const CtrlType ty = currentType(); lex.advance(); parseDecl(ty); } if (failed) return false; bool any = false; @@ -835,6 +1109,7 @@ CompileResult compileSource(const char* source, const BuiltinTable& table, r.ok = true; r.len = len; // Surface the declared controls so the binding can create real MoonModule controls. + r.stringLen = parser.stringLen; r.memberCount = parser.memberCount; for (uint8_t i = 0; i < parser.memberCount; i++) r.members[i] = parser.members[i]; // The functions the class defined, each with the byte its code starts at. The parser recorded diff --git a/src/core/moonlive/MoonLiveCompiler.h b/src/core/moonlive/MoonLiveCompiler.h index 03047ce8..3aef7c02 100644 --- a/src/core/moonlive/MoonLiveCompiler.h +++ b/src/core/moonlive/MoonLiveCompiler.h @@ -63,10 +63,13 @@ struct CompileResult { DeclaredControl members[kMaxCtrls]; uint8_t memberCount = 0; // Text a script wrote as a string literal, NUL-separated. The source buffer is freed as soon - // as a compile returns, so a `const char*` the emitted code carries cannot point into it; the - // engine copies this pool alongside the code and the emitted pointers are rebased onto its - // copy. 128 bytes is a handful of control labels, which is all a string is used for today. + // as a compile returns, so a `const char*` the emitted code carries cannot point into it. The + // parser interns each literal directly into this pool, which the ENGINE owns and outlives the + // compile, so the address the emitted code carries is already its final one: nothing is copied + // or rebased afterwards. 128 bytes is a handful of control labels, all a string is used for. static constexpr uint16_t kStringPool = 128; + // How many of those bytes this program interned, so a binding can report the headroom. + uint16_t stringLen = 0; // The name the script gave its class. What diagnostics and the module status report, so a // renamed FILE does not change what a user is told: the filename is what the engine loads, the // class name is what it is. Copied out of the source, which is freed after the compile. diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index baa34ec1..d19d67cb 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -93,6 +93,33 @@ 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 + 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. + 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), + // NOT carried in c/d. Those are VREG fields, and the spill pass renumbers every + // vreg an op reports as a source, so a width parked there is rewritten into a + // register number: the array then addresses the wrong byte with the wrong stride. + // Call documents the same trap for its argument count. This one reached a device, + // because arm64's register map made the renumbering a no-op and every host test + // passed; only Xtensa showed it, as a fixture that stayed dark. + // + // The pack lets the lowering scale the index and bounds-check it: an out-of-range index is CLAMPED to the + // last element rather than faulting, because a script computes indices from live + // control values and a fixture must degrade visibly, never crash (the robustness + // rule). Clamping keeps every write inside the member, so it cannot corrupt the + // system variables or the depth counter that share the arena. + StoreCtrl, // ((uint8_t*)kArg4)[imm] = a: write an arena byte, which is what a MEMBER is. + // A control is read-only to the script (the UI owns its value); a member is the + // script's own state, so it needs the other direction. Same storage and the same + // offset space, so the only new thing is the store. An arena byte outlives every + // call and keeps its address across a recompile, which is exactly what "survives + // the tick" means, and is why a stateful effect needs this op and not a frame slot. Mov, // dst = a β€” the assignment a loop variable needs (vregs are otherwise write-once) Label, // a branch target; `imm` is the label id. Emits no instruction. BranchGe, // if (a >= b) goto label `imm` β€” UNSIGNED. The loop's ENTRY guard: skip a loop @@ -127,16 +154,47 @@ struct IrInst { // knows {name, a neutral type, range, default, and the byte offset into the run-time controls // arena it lives at}. The light-domain binding turns this into a real MoonModule control bound to // the arena slot. `type` is a neutral kind β€” Uint8 only in Stage 1 β€” NOT a projectMM ControlType. -enum class CtrlType : uint8_t { Uint8 }; +/// A member's element type. The WIDTH is derived from it rather than stored beside it, so the two +/// can never disagree: a record carrying both would let a `Uint16` claim one byte. +enum class CtrlType : uint8_t { Uint8, Uint16 }; + +/// Bytes one element of `t` occupies in the arena. +constexpr uint8_t ctrlWidth(CtrlType t) { return t == CtrlType::Uint16 ? 2 : 1; } struct DeclaredControl { const char* name = nullptr; // script-declared name (points into the source buffer) - uint8_t min = 0, max = 255, def = 0; // uint8 range/default (Stage 1 is uint8 controls) + uint8_t min = 0, max = 255; // the UI range; a control is a uint8 slider either way + // The initializer, wide enough for the widest member type. A control's range stays 0..255 + // because that is what addUint8 declares and what a slider spans; the DEFAULT is separate, + // since a uint16_t member holds a value no slider needs to reach. + uint16_t def = 0; uint8_t nameLen = 0; // length (the source is not NUL-terminated per token) CtrlType type = CtrlType::Uint8; - uint8_t offset = 0; // byte offset into the controls arena (declaration order) + // 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 + // 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). + uint8_t count = 1; }; +/// LoadIdx/StoreIdx pack (base, width, count) into the single `imm` field, because `imm` is the +/// only per-instruction field the register allocator does not rewrite. One encoder and three +/// accessors, so the emitter and the lowering cannot disagree about the layout. +constexpr int32_t idxPack(uint8_t base, uint8_t width, uint8_t count) { + return int32_t(base) | (int32_t(width) << 8) | (int32_t(count) << 16); +} +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 β€” /// so this bounds the TOTAL number of loops in a script (8), not how deeply they nest. Nesting diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index 7348aaf3..35fe078e 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -58,6 +58,18 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { case IrOp::AddImm: case IrOp::Spill: out[0] = in.a; return 1; case IrOp::LoadCtrl: out[0] = kArg4; return 1; // reads the arena pointer + // A member STORE reads two things: the arena pointer and the value being written. + case IrOp::StoreCtrl: + case IrOp::StoreCtrl16: out[0] = kArg4; out[1] = in.a; return 2; + case IrOp::LoadCtrl16: 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 + // place along, leaving the index in the value's field. LoadCtrl gets away with reporting + // it because it has no other source and reads the pointer through host(kArg4); these ops + // 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; case IrOp::Add: case IrOp::Mul: case IrOp::BranchGe: @@ -87,6 +99,10 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { bool writesDst(IrOp op) { switch (op) { case IrOp::Label: case IrOp::BranchGe: case IrOp::BranchNe: + // A member store writes MEMORY, not a register: its `a` is the value and `imm` the arena + // offset, so reading its dst as a definition would give vreg 0 a spurious live range. + case IrOp::StoreCtrl: + case IrOp::StoreCtrl16: // 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 3c74390d..d18d31e4 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -23,7 +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, +// movImm, movPtr, movReg, addImm, addReg, mulReg, store8, load8, store16, load16, +// load8Idx, load16Idx, // 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 @@ -253,6 +254,63 @@ 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::LoadCtrl16: a.load16(reg(op.dst), host(kArg4), op.imm); break; // dst = *(u16*)(ctrls+imm) + // An ARRAY element. `imm` is the array's base, op.c the element width and op.d the + // element count, so both the scaling and the bound come from the IR rather than from a + // rule the backends would each have to know. + // + // The index is CLAMPED, not checked-and-skipped. A script computes an index from live + // control values, so out of range is an ordinary run-time state rather than a defect, + // and every arena write has to stay inside the member: the system variables and the + // recursion depth counter share this allocation, and a stray write would corrupt the + // engine rather than the picture. Clamping costs one compare and one conditional + // branch, which is what a bounds check costs anyway. + case IrOp::LoadIdx: + case IrOp::StoreIdx: { + const uint8_t width = idxWidth(op.imm); + const uint8_t count = idxCount(op.imm); + const RegId idx = reg(op.a); + // clamp: if (idx >= count) idx = count - 1, as ONE branch. sCtr holds the LAST + // valid index rather than the count, so the guard is `last >= idx`, which is true + // exactly when idx is in range and skips the fix-up. branchGeU is unsigned and the + // index is unsigned, so this also catches a negative index: it arrives as a huge + // unsigned value, fails the test, and clamps to the last element like any other + // out-of-range one. + const LabelId inRange = a.newLabel(); + a.movImm(sCtr, count - 1); + a.branchGeU(sCtr, idx, inRange); + a.movReg(idx, sCtr); + a.bind(inRange); + 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 + if (op.op == IrOp::LoadIdx) { + if (width == 2) a.load16Idx(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)); + 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 + // goes into the shared scratch first. sCtr rather than sAddr: store8 clobbers its + // own address temp on some backends, and sAddr is that temp. + const RegId arena = host(kArg4); + a.movImm(sCtr, op.imm); + a.store8(arena, sCtr, reg(op.a)); + break; + } // The allocator's two ops. `imm` is a slot INDEX; the assembler owns the frame layout. case IrOp::CallScript: { // `imm` is the callee's function NUMBER, so the label is a direct index. The diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index 4e1e5dd1..0eb8e76a 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -264,11 +264,14 @@ inline const AddControlSink& addControlSink() { } /// Point addUint8 at a consumer for the duration of one defineControls() run; nullptr to detach. -inline void setAddControlSink(AddControlFn fn, void* ctx) { +/// False when the two-slot table is full, which the caller must not treat as an installed sink: +/// every addUint8 would then be a silent no-op and the script would publish no controls at all. +inline bool setAddControlSink(AddControlFn fn, void* ctx) { detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); - if (!s) return; + if (!s) return false; s->controls = {fn, ctx}; if (!fn) detail::releaseIfEmpty(s); + return true; } /// Point addLight at a consumer for the duration of one run; pass nullptr to detach. @@ -305,6 +308,10 @@ extern "C" inline uint32_t mm_light_addUint8(const uintptr_t* args, uint32_t, co 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 + // A bound is a byte, and the range is an ARBITRARY EXPRESSION, so `addUint8("n", n, 0, x * 64)` + // can compute past 255. 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. + if (args[2] > 255 || args[3] > 255) return 0; s.fn(s.ctx, name, static_cast(args[1]), static_cast(args[2]), static_cast(args[3])); return 0; @@ -367,16 +374,16 @@ extern "C" inline uint32_t mm_light_line(const uintptr_t* args, uint32_t, const // // `t` is an argument register (free to read); the rest are arena slots the BINDING writes each // frame from the layer it renders into. Their offsets are fixed constants above the script's -// control range (see kMaxCtrls) β€” a binding caches these slot pointers, so they must never move. +// control range (see kCtrlBytes): a binding caches these slot pointers, so they must never move. // // Adding one is a single line here plus the binding writing its slot. enum : uint8_t { - kSysWidth = kMaxCtrls + 0, - kSysHeight = kMaxCtrls + 1, - kSysDepth = kMaxCtrls + 2, - kSysX = kMaxCtrls + 3, - kSysY = kMaxCtrls + 4, - kSysZ = kMaxCtrls + 5, + kSysWidth = kCtrlBytes + 0, + kSysHeight = kCtrlBytes + 1, + kSysDepth = kCtrlBytes + 2, + kSysX = kCtrlBytes + 3, + kSysY = kCtrlBytes + 4, + kSysZ = kCtrlBytes + 5, }; /// The system variables a light script can read. Each binding registers the names it actually @@ -505,10 +512,14 @@ inline void runDefineControls(MoonLive& engine) { // A script with no defineControls() declares no controls, which is the honest answer for one // that wants no UI: there is nothing to clear and nothing to run. if (!engine.hasEntry(kEntryDefineControls)) return; + // Install BEFORE clearing. With the two-slot table full the sink cannot be installed, and a + // clear-then-run would drop every control and rebuild none of them: the script would appear to + // 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, uint8_t lo, uint8_t hi) { + static_cast(ctx)->addDeclaredControl(n, off, lo, hi); + }, &engine)) return; engine.clearDeclaredControls(); // re-runnable: rebuild rather than append - setAddControlSink([](void* ctx, const char* n, uint8_t off, uint8_t lo, uint8_t hi) { - static_cast(ctx)->addDeclaredControl(n, off, lo, hi); - }, &engine); // A one-light scratch buffer: this entry point writes no pixels, but `run` refuses a null or // undersized one, and honoring that contract costs less than carving out an exception. uint8_t scratch[3] = {}; diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index ebee4aaf..f7aa593e 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -76,7 +76,11 @@ class MoonLiveEffect : public EffectBase { // RUNNING defineControls(). Before rebuildControls() below, which is what // turns the declared list into UI cards. moonlive::runDefineControls(engine_); - clearStatus(); + // A compiled script is not an error, but it has something to say: how big it is, + // and the one budget it is closest to using up. The card's memory figure is the + // ALLOCATION, word-rounded, which says nothing about the program itself. + engine_.describe(statusBuf_, sizeof(statusBuf_)); + setStatus(statusBuf_, Severity::Status); } else { setStatus(err, Severity::Error); } @@ -129,6 +133,9 @@ class MoonLiveEffect : public EffectBase { private: moonlive::MoonLive engine_; + // Backing store for the status line: MoonModule::setStatus keeps a POINTER, so the text has to + // outlive the call. The same module-owned pattern NetworkModule uses. + char statusBuf_[48] = {}; // Default script β€” random pixels: each tick lights one random light in a random RGB color. // A live, always-visible starting example (and a good demo-reel slot). The index random16(256) // covers a typical grid; setRGB bounds-guards it (an index past the light count is skipped, and diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h index 1b8eac1e..bcf465fa 100644 --- a/src/light/moonlive/MoonLiveLayout.h +++ b/src/light/moonlive/MoonLiveLayout.h @@ -47,7 +47,9 @@ class MoonLiveLayout : public LayoutBase { // RECEIVE a width: the pipeline derives its bounding box from the coordinates the layouts // actually place (Layouts::prepare, "max coordinate + 1 per axis"), so a width handed in // from outside would be a second, disagreeing source of truth. A script that wants one - // declares it (`addUint8("width", width, 1, 64)`) and it becomes a real slider. + // declares it under its OWN name (`addUint8("cols", cols, 1, 64)`) and it becomes a real + // slider. Not `width`: that is a system variable the engine writes, so a script cannot + // declare it and the compiler refuses the name. uint8_t n = 0; const moonlive::DeclaredControl* decls = engine_.declaredControls(n); for (uint8_t i = 0; i < n; i++) { @@ -141,7 +143,11 @@ class MoonLiveLayout : public LayoutBase { // RUNNING defineControls(). Before rebuildControls(), which turns the declared // list into UI cards. moonlive::runDefineControls(self->engine_); - self->clearStatus(); + // A compiled script is not an error, but it has something to say: how big it is, + // and the one budget it is closest to using up. The card's memory figure is the + // ALLOCATION, word-rounded, which says nothing about the program itself. + self->engine_.describe(self->statusBuf_, sizeof(statusBuf_)); + self->setStatus(self->statusBuf_, Severity::Status); self->compileFailed_ = false; } else { self->setStatus(err, Severity::Error); @@ -184,6 +190,9 @@ class MoonLiveLayout : public LayoutBase { } mutable moonlive::MoonLive engine_; + // Backing store for the status line: MoonModule::setStatus keeps a POINTER, so the text has to + // outlive the call. The same module-owned pattern NetworkModule uses. + char statusBuf_[48] = {}; // The script's FILE NAME, inside the shared script directory. Empty on a fresh card: it reports // "no script" and places no lights until one is named, rather than every new layout compiling diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h index 45304d84..87bff894 100644 --- a/src/light/moonlive/MoonLiveModifier.h +++ b/src/light/moonlive/MoonLiveModifier.h @@ -77,7 +77,11 @@ class MoonLiveModifier : public ModifierBase { // RUNNING defineControls(). Before rebuildControls(), which turns the declared // list into UI cards. moonlive::runDefineControls(engine_); - clearStatus(); + // A compiled script is not an error, but it has something to say: how big it is, + // and the one budget it is closest to using up. The card's memory figure is the + // ALLOCATION, word-rounded, which says nothing about the program itself. + engine_.describe(statusBuf_, sizeof(statusBuf_)); + setStatus(statusBuf_, Severity::Status); } else { setStatus(err, Severity::Error); } @@ -168,6 +172,9 @@ class MoonLiveModifier : public ModifierBase { private: mutable moonlive::MoonLive engine_; + // Backing store for the status line: MoonModule::setStatus keeps a POINTER, so the text has to + // outlive the call. The same module-owned pattern NetworkModule uses. + char statusBuf_[48] = {}; // Default script β€” a mirror on x. Chosen because it is instantly readable on a bench strand // (the pattern runs the other way) and is a modifier people actually reach for, so a working diff --git a/src/platform/desktop/moonlive_asm_host.cpp b/src/platform/desktop/moonlive_asm_host.cpp index e36a61c9..eb9cfa6f 100644 --- a/src/platform/desktop/moonlive_asm_host.cpp +++ b/src/platform/desktop/moonlive_asm_host.cpp @@ -152,6 +152,26 @@ void HostAssembler::store8(Reg base, Reg off, Reg val) { // strb wVal, [xBase, 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)); +} +// 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)); +} +// ldrb wDst, [xBase, xOff] and ldrh wDst, [xBase, xOff]. The register-offset form takes the index +// UNSCALED for a byte; for a halfword the LSL amount would scale it, and it is left at 0 so the +// index the caller passes is a BYTE offset in both cases. That keeps one rule for the lowering: +// an element index is multiplied by the element width before it gets here, never after. +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 d3d8c393..8b5fa3c9 100644 --- a/src/platform/desktop/moonlive_asm_host.h +++ b/src/platform/desktop/moonlive_asm_host.h @@ -92,6 +92,10 @@ class HostAssembler { void mulReg(Reg d, Reg a, Reg b); // d = a * b (index scaling by a runtime cpl) 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 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/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp index 0b209c52..4fd364b6 100644 --- a/src/platform/esp32/moonlive_asm_riscv.cpp +++ b/src/platform/esp32/moonlive_asm_riscv.cpp @@ -189,6 +189,26 @@ 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); +} +// RISC-V has no register-offset addressing mode, so the address is computed first. Same shape as +// store8/store16, which is why they share kScratchAddr. +void RiscvAssembler::load8Idx(Reg d, Reg base, Reg off) { + emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off + emit32((uint32_t(kScratchAddr) << 15) | (4 << 12) | (xr(d) << 7) | 0x03); // lbu d, 0(t6) +} +void RiscvAssembler::load16Idx(Reg d, Reg base, Reg off) { + emit32(encAdd(kScratchAddr, xr(base), xr(off))); // t6 = base + off + emit32((uint32_t(kScratchAddr) << 15) | (5 << 12) | (xr(d) << 7) | 0x03); // lhu d, 0(t6) +} void RiscvAssembler::branchIfZero(Reg a, Label l) { // a == 0 ⇔ bgeu x0, a (unsigned 0 >= a) addFixup(len_, l); emit32(encBranch(0, xr(a), 7, 0)); // bgeu x0, a, l (patched) diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h index 5e3a4fa3..2022d533 100644 --- a/src/platform/esp32/moonlive_asm_riscv.h +++ b/src/platform/esp32/moonlive_asm_riscv.h @@ -88,6 +88,10 @@ class RiscvAssembler { void mulReg(Reg d, Reg a, Reg b); // mul rd, ra, rb 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 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 branchNe(Reg a, Reg b, Label l); // bne a, b, l diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp index 2c3ef487..7a57deab 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.cpp +++ b/src/platform/esp32/moonlive_asm_xtensa.cpp @@ -264,9 +264,26 @@ void XtensaAssembler::movReg(Reg d, Reg a) { const uint8_t b[2] = {uint8_t((ar(d) << 4) | 0xd), ar(a)}; emit(b, 2); } -// addi.n aD, aA, #imm (1..15) : word (d<<12)|(a<<8)|(imm<<4)|0xb +// addi.n aD, aA, #imm : word (d<<12)|(a<<8)|(imm<<4)|0xb. +// +// The narrow form's 4-bit field encodes 1..15, and the bit pattern 0 means MINUS ONE, not zero. +// So a caller asking to add 0 must emit no add at all, and anything outside 1..15 needs the wide +// `addi` (8-bit signed) instead. Every caller passed a literal 1 until an array whose base offset +// is 0 asked for `+0`; that emitted `addi.n aX, aX, -1` and shifted every element access down a +// byte, which reached a device as a fixture that stayed dark while all host tests passed. void XtensaAssembler::addImm(Reg d, Reg a, int32_t imm) { - emit2(uint16_t((ar(d) << 12) | (ar(a) << 8) | ((imm & 0xf) << 4) | 0xb)); + if (imm == 0) { + if (ar(d) != ar(a)) movReg(d, a); // still a move: d = a + 0 + return; + } + if (imm >= 1 && imm <= 15) { + emit2(uint16_t((ar(d) << 12) | (ar(a) << 8) | ((imm & 0xf) << 4) | 0xb)); + return; + } + // addi aD, aA, #imm8 (RRI8, signed -128..127): bytes [ (d<<4)|2, 0xc0|a, imm ]. + const uint8_t b[3] = {uint8_t((ar(d) << 4) | 0x2), uint8_t(0xc0 | ar(a)), + uint8_t(imm & 0xff)}; + emit(b, 3); } // mull aD, aA, aB : 24-bit 0x820000 | (d<<12) | (a<<8) | (b<<4) void XtensaAssembler::mulReg(Reg d, Reg a, Reg b) { @@ -288,6 +305,34 @@ 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); +} + +// 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. +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 // branch (no separate beqz form / offset width). a13 is a scratch outside the vreg map. diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h index be4dc9fc..587b5679 100644 --- a/src/platform/esp32/moonlive_asm_xtensa.h +++ b/src/platform/esp32/moonlive_asm_xtensa.h @@ -91,6 +91,10 @@ class XtensaAssembler { void mulReg(Reg d, Reg a, Reg b); // mull aD, aA, aB 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 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 branchNe(Reg a, Reg b, Label l); // bne aA, aB, l (loop test) diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index ce095d4e..b0f49a5e 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -128,7 +128,7 @@ "desktop-macos": { "tick_us": [ 5, - 19 + 21 ], "free_heap": [ 0, @@ -140,7 +140,7 @@ ], "at": [ "2026-08-09", - "2026-08-16" + "2026-08-18" ] } } @@ -166,7 +166,7 @@ "desktop-macos": { "tick_us": [ 5, - 14 + 30 ], "free_heap": [ 0, @@ -178,7 +178,7 @@ ], "at": [ "2026-08-09", - "2026-08-16" + "2026-08-18" ] } } diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index d0abbd72..edaf2c71 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -271,8 +271,17 @@ TEST_CASE("every shipped script compiles for " MM_ISA_NAME) { INFO("script: ", kind, "/", entry.path().filename().string(), " on " MM_ISA_NAME); CHECK(ok); CHECK(n > 0); + // And it fits its own SIZING ESTIMATE with room to spare. codeCapFor allocates from a + // token count before a byte is emitted, so the constant is only right while the widest + // real script stays well inside it. This is what fails if a change makes codegen denser + // (a wider call sequence, a new per-statement guard) or if the constant is trimmed too + // far: 1.5x leaves margin for the next script, which the shipped set cannot predict. + const size_t budget = mm::moonlive::codeCapFor(mm::moonlive::countTokens(src.c_str())); + INFO("emitted ", n, " of a ", budget, "-byte budget"); + CHECK(n * 3 <= budget * 2); checked++; } } CHECK(checked > 0); // a silently empty folder would pass without this } + diff --git a/test/unit/core/moonlive_structural.inc b/test/unit/core/moonlive_structural.inc index fd89994b..c4333e0b 100644 --- a/test/unit/core/moonlive_structural.inc +++ b/test/unit/core/moonlive_structural.inc @@ -176,7 +176,7 @@ TEST_CASE("emitted " MM_ISA_NAME " code reads no register a call destroyed") { // The shape that crashed on hardware, and the nearest ones that did not. {"sysvar bound + call in body", mmScript("for (x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1}, - {"control bound + call in body", + {"member bound + call in body", mmScript("uint8_t 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", @@ -229,3 +229,4 @@ TEST_CASE("emitted " MM_ISA_NAME " code reads no register a call destroyed") { CHECK(callsSeen > 0); } } + diff --git a/test/unit/core/unit_moonlive_codegen_xtensa.cpp b/test/unit/core/unit_moonlive_codegen_xtensa.cpp index d0dc1bea..bf39cee7 100644 --- a/test/unit/core/unit_moonlive_codegen_xtensa.cpp +++ b/test/unit/core/unit_moonlive_codegen_xtensa.cpp @@ -183,3 +183,50 @@ TEST_CASE("the Xtensa vreg map names only registers the windowed ABI leaves free // whenever codegen does. A failure here means "read the diff and decide", not "you broke it" β€” // update the number and say why in the commit. It exists because the alternative way to notice an // emission change was to flash a board and watch it reset. + +// `addi.n aD, aA, #imm` encodes its immediate in a 4-bit field whose value 0 means MINUS ONE: the +// narrow form covers 1..15 and cannot express "add zero" at all. Every caller passed a literal 1 +// until an array based at arena offset 0 asked for `+0`, which emitted `addi.n aX, aX, -1` and +// shifted every element access down a byte. It compiled, emitted a plausible length, and passed +// every host test, because only this backend has the narrow form. The fixture stayed dark. +// +// Asserted on the ENCODER rather than on a script's bytes: a difference-based test cannot see it +// (the wrong bytes still differ from other wrong bytes), which a control run confirmed. +TEST_CASE("Xtensa addImm never encodes an add of zero as the narrow form") { + using Asm = mm_xtensa_backend::mm::moonlive::XtensaAssembler; + using mm_xtensa_backend::mm::moonlive::R0; + using mm_xtensa_backend::mm::moonlive::R1; + // add 0 into the SAME register is a no-op and must emit nothing at all. + { + Asm a(64); + a.addImm(R0, R0, 0); + CHECK(a.size() == 0); + } + // Into a DIFFERENT register it is still a move, so it must emit something that is not the + // narrow add: the low nibble of a narrow addi.n is 0xb. + { + Asm a(64); + a.addImm(R1, R0, 0); + REQUIRE(a.size() > 0); + CHECK((a.bytes()[0] & 0x0f) != 0x0b); + } + // 1..15 keep the narrow form, and the immediate field must hold the value itself. + for (int imm = 1; imm <= 15; imm++) { + Asm a(64); + a.addImm(R0, R0, imm); + REQUIRE(a.size() == 2); + const uint16_t w = uint16_t(a.bytes()[0]) | uint16_t(uint16_t(a.bytes()[1]) << 8); + INFO("imm " << imm); + CHECK((w & 0x0f) == 0x0b); // still addi.n + CHECK(((w >> 4) & 0x0f) == imm); // and it carries the right immediate + } + // Past 15 the narrow field cannot hold it, so the wide RRI8 form has to take over rather than + // silently truncating: `addi.n` with imm 16 would wrap to 0, which is the -1 bug again. + { + Asm a(64); + a.addImm(R0, R0, 40); + REQUIRE(a.size() == 3); + CHECK((a.bytes()[1] & 0xf0) == 0xc0); // addi (RRI8) + CHECK(a.bytes()[2] == 40); + } +} diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index e70f16e1..66ef38a5 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -188,6 +188,46 @@ TEST_CASE("elapsed time survives a call that happens before it is read") { } #endif +// The card says how big the compiled program is, and warns only when a budget is nearly gone. +// +// Size, because nothing could answer "how big is my script": the card's memory figure is the +// word-rounded ALLOCATION, which says nothing about the program. One budget, not five, and only +// past half full: a script can hit ten ceilings, but five are derived from code size or nesting +// and a number the author cannot act on is noise. +TEST_CASE("a compiled script reports its size, and its tightest budget only when it is filling up") { + moonlive::MoonLive eng; + char buf[48] = {}; + + // Nothing compiled: nothing to say. + eng.describe(buf, sizeof(buf)); + 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" + " tick() { setRGB(0, bpm, 0, 0); }\n}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + eng.describe(buf, sizeof(buf)); + INFO("described: " << buf); + CHECK(std::strstr(buf, " B") != nullptr); // a byte count + CHECK(std::strstr(buf, "/") == nullptr); // and no budget: 1 of 8 controls is not news + CHECK(eng.codeLen() > 0); + + // 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" + " tick() { setRGB(0, a, b, c); }\n}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + eng.describe(buf, sizeof(buf)); + INFO("described: " << buf); + CHECK(std::strstr(buf, "controls 8/8") != nullptr); + eng.free(); +} + // A FAILED recompile drops the declared controls rather than leaving them named "". // // The editor loop pushes broken text constantly: that is what editing is. A control's `name` is a @@ -436,4 +476,460 @@ TEST_CASE("calling a function no one declared is a compile error") { CHECK(std::string(r.error) == "unknown function"); } -#endif // MM_MOONLIVE_HAS_HOST_JIT + +// A member could be declared and read but never WRITTEN: `x = expr;` was reachable only inside a +// for header. That made every member a constant, so the whole class of effects that carry state +// forward (fire, trails, decay) was inexpressible. This is the statement that makes a member state. +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" + " tick() {\n" + " level = level + 10;\n" + " setRGB(0, level, 0, 0);\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 10); // seeded 0, plus this tick's 10 + eng.run(px, 1, 3, 0); + CHECK(px[0] == 20); // the arena byte carried the 10 across the call + eng.run(px, 1, 3, 0); + CHECK(px[0] == 30); + eng.free(); +} + +// The other half of "a member is the script's own state": one function writes it, another reads it. +// A frame slot could not do this, because each function has its own frame. +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" + " stash() { shared = 7; }\n" + " tick() { stash(); setRGB(0, shared * 3, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0, "tick"); // named: a multi-function class has no single "the program" + CHECK(px[0] == 21); + eng.free(); +} + +// A loop counter is a frame slot, and the step clause already writes one, so a body assignment is +// the same store: refusing it would have made the header a special case for no reason. +TEST_CASE("a loop variable can be assigned in the loop body") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " tick() {\n" + " for (i = 0; i < 8; i = i + 1) {\n" + " i = i + 1;\n" // skips every other light + " setRGB(i, 99, 0, 0);\n" + " }\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[24] = {}; + eng.run(px, 8, 3, 0); + CHECK(px[1 * 3] == 99); // 1, 3, 5, 7 written + CHECK(px[3 * 3] == 99); + CHECK(px[0] == 0); // 0, 2, 4, 6 skipped + CHECK(px[2 * 3] == 0); + eng.free(); +} + +// The engine rewrites a system variable before every call, so a store to one would be silently +// undone. Refused with the reason, rather than compiling into something that does not work. +TEST_CASE("a system variable cannot be assigned") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { tick() { width = 4; } }", kCtrlTable, kSys)); + eng.free(); +} + +// An assignment to a name nothing declared is a typo, and the message says where a name comes from. +TEST_CASE("assigning to an undeclared name is refused") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { tick() { nope = 4; } }", kCtrlTable, kSys)); + eng.free(); +} + + +// Every comparison, at, above and below the boundary. Six operators lower onto TWO branch ops by +// swapping operands and negating the sense, so an off-by-one in that mapping is invisible except at +// the boundary itself: `a < b` and `a <= b` differ on exactly one input. The table is the proof. +TEST_CASE("if: every comparison is exact at its boundary") { + struct Case { const char* op; uint8_t lit; uint8_t expect[3]; }; // probe a = 4, 5, 6 against 5 + const Case cases[] = { + {"<", 5, {1, 0, 0}}, + {"<=", 5, {1, 1, 0}}, + {">", 5, {0, 0, 1}}, + {">=", 5, {0, 1, 1}}, + {"==", 5, {0, 1, 0}}, + {"!=", 5, {1, 0, 1}}, + }; + for (const auto& c : cases) { + for (uint8_t i = 0; i < 3; i++) { + const uint8_t a = uint8_t(4 + i); + char src[192]; + std::snprintf(src, sizeof(src), + "class T { tick() { if (%u %s %u) { setRGB(0, 1, 0, 0); } } }", + a, c.op, c.lit); + moonlive::MoonLive eng; + CAPTURE(src); + REQUIRE(eng.compile(src, kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == c.expect[i]); + eng.free(); + } + } +} + +// An else-block must run when, and only when, the then-block did not: the then-block falls through +// to the end label rather than into the else, which is the jump an if without an else never needs. +TEST_CASE("if/else takes exactly one branch") { + for (uint8_t a = 4; a <= 6; a++) { + char src[224]; + std::snprintf(src, sizeof(src), + "class T { tick() { if (%u < 5) { setRGB(0, 11, 0, 0); }" + " else { setRGB(0, 22, 0, 0); } } }", a); + moonlive::MoonLive eng; + CAPTURE(src); + REQUIRE(eng.compile(src, kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == (a < 5 ? 11 : 22)); + eng.free(); + } +} + +// A conditional inside a loop is where a mis-scoped label shows: the if's skip must land inside the +// body, not past the back edge, or the loop runs once and exits. +TEST_CASE("an if inside a for runs the body every iteration") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " tick() {\n" + " for (i = 0; i < 6; i = i + 1) {\n" + " if (i < 3) { setRGB(i, 50, 0, 0); }\n" + " else { setRGB(i, 200, 0, 0); }\n" + " }\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[18] = {}; + eng.run(px, 6, 3, 0); + for (uint8_t i = 0; i < 6; i++) CHECK(px[i * 3] == (i < 3 ? 50 : 200)); + eng.free(); +} + +// The condition is an ordinary expression on both sides, not a name-against-literal special case: +// the same orthogonality that lets addUint8 take a computed range. +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" + " tick() { if (base * 2 >= base + 2) { setRGB(0, 42, 0, 0); } }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 42); // 6 >= 5 + eng.free(); +} + +// A conditional makes a member's value decide control flow, which is the combination step 3a and +// step 6 exist for: state that steers, rather than state that is only read out. +TEST_CASE("a member decides which branch a tick takes") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint8_t 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" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); CHECK(px[0] == 7); + eng.run(px, 1, 3, 0); CHECK(px[0] == 9); + eng.run(px, 1, 3, 0); CHECK(px[0] == 7); + eng.free(); +} + +// `=` and `==` differ by one character and mean opposite things. Maximal munch is what keeps them +// apart, and lexing `==` as two assignments would make a comparison silently parse as something else. +TEST_CASE("== is one token, not two assignments") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { tick() { if (1 = 1) { setRGB(0,1,0,0); } } }", kCtrlTable, kSys)); + 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") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint8_t a = 1;\n" + " uint8_t b = 2;\n" + " uint8_t c = 3;\n" + " defineControls() {\n" + " addUint8(\"a\", a, 0, 9);\n" + " addUint8(\"b\", b, 0, 9);\n" + " addUint8(\"c\", c, 0, 9);\n" + " }\n" + " tick() { setRGB(0, a, b, c); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t n = 0; + const moonlive::DeclaredControl* dc = eng.declaredControls(n); + REQUIRE(n == 3); + // 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); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0, "tick"); + CHECK(px[0] == 1); + CHECK(px[1] == 2); + CHECK(px[2] == 3); + eng.free(); +} + +// The arena's byte budget and the record count are now two different limits, and a script can +// exhaust either one first. Asking for more member data than the arena holds is a compile error +// with a message about the arena, rather than a member silently landing on top of another one. +TEST_CASE("a class declaring more member data than the arena holds is refused") { + // The record limit (kMaxCtrls) is reached long before the byte limit when every member is a + // byte, so the BYTE limit is provoked with arrays: two of them exceed kCtrlBytes together + // while staying well inside the record count. Sized from the constants so raising either one + // 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; } }", + moonlive::kCtrlBytes, moonlive::kCtrlBytes); + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(src, kCtrlTable, kSys)); + eng.free(); +} + +// 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") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint16_t big = 1000;\n" + " tick() {\n" + " big = big + 300;\n" + " setRGB(0, big - 1300, 0, 0);\n" // 1300 - 1300 = 0 on the first tick + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 0); // 1000 + 300 = 1300, which a byte member could not have held + eng.run(px, 1, 3, 0); + CHECK(px[0] == 44); // 1600 - 1300 = 300, truncated to a byte by setRGB: 300 & 0xff + eng.free(); +} + +// 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") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint16_t n = 255;\n" + " tick() { n = n + 1; if (n == 256) { setRGB(0, 77, 0, 0); } }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 77); // a byte member would be 0 here, and the branch would not be taken + 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") { + 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 + " defineControls() {\n" + " addUint8(\"small\", small, 0, 9);\n" + " addUint8(\"wide\", wide, 0, 9);\n" + " }\n" + " tick() { setRGB(0, small, 0, 0); }\n" + "}\n", kCtrlTable, kSys)); + moonlive::runDefineControls(eng); + uint8_t n = 0; + const moonlive::DeclaredControl* dc = eng.declaredControls(n); + REQUIRE(n == 2); + CHECK(dc[0].offset == 0); + CHECK(dc[1].offset == 2); // byte 1 is padding, not a member + CHECK(dc[1].offset % 2 == 0); + eng.free(); +} + +// 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") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class T { uint8_t 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") { + moonlive::MoonLive eng; + CHECK(eng.compile("class T { uint16_t x = 300; tick() { setRGB(0, x - 300, 0, 0); } }", kCtrlTable, kSys)); + eng.free(); +} + +// An array is the difference between an effect that draws a formula and one that SIMULATES +// something: a particle list, a heat buffer, a per-light decay. Write each element, read it back. +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" + " 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" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[24] = {}; + eng.run(px, 8, 3, 0); + for (uint8_t i = 0; i < 8; i++) CHECK(px[i * 3] == i * 10); + eng.free(); +} + +// Array contents survive across ticks, like any other member: the arena outlives every call. This +// is what a decay or trail effect is built on, where each frame reads what the last frame left. +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" + " tick() {\n" + " for (i = 0; i < 4; i = i + 1) { acc[i] = acc[i] + 5; setRGB(i, acc[i], 0, 0); }\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[12] = {}; + eng.run(px, 4, 3, 0); + CHECK(px[0] == 5); // seeded to zero, plus this tick + eng.run(px, 4, 3, 0); + CHECK(px[0] == 10); // the previous frame's value was still there + eng.run(px, 4, 3, 0); + CHECK(px[0] == 15); + eng.free(); +} + +// THE SAFETY CASE. A script computes an index from live control values, so out of range is an +// ordinary run-time state, not a defect. It must not write outside the member: the system +// variables and the recursion depth counter share the arena, so a stray write would corrupt the +// engine rather than the picture. The index is clamped to the last element, which degrades +// visibly (the last light repeats) and never crashes. +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" + " tick() {\n" + " for (i = 0; i < 4; i = i + 1) { a[i] = 1; }\n" + " a[9] = 200;\n" // far past the end + " for (j = 0; j < 4; j = j + 1) { setRGB(j, a[j], 0, 0); }\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[12] = {}; + eng.run(px, 4, 3, 0); + CHECK(px[0 * 3] == 1); // untouched + CHECK(px[1 * 3] == 1); + CHECK(px[2 * 3] == 1); + CHECK(px[3 * 3] == 200); // clamped onto the LAST element + eng.free(); +} + +// The same clamp on the READ side, and the system variables must be intact afterwards: reading +// past the end must not reach into the arena region the host owns. +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" + " tick() {\n" + " a[3] = 42;\n" + " setRGB(0, a[200], 0, 0);\n" // clamps to a[3] + " setRGB(1, width, 0, 0);\n" // a system variable, still correct + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t* w = eng.controlSlot(moonlive::kSysWidth); + REQUIRE(w != nullptr); + *w = 33; + uint8_t px[6] = {}; + eng.run(px, 2, 3, 0); + CHECK(px[0] == 42); // the clamped read found the last element + CHECK(px[3] == 33); // width was not overwritten by the out-of-range access + eng.free(); +} + +// The index is an arbitrary EXPRESSION, not a bare loop counter: the same orthogonality that lets +// addUint8 take a computed range and an if condition take one on both sides. +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" + " tick() {\n" + " a[base * 2 + 1] = 88;\n" // a[3] + " setRGB(0, a[3], 0, 0);\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 88); + eng.free(); +} + +// 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") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint16_t 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" + " if (v[3] == 303) { setRGB(1, 1, 0, 0); }\n" + " }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[6] = {}; + eng.run(px, 2, 3, 0); + CHECK(px[0] == 1); // element 0 kept its full value + CHECK(px[3] == 1); // and so did the last one, so the stride was right + eng.free(); +} + +// An array has no single arena byte, so assigning one as a whole is refused with the shape that +// 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)); + 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)); + eng.free(); +} + +// An array asking for more bytes than the arena holds is a COMPILE error, not a failed allocation +// while a fixture is running: a script must not be able to ask a classic ESP32 for memory it has +// 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; } }", + moonlive::kCtrlBytes + 1); + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile(src, kCtrlTable, kSys)); + eng.free(); +} + +#endif // MM_MOONLIVE_HAS_HOST_JIT β€” every case above needs compile() to SUCCEED, so + // they all gate on the JIT: on a target with no backend (x86-64 desktop today) + // the helpers they call are compiled out with it. diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index aef5511b..7ba43f8e 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -256,6 +256,15 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") { l.prepare(); CHECK(l.lightCount() == 16); + // 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" + "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" @@ -607,3 +616,27 @@ TEST_CASE("a script name at the accepted length survives the control it is store CHECK(l.lightCount() == 1); #endif } + +#if MM_MOONLIVE_HAS_HOST_JIT +// A SERPENTINE over an arbitrary number of rows: every other row reversed. This was the standing +// example of what the language could not express, because it needs a per-row decision and there +// was no `if`. It is also the most common real panel wiring, so it is worth pinning as a layout +// rather than only as a compiler test. +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" + "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" + " else { addLight(cols - 1 - x, y, 0); }\n" + " }\n" + " if (odd == 0) { odd = 1; } else { odd = 0; }\n" + "}"))); + l.prepare(); + CHECK(l.lightCount() == 12); // 4 x 3, every cell placed once and none twice +} +#endif // MM_MOONLIVE_HAS_HOST_JIT: the script must COMPILE for the count to mean anything. From f94eb6c4481f07ede23e96c66296accb1690a27c Mon Sep 17 00:00:00 2001 From: ewowi Date: Wed, 19 Aug 2026 12:17:36 +0200 Subject: [PATCH 3/4] Edit a MoonLive script on its own card, and saving it recompiles Editing a script meant leaving the module: find the file in the File Manager, open the modal editor, save, then go back and re-name the script on the card to force a recompile. Now the card carries a file picker and an editor, and saving is all it takes. The picker offers only the scripts a card can use, because a script's role is its extension: `.mle` an effect, `.mll` a layout, `.mlm` a modifier. Perf: no per-tick cost. Flash +1.3KB S3, +1.5KB classic, +1.7KB S31, +5.3KB desktop; the growth is one control type plus the editor's markup, all cold path. Core - A file write asks the module tree to re-derive. Core enforced "re-derive what depends on it" for a CONTROL write only, so saving a file's contents under an unchanged name changed nothing on the device. applyFileChanged is a sibling of the existing apply-core (applySetControl, applyOp): transport-free, so it is provable without a socket and a future write path reaches one implementation. - Nothing new was needed to carry it. HttpServerModule already holds the scheduler and already calls requestPrepareTree in five places, and that call already coalesces (an atomic flag tick() consumes), so a multi-file upload costs one sweep rather than one per file. - ControlType::FilePath: a control whose value NAMES a file while the UI edits that file's contents. A separate type rather than a flag on TextArea because the two store opposite things (TextArea's value IS the body), and a file body cannot ride /api/control at all: every route but /api/file returns 413 above the request buffer. `aux` carries {directory, extension, template} from the module, so the UI lists a directory without knowing what lives there. Light domain - MoonLiveScript, held by value in each binding, states the rule once: if the file changed, recompile. The three bindings each grew their own bookkeeping around that and two were wrong. An effect had no content hash at all, so editing its text did nothing until the file was renamed; a layout cleared its hash only on a name change; only a modifier re-read the file. 116 lines go. - The modifier's hash comparison is now enforced by sync()'s return value rather than by a comment. It is load-bearing: an unconditional rebuild makes prepare() and the Layer's applyState() call each other forever and the fixture renders nothing. - The failure latch keys on name AND content. Keyed on the name alone a script fixed in place stayed refused until renamed, which is the same bug one level along. An unreadable file latches on the name only, because a missing file hashes to nothing and would otherwise retry on every lightCount(). - A script's role is its file extension, stated by the author rather than derived from what the class defines. Deriving it was tempting (the entry point already tells the ENGINE which moment to call) but ties a UI filter to a language feature: the day a modifier wants a per-frame tick(), every modifier would start appearing in effect pickers with nothing changed. The engine stays role-blind and the loader accepts all three, so a class serving several moments is still legal. - setXYZ takes THREE arguments. The index was a constant every modifier author typed and none could explain, since a modifier is handed one coordinate per call. Implemented as a distinct StoreFirst op rather than a flag that hides an argument: "the one slot I was given" is a different question from "slot number zero". One arm in the shared lowering, no backend change, and the emitted code got SMALLER (mirror.mlm on Xtensa 163 -> 124 bytes: no index multiply, no bounds compare). - A new script starts as a working example rather than an empty file, per role. An empty file fails to parse the moment it is made, so the first thing a new script would say is an error. - scriptFileHash reads the whole file in ONE open. Hashing through a small window cost an open/close per chunk on a path that runs every prepare sweep. UI - One editor, two hosts. openFileEditor is split into fmLoadInto / fmSaveFrom / fmMountEditor / fmCreateFile; the File Manager keeps its modal and a card mounts the same pane inline. Every guard is shared rather than re-typed: a binary or truncated file loads read-only so a re-save cannot destroy it. - Saves on blur, Ctrl/Cmd+S and the Save button, never per keystroke: a save writes to flash and re-derives, against text that is mid-edit and usually invalid. A dot on Save marks unsaved work. - The card's own buttons (.card-btn) rather than a second toolbar, and one status line instead of two: the module's own status already reports what compiled, and it is the authoritative one because the device writes it. Tests - A written file asks the tree to re-derive, a burst costs one sweep, and no scheduler is a no-op rather than a crash. Control-checked: removing the request fails them. - Editing a script's text recompiles it without renaming the file, an unchanged script installs no new program, and a broken script fixed in place compiles. Each control-checked against the old behaviour. - A file-path control carries its directory and extension, stores a name and never a body (5 KB truncates to the buffer and stays NUL-terminated), and persists like the text control it is. - setXYZ with three arguments writes the coordinate; the four-argument form is refused rather than reinterpreted; setRGB still names its light. - The loader accepts any role extension and nothing else. - NOT pinned, and backlogged instead: that a broken script is tried once rather than on every ask. The latch exists for a device-only reason (LittleFS reads starving the render task), and on the host four test shapes each still passed with the latch removed entirely. Docs/CI - The control type and the save-recompile loop; moonlive/README states the role rule and why it is a name rather than an inference. The backlogged "editing a script's CONTENTS does not recompile it" entry is deleted, which this closes. Bench: S3, S31 and classic all flashed, wiped and re-seeded with the 17 renamed scripts, each compiling its layout and effect with the pickers filtering by role. Save-recompile confirmed on hardware: writing different text into lines.mle took the S3 from 1233 B to 107 B with no /api/control call. Co-Authored-By: Claude Opus 5 (1M context) --- docs/backlog/backlog-light.md | 24 +- ...or control and a filesystem change seam.md | 233 ++++++++++ docs/metrics/repo-health.json | 48 +-- docs/metrics/repo-health.md | 40 +- docs/moonmodules/core/ui.md | 13 + docs/moonmodules/light/MoonLiveEffect.md | 16 +- docs/moonmodules/light/MoonLiveModifier.md | 12 +- moondeck/moonlive/disasm.py | 4 +- moondeck/moonlive/emit_isa.cpp | 6 +- moonlive/README.md | 21 +- .../effects/{crosshair.mlv => crosshair.mle} | 0 moonlive/effects/{ember.mlv => ember.mle} | 0 .../effects/{gradient.mlv => gradient.mle} | 0 moonlive/effects/{lines.mlv => lines.mle} | 0 moonlive/effects/{plasma.mlv => plasma.mle} | 0 .../{random-pixel.mlv => random-pixel.mle} | 0 moonlive/effects/{ripples.mlv => ripples.mle} | 0 .../layouts/{diagonal.mlv => diagonal.mll} | 0 moonlive/layouts/{grid.mlv => grid.mll} | 0 moonlive/layouts/{lattice.mlv => lattice.mll} | 0 .../{reversed-row.mlv => reversed-row.mll} | 0 moonlive/layouts/{ring.mlv => ring.mll} | 0 moonlive/layouts/{rose.mlv => rose.mll} | 0 .../layouts/{two-rows.mlv => two-rows.mll} | 0 moonlive/modifiers/{mirror.mlv => mirror.mlm} | 2 +- moonlive/modifiers/{shift.mlv => shift.mlm} | 2 +- .../{transpose.mlv => transpose.mlm} | 2 +- src/core/Control.cpp | 18 +- src/core/Control.h | 25 ++ src/core/HttpServerModule.cpp | 12 + src/core/HttpServerModule.h | 17 + src/core/moonlive/MoonLiveBuiltins.h | 13 +- src/core/moonlive/MoonLiveIr.h | 2 +- src/core/moonlive/MoonLiveSpill.cpp | 2 +- src/core/moonlive/moonlive_lower.h | 14 + src/light/moonlive/MoonLiveBuiltins_light.h | 19 +- src/light/moonlive/MoonLiveEffect.h | 68 ++- src/light/moonlive/MoonLiveLayout.h | 96 +---- src/light/moonlive/MoonLiveModifier.h | 97 ++--- src/light/moonlive/MoonLiveScript.h | 138 ++++++ src/light/moonlive/MoonLiveScriptFile.h | 138 +++++- src/platform/esp32/moonlive_asm_xtensa.cpp | 2 +- src/ui/app.js | 402 +++++++++++++++--- src/ui/style.css | 66 +++ test/CMakeLists.txt | 1 + .../light/scenario_MoonLive_pipeline.json | 4 +- test/unit/core/moonlive_device_codegen.inc | 18 +- test/unit/core/unit_Control_filepath.cpp | 95 +++++ .../unit/core/unit_HttpServerModule_apply.cpp | 65 +++ test/unit/core/unit_moonlive_compiler.cpp | 2 +- test/unit/core/unit_moonlive_fill.cpp | 38 +- test/unit/light/MoonLiveScriptFixture.h | 4 +- test/unit/light/unit_MoonLiveLayout.cpp | 127 +++++- test/unit/light/unit_MoonLiveModifier.cpp | 66 +-- test/unit/light/unit_MoonLiveScripts.cpp | 16 +- 55 files changed, 1574 insertions(+), 414 deletions(-) create mode 100644 docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md rename moonlive/effects/{crosshair.mlv => crosshair.mle} (100%) rename moonlive/effects/{ember.mlv => ember.mle} (100%) rename moonlive/effects/{gradient.mlv => gradient.mle} (100%) rename moonlive/effects/{lines.mlv => lines.mle} (100%) rename moonlive/effects/{plasma.mlv => plasma.mle} (100%) rename moonlive/effects/{random-pixel.mlv => random-pixel.mle} (100%) rename moonlive/effects/{ripples.mlv => ripples.mle} (100%) rename moonlive/layouts/{diagonal.mlv => diagonal.mll} (100%) rename moonlive/layouts/{grid.mlv => grid.mll} (100%) rename moonlive/layouts/{lattice.mlv => lattice.mll} (100%) rename moonlive/layouts/{reversed-row.mlv => reversed-row.mll} (100%) rename moonlive/layouts/{ring.mlv => ring.mll} (100%) rename moonlive/layouts/{rose.mlv => rose.mll} (100%) rename moonlive/layouts/{two-rows.mlv => two-rows.mll} (100%) rename moonlive/modifiers/{mirror.mlv => mirror.mlm} (76%) rename moonlive/modifiers/{shift.mlv => shift.mlm} (87%) rename moonlive/modifiers/{transpose.mlv => transpose.mlm} (73%) create mode 100644 src/light/moonlive/MoonLiveScript.h create mode 100644 test/unit/core/unit_Control_filepath.cpp diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 4e5be9ef..c462808c 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -292,17 +292,6 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on - **A scripted modifier that reshapes the grid** (2026-08-10). `ModifierBase::modifyLogicalSize` lets a modifier change the logical `width`/`height`/`depth` β€” a Multiply kaleidoscope grows the grid, a crop shrinks it β€” and a compiled modifier uses it. A SCRIPTED one cannot: system variables are read-only, so `MoonLiveModifier` writes the box in and never reads it back. Needs a writable system variable β€” the binding reads the slots after the script returns and reports the result through `modifyLogicalSize` β€” which is a new `SysVarKind` (or a mutable flag on `SysVar`) plus the read-back, not a new builtin. Until then a scripted modifier can fold coordinates but not resize the grid they live in. -- **Editing a script's CONTENTS through /api/file does not recompile it** (2026-08-14). A binding - caches `compiledHash_` and skips the compile while it is non-zero; the hash is cleared when the - script NAME changes (`onControlChanged`, `setScript`), but a write to `/moonlive/` via - the File Manager leaves it set, so the layout keeps running the previous code until the name is - touched or the device reboots. `MoonLiveModifier` does not have this: it re-hashes the source on - every prepare and compares, which is the shape to copy. - - The fix belongs at the filesystem seam rather than in the binding β€” a write under `/moonlive/` - invalidates whatever compiled from that path β€” so it is a small core/HTTP change, not a MoonLive - one. Pre-existing, not introduced by the stack-machine work. - - **MoonLive has no x86-64 backend β€” scripts do not run on Windows** (2026-08-14). The desktop assembler (`moonlive_asm_host.cpp`) is arm64-only, so `MM_MOONLIVE_HAS_HOST_JIT` is 0 on x86-64 Windows, x86-64 Linux and Intel macOS. `compileSource` fails cleanly there and scripted modules @@ -317,6 +306,19 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on `disasm.py --isa x86_64` should land with it, since no test executes emitted bytes for any backend but the host's. +- **The compile-failure latch is not provable on the host** (2026-08-18). `MoonLiveScript::sync` + refuses to re-attempt a script that failed until its (name, content) changes. The latch exists for + a device-only reason: each attempt is two LittleFS reads (~5 ms on an S3), a layout is asked from + `lightCount()`/`placeLights()` as well as `prepare()`, and the pipeline asks repeatedly while + sizing a fixture, so the retries starve the render task until the watchdog resets the device. + + On the host a re-read costs microseconds and nothing observable differs. Four test shapes were + tried and each still passed with the latch REMOVED ENTIRELY, so none was kept: what survives pins + only that a script fixed in place compiles without a rename, which is control-checked. Closing + this needs either a counting seam (a compile counter the test can read) or a platform fake whose + reads are observable. Until then the latch is protected by its comment and by hardware, not by a + test. + - **Catch device-backend operand defects on the host** (2026-08-18). Two array-codegen bugs shipped to an S3 while all 1313 host tests stayed green, and both were control-checked: reintroducing either one leaves the suite fully passing. `IrInst::c`/`d` are VREG fields the spill pass diff --git a/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md new file mode 100644 index 00000000..a8344adf --- /dev/null +++ b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md @@ -0,0 +1,233 @@ +# Plan: a file-editor control, and a filesystem change seam + +## Context + +Editing a MoonLive script means leaving the module: find the file in the File Manager, open the +modal editor, save, then go back and re-name the script on the card to force a recompile. The thing +a user wants is to type on the module's own card and watch the fixture change. + +Two things block that, and only one is about the editor. + +**The editor exists but is not reachable from a card.** `openFileEditor` ([app.js:4510](../../../src/ui/app.js)) +is a working text editor over `/api/file` with a truncation guard, a binary guard and a prettify +hook. It is welded to a `` opened from a File Manager tree row. + +**Saving a file's CONTENTS notifies nothing.** `requestPrepareTree()` is reached only from a control +write (Scheduler.cpp:270), so nothing recompiles when a path stays the same and the bytes change. +The three MoonLive bindings paper over this three different ways, and two are wrong: + +- `MoonLiveEffect` has no content hash. `affectsPrepare` (:57) fires only on the script NAME, so a + content change never re-enters `prepare()`. Its comment at :63 claims a source edit recompiles; + the code does not do it. +- `MoonLiveLayout` caches `compiledHash_` (:205), cleared only on a name change (:99, :106). +- `MoonLiveModifier` re-hashes every prepare and compares (:96). Correct, and the comparison is + REQUIRED: the comment at :90-95 records that an unconditional `needsRebuild_` makes `prepare()` + and the Layer's `applyState()` call each other forever, and the fixture renders nothing. + +The outcome is one capability (a file editor control) plus one core rule extended to its next path. + +## Design + +### 1. Core: a filesystem change seam + +`handleWriteFile` has a single success branch (HttpServerModule.cpp:711). On success it calls a hook +that asks the scheduler to re-derive, the same construct `Scheduler` already takes for +`loadAllHook_` and `noteDirtyHook_` (Scheduler.h:56-62): + +```cpp +// A successful write to `path` changed persistent state that a module may have derived from. +// Core's existing rule (a control write re-derives the tree) extended to the file path. +using FileChangedFn = void (*)(const char* path); +``` + +`main.cpp` wires it to `Scheduler::requestPrepareTree()`. Every module's `prepare()` then re-runs +and each decides for itself whether anything actually changed, which is what the shared +`MoonLiveScript` hash comparison answers in 4 bytes without holding the source. + +**Why the seam rather than a client-side nudge.** The alternative was to have the browser re-POST +the unchanged path to `/api/control` after saving, which re-enters the existing notification path +without any core change. Rejected on [CLAUDE.md](../../../CLAUDE.md) Principle 3: when core enforces +a rule on one path, extend core to the next path, never paste the check into the caller. The rule is +"a change to persistent state re-derives what depends on it", core already enforces it for a control +write, and a file write is the next path. A client-side nudge also only works for whoever remembers +it, so `curl`, MoonDeck and the File Manager's own modal would each leave a stale program running. + +Deliberately a whole-tree request rather than a path-to-module registry: a registry needs an +association nothing else in the system keeps, and `prepare()` is already the cold path that exists +to be re-entered. If profiling ever shows the sweep is too broad, the hook has the path in hand and +can narrow later without changing its callers. + +**Coalescing is already there, so nothing needs building.** `requestPrepareTree()` sets an atomic +flag that `tick()` consumes with `exchange(false)` (Scheduler.h:82, Scheduler.cpp:85), so a burst of +writes inside one tick (the File Manager's multi-file upload) already collapses to a single sweep. +It is also the call the hook must use rather than `prepareTree()`: the doc at Scheduler.h:74-77 +warns that the immediate walk runs a scripted layout's JIT'd code on the CALLING task's stack, and a +file-write hook runs on the small web-server task. + +### 2. Core: `ControlType::FilePath`, wire name `filepath` + +A path-valued control: storage identical to `Text` (a module-owned `char[]`), but the UI renders a +picker plus an inline editor over `/api/file`. + +A separate type rather than a flag on `TextArea`, because they store opposite things. TextArea's +value IS the body; FilePath's value is a ~40-byte reference, and the body cannot enter +`/api/control` at all (413 above the request buffer, HttpServerModule.cpp:181-197). Every existing +flag (`hidden`, `readonly`, `advanced`, `numberField`, `fader`, `encoder`) leaves the value's +meaning untouched, so a flag that changes what the value MEANS is not a rendering hint. + +`aux` carries a `const char* const[2]` of `{directory, extension}`, the same shape `Select` already +uses for its options array (Control.h:442). No new descriptor field, so the positional-initializer +hazard noted at Control.h:295 does not apply. `writeControlMetadata` emits `dir` and `ext`, so the +UI needs no hardcoded knowledge of MoonLive. + +```cpp +addFilePath(name, buf, bufSize, dirAndExt) +``` + +### 3. UI: one editor, two hosts + +Split `openFileEditor` into a body-owning core that the modal wraps, so the modal keeps its exact +behavior and the card mounts the same code inline: + +- `fmLoadInto(textarea, relPath, expectedSize)`: load, truncation guard, binary guard, prettify. +- `fmSaveFrom(textarea, relPath)`: POST the body. +- `fmMountEditor(host, relPath, {expectedSize, onSaved})`: the pane (textarea, status, Save, dirty + dot) wired for blur-save, Ctrl/Cmd+S and the Save button. +- `fmCreateFile(dir, name)`: shared by the File Manager toolbar and the card's create button. + +`openFileEditor` keeps its signature and becomes a dialog shell around `fmMountEditor`. Reuse the +`.fm-editor-*` class names so style.css:1629-1655 serves both. Lift the existing `textareaSizes` and +`ResizeObserver` height persistence (app.js:1737-1751) into `fmMountEditor`, so the `textarea` case +and the new case share one copy instead of two. + +**Save on blur, on Ctrl/Cmd+S, and on the Save button**, with a dirty dot and a `beforeunload` guard. +Explicitly not per-keystroke autosave: a save writes to flash and re-derives whatever depends on the +file, so autosaving would do both on every keystroke, against a file that is half-typed and +therefore usually invalid. Blur and an explicit key are what every editor a user already knows do. + +**The card row**: a native `` is the source of truth) | sends on change | none | | `select` | dropdown | sends immediately; server may rebuild controls (dynamic `defineControls`) | none | | `text` | text input | sends debounced | 500 ms | +| `textarea` | resizable multi-line box | sends debounced; the value IS the text, so it rides `/api/control` | 500 ms | +| `filepath` | file picker + inline editor (new/delete buttons) | picker sends on change; the file's CONTENTS save on blur, Ctrl/Cmd+S or the Save button, and a dot marks unsaved work | none | | `password` | password input | masked; hold-to-peek reveals the stored value | 500 ms | | `display` (read-only) | static text | WS push updates in place | n/a | | `display-int` (read-only int + unit) | formatted text (`-58 dBm`) | unit suffix set device-side at `addReadOnlyInt` time, carried in the descriptor's `aux` slot | n/a | @@ -148,6 +150,17 @@ how each *renders*.) | `ipv4` | text input (dotted-quad) | server validates (`parseDottedQuad`), 400 on malformed; stored as 4 octets device-side | n/a | | `button` | clickable button | sends value = 1 on click | none | +- **`filepath` stores a NAME, not a body.** The value is a ~40-byte reference that travels through + `/api/control` like any text control; the file's contents move over `GET`/`POST /api/file`, + because that is the only route allowed to exceed the request buffer (everything else returns 413). + The module declares where its files live and which to offer (`addFilePath(name, buf, size, + dirAndExt)`), so the UI lists a directory without knowing what kind of file it holds. Saving is + all it takes: a written file asks the module tree to re-derive, so whatever was built from that + file rebuilds itself, with no second request from the browser. + Editing is the same code the File Manager's modal editor uses, mounted inline instead of in a + ``, so both share one set of guards (a binary or truncated file loads read-only rather + than risking a lossy re-save). + - **Reset-to-default (β†Ί)** appears next to controls whose default is known (captured from a fresh probe instance per type, emitted in `/api/types`); dim when value == default, clicking sends the default. diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 907df068..1a27db45 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -19,13 +19,19 @@ class RandomPixelEffect { Inside a function the grammar is a sequence of **statements** β€” a function call, or a `for` loop over them β€” with **expression arguments**, so any argument may be a literal or a nested call. The class declaration is required: one top-level form rather than two means one set of rules to learn and one parse path to maintain. -The **class name is not the file name**. `plasma.mlv` may declare `class PlasmaEffect`; the file is what the engine loads, the class is what diagnostics and the module status report. Renaming either leaves the other alone, the same way a C translation unit and the functions inside it are independent. +**A script's role is its extension**: `.mle` an effect, `.mll` a [layout](MoonLiveLayout.md), `.mlm` a [modifier](MoonLiveModifier.md). That is what a card filters its picker on, so an effect card offers effects. The engine is role-blind and runs whichever moment the binding asks for; the extension decides what is OFFERED, not what runs. + +The **class name is not the file name**. `plasma.mle` may declare `class PlasmaEffect`; the file is what the engine loads, the class is what diagnostics and the module status report. Renaming either leaves the other alone, the same way a C translation unit and the functions inside it are independent. The functions are **not built into the compiler** β€” `setRGB`, `fill`, `random16` are registered by the *host* (the light domain) in a builtin table; the core compiler owns only the grammar and a generic call/inline mechanism (the ESPLiveScript / ARTI bound-function model). The compiler emits machine code for whichever ISA the device runs (Xtensa on the classic/S3) or the host ISA on desktop, places it in executable memory, and the engine calls it each render tick. ## Controls -- `script` β€” the file name under `/moonlive/`, e.g. `lines.mlv`. A fresh module has none: it reports `no script β€” set the script name` and renders nothing, rather than every new module compiling the same default. Naming one (or re-naming it after an edit) recompiles live: 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 fixed (the script-editor loop, robust + no reboot). The directory is created on demand. +- `script`: the script this module runs, picked from `/moonlive/` and **edited on the card itself**. A fresh module has none: it reports `no script β€” set the script name` and renders nothing, rather than every new module compiling the same default. + + 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` inside a `defineControls()`, the same call a compiled module makes. Each becomes a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c @@ -64,7 +70,7 @@ Some names are **reserved**: the engine defines them, the script only reads them Every one but `t` is a byte, because it lives in the controls arena. A grid extent past 255 reports 255 rather than wrapping to a small number, and a modifier handed a coordinate outside `0..255` passes it through untransformed instead of folding a wrong position β€” so a script never silently sees a value that means something else. -The coordinate is `xPos`/`yPos`/`zPos` rather than `x`/`y`/`z` so that **`x` and `y` stay free as loop counters in every script**, which is what an author reaches for and what the shipped `grid.mlv` uses. Reserving them globally would break the most ordinary code there is; a per-role reservation was the alternative and was worse, because a name then meant one thing in one role and was refused in another β€” which is how `disasm.py`, compiling against the widest vocabulary, came to refuse the shipped default layout. +The coordinate is `xPos`/`yPos`/`zPos` rather than `x`/`y`/`z` so that **`x` and `y` stay free as loop counters in every script**, which is what an author reaches for and what the shipped `grid.mll` uses. Reserving them globally would break the most ordinary code there is; a per-role reservation was the alternative and was worse, because a name then meant one thing in one role and was refused in another β€” which is how `disasm.py`, compiling against the widest vocabulary, came to refuse the shipped default layout. `width`/`height`/`depth` are the Layer's own dimensions, derived from the layouts and the modifier chain. An effect is *told* its canvas rather than declaring it: a size restated as a control is a second answer that can disagree with the first, and a script that sets `width` to 16 on an 8Γ—8 panel draws off the edge. A [layout](MoonLiveLayout.md) is upstream of that grid β€” it is what the dimensions are derived *from* β€” so it names its own controls instead (`cols`, `rows`) and reads the grid only if it has a use for it. @@ -77,7 +83,7 @@ Registered by the light domain, not built into the compiler (the core owns only | call | does | |---|---| | `setRGB(index, r, g, b)` | write one light | -| `setXYZ(index, x, y, z)` | write one position (a [modifier](MoonLiveModifier.md)) | +| `setXYZ(x, y, z)` | write one position (a [modifier](MoonLiveModifier.md)) | | `fill(r, g, b)` | write every light | | `addLight(x, y, z)` | place the next light (a [layout](MoonLiveLayout.md)) | | `line(x1, y1, x2, y2, r, g, b)` | a straight segment on the grid, via the shared `draw::line` | @@ -96,7 +102,7 @@ Registered by the light domain, not built into the compiler (the core owns only ### The script's own functions -A class may define functions beside its entry point and call them, including calling itself. `effects/crosshair.mlv` is the worked example: a `column()` and a `row()`, both called from `tick()`. +A class may define functions beside its entry point and call them, including calling itself. `effects/crosshair.mle` is the worked example: a `column()` and a `row()`, both called from `tick()`. These are real calls, not text pasted in by the compiler: the callee allocates its own frame when it runs, which is what lets one helper call another and what makes recursion work. A function takes no arguments and returns nothing yet, so a helper does a whole job rather than computing a value. diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index de49f7b9..a8bcabc5 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -12,7 +12,7 @@ The script transforms **one coordinate**. It needs no loop over the lights, beca ```c class MirrorModifier { - modifyLogical() { setXYZ(0, width - 1 - xPos, yPos, zPos); } // mirror along x + modifyLogical() { setXYZ(width - 1 - xPos, yPos, zPos); } // mirror along x } ``` @@ -21,12 +21,12 @@ The function is named `modifyLogical` because that is the moment a modifier is a The body is one expression per axis. Other shapes, in the same place: ```c -setXYZ(0, yPos, xPos, zPos); // swap the axes -setXYZ(0, xPos + 4, yPos, zPos); // shift by four -setXYZ(0, (width - 1 - xPos) * 2, yPos, zPos); // mirror, then stretch +setXYZ(yPos, xPos, zPos); // swap the axes +setXYZ(xPos + 4, yPos, zPos); // shift by four +setXYZ((width - 1 - xPos) * 2, yPos, zPos); // mirror, then stretch ``` -`setXYZ(index, x, y, z)` writes the transformed position, mirroring `setRGB(index, r, g, b)`. The index is the destination slot: today the script is handed a single coordinate, so it is always `0`. +`setXYZ(x, y, z)` writes the transformed position, mirroring `setRGB(index, r, g, b)`. The index is the destination slot: today the script is handed a single coordinate, so it is always `0`. ### What a script can read @@ -36,7 +36,7 @@ setXYZ(0, (width - 1 - xPos) * 2, yPos, zPos); // mirror, then stretch ### Seeing inside a script -`print(v)` logs a value and returns it, so it wraps any part of an expression: `setXYZ(0, print(width - 1 - xPos), yPos, zPos)`. +`print(v)` logs a value and returns it, so it wraps any part of an expression: `setXYZ(print(width - 1 - xPos), yPos, zPos)`. It is for debugging and comes back out again β€” [what print costs](../../../moonlive/README.md#debugging-print). ## Limits diff --git a/moondeck/moonlive/disasm.py b/moondeck/moonlive/disasm.py index 8ca20ae9..2589bb0e 100644 --- a/moondeck/moonlive/disasm.py +++ b/moondeck/moonlive/disasm.py @@ -11,8 +11,8 @@ this compiles a script through a REAL backend and pipes the bytes to that ISA's objdump. uv run moondeck/moonlive/disasm.py "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }" - uv run moondeck/moonlive/disasm.py --isa riscv moonlive/effects/plasma.mlv effect - uv run moondeck/moonlive/disasm.py --isa all moonlive/layouts/grid.mlv layout + uv run moondeck/moonlive/disasm.py --isa riscv moonlive/effects/plasma.mle effect + uv run moondeck/moonlive/disasm.py --isa all moonlive/layouts/grid.mll layout Note there is no x86_64 backend to disassemble: the desktop assembler is arm64-only (`moonlive_asm_host.cpp`), and on an x86 host a compile fails cleanly and the module renders dark diff --git a/moondeck/moonlive/emit_isa.cpp b/moondeck/moonlive/emit_isa.cpp index 2f164a07..b4d1bd4a 100644 --- a/moondeck/moonlive/emit_isa.cpp +++ b/moondeck/moonlive/emit_isa.cpp @@ -54,15 +54,15 @@ using namespace mm; int main(int argc, char** argv) { const char* src = argc > 1 ? argv[1] : "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"; // Sized the way the ENGINE sizes it, from the script's own token count. A fixed 4 KB refused - // ripples.mlv and rose.mlv on RISC-V, which emits ~1.3x Xtensa: the tool reported "codegen + // ripples.mle and rose.mll on RISC-V, which emits ~1.3x Xtensa: the tool reported "codegen // failed (too large)" for scripts a device compiles without trouble, so the one place that // measures emitted size was lying about the two largest scripts. static uint8_t buf[moonlive::kCodeCap]; // Which BINDING to compile as, because the system-variable tables are different vocabularies and // not nested supersets: a modifier is handed `x`/`y`/`z`, and a LAYOUT deliberately is not, so it - // may use those names as ordinary loop counters β€” which the shipped grid.mlv does. Compiling + // may use those names as ordinary loop counters β€” which the shipped grid.mll does. Compiling // every script against the widest table therefore refuses exactly the scripts most worth - // inspecting ("name is a system variable"), which is how this tool came to never see grid.mlv. + // inspecting ("name is a system variable"), which is how this tool came to never see grid.mll. const char* binding = argc > 2 ? argv[2] : "layout"; const auto sysvars = std::strcmp(binding, "modifier") == 0 ? moonlive::modifierSysVars() : std::strcmp(binding, "effect") == 0 ? moonlive::effectSysVars() diff --git a/moonlive/README.md b/moonlive/README.md index c4e269b2..7c6408ed 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -24,7 +24,7 @@ class CrosshairEffect { These are real calls, not pasted-in text: the callee gets its own frame when it runs, which is what lets one helper call another and lets a function recurse. A function takes no arguments and returns -nothing yet, so a helper does a whole job rather than computing a value. `effects/crosshair.mlv` is +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 @@ -69,10 +69,10 @@ repeated last light instead of crashing. All of a class's members share a small fixed budget (`kCtrlBytes`), so a class that declares more than fits is a compile error naming the arena, not a failed allocation while a fixture runs. -`effects/ember.mlv` is the worked example: a heat array that decays and re-ignites, so what it +`effects/ember.mle` is the worked example: a heat array that decays and re-ignites, so what it draws this frame depends on the last one. That is the line between an effect that evaluates a -formula and one that runs a simulation, and it is the reason arrays exist. `plasma.mlv` would look -identical if every frame started from scratch; `ember.mlv` would go dark. +formula and one that runs a simulation, and it is the reason arrays exist. `plasma.mle` would look +identical if every frame started from scratch; `ember.mle` would go dark. **Declare a helper above the function that calls it.** Only functions already parsed are visible, so a call to one declared further down reports `unknown function`. A function can always call itself. @@ -82,11 +82,22 @@ task has a fixed stack, so the alternative to a limit is a device that resets mi see if you hit it is the picture being wrong where the recursion stopped, on a device that keeps running. Nothing is reported; the exact depth is `kMaxCallDepth`. +**A script's ROLE is its file extension**: `.mle` an effect, `.mll` a layout, `.mlm` a modifier. One +language, three names, the way GLSL uses `.vert`/`.frag` for one shading language. It is what a card +filters its picker on, so an effect card offers effects. + +Stated in the name rather than worked out from the file's contents, and deliberately: the entry +point a class defines (`tick`, `placeLights`, `modifyLogical`) already tells the ENGINE which moment +to call, but reusing that as the role would tie a UI filter to a language feature. The day a modifier +wants a per-frame `tick()`, every modifier would start appearing in effect pickers with nothing +changed. The engine stays role-blind either way: it runs whichever moment the binding asks for, so a +class defining several is still legal. + | folder | run by | a script writes | |---|---|---| | `layouts/` | [MoonLiveLayout](../docs/moonmodules/light/MoonLiveLayout.md) | where the lights physically are β€” `addLight(x, y, z)` | | `effects/` | [MoonLiveEffect](../docs/moonmodules/light/MoonLiveEffect.md) | a colour per light: `setRGB(index, r, g, b)`, or a whole shape at once with `line(x1, y1, x2, y2, r, g, b)` | -| `modifiers/` | [MoonLiveModifier](../docs/moonmodules/light/MoonLiveModifier.md) | where one light lands: `setXYZ(0, xPos, yPos, zPos)` | +| `modifiers/` | [MoonLiveModifier](../docs/moonmodules/light/MoonLiveModifier.md) | where one light lands: `setXYZ(xPos, yPos, zPos)` | Each module ships one of these as its default, so the folder doubles as the reference for what a working script looks like. diff --git a/moonlive/effects/crosshair.mlv b/moonlive/effects/crosshair.mle similarity index 100% rename from moonlive/effects/crosshair.mlv rename to moonlive/effects/crosshair.mle diff --git a/moonlive/effects/ember.mlv b/moonlive/effects/ember.mle similarity index 100% rename from moonlive/effects/ember.mlv rename to moonlive/effects/ember.mle diff --git a/moonlive/effects/gradient.mlv b/moonlive/effects/gradient.mle similarity index 100% rename from moonlive/effects/gradient.mlv rename to moonlive/effects/gradient.mle diff --git a/moonlive/effects/lines.mlv b/moonlive/effects/lines.mle similarity index 100% rename from moonlive/effects/lines.mlv rename to moonlive/effects/lines.mle diff --git a/moonlive/effects/plasma.mlv b/moonlive/effects/plasma.mle similarity index 100% rename from moonlive/effects/plasma.mlv rename to moonlive/effects/plasma.mle diff --git a/moonlive/effects/random-pixel.mlv b/moonlive/effects/random-pixel.mle similarity index 100% rename from moonlive/effects/random-pixel.mlv rename to moonlive/effects/random-pixel.mle diff --git a/moonlive/effects/ripples.mlv b/moonlive/effects/ripples.mle similarity index 100% rename from moonlive/effects/ripples.mlv rename to moonlive/effects/ripples.mle diff --git a/moonlive/layouts/diagonal.mlv b/moonlive/layouts/diagonal.mll similarity index 100% rename from moonlive/layouts/diagonal.mlv rename to moonlive/layouts/diagonal.mll diff --git a/moonlive/layouts/grid.mlv b/moonlive/layouts/grid.mll similarity index 100% rename from moonlive/layouts/grid.mlv rename to moonlive/layouts/grid.mll diff --git a/moonlive/layouts/lattice.mlv b/moonlive/layouts/lattice.mll similarity index 100% rename from moonlive/layouts/lattice.mlv rename to moonlive/layouts/lattice.mll diff --git a/moonlive/layouts/reversed-row.mlv b/moonlive/layouts/reversed-row.mll similarity index 100% rename from moonlive/layouts/reversed-row.mlv rename to moonlive/layouts/reversed-row.mll diff --git a/moonlive/layouts/ring.mlv b/moonlive/layouts/ring.mll similarity index 100% rename from moonlive/layouts/ring.mlv rename to moonlive/layouts/ring.mll diff --git a/moonlive/layouts/rose.mlv b/moonlive/layouts/rose.mll similarity index 100% rename from moonlive/layouts/rose.mlv rename to moonlive/layouts/rose.mll diff --git a/moonlive/layouts/two-rows.mlv b/moonlive/layouts/two-rows.mll similarity index 100% rename from moonlive/layouts/two-rows.mlv rename to moonlive/layouts/two-rows.mll diff --git a/moonlive/modifiers/mirror.mlv b/moonlive/modifiers/mirror.mlm similarity index 76% rename from moonlive/modifiers/mirror.mlv rename to moonlive/modifiers/mirror.mlm index 28aa38cf..657a840a 100644 --- a/moonlive/modifiers/mirror.mlv +++ b/moonlive/modifiers/mirror.mlm @@ -2,6 +2,6 @@ class MirrorModifier { modifyLogical() { - setXYZ(0, width - 1 - xPos, yPos, zPos); + setXYZ(width - 1 - xPos, yPos, zPos); } } diff --git a/moonlive/modifiers/shift.mlv b/moonlive/modifiers/shift.mlm similarity index 87% rename from moonlive/modifiers/shift.mlv rename to moonlive/modifiers/shift.mlm index 5731f350..61ecfcaa 100644 --- a/moonlive/modifiers/shift.mlv +++ b/moonlive/modifiers/shift.mlm @@ -9,6 +9,6 @@ class ShiftModifier { } modifyLogical() { - setXYZ(0, xPos + amount, yPos, zPos); + setXYZ(xPos + amount, yPos, zPos); } } diff --git a/moonlive/modifiers/transpose.mlv b/moonlive/modifiers/transpose.mlm similarity index 73% rename from moonlive/modifiers/transpose.mlv rename to moonlive/modifiers/transpose.mlm index 53102c43..6252843d 100644 --- a/moonlive/modifiers/transpose.mlv +++ b/moonlive/modifiers/transpose.mlm @@ -2,6 +2,6 @@ class TransposeModifier { modifyLogical() { - setXYZ(0, yPos, xPos, zPos); + setXYZ(yPos, xPos, zPos); } } diff --git a/src/core/Control.cpp b/src/core/Control.cpp index b7e6232b..4fcc9e5d 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -29,6 +29,7 @@ const char* controlTypeName(ControlType t) { case ControlType::Bool: return "bool"; case ControlType::Text: return "text"; case ControlType::TextArea: return "textarea"; + case ControlType::FilePath: return "filepath"; case ControlType::Password: return "password"; case ControlType::ReadOnly: return "display"; case ControlType::ReadOnlyInt: return "display-int"; @@ -99,6 +100,7 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { return; case ControlType::Text: case ControlType::TextArea: + case ControlType::FilePath: case ControlType::Password: case ControlType::ReadOnly: // All char-buffer-backed. Password is rendered as a @@ -228,6 +230,19 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { case ControlType::IPv4: case ControlType::Button: return; + // Where the module keeps its files, and which of them to offer. Both borrowed from the + // module (addFilePath), so the UI can list a directory without knowing what lives there. + case ControlType::FilePath: { + auto* pick = reinterpret_cast(c.aux); + if (!pick || !pick[0]) return; // no picker: an editor with a fixed path + sink.append(",\"dir\":"); + sink.writeJsonString(pick[0]); + if (pick[1]) { sink.append(",\"ext\":"); sink.writeJsonString(pick[1]); } + // What a NEW file starts as. Sent with the metadata rather than fetched: it is a + // property of the control, and it is the module that knows what a usable file holds. + if (pick[2]) { sink.append(",\"tmpl\":"); sink.writeJsonString(pick[2]); } + return; + } } } @@ -294,8 +309,9 @@ ApplyResult applyControlValue(const ControlDescriptor& c, return ApplyResult::Ok; case ControlType::Text: case ControlType::TextArea: + case ControlType::FilePath: case ControlType::Password: { - // TextArea and Password parse identically to Text β€” only the UI render + // TextArea, FilePath and Password parse identically to Text: only the UI render // (TextArea) or serialization (Password) differs. // c.max is the buffer size; parseString writes up to maxLen-1 then // NUL-terminates, so passing c.max gives "fill the buffer". uint16_t (not uint8_t) so diff --git a/src/core/Control.h b/src/core/Control.h index d5614f65..c9778a57 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -105,6 +105,15 @@ enum class ControlType : uint8_t { Text, ///< char[N] β€” a text input. TextArea, ///< multi-line text β€” same storage/persist path as Text, a resizable ///< `' + - '
' + - ' ' + - ' ' + - '
'; - dlg.querySelector(".fm-editor-path").textContent = relPath; - const body = dlg.querySelector(".fm-editor-body"); - const status = dlg.querySelector(".fm-editor-status"); - const saveBtn = dlg.querySelector(".fm-editor-save"); - document.body.appendChild(dlg); - dlg.addEventListener("close", () => dlg.remove()); - dlg.showModal(); - +// --- the file editor, shared by the File Manager modal and the filepath control --------------- +// +// One implementation, two hosts. The File Manager opens it in a from a tree row; a +// `filepath` control mounts the same pane inline on a module's card. The guards below are the +// reason this is worth sharing rather than re-typing: each one exists because a re-save could +// otherwise destroy a file the editor could not faithfully represent. + +// Load `relPath` into `textarea`. Returns {readOnly, message}: read-only when the file cannot be +// safely round-tripped through a ' + + (ownFooter + ? '
' + + (statusEl ? '' : ' ') + + (saveButton ? '' : ' ') + + '
' + : ''); + const body = wrap.querySelector(".fm-editor-body"); + const status = statusEl || wrap.querySelector(".fm-editor-status"); + const saveBtn = saveButton || wrap.querySelector(".fm-editor-save"); + host.appendChild(wrap); + + let path = relPath; + let dirty = false; + const setDirty = (d) => { + dirty = d; + saveBtn.classList.toggle("dirty", d); + if (d) status.textContent = "unsaved changes"; + // The button says it too, for a host that shows no status line: the dot marks unsaved work + // and the tooltip spells it out, which is where a user looks when a control has a dot on it. + if (saveBtn.title !== undefined) { + saveBtn.title = d ? "Save (unsaved changes)" : "Save (or click away, or Ctrl/Cmd+S)"; } + }; + + // Restore a previously dragged height, the same view-state the plain textarea control keeps, so + // an editor a user sized once stays that size. Keyed per control, or per path in the modal. + const key = sizeKey || ("fm:" + path); + const savedH = textareaSizes[key]; + if (typeof savedH === "number" && savedH > 0) body.style.height = savedH + "px"; + let taRaf = 0, taPrevH = Math.round(savedH > 0 ? savedH : 0); + const taObserver = new ResizeObserver((entries) => { + const h = Math.round(entries[0].contentRect.height); + if (taRaf || h <= 0 || h === taPrevH) return; + taRaf = requestAnimationFrame(() => { taRaf = 0; taPrevH = h; saveTextareaSize(key, h); }); + }); + taObserver.observe(body); + + const save = async () => { + if (body.readOnly || !dirty || !path) return; + status.textContent = "saving…"; + const r = await fmSaveFrom(body, path); + status.textContent = r.message; + if (r.ok) { setDirty(false); if (onSaved) onSaved(path); } + }; + + body.addEventListener("input", () => { if (!body.readOnly) setDirty(true); }); + body.addEventListener("blur", save); + body.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "S")) { e.preventDefault(); save(); } + }); + saveBtn.addEventListener("click", save); + + const load = async (p, size) => { + path = p; + setDirty(false); + if (!path) { body.value = ""; body.readOnly = true; saveBtn.disabled = true; status.textContent = ""; return; } + const r = await fmLoadInto(body, path, size); + body.readOnly = r.readOnly; + saveBtn.disabled = r.readOnly; + status.textContent = r.message; + }; + load(path, expectedSize); + + return { + textarea: body, + load, + isDirty: () => dirty, + dispose: () => { taObserver.disconnect(); wrap.remove(); }, + }; +} + +// Open the shared editor in a modal, for the File Manager's tree rows. Uses the native , +// no bespoke overlay code, and mounts exactly the pane a card mounts inline. +async function openFileEditor(relPath, expectedSize) { + const dlg = document.createElement("dialog"); + dlg.className = "fm-editor"; + dlg.innerHTML = + '
' + + ' ' + + ' ' + + '
'; + dlg.querySelector(".fm-editor-path").textContent = relPath; + document.body.appendChild(dlg); + const ed = fmMountEditor(dlg, relPath, { expectedSize }); + dlg.showModal(); + // Resolves when the dialog CLOSES, not when it opens: a caller that re-reads the file + // afterwards (the card's pane shows the same file) would otherwise read it before any edit. + await new Promise((resolve) => { + dlg.addEventListener("close", () => { ed.dispose(); dlg.remove(); resolve(); }, { once: true }); }); } diff --git a/src/ui/style.css b/src/ui/style.css index be8536ac..b6b643fa 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1647,12 +1647,78 @@ body.cards-resizing { font-size: 0.85rem; line-height: 1.5; background: var(--bg-1); color: var(--fg); } .fm-editor-body:focus { outline: none; } +/* wrap="off": code reads by indentation, and a wrapped line hides its structure. The attribute + stops the wrapping, this makes the overflow reachable. */ +.fm-editor-body { overflow-x: auto; white-space: pre; } .fm-editor-foot { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-top: 1px solid var(--border); } .fm-editor-status { color: var(--fg-muted); font-size: 0.85rem; } +/* The same editor pane mounted INLINE on a module card rather than in the dialog. Only the frame + differs: no backdrop, no fixed viewport height, and it takes the card's width. The .fm-editor-* + internals above are shared, which is the point of the extraction. */ +.fm-editor-pane { display: flex; flex-direction: column; } +.control-fileedit { + margin-top: 0; + border: 1px solid var(--border); border-radius: 8px; overflow: hidden; + background: var(--bg-1); +} +/* Shorter than the modal's: a card is one of several on screen, and the grip resizes it. */ +.control-fileedit .fm-editor-body { min-height: 140px; } +.control-fileedit .fm-editor-foot { padding: 6px 10px; } + +/* Picker row ABOVE the editor, as one column. A .control-row is a flex row, so the two have to be + wrapped in a single item: appended separately they become two flex children and split the width + between them, which leaves the editor as a narrow strip beside the dropdown. */ +.control-fileedit-stack { + flex: 1 1 auto; min-width: 0; + display: flex; flex-direction: column; gap: 3px; +} +/* Top-align the label: the control is now tall, and a centered label floats beside its middle. */ +.control-row:has(.control-fileedit-stack) { align-items: flex-start; } +.control-row:has(.control-fileedit-stack) .control-label { padding-top: 6px; } + +/* The picker row itself: file chooser, save, popup, new, delete, then the status text. */ +.fileedit-bar { display: flex; align-items: center; gap: 6px; } +/* The file actions ride the RIGHT edge as one tight group, so the picker keeps everything left + over: a file name is the one thing here that is long and worth reading in full. */ +/* The card's own action buttons (.card-btn, 26x26 outlined), so the editor's four read as part of + the card rather than as a second toolbar. 3px apart: they are one cluster, and every pixel here + is a pixel the file name does not get. */ +.fileedit-tools { display: flex; align-items: center; gap: 3px; flex: 0 0 auto; } +/* ⎘ (store) and β€’ (expand) are drawn small inside their em box, where Γ— and + fill theirs, so at + the shared 12px they read as specks next to the others. Sized per GLYPH rather than by raising + .card-btn, which would make every card's Γ— and + oversized to fix two symbols here. */ +.fileedit-tools .fileedit-glyph-lg { font-size: 17px; } +/* The status sits UNDER the toolbar rather than in it: sharing the row cost the picker the width + it needed, and this line is a few words that appear only while saving. */ +.fileedit-status { + display: block; font-size: 0.8rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* The picker keeps a real minimum: with min-width 0 it collapsed to its arrow whenever the status + text competed for the row, leaving a dropdown whose own value was invisible. It still grows into + spare space, but never below a width that shows a file name. */ +.fileedit-pick { flex: 1 1 auto; min-width: 9ch; } + +/* Unsaved work, marked the way a modified editor tab is: a dot on the Save button. The button stays + readable on its own, so the dot is an addition rather than the only signal. */ +.fm-editor-save.dirty::after { + content: ""; + display: inline-block; width: 6px; height: 6px; margin-left: 6px; + border-radius: 50%; background: currentColor; vertical-align: middle; +} +/* On the icon button the dot rides the corner instead, since there is no text to sit beside. */ +.fm-tool.fm-editor-save { position: relative; } +.fm-tool.fm-editor-save.dirty::after { + position: absolute; top: 2px; right: 2px; + margin: 0; width: 7px; height: 7px; background: var(--accent); +} +/* Nothing to save reads as nothing to do, rather than a button that silently ignores a click. */ +.fm-tool.fm-editor-save:disabled { opacity: 0.4; } + /* The capture toggles inside a pad popup: what a preset carries. A compact two-column grid so the four of them cost one popup row rather than four. */ .surface-popup-caption { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d7a54fec..c80a2c2b 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_filepath.cpp unit/core/unit_Control_list.cpp unit/core/unit_DeviceIdentify.cpp unit/core/unit_DevicesModule_ageout.cpp diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index b0f49a5e..25a10751 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -157,7 +157,7 @@ "op": "set_control", "id": "MOD", "key": "source", - "value": "setXYZ(0, width - 1 - x, y, z);" + "value": "setXYZ(width - 1 - x, y, z);" }, { "name": "measure-all-three-scripted", @@ -188,7 +188,7 @@ "op": "set_control", "id": "MOD", "key": "source", - "value": "setXYZ(0, y, x, z);" + "value": "setXYZ(y, x, z);" }, { "name": "measure-modifier-edited", diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc index edaf2c71..7e1927fe 100644 --- a/test/unit/core/moonlive_device_codegen.inc +++ b/test/unit/core/moonlive_device_codegen.inc @@ -19,6 +19,7 @@ // included in the enclosing namespace. #include "light/moonlive/MoonLiveBuiltins_light.h" +#include "light/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on #include #include @@ -28,6 +29,15 @@ #include #include +// Any of the three role extensions: one language, and the sweep compiles every script whatever +// role its name claims. +inline bool mmIsScript(const std::filesystem::path& p) { + const auto e = p.extension().string(); + return e == mm::moonlive::kEffectExt || e == mm::moonlive::kLayoutExt || + e == mm::moonlive::kModifierExt; +} + + namespace { // The shipped default layout: a nested loop whose body calls addLight. The script that crashed @@ -129,7 +139,7 @@ TEST_CASE("fill plus a loop on " MM_ISA_NAME " emits what it currently can") { // A scripted MODIFIER is the third binding, and the one handed a per-light coordinate. TEST_CASE("a scripted modifier compiles for " MM_ISA_NAME) { bool ok = false; - CHECK(emitLen(mmScript("setXYZ(0, xPos, yPos, zPos);\n"), mm::moonlive::modifierSysVars(), ok) > 0); + CHECK(emitLen(mmScript("setXYZ(xPos, yPos, zPos);\n"), mm::moonlive::modifierSysVars(), ok) > 0); CHECK(ok); } @@ -206,7 +216,7 @@ TEST_CASE("the " MM_ISA_NAME " assembler stays small enough to build on a render // outright ("unaligned entry instruction"), and CALLn encodes its target as a count of 4-byte units, // so an unaligned callee is not expressible either. Xtensa instructions are 2 or 3 bytes, so a // function following another lands wherever the previous one ended and the alignment is not free. -// crosshair.mlv is the script that found this: three functions, and the compile was refused with the +// crosshair.mle is the script that found this: three functions, and the compile was refused with the // generic "too large" while the same script built fine on RISC-V, whose instructions are all 4 bytes. // // Checked on every backend rather than only Xtensa, because the requirement is a property of the @@ -238,7 +248,7 @@ TEST_CASE("every function in a class starts where a call can reach it, on " MM_I } // Every script we SHIP has to compile on every backend, or a user's board choice silently decides -// which effects exist. `plasma.mlv` is why this test is here: it ran on an S3 and on desktop and was +// which effects exist. `plasma.mle` is why this test is here: it ran on an S3 and on desktop and was // refused on an S31, because one `kCodeCap` constant was sized to the densest ISA while RISC-V emits // up to 1.9x more for identical source. The buffer is now sized per script, and this pins that every // shipped script still fits on every backend. @@ -254,7 +264,7 @@ TEST_CASE("every shipped script compiles for " MM_ISA_NAME) { / "moonlive" / sub; if (!std::filesystem::exists(dir)) continue; for (const auto& entry : std::filesystem::directory_iterator(dir)) { - if (!entry.is_regular_file() || entry.path().extension() != ".mlv") continue; + if (!entry.is_regular_file() || !mmIsScript(entry.path())) continue; std::ifstream f(entry.path()); std::ostringstream ss; ss << f.rdbuf(); diff --git a/test/unit/core/unit_Control_filepath.cpp b/test/unit/core/unit_Control_filepath.cpp new file mode 100644 index 00000000..417dbd1e --- /dev/null +++ b/test/unit/core/unit_Control_filepath.cpp @@ -0,0 +1,95 @@ +// @module Control + +// Pins ControlType::FilePath: a control whose VALUE is the name of a file, while the file's +// CONTENTS are edited in the UI and travel over /api/file. +// +// Why the type exists as its own type: a file body cannot ride /api/control at all (every route but +// /api/file and the firmware upload returns 413 once the request exceeds the server's buffer), so a +// control that means "this file" has to store a reference and leave the bytes to the streaming +// route. TextArea is its opposite: there the value IS the body. That is a difference in what the +// value MEANS, which no rendering flag expresses. + +#include "doctest.h" +#include "core/Control.h" +#include "core/JsonSink.h" + +#include +#include + +namespace { +// What a module declares: where its files live and which of them to offer. Borrowed by the +// descriptor, so it has to outlive the control, exactly like addSelect's options array. +const char* const kScriptPick[3] = {"/moonlive", ".mle", nullptr}; +} // namespace + +TEST_CASE("a file-path control carries the directory and extension the module declared") { + char script[41] = "plasma.mle"; + mm::ControlList controls; + controls.addFilePath("script", script, sizeof(script), kScriptPick); + REQUIRE(controls.count() == 1); + CHECK(controls[0].type == mm::ControlType::FilePath); + CHECK(std::strcmp(mm::controlTypeName(controls[0].type), "filepath") == 0); + + // The UI lists a directory and filters it without knowing what a script is: the module supplies + // both facts, which is what keeps the control domain-neutral. + mm::JsonSink sink; + mm::writeControlMetadata(sink, controls[0]); + const std::string meta = sink.data(); + CHECK(meta.find("\"dir\":\"/moonlive\"") != std::string::npos); + CHECK(meta.find("\"ext\":\".mle\"") != std::string::npos); +} + +TEST_CASE("a file-path control with no directory offers no picker rather than a broken one") { + char path[41] = ""; + mm::ControlList controls; + controls.addFilePath("file", path, sizeof(path)); // no pair: an editor with a fixed path + mm::JsonSink sink; + mm::writeControlMetadata(sink, controls[0]); + const std::string meta = sink.data(); + CHECK(meta.find("\"dir\"") == std::string::npos); +} + +TEST_CASE("a file-path control listing every file omits the extension filter") { + static const char* const anyFile[2] = {"/presets", nullptr}; + char path[41] = ""; + mm::ControlList controls; + controls.addFilePath("file", path, sizeof(path), anyFile); + mm::JsonSink sink; + mm::writeControlMetadata(sink, controls[0]); + const std::string meta = sink.data(); + CHECK(meta.find("\"dir\":\"/presets\"") != std::string::npos); + CHECK(meta.find("\"ext\"") == std::string::npos); +} + +// The value is a NAME, never a body. A control write that tried to carry a file's contents would +// arrive here, and it must fill the buffer and stop rather than run off the end of it: any input, +// any size, degrade visibly (the robustness rule). +TEST_CASE("a file-path control stores a name, never a file body") { + char script[41] = ""; + mm::ControlList controls; + controls.addFilePath("script", script, sizeof(script), kScriptPick); + + const std::string big = "{\"script\":\"" + std::string(5000, 'x') + "\"}"; + const mm::ApplyResult r = mm::applyControlValue(controls[0], big.c_str(), + "script", mm::ApplyPolicy::Clamp); + CHECK(r == mm::ApplyResult::Ok); // accepted, and bounded by the buffer rather than refused + CHECK(std::strlen(script) == sizeof(script) - 1); // filled, and no further + CHECK(script[sizeof(script) - 1] == '\0'); // still a valid C string +} + +// It persists like the text control it is: a device that reboots comes back pointing at the same +// file, which is what makes "the script survives a power cycle" true. +TEST_CASE("a file-path control persists and reloads its name") { + char script[41] = "ember.mle"; + mm::ControlList controls; + controls.addFilePath("script", script, sizeof(script), kScriptPick); + CHECK(mm::isPersistable(controls[0])); + + mm::JsonSink sink; + mm::writeControlValue(sink, controls[0]); + CHECK(std::strcmp(sink.data(), "\"ember.mle\"") == 0); + + std::snprintf(script, sizeof(script), "%s", "something-else.mle"); + mm::applyControlValue(controls[0], "{\"script\":\"ember.mle\"}", "script", mm::ApplyPolicy::Clamp); + CHECK(std::strcmp(script, "ember.mle") == 0); +} diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp index 7aa08ebe..a5cbbb10 100644 --- a/test/unit/core/unit_HttpServerModule_apply.cpp +++ b/test/unit/core/unit_HttpServerModule_apply.cpp @@ -493,3 +493,68 @@ TEST_CASE("apply-core: enabled toggle requests a full resync; a plain value chan mm::MoonModule::setSchemaChangedHook(nullptr); // don't leak the spy into other tests s.deleteTree(root); } + +// --- a file write re-derives what was built from the file ------------------------------------- +// +// Persistent state changes two ways: a control write, and a file write. Core enforced "re-derive +// what depends on it" for the first only, so saving a file's CONTENTS under an unchanged name +// changed nothing on the device: a scripted module kept running the program compiled from the +// PREVIOUS text, and the only way to make it notice was to re-name the file. Editing a script and +// seeing the fixture change is the loop this closes. +namespace { +// A module that counts its own prepare() calls. Observing the COUNT rather than the scheduler's +// private request flag keeps the test on the behavior (something re-derived) instead of on the +// mechanism (a bool was set), so a future change of mechanism does not have to rewrite it. +struct Prepares : public mm::MoonModule { + uint8_t prepared = 0; + void prepare() override { prepared++; } +}; +} // namespace + +TEST_CASE("a written file asks the tree to re-derive") { + mm::Scheduler s; + auto* root = new Prepares(); + root->setName("Root"); + s.addModule(root); + s.setup(); + + mm::HttpServerModule http; + http.setScheduler(&s); + const uint8_t afterSetup = root->prepared; + + // A tick with nothing pending must not re-derive: the request is what triggers it, not the + // passage of time. Without this the next check would pass even if the write did nothing. + s.tick(); + REQUIRE(root->prepared == afterSetup); + + http.applyFileChanged("/moonlive/plasma.mle"); + s.tick(); + CHECK(root->prepared == afterSetup + 1); +} + +TEST_CASE("a burst of file writes costs one re-derive, not one per file") { + mm::Scheduler s; + auto* root = new Prepares(); + root->setName("Root"); + s.addModule(root); + s.setup(); + + mm::HttpServerModule http; + http.setScheduler(&s); + const uint8_t before = root->prepared; + + // The File Manager's multi-file upload writes several files back to back. Each asks, and the + // request coalesces into the single sweep the next tick performs, so a ten-file upload does not + // rebuild the tree ten times on the render thread. + for (int i = 0; i < 10; i++) http.applyFileChanged("/moonlive/x.mle"); + s.tick(); + CHECK(root->prepared == before + 1); +} + +TEST_CASE("a file write with no scheduler is a no-op, not a crash") { + // HttpServerModule is constructed before it is wired, and the Improv path builds one without a + // tree at all. Degrade visibly, never crash (the robustness rule). + mm::HttpServerModule http; + http.applyFileChanged("/moonlive/plasma.mle"); // must simply return + CHECK(true); +} diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 7b7d2343..932b5bdf 100644 --- a/test/unit/core/unit_moonlive_compiler.cpp +++ b/test/unit/core/unit_moonlive_compiler.cpp @@ -395,7 +395,7 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") { CHECK((ok.ok || std::string(ok.error) == moonlive::kCodegenFailed)); #endif // Sequential loops REUSE a name legitimately: the first has left scope by the time the second - // binds, so this must still compile (two-rows.mlv is exactly this shape). + // binds, so this must still compile (two-rows.mll is exactly this shape). auto seq = moonlive::compileSource( mmScript("for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } for (i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); }"), kTable, kSys, out, sizeof(out)); diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 66ef38a5..8aa93713 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -436,7 +436,7 @@ TEST_CASE("one class can serve several moments, and each is called on its own") moonlive::MoonLive eng; REQUIRE(eng.compile("class Both {\n" " tick() { setRGB(0, 7, 0, 0); }\n" - " modifyLogical() { setXYZ(0, 3, 4, 5); }\n" + " modifyLogical() { setXYZ(3, 4, 5); }\n" "}\n", kCtrlTable, kSys)); CHECK(eng.hasEntry("tick")); CHECK(eng.hasEntry("modifyLogical")); @@ -930,6 +930,42 @@ TEST_CASE("an array larger than the arena is refused at compile time") { eng.free(); } + +// setXYZ takes THREE arguments, not four. The op it lowers to writes three bytes at +// `index * stride` and still takes that index, exactly as setRGB does: what changed is only the +// syntax. A modifier is handed ONE coordinate per call and can write nothing but slot 0, so an +// explicit index was a constant every author typed and none could explain. setRGB keeps its index +// because an effect picks a pixel out of a whole buffer, where the index is the whole point. +TEST_CASE("a modifier writes its coordinate without naming a destination slot") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class M { modifyLogical() { setXYZ(3, 4, 5); } }\n", kCtrlTable, kSys)); + uint8_t xyz[3] = {0, 0, 0}; + eng.run(xyz, 1, 3, 0, moonlive::kEntryModify); + CHECK(xyz[0] == 3); + CHECK(xyz[1] == 4); + CHECK(xyz[2] == 5); + eng.free(); +} + +// The old four-argument form is REFUSED rather than quietly reinterpreted: taking it would read the +// coordinate's x as the slot index and silently write the wrong thing. +TEST_CASE("the old four-argument setXYZ is refused, not reinterpreted") { + moonlive::MoonLive eng; + CHECK_FALSE(eng.compile("class M { modifyLogical() { setXYZ(0, 3, 4, 5); } }\n", kCtrlTable, kSys)); + eng.free(); +} + +// setRGB is untouched: its index is meaningful, so it still takes four. +TEST_CASE("setRGB still names the light it writes") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T { tick() { setRGB(1, 9, 8, 7); } }\n", kCtrlTable, kSys)); + uint8_t px[6] = {}; + eng.run(px, 2, 3, 0); + CHECK(px[3] == 9); // light 1, not light 0 + CHECK(px[0] == 0); + eng.free(); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT β€” every case above needs compile() to SUCCEED, so // they all gate on the JIT: on a target with no backend (x86-64 desktop today) // the helpers they call are compiled out with it. diff --git a/test/unit/light/MoonLiveScriptFixture.h b/test/unit/light/MoonLiveScriptFixture.h index e618c402..b2d06084 100644 --- a/test/unit/light/MoonLiveScriptFixture.h +++ b/test/unit/light/MoonLiveScriptFixture.h @@ -26,7 +26,7 @@ /// Every script this fixture wrote, removed when the test process exits. /// /// The scripts go in the SAME directory a real install keeps its scripts in β€” that is what makes the -/// test meaningful β€” so leaving them behind drops a hundred `t*.mlv` files among the user's own, and +/// test meaningful β€” so leaving them behind drops a hundred `t*.mle` files among the user's own, and /// the next run adds another hundred. Deleting each file at the end of its test would be wrong: a /// test compiles the script and then re-reads it through the module, so the file has to outlive the /// call. Process exit is the first moment they are all certainly finished with. @@ -45,7 +45,7 @@ inline std::vector& mmScriptRegistry() { inline const char* mmWriteScript(const char* text) { static std::atomic counter{0}; thread_local char name[32]; - std::snprintf(name, sizeof(name), "t%d.mlv", ++counter); + std::snprintf(name, sizeof(name), "t%d.mle", ++counter); char path[96]; std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name); diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index 7ba43f8e..f8f63998 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -14,6 +14,7 @@ #include "MoonLiveScriptFixture.h" #include "../core/moonlive_script_wrap.h" #include "light/moonlive/MoonLiveLayout.h" +#include "light/moonlive/MoonLiveModifier.h" // the cycle-break case below drives a modifier #include "light/moonlive/MoonLiveBuiltins_light.h" #include "platform/platform.h" #include "core/moonlive/moonlive_emit.h" @@ -485,7 +486,7 @@ TEST_CASE("naming a different script through the control actually swaps the prog TEST_CASE("a layout whose script is missing reports it without retrying forever") { MoonLiveLayout l; l.defineControls(); - l.setScript("definitely-not-there.mlv"); + l.setScript("definitely-not-there.mll"); l.prepare(); CHECK(l.severity() == MoonModule::Severity::Error); // Every ask the pipeline could make, several times over. Each one used to re-read the file. @@ -552,7 +553,7 @@ TEST_CASE("a layout that starts empty still compiles the first script it is give TEST_CASE("a script name cannot escape the script folder") { MoonLiveLayout l; l.defineControls(); - for (const char* bad : {"../.config/NetworkModule.json", "..", "sub/dir.mlv", "grid.txt"}) { + for (const char* bad : {"../.config/NetworkModule.json", "..", "sub/dir.mll", "grid.txt"}) { INFO(bad); l.setScript(bad); l.prepare(); @@ -576,7 +577,7 @@ TEST_CASE("a script that disappears takes its lights with it") { CHECK(l.severity() != MoonModule::Severity::Error); #endif - l.setScript("gone.mlv"); // never written, so the loader rejects it + l.setScript("gone.mll"); // never written, so the loader rejects it l.prepare(); CHECK(l.severity() == MoonModule::Severity::Error); CHECK(l.lightCount() == 0); // the old program is gone, not just unreported @@ -584,16 +585,17 @@ TEST_CASE("a script that disappears takes its lights with it") { // The name the LOADER accepts and the name the CONTROL can hold must be the same length. They were // not: the control held 31 characters while the loader accepted 40, so a longer valid name was -// silently truncated on its way in β€” and truncation can cut the `.mlv` off, turning a real script -// into a name the loader then rejects. The user sees "script must end in .mlv" for a file that does. +// silently truncated on its way in, and truncation can cut the extension off, turning a real +// script into a name the loader then rejects. The user sees an extension complaint for a file that +// has one. TEST_CASE("a script name at the accepted length survives the control it is stored in") { - // A name exactly at the limit: filler + ".mlv", written so the file really exists. + // A name exactly at the limit: filler + a role extension, written so the file really exists. std::string longName(mm::moonlive::kMaxScriptName - 4, 'a'); - longName += ".mlv"; + longName += mm::moonlive::kLayoutExt; REQUIRE(longName.size() == mm::moonlive::kMaxScriptName); // Write a real script under that name, then name it. If the control clipped it, the loader - // would see a truncated name (possibly without .mlv) and report an error instead of rendering. + // would see a truncated name (possibly without its extension) and report an error instead. char path[128]; std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, longName.c_str()); mm::platform::fsMkdir(mm::moonlive::kScriptDir); @@ -610,7 +612,7 @@ TEST_CASE("a script name at the accepted length survives the control it is store // this way because a host without a MoonLive backend (x86-64) fails every compile by design, // and this test is about the control buffer, not about codegen. if (l.severity() == MoonModule::Severity::Error) - CHECK(std::string(l.status()).find(".mlv") == std::string::npos); + CHECK(std::string(l.status()).find(".mll") == std::string::npos); #if MM_MOONLIVE_HAS_HOST_JIT CHECK(l.severity() != MoonModule::Severity::Error); CHECK(l.lightCount() == 1); @@ -639,4 +641,111 @@ TEST_CASE("a serpentine layout places every light exactly once") { l.prepare(); CHECK(l.lightCount() == 12); // 4 x 3, every cell placed once and none twice } + +// --- editing a script's CONTENTS recompiles it ------------------------------------------------- +// +// The gap this closes: a binding keyed its recompile on the script's NAME, so saving new text into +// the same file changed nothing. The module kept running the program built from the PREVIOUS text, +// and the only way to make it notice was to rename the file. That is why editing a script on its +// own card could not work, and it is what a file write now triggers tree-wide. +TEST_CASE("editing a script's text recompiles it, without renaming the file") { + MoonLiveLayout l; + l.defineControls(); + const char* name = mmWriteScript(mmScriptAs("placeLights", + "for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }")); + l.setScript(name); + l.prepare(); + CHECK(l.lightCount() == 3); + + // Rewrite THE SAME FILE, exactly as a save from the editor does. + char path[96]; + std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name); + const std::string edited = mmScriptAs("placeLights", + "for (i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }"); + REQUIRE(mm::platform::fsWriteAtomic(path, edited.c_str(), edited.size())); + + l.prepare(); + CHECK(l.lightCount() == 7); +} + +// The other half of the same rule, and the one a modifier depends on: an unchanged file must be +// RECOGNISED as unchanged. A modifier turns "a new program was installed" into "ask the Layer to +// rebuild", and the Layer's rebuild calls prepare() again, so answering "changed" every time makes +// the two call each other forever and the fixture renders nothing at all. +TEST_CASE("preparing an unchanged script installs no new program") { + MoonLiveModifier m; + m.defineControls(); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos, zPos);"))); + + m.prepare(); + CHECK(m.consumeNeedsRebuild()); // the first compile is a real change + + m.prepare(); + CHECK_FALSE(m.consumeNeedsRebuild()); // nothing changed, so the Layer is not asked again + m.prepare(); + CHECK_FALSE(m.consumeNeedsRebuild()); +} + +// A broken script that is FIXED IN PLACE compiles, without being renamed. This is the failure the +// editor makes routine: type a typo, see the parse error, correct it, save. Keyed on the name alone +// (which is what the bindings did before) the corrected script stays refused until it is renamed. +// +// NOT pinned here: that a broken script is tried ONCE rather than on every ask. The latch exists +// because each retry is two LittleFS reads (~5 ms on an S3) and the pipeline asks repeatedly while +// sizing a fixture, so the retries starve the render task until the watchdog resets the device. On +// the host a re-read costs microseconds and nothing observable differs, which four attempts at a +// test confirmed: removing the latch entirely leaves every assertion passing. Backlogged rather +// than papered over with a test that cannot fail. +TEST_CASE("a broken script fixed in place compiles, without being renamed") { + MoonLiveLayout l; + l.defineControls(); + const char* name = mmWriteScript("class T { this is not a script }\n"); + l.setScript(name); + l.prepare(); + CHECK(l.lightCount() == 0); // refused, and the card carries the parse error + REQUIRE_FALSE(std::string(l.status()).empty()); + + // Fix it IN PLACE, under the same name, exactly as saving from the editor does. + char path[96]; + std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name); + const std::string fixed = mmScriptAs("placeLights", + "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }"); + REQUIRE(mm::platform::fsWriteAtomic(path, fixed.c_str(), fixed.size())); + + CHECK(l.lightCount() == 5); // the content moved, so the failure latch released +} + + + + +// A script's ROLE is its file extension: `.mle` an effect, `.mll` a layout, `.mlm` a modifier. It is +// stated by the author rather than derived from what the class defines, so that adding (say) a +// per-frame tick() to modifiers later cannot silently start listing them in effect pickers. +// +// The LOADER is role-blind and accepts all three, exactly as the engine is: which picker offered a +// file is the binding's business, and a class may serve several moments. What the extension decides +// is which card offers the file, not what the engine will do with it. +TEST_CASE("the loader accepts any role extension, and nothing else") { + MoonLiveLayout l; + l.defineControls(); + for (const char* ext : {".mle", ".mll", ".mlm"}) { + std::string name = std::string("roletest") + ext; + char path[128]; + std::snprintf(path, sizeof(path), "%s/%s", mm::moonlive::kScriptDir, name.c_str()); + mm::platform::fsMkdir(mm::moonlive::kScriptDir); + const char* body = mmScriptAs("placeLights", "addLight(1, 1, 0);"); + REQUIRE(mm::platform::fsWriteAtomic(path, body, std::strlen(body))); + mmScriptRegistry().push_back(path); + + l.setScript(name.c_str()); + l.prepare(); + INFO("extension " << ext); + CHECK(std::string(l.status()).find("must end in") == std::string::npos); + } + // Anything else is refused with the three it accepts, rather than a bare "bad name". + l.setScript("notascript.txt"); + l.prepare(); + CHECK(std::string(l.status()).find("must end in") != std::string::npos); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT: the script must COMPILE for the count to mean anything. diff --git a/test/unit/light/unit_MoonLiveModifier.cpp b/test/unit/light/unit_MoonLiveModifier.cpp index 99a63255..324bc84c 100644 --- a/test/unit/light/unit_MoonLiveModifier.cpp +++ b/test/unit/light/unit_MoonLiveModifier.cpp @@ -49,7 +49,7 @@ Coord3D transform(const char* script, lengthType x, lengthType y, lengthType z, TEST_CASE("a scripted modifier mirrors the pattern, the way a hand-written one would") { // The default script. A mirror is the shape that makes a working binding obvious on a bench // strand β€” the pattern simply runs the other way. - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"), 10, 20, 0); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"), 10, 20, 0); CHECK(p.x == 244); // width(255) - 1 - 10 CHECK(p.y == 20); // untouched axes stay put CHECK(p.z == 0); @@ -58,25 +58,25 @@ TEST_CASE("a scripted modifier mirrors the pattern, the way a hand-written one w TEST_CASE("the script reads the light's own position, not a fixed value") { // The whole seam in one assertion: `x` inside the script has to BE this light's x. If the // binding failed to write the input slots, every light would transform identically. - const Coord3D a = transform(mmScriptAs("modifyLogical", "setXYZ(0, xPos, yPos, zPos);"), 7, 3, 1); + const Coord3D a = transform(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos, zPos);"), 7, 3, 1); CHECK(a.x == 7); CHECK(a.y == 3); CHECK(a.z == 1); - const Coord3D b = transform(mmScriptAs("modifyLogical", "setXYZ(0, xPos, yPos, zPos);"), 200, 100, 2); + const Coord3D b = transform(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos, zPos);"), 200, 100, 2); CHECK(b.x == 200); CHECK(b.y == 100); CHECK(b.z == 2); } TEST_CASE("a script can swap axes, which is a transform no control could express") { - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, yPos, xPos, zPos);"), 5, 60, 0); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(yPos, xPos, zPos);"), 5, 60, 0); CHECK(p.x == 60); CHECK(p.y == 5); } TEST_CASE("a script can offset a coordinate, the scroll a modifier usually hard-codes") { - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, xPos + 4, yPos, zPos);"), 10, 10, 0); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(xPos + 4, yPos, zPos);"), 10, 10, 0); CHECK(p.x == 14); } @@ -86,7 +86,7 @@ TEST_CASE("a broken script leaves the pattern alone rather than taking the layer // the pipeline keeps rendering. MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, xPos, yPos"))); // no closing paren, no semicolon + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos"))); // no closing paren, no semicolon m.prepare(); Coord3D box{255, 255, 1}; m.modifyLogicalSize(box); @@ -102,7 +102,7 @@ TEST_CASE("a coordinate too large for a script input passes through untransforme // A script input is one byte, so an axis beyond 255 cannot be handed to the script at all. // Passing it through unchanged is the honest degrade: wrapping it would silently place the // light somewhere it is not. The 16-bit element store that lifts this is backlogged. - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, 255 - xPos, yPos, zPos);"), 300, 10, 0); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(255 - xPos, yPos, zPos);"), 300, 10, 0); CHECK(p.x == 300); // untouched, not wrapped to 44 CHECK(p.y == 10); } @@ -111,7 +111,7 @@ TEST_CASE("editing the script changes the transform without a rebuild of the fir // The live-edit loop: the same module, a new script, a different mapping. MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"))); m.prepare(); Coord3D box{255, 255, 1}; m.modifyLogicalSize(box); // the Layer hands every modifier its box before folding @@ -120,7 +120,7 @@ TEST_CASE("editing the script changes the transform without a rebuild of the fir m.modifyLogical(a); CHECK(a.x == 244); // the mirror - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos, zPos);"))); m.prepare(); Coord3D b{10, 20, 0}; @@ -134,21 +134,21 @@ TEST_CASE("editing the script changes the transform without a rebuild of the fir // precedence is real β€” `2 + 3 * 4` silently giving 20 would corrupt every non-trivial transform. TEST_CASE("a script computes with the usual precedence, so a transform means what it reads like") { // Multiplication binds tighter than addition. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, 2 + 3 * 4, yPos, zPos);"), 0, 0, 0).x == 14); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(2 + 3 * 4, yPos, zPos);"), 0, 0, 0).x == 14); // Parentheses override it. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, (2 + 3) * 4, yPos, zPos);"), 0, 0, 0).x == 20); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ((2 + 3) * 4, yPos, zPos);"), 0, 0, 0).x == 20); // Subtraction, which no ISA here has an instruction for: a - b is emitted as a + (b * -1). - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, 100 - 40, yPos, zPos);"), 0, 0, 0).x == 60); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(100 - 40, yPos, zPos);"), 0, 0, 0).x == 60); // Left-associative, so 100 - 40 - 20 is 40 rather than 80. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, 100 - 40 - 20, yPos, zPos);"), 0, 0, 0).x == 40); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(100 - 40 - 20, yPos, zPos);"), 0, 0, 0).x == 40); // The coordinate inputs compose with all of it β€” this is the shape a real modifier uses. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, xPos * 2 + 1, yPos, zPos);"), 10, 0, 0).x == 21); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(xPos * 2 + 1, yPos, zPos);"), 10, 0, 0).x == 21); } TEST_CASE("a scaled mirror, the transform this binding exists to make possible") { // Two operators and an input in one expression: reflect, then halve. Expressible now, and not // expressible at all before arithmetic landed. - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, (255 - xPos) * 2, yPos, zPos);"), 100, 5, 0); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ((255 - xPos) * 2, yPos, zPos);"), 100, 5, 0); CHECK(p.x == 54); // (255-100)*2 = 310, truncated into the byte the input slot holds CHECK(p.y == 5); } @@ -166,7 +166,7 @@ TEST_CASE("folding a wall's worth of lights compiles the script once, not once p // an unchanged value across the whole fold proves no compile happened inside it. MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"))); m.prepare(); Coord3D box{255, 255, 1}; m.modifyLogicalSize(box); @@ -189,12 +189,12 @@ TEST_CASE("folding a wall's worth of lights compiles the script once, not once p TEST_CASE("editing a script asks the layer to rebuild its mapping") { MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(xPos, yPos, zPos);"))); m.prepare(); CHECK(m.consumeNeedsRebuild() == true); // the first compile needs one too CHECK(m.consumeNeedsRebuild() == false); // and it is consumed, not sticky - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, 7 - xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(7 - xPos, yPos, zPos);"))); m.prepare(); CHECK(m.consumeNeedsRebuild() == true); // an edit asks again @@ -210,13 +210,13 @@ TEST_CASE("editing a script asks the layer to rebuild its mapping") { // be able to read the EXTENT it is folding within, and the default has to use it. TEST_CASE("the default script mirrors within the grid it is given, not a fixed 255") { // A 16-wide grid: x=0 must land on the far end of THAT grid, 15 β€” not 245. - const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"), 0, 0, 0, /*w=*/16, /*h=*/16, /*d=*/1); + const Coord3D p = transform(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"), 0, 0, 0, /*w=*/16, /*h=*/16, /*d=*/1); CHECK(p.x == 15); CHECK(p.y == 0); // Every coordinate has to stay inside the box, or the Layer discards it. for (lengthType i = 0; i < 16; i++) { - const Coord3D q = transform(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"), i, 0, 0, 16, 16, 1); + const Coord3D q = transform(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"), i, 0, 0, 16, 16, 1); CAPTURE(i); CHECK(q.x >= 0); CHECK(q.x < 16); @@ -224,8 +224,8 @@ TEST_CASE("the default script mirrors within the grid it is given, not a fixed 2 } TEST_CASE("a script can read the grid extent it is folding within") { - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, width, yPos, zPos);"), 0, 0, 0, 32, 16, 1).x == 32); - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, height, yPos, zPos);"), 0, 0, 0, 32, 16, 1).x == 16); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(width, yPos, zPos);"), 0, 0, 0, 32, 16, 1).x == 32); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(height, yPos, zPos);"), 0, 0, 0, 32, 16, 1).x == 16); } // The black-screen failure end to end, through a real Layer. Byte arithmetic wraps: a script that @@ -243,7 +243,7 @@ TEST_CASE("a script that computes a position outside the grid leaves lights mapp // bytes cannot fail: draw::fill writes every byte itself, whatever the fold decided. MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, xPos + 200, yPos, zPos);"))); // deliberately off the end of a 16-wide grid + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(xPos + 200, yPos, zPos);"))); // deliberately off the end of a 16-wide grid m.prepare(); Coord3D box{16, 16, 1}; m.modifyLogicalSize(box); @@ -279,7 +279,7 @@ TEST_CASE("re-preparing with an unchanged script does not ask for another rebuil // A module with no script compiles nothing and therefore asks for nothing β€” the rebuild request // exists to APPLY a new transform, and there is none. Name one, so the first prepare has // something to compile and the "unchanged" case below is the real question. - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, width - 1 - xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(width - 1 - xPos, yPos, zPos);"))); m.prepare(); CHECK(m.consumeNeedsRebuild() == true); // the first compile needs one @@ -291,7 +291,7 @@ TEST_CASE("re-preparing with an unchanged script does not ask for another rebuil CHECK(m.consumeNeedsRebuild() == false); // A real edit still asks. - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(0, yPos, xPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "setXYZ(yPos, xPos, zPos);"))); m.prepare(); CHECK(m.consumeNeedsRebuild() == true); } @@ -301,9 +301,9 @@ TEST_CASE("re-preparing with an unchanged script does not ask for another rebuil // changing the result β€” `print(x)` where `x` stood still computes x. TEST_CASE("print reports a value without changing what the script computes") { // Wrapping the coordinate in print() must leave the transform identical. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, print(xPos), yPos, zPos);"), 7, 3, 0, 16, 16, 1).x == 7); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(print(xPos), yPos, zPos);"), 7, 3, 0, 16, 16, 1).x == 7); // And it composes inside arithmetic. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, print(width - 1 - xPos), yPos, zPos);"), 0, 0, 0, 16, 16, 1).x == 15); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(print(width - 1 - xPos), yPos, zPos);"), 0, 0, 0, 16, 16, 1).x == 15); } // Subtraction is emitted as `a + (b * -1)`, and -1 has to survive into the register. The assemblers @@ -322,9 +322,9 @@ TEST_CASE("a subtraction produces the whole value, not just its low byte") { // The consequences the byte hides: an index computed by subtraction becomes ~65k, the element // store's bounds guard rejects it, and the light silently never lights; a subtraction handed to // a host call (random16, print) gets a wrong argument. - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, print(width - 1 - xPos), yPos, zPos);"), 0, 0, 0, 16, 16, 1).x == 15); - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, print(100 - 1), yPos, zPos);"), 0, 0, 0, 255, 255, 1).x == 99); - CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(0, print(5 - 5), yPos, zPos);"), 0, 0, 0, 255, 255, 1).x == 0); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(print(width - 1 - xPos), yPos, zPos);"), 0, 0, 0, 16, 16, 1).x == 15); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(print(100 - 1), yPos, zPos);"), 0, 0, 0, 255, 255, 1).x == 99); + CHECK(transform(mmScriptAs("modifyLogical", "setXYZ(print(5 - 5), yPos, zPos);"), 0, 0, 0, 255, 255, 1).x == 0); } // --- for -------------------------------------------------------------------------------------- @@ -336,7 +336,7 @@ TEST_CASE("a subtraction produces the whole value, not just its low byte") { TEST_CASE("a for loop runs its body once per step") { MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 4; i = i + 1) { print(i); } setXYZ(0, xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 4; i = i + 1) { print(i); } setXYZ(xPos, yPos, zPos);"))); m.prepare(); CHECK(m.severity() != MoonModule::Severity::Error); // it compiles at all Coord3D box{16, 16, 1}; m.modifyLogicalSize(box); @@ -349,7 +349,7 @@ TEST_CASE("a loop over an empty range runs its body no times") { // The entry guard: `i < 0` must skip the body entirely rather than wrap and run forever. MoonLiveModifier m; m.defineControls(); - m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 0; i = i + 1) { print(99); } setXYZ(0, xPos, yPos, zPos);"))); + m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (i = 0; i < 0; i = i + 1) { print(99); } setXYZ(xPos, yPos, zPos);"))); m.prepare(); CHECK(m.severity() != MoonModule::Severity::Error); Coord3D box{16, 16, 1}; m.modifyLogicalSize(box); @@ -362,7 +362,7 @@ TEST_CASE("loops nest, which is what placing a grid of lights needs") { MoonLiveModifier m; m.defineControls(); m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (a = 0; a < 2; a = a + 1) { for (b = 0; b < 2; b = b + 1) { print(a); } }" - " setXYZ(0, xPos, yPos, zPos);"))); + " setXYZ(xPos, yPos, zPos);"))); m.prepare(); CHECK(m.severity() != MoonModule::Severity::Error); Coord3D box{16, 16, 1}; m.modifyLogicalSize(box); diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp index 696951a8..f9968ce8 100644 --- a/test/unit/light/unit_MoonLiveScripts.cpp +++ b/test/unit/light/unit_MoonLiveScripts.cpp @@ -17,6 +17,7 @@ #include "platform/platform.h" #include "core/moonlive/moonlive_emit.h" #include "light/moonlive/MoonLiveBuiltins_light.h" +#include "light/moonlive/MoonLiveScriptFile.h" // the role extensions the sweep filters on #include #include @@ -25,6 +26,15 @@ #include #include +// Any of the three role extensions: one language, and the sweep compiles every script whatever +// role its name claims. +inline bool mmIsScript(const std::filesystem::path& p) { + const auto e = p.extension().string(); + return e == mm::moonlive::kEffectExt || e == mm::moonlive::kLayoutExt || + e == mm::moonlive::kModifierExt; +} + + using namespace mm; namespace { @@ -40,7 +50,7 @@ std::vector scriptsIn(const char* sub) { const std::filesystem::path dir = scriptRoot() / sub; if (!std::filesystem::exists(dir)) return out; for (const auto& e : std::filesystem::directory_iterator(dir)) - if (e.is_regular_file() && e.path().extension() == ".mlv") out.push_back(e.path()); + if (e.is_regular_file() && mmIsScript(e.path())) out.push_back(e.path()); return out; } @@ -102,7 +112,7 @@ TEST_CASE("every script in moonlive/ compiles") { // compile error, which is the same outcome as reading a value that is always zero. What they did // create was a trap, because they were different vocabularies rather than nested ones, so a name // was legal in one role and RESERVED in another. `disasm.py` compiled against the widest table and -// therefore refused `grid.mlv`, the shipped default layout, as "name is a system variable". +// therefore refused `grid.mll`, the shipped default layout, as "name is a system variable". TEST_CASE("every script reads the same system-variable vocabulary") { struct Case { const char* src; bool ok; const char* what; }; const Case cases[] = { @@ -112,7 +122,7 @@ TEST_CASE("every script reads the same system-variable vocabulary") { {mmScript("for (i = 0; i < width; i = i + 1) { addLight(i, 0, 0); }"), true, "a layout may read width: same name, same meaning, whoever asks"}, {mmScript("setRGB(width, 0, 0, 0);"), true, "an effect reads the layer's width"}, - {mmScript("setXYZ(0, width - 1 - xPos, yPos, zPos);"), + {mmScript("setXYZ(width - 1 - xPos, yPos, zPos);"), true, "a modifier reads its coordinate AND the box it lives in"}, {mmScript("setRGB(xPos, 0, 0, 0);"), true, "reading a coordinate outside a modifier is legal and reads 0: no binding writes " From 66dd789d40d79e3f63adc6e7b13bd7be22ea1770 Mon Sep 17 00:00:00 2001 From: ewowi Date: Wed, 19 Aug 2026 14:38:13 +0200 Subject: [PATCH 4/4] Fix four review defects, two CI findings, and the P4 boot loop Review and CI found six real bugs in the three commits before this one, four of them invisible to 1329 passing tests. The engine also shrank from 1440 to 784 bytes, which is what was boot-looping the P4. Perf: no per-tick cost. The engine is 656 bytes smaller PER SCRIPTED MODULE (three per pipeline), which is stack at boot and heap for the life of the device. Core - A member assignment stored the WRONG REGISTER once the spill pass engaged. StoreCtrl/StoreCtrl16 reported kArg4 as their first source, and the rewriter writes sources back positionally, so the value moved to `b` while `a` became kArg4's register; both lowerings read the value from `a`. Shipped Xtensa scripts already reach the spill threshold. This is the same trap this project documented in its own backlog after hitting it on LoadIdx/StoreIdx, left in the sibling case: the fix there was to report only the real vreg operands, and it is the fix here. - The seeding mask was a uint32_t while member offsets run 0..63. A shift of 32 or more is undefined behaviour and in practice aliased mod 32, so a member above offset 31 was never recorded as seeded: it snapped back to its initializer on every recompile, losing the live value the mechanism exists to keep, and corrupted the bit of the member it aliased. The mask was written when the budget was 16 bytes and did not grow with it; a static_assert now ties the two together. - An ARRAY was reseeded only at element 0, so after an edit that moved it, elements 1..n held the previous program's bytes. Every element is seeded now, which is what "an array starts at zero" has to mean. - sizeof(MoonLive) was 1440 bytes, held BY VALUE in every scripted module and constructed on the main task's stack by registerType's probe. 876 of that was a seeded-name table indexed by ARENA BYTE (64 rows) when a class can hold at most 8 members. Indexed by member instead: 784 bytes. THIS WAS THE P4 BOOT LOOP; the board now boots and runs at ~140 fps. - A uint16_t member's initializer was cast to a byte on the way in, so `uint16_t phase = 1000;` started at 232. Invisible to every existing test, which observe through setRGB's byte truncation while the error is always a multiple of 256. - ControlType::FilePath's picker is a FIXED-SIZE type. A caller passing two elements where three are read compiled fine and read past the end (ASan caught it). The parameter refuses it now, which is where a fixed-size contract belongs. - A name-length loop read `name[n]` before testing the bound, so a name filling its buffer without a terminator was read one past the end (CodeQL, high). - A file DELETE now fires the re-derive seam, as a write does. The seam's own rationale says the rule belongs in core rather than in whichever client remembers a follow-up, and a deleted file is a change to persistent state. Light domain - Dead code removed: scriptHashChunk (its only caller was scriptHash, and the chunked walk it existed for is gone) and MoonLive::stringBytes (no callers). - Comments that contradicted their own implementation: three claimed scriptFileHash reads "through a small stack buffer and allocates nothing" while the code below did one whole-file read. Also the layout's onControlChanged doc described clearing a hash the body no longer clears, the modifier's described a four-argument setXYZ that no longer exists, and the effect carried a default-script comment for a default that was removed. UI - The dirty dot and the disabled style did NOTHING on a card: the CSS targets .fm-tool and the card mounts a .card-btn. Both are covered now. - A FAILED save was silent. The card has no status line by design (the module's own status is authoritative and the device writes it), so a failed write now alerts rather than leaving the dot as the only signal. - currentColor -> currentcolor (Stylelint). Docs - The P4's remaining crash is backlogged with its evidence: it panics with `Cache error` every few minutes while IDLE, and it is pre-existing rather than a regression. Established twice over: the board runs the default module tree with no MoonLive module at all, and a firmware built from a clean main crashes identically. bootReason alternates PANIC and WDT because the panic handler sometimes dies before finishing its dump. The fault site is FreeRTOS's RISC-V coprocessor save on interrupt entry, and the prior art at Plan-20260718 is a different cause with the same panic name, worth re-reading first. - Plan-20260817: steps 5 and 10 are done, both by the work in Plan-20260818, and the arena ceiling has its number. Only `get(x, y)` is open now. Step 10's own reasoning is recorded as wrong: it said the editing loop should come last, against a finished language, but the shape an editor needs does not change with the language, and it paid for itself immediately. - Plan-20260818 records what the plan did not predict: the seam needed nothing built, the editor already existed, and two language decisions were corrected during the work. Tests - A uint16_t member starts at the value it was initialized to, observed through a COMPARISON rather than a colour channel, which is why the bug survived until now. Control-checked: restoring the cast fails it. Bench: all four boards flashed and re-seeded with the 17 role-named scripts. The P4 boots and holds its scripts for the first time on this branch. Co-Authored-By: Claude Opus 5 (1M context) --- docs/backlog/backlog-core.md | 37 +++++++++++ ...20260817 - MoonLive scripts are classes.md | 60 ++++++++++++++---- ...or control and a filesystem change seam.md | 63 ++++++++++++++++--- docs/moonmodules/core/ui.md | 3 +- src/core/Control.h | 28 ++++++++- src/core/HttpServerModule.cpp | 11 +++- src/core/HttpServerModule.h | 3 +- src/core/moonlive/MoonLive.cpp | 50 ++++++++++----- src/core/moonlive/MoonLive.h | 26 ++++++-- src/core/moonlive/MoonLiveCompiler.cpp | 5 +- src/core/moonlive/MoonLiveSpill.cpp | 10 ++- src/light/moonlive/MoonLiveEffect.h | 6 +- src/light/moonlive/MoonLiveLayout.h | 10 +-- src/light/moonlive/MoonLiveModifier.h | 6 +- src/light/moonlive/MoonLiveScript.h | 4 +- src/light/moonlive/MoonLiveScriptFile.h | 24 +++---- src/ui/app.js | 4 ++ src/ui/style.css | 16 +++-- .../light/scenario_MoonLive_pipeline.json | 4 +- test/unit/core/unit_Control_filepath.cpp | 6 +- test/unit/core/unit_moonlive_fill.cpp | 17 +++++ 21 files changed, 298 insertions(+), 95 deletions(-) diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 766237d5..f6f7d913 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -19,6 +19,43 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co - **Live RMII Ethernet reconfigure** β€” runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** β€” `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. - **Installer UX polish** β€” clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. - **Offer projectMM/MoonLight as a library** β€” a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* β€” the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md Β§ Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. +- **ESP32-P4 panics with `Cache error` every few minutes, pre-existing** (2026-08-19): the bench + P4 (Waveshare P4-NANO, `esp32p4-eth`) reboots roughly every four minutes while IDLE, with + `Guru Meditation Error: Core 0 panic'ed (Cache error)`, sometimes followed by an + `Illegal instruction` and a `CHIP_LP_WDT_RESET` on the way down. + + **What the device reports about its own restarts:** `bootReason` alternates between `PANIC` and + the watchdog. The serial shows why: the `Cache error` panic sometimes completes its dump and + reboots cleanly (`PANIC`), and sometimes the panic HANDLER itself then dies with an + `Illegal instruction` before it finishes, leaving the low-power watchdog to reset the chip + (`rst:0x10 CHIP_LP_WDT_RESET`, `W boot.esp32p4: CPU has been reset by WDT`). So a WDT boot reason + here is a SYMPTOM of the same fault, not a second one: nothing is hanging a task. Worth checking + `bootReason` over several restarts rather than one, because either value can appear. + + **Not caused by MoonLive, and not a regression.** Established by two independent checks: the board + runs the DEFAULT module tree (GridLayout + NoiseEffect, no MoonLive module at all, so none of that + code executes), and a firmware built from a clean `main` crashes identically. The filesystem is + healthy throughout: LittleFS mounts, `/.config` lists, and writes succeed. + + What is known about the fault site: `MEPC` resolves to `pxPortGetCoprocArea` + (`freertos/.../portable/riscv/port.c`) reached from `rtos_int_enter` (`portasm.S`), which is FreeRTOS's + RISC-V coprocessor-context save on INTERRUPT ENTRY. That is a symptom of something faulting inside + an ISR context rather than a bug in the kernel itself, and the P4 is the only RISC-V target with a + coprocessor, which is why no other board shows it. The prior art at + [Plan-20260718](../history/plans/Plan-20260718%20-%20MoonI80%20lapping-v2%20clock-oracle%20ring%20(shipped).md) + is a DIFFERENT cause with the same panic name (an ISR reading PSRAM while a flash write disabled + the cache, fixed with a `spi_flash_cache_enabled()` defer guard) and is worth re-reading first: + the same shape on another ISR would present exactly like this. + + Next step is a decoded backtrace from the full panic dump rather than the register line, then + bisecting which ISR is live (audio, the LED driver, ethernet) by disabling each. Two hypotheses + were tested and falsified during the session that found it, so start from evidence. + + A SEPARATE P4 boot loop, also found that session, WAS a real regression and is fixed: the MoonLive + engine had grown to 1440 bytes held by value in every scripted module, and `registerType`'s `T + probe` constructs each module on the main task's stack at boot. Re-indexing its seeded-member table + by member rather than by arena byte took it to 784 bytes and the board boots clean. + - **ESP32-P4 DHCP hostname not shown by the router (recheck later)** β€” the device sets its DHCP hostname (option 12 = `deviceName`, default `MM-XXXX`) in the `ETHERNET_EVENT_CONNECTED` handler, verified working on two boards: the S3 over WiFi (router shows `MM-70BC`) and the Olimex over RMII Ethernet (`MM-BD3C`) β€” the *same* `ethEventHandler` code path the P4 uses. Yet the bench P4 (Waveshare P4-NANO, RMII) still shows as blank/"Unknown" in the GL.iNet client list, while serial confirms `set_hostname` succeeds with no error. Two unconfirmed suspects, neither our logic: (1) the router holds a **sticky lease** for the P4's MAC and won't relearn the hostname until it fully expires (the per-client "forget" isn't exposed in this GL.iNet UI, and a plain reboot didn't clear it); (2) a P4-specific IDF netif quirk serializing option 12 differently on the newer P4 Ethernet path. Since the shared code path is proven on two other boards, this is not treated as a code bug. Recheck after the P4's lease naturally expires, or on a different router, before spending more on it. ### DevicesModule β€” interop plugins + the command half (discovery shipped) diff --git a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md index b37696b7..e8d84fd2 100644 --- a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md +++ b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md @@ -448,7 +448,15 @@ It settles step 5 before step 5 starts. The three bindings already differ only b own, so there is no inheritance question left to answer, and `tick` stays available to mean something in a layout or a modifier later without a grammar change. -5. ⬜ **Consolidate the three bindings onto a HELD HELPER.** The design question this step existed +5. βœ… **Consolidate the three bindings onto a HELD HELPER.** Done, in Plan-20260818, because the + editing loop needed it: saving a script only recompiles if the bindings agree on what "changed" + means, and they did not. `MoonLiveScript` is the held member this step describes, and it removed + 116 lines. It also settled the recompile rule the three had drifted on: an effect had NO content + hash (so editing its text did nothing until the file was renamed), a layout cleared its hash only + on a name change, and only a modifier re-read the file. One rule now: if the file changed, + recompile. + + The design question this step existed to answer is settled: the moment model above means the bindings no longer differ in behaviour, only in which base they extend and which moment they own. What is left is measurable duplication, and the shape it should take is now concrete rather than anticipated. @@ -503,8 +511,9 @@ mechanism or a language people build with. comes back: three builtins (`red`/`green`/`blue`) or bit operators, which is the same question the seven-argument `line()` answered for arguments and would answer once for both. -8. 🟑 **Arrays** (arrays of structs not yet) and **9. 🟑 Wider values than a byte.** Both built; - the ceiling NUMBER is the open item, see below. +8. βœ… **Arrays** (arrays of structs not yet) and **9. βœ… Wider values than a byte.** Both built and + on hardware; the ceiling has its number. Arrays OF STRUCTS remain unbuilt, and are their own step + whenever a script wants one. What shipped, in the order it had to be built: @@ -552,10 +561,16 @@ mechanism or a language people build with. (control-checked to fail on the bug); the other two are backlogged by name, because three attempts at a host test each passed with the defect reintroduced. - **Open: `kCtrlBytes` is a placeholder 16.** The compile error and its diagnostic are built and - tested; the NUMBER is a product-owner decision, since it trades what a script can hold against - RAM on the smallest target (25 bytes per engine today, three engines per pipeline). A particle - array wants more than 16; a classic ESP32 driving a large fixture is what bounds it. + **The ceiling is 64 bytes, sized against a real script rather than guessed.** It was a + placeholder 16 until the first realistic effect written against it (`ember.mle`: two byte + controls, a `uint16_t` counter and a 16-element heat buffer) was refused at 20 bytes. 64 holds a + `uint8_t[64]` or a `uint16_t[32]` alongside scalars. Raise it against a script that needs more, + not on speculation: the failure is a compile error naming the arena, so hitting it is visible. + + Widening it also exposed a defect worth recording, found by the pre-merge Reviewer: the seeding + mask was a `uint32_t` written when the budget was 16, so a member at offset 32 or beyond shifted + past the mask's width. Undefined behaviour that in practice aliased mod 32, silently losing a + member's live value on every recompile. A `static_assert` now ties the mask width to the budget. **8 and 9 were ONE step, done together.** Step 2 was expected to have designed this storage, and it did not (see the correction under *Where script-level state lives*): the arena is a fixed row @@ -609,11 +624,18 @@ same storage-and-ceiling question arrays face in step 8, so the two want one ans two. The one concrete use case is a text overlay in a showcase effect, and that can go a long way on literals plus the numeric vocabulary already present. -10. ⬜ **The editing loop, which is the thing people will actually see.** Editing a script means the - File Manager today: find the file, edit it, save it, then re-name it on the module. The demo is - live authoring, and that wants an editor on the module's own card, saving to the same file the - engine compiles. Tooling rather than language, and the last step because it is worth building - against the finished shape rather than twice. +10. βœ… **The editing loop, which is the thing people will actually see.** Done, in + [Plan-20260818](Plan-20260818%20-%20A%20file%20editor%20control%20and%20a%20filesystem%20change%20seam.md). + A card carries a file picker and an editor; typing and clicking away recompiles. + + Built EARLIER than this plan's "last step, against the finished shape" reasoning suggested, and + that reasoning turned out to be wrong: the shape a text editor needs (a file, and a compile + result) does not change when `get()` or arrays-of-structs arrive, and waiting for a language that + is not finished means never building it. It paid for itself immediately, since every one of this + plan's own codegen bugs was debugged by editing a file and re-uploading it. + + Two things it needed that were not tooling at all: a CORE seam, because a file write notified + nothing (`requestPrepareTree` was reachable only from a control write), and step 5's helper. ## Files @@ -685,7 +707,7 @@ therefore needs a host test that proves the semantics and a bench run that prove source from the constants so raising either limit cannot turn it into a test of the other. The ceiling proved itself immediately: the first realistic effect written against it (a 16-element fire buffer) was refused at the placeholder 16 bytes, which is how `kCtrlBytes` came to be 64. -7. 🟑 **The bench, on all four boards**, after each step: S3 and classic (Xtensa), P4 and S31 +7. βœ… **The bench, on all four boards**, after each step: S3 and classic (Xtensa), P4 and S31 (RISC-V), a scripted layout and a scripted effect. Exec-block sizes compared against the previous step, since an unexplained jump is the cheapest signal that codegen went wrong. @@ -720,6 +742,18 @@ therefore needs a host test that proves the semantics and a bench run that prove | `grid.mlv` | 499 B | 880 B | | `plasma.mlv` | 1378 B | 2644 B | + After the editing loop (Plan-20260818): **all four boards flashed, wiped and re-seeded** with the + 17 role-named scripts, each compiling its layout and effect with the pickers filtering by role. + Save-recompile proven on hardware: writing different text into `lines.mle` took the S3 from + 1233 B to 107 B with no `/api/control` call. + + The P4 is up and holds its scripts, but it panics with `Cache error` every few minutes while + idle. Established as PRE-EXISTING rather than a regression: it runs the default module tree with + no MoonLive module at all, and a firmware built from a clean `main` crashes identically. Recorded + in [backlog-core](../../backlog/backlog-core.md). A SEPARATE P4 boot loop found in the same + session WAS this branch's regression and is fixed: the engine had grown to 1440 bytes held by + value in every scripted module, which `registerType`'s stack probe could not absorb. + 8. **`collect_kpi.py` after typed members** (now step 3), because members change how EVERY variable is accessed. That is the one step where a hot-path regression is plausible, so it is measured rather than assumed. It moved with the step when 2 and 3 swapped: `defineControls()` runs once diff --git a/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md index a8344adf..e3ae8a59 100644 --- a/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md +++ b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md @@ -221,13 +221,56 @@ compile or report a diagnostic, and never crash. ## Steps -Three steps, each stopping at a point the product owner can review and judge. Commit timing is the -product owner's call and is not part of this plan. - -1. The filesystem seam and the `MoonLiveScript` convergence. Verifiable on its own without any UI - change: saving through the File Manager that exists today already recompiles. This is the step - with hardware risk, because it changes when `prepare()` runs. -2. `ControlType::FilePath` and the JS editor extraction, with the File Manager unchanged. The - extraction is verifiable by the File Manager still behaving exactly as it did before. -3. The card control replacing `addText("script", ...)` in the three bindings: the loop the product - owner asked for, where typing runs the script. +All three shipped. Each stopped at a point the product owner reviewed. + +1. βœ… The filesystem seam and the `MoonLiveScript` convergence, which removed 116 lines from the + three bindings. +2. βœ… `ControlType::FilePath` and the JS editor extraction, with the File Manager unchanged. +3. βœ… The card control replacing `addText("script", ...)` in the three bindings. + +## What the plan did not predict + +Three things were smaller than planned, and two were bigger. + +**The seam needed nothing built.** The plan specified a `FileChangedFn` hook, `main.cpp` wiring, a +`Scheduler` change and a coalescing mechanism. None was needed: `HttpServerModule` already held the +scheduler and already called `requestPrepareTree` in five places, and that call already coalesces +through an atomic flag `tick()` consumes. The seam is one call at the write success branch, extracted +into `applyFileChanged` so it is provable without a socket. It fires on DELETE too, which the plan +missed and the pre-merge review caught. + +**The editor was already written.** `openFileEditor` had the load, save, truncation guard, binary +guard and prettify hook; the work was extracting it from its ``, not writing one. + +**Two things the plan got wrong about the language, both corrected by the product owner:** + +- A script's ROLE is its file EXTENSION (`.mle` / `.mll` / `.mlm`), not something derived from the + entry point it defines. Deriving it would have tied a UI filter to a language feature: the day a + modifier wants a per-frame `tick()`, every modifier would appear in effect pickers with nothing + changed. One language, three extensions, the way GLSL uses `.vert` / `.frag`. +- `setXYZ` lost its always-zero index. Implemented as a distinct `StoreFirst` op rather than a flag + that hides an argument, because "the one slot I was given" is a different question from "slot + number zero". The emitted code got SMALLER (Xtensa 163 to 124 bytes for `mirror.mlm`). + +**A new file is a working example**, per role, rather than an empty file that fails to parse the +moment it is created. + +## What the pre-merge review found + +Four defects that tests and the bench had both missed, all fixed: + +- `StoreCtrl` reported `kArg4` as its first source, and the spill pass writes sources back + positionally, so every member assignment stored the wrong register once spilling engaged. The + same trap this project had already documented in its backlog, left in a sibling case. +- The seeding mask was a `uint32_t` after the arena budget grew to 64 bytes: undefined behaviour + above offset 31, silently losing a member's live value on every recompile. +- A `uint16_t` member's initializer was cast to a byte on the way in, so `uint16_t phase = 1000` + started at 232. Invisible to every existing test, because they observe through `setRGB`, which + truncates to a byte, and the error is always a multiple of 256. +- `sizeof(MoonLive)` had grown to 1440 bytes, held by value in every scripted module and constructed + on the main task's stack by `registerType`'s probe. This was the P4 boot loop. Re-indexing its + seeded-member table by member rather than by arena byte took it to 784 bytes. + +CI added two more: an ASan global-buffer-overflow from a caller passing a two-element array where +three were read (now impossible: the parameter is a fixed-size type), and a CodeQL high-severity +read-before-bound in a name-length loop. diff --git a/docs/moonmodules/core/ui.md b/docs/moonmodules/core/ui.md index 1767332a..a27dfe9d 100644 --- a/docs/moonmodules/core/ui.md +++ b/docs/moonmodules/core/ui.md @@ -154,7 +154,8 @@ how each *renders*.) `/api/control` like any text control; the file's contents move over `GET`/`POST /api/file`, because that is the only route allowed to exceed the request buffer (everything else returns 413). The module declares where its files live and which to offer (`addFilePath(name, buf, size, - dirAndExt)`), so the UI lists a directory without knowing what kind of file it holds. Saving is + pick)`, a {directory, extension, template} triple), so the UI lists a directory, filters it, and + seeds a new file without knowing what kind of file it holds. Saving is all it takes: a written file asks the module tree to re-derive, so whatever was built from that file rebuilds itself, with no second request from the browser. Editing is the same code the File Manager's modal editor uses, mounted inline instead of in a diff --git a/src/core/Control.h b/src/core/Control.h index c9778a57..92d7521a 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -92,6 +92,14 @@ inline void sanitizeHostname(char* buf) { /// members are noted per value below. There is no RGB color-picker type β€” effects /// use a palette index (a Uint8) instead; `float` and `Coord3D` exist but are used /// minimally, prefer Uint8. +/// What a `filepath` control tells the UI: {directory, extension, template}. Exactly three, because +/// writeControlMetadata reads all three: a shorter array compiled fine through a bare pointer and +/// was read past its end (caught by ASan). A module owns the storage and the descriptor borrows it, +/// the same way addSelect borrows its options array. +/// +/// `extension` may be null to offer every file; `template` may be null to start a new file empty. +using FilePathPick = const char* const[3]; + enum class ControlType : uint8_t { Uint8, ///< 1 byte, min/max β€” a 0–255 slider. The preferred default; DMX-mappable. Uint16, ///< 2 bytes β€” a number input (universe, port). DMX-mappable. @@ -426,12 +434,28 @@ class ControlList { // and is what a newly created file is seeded with, so a new file is a working example rather // than a blank that fails to parse. Borrowed, not copied, exactly as addSelect borrows its // options array, so a control costs no storage beyond the descriptor. + // + // `pick` is typed as an array of EXACTLY THREE, not a bare `const char* const*`: the reader + // (writeControlMetadata) indexes all three slots, and the loose pointer form accepted a shorter + // array and read past its end. ASan caught exactly that from a two-element caller. A + // fixed-length parameter refuses it at compile time, which is where a fixed-size contract + // belongs; a caller with nothing to offer passes nothing (the no-picker overload below). void addFilePath(const char* name, char* var, uint16_t bufSize, - const char* const* pick = nullptr, + const FilePathPick& pick, bool (*validate)(const char*) = nullptr) { grow(); controls_[count_++] = {.ptr = var, .name = name, - .aux = reinterpret_cast(pick), + .aux = reinterpret_cast(&pick[0]), + .type = ControlType::FilePath, + .max = bufSize, .validate = validate}; + } + + /// A file-path control with no picker: an editor over one fixed path, so there is no directory + /// to list and nothing to seed a new file with. + void addFilePath(const char* name, char* var, uint16_t bufSize, + bool (*validate)(const char*) = nullptr) { + grow(); + controls_[count_++] = {.ptr = var, .name = name, .aux = 0, .type = ControlType::FilePath, .max = bufSize, .validate = validate}; } diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index c3f26865..aec10924 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -569,8 +569,15 @@ void HttpServerModule::handleRemoveEntry(platform::TcpConnection& conn, const ch sendResponse(conn, 400, "application/json", "{\"error\":\"bad path\"}"); return; } - if (platform::fsRemove(path)) sendResponse(conn, 200, "application/json", "{\"ok\":true}"); - else sendResponse(conn, 500, "application/json", "{\"error\":\"delete failed (folder not empty?)\"}"); + if (platform::fsRemove(path)) { + // A REMOVED file is a change to persistent state exactly as a written one is: a module that + // derived something from it is now running against a file that is gone, and should say so + // rather than keep running the vanished program until something else happens to sweep. + applyFileChanged(path); + sendResponse(conn, 200, "application/json", "{\"ok\":true}"); + } else { + sendResponse(conn, 500, "application/json", "{\"error\":\"delete failed (folder not empty?)\"}"); + } } void HttpServerModule::serveFileContents(platform::TcpConnection& conn, const char* query) { diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 832ccea7..8f387f2f 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -210,7 +210,8 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { /// to the three above. The wire shape the Improv APPLY_OP frame carries. OpResult applyOp(const char* opJson); - /// A file at `path` was written, so ask the tree to re-derive whatever was built from it. + /// A file at `path` changed (written or removed), so ask the tree to re-derive whatever was + /// built from it. /// /// The rule core already enforces for the OTHER way persistent state changes: applySetControl /// ends in the same request. A file is the second path to it, so it belongs here rather than diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index 24412a95..192ac712 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -143,31 +143,49 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { // is offset AND name: a member inserted at the top of the class shifts every later declaration // to a new offset, and each of those is a different member now occupying a seeded byte, so it // must take its own initializer rather than inherit the previous occupant's value. - uint32_t seeding = 0; + uint64_t seeding = 0; + uint8_t kept = 0; // rows written this pass; the table is per MEMBER for (uint8_t i = 0; i < count; i++) { const uint8_t off = decls[i].offset; - if (off >= kArenaBytes) continue; // the parser bounds it; belt and braces + // Bounded by the SCRIPT's region, which is what the mask and the name table cover: a member + // never sits above it, and the parser already refuses one that would. + if (off >= kCtrlBytes) continue; // The declared name is a SPAN of the source (nameLen, no terminator), so it is compared // and stored length-bounded: strcmp would read past it into the rest of the script. const uint8_t n = decls[i].nameLen < kSeedNameLen - 1 ? decls[i].nameLen : uint8_t(kSeedNameLen - 1); - const bool same = ((seeded_ >> off) & 1u) && - std::strncmp(seededName_[off], decls[i].name, n) == 0 && - seededName_[off][n] == '\0'; + // Same MEMBER means same (offset, name). The offset alone is not identity: inserting a + // member at the top of a class shifts every later one down, and each then occupies a byte + // that was seeded for something else. + const SeededMember* prev = nullptr; + if ((seeded_ >> off) & 1ull) + for (uint8_t k = 0; k < seededCount_; k++) + if (seededName_[k].offset == off) { prev = &seededName_[k]; break; } + const bool same = prev && std::strncmp(prev->name, decls[i].name, n) == 0 && + prev->name[n] == '\0'; if (!same) { - // Seed the member's WHOLE width, little-endian to match every backend's halfword - // load: writing only the low byte would leave the high half holding whatever the - // previous program left there, so a fresh uint16_t member would start at a value its - // script never wrote. - ctrlArena_[off] = static_cast(decls[i].def & 0xff); - if (ctrlWidth(decls[i].type) == 2 && off + 1 < kArenaBytes) - ctrlArena_[off + 1] = static_cast(decls[i].def >> 8); + // 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 + // 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); + 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 c = 0; c < n; c++) seededName_[off][c] = decls[i].name[c]; - seededName_[off][n] = '\0'; - seeding |= 1u << off; + if (kept < kMaxCtrls) { + seededName_[kept].offset = off; + for (uint8_t c = 0; c < n; c++) seededName_[kept].name[c] = decls[i].name[c]; + seededName_[kept].name[n] = '\0'; + kept++; + } + seeding |= 1ull << off; } seeded_ = seeding; // a member the new script dropped is unseeded: its byte reseeds if it returns + seededCount_ = kept; return true; } @@ -190,7 +208,7 @@ void MoonLive::free() { // treat every member as one it had already seeded and skip the initializers, so a script would // start every value at zero instead of what it declared. seeded_ = 0; - for (uint8_t i = 0; i < kArenaBytes; i++) seededName_[i][0] = '\0'; + seededCount_ = 0; } } // namespace mm::moonlive diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index 1830f4d7..ef69490f 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -131,8 +131,10 @@ class MoonLive { controls_[controlCount_] = {name, lo, hi, def, 0, CtrlType::Uint8, offset}; // nameLen is what the binding reports; measured here rather than passed, so a caller // cannot disagree with the string it handed over. + // The BOUND is tested first: `name[n] && n < limit` reads the byte before deciding whether + // it may, so a name that fills the buffer without a terminator is read one past the end. uint8_t n = 0; - while (name[n] && n < kMaxControlName - 1) n++; + while (n < kMaxControlName - 1 && name[n]) n++; controls_[controlCount_].nameLen = n; controlCount_++; } @@ -175,9 +177,6 @@ class MoonLive { /// report those bytes twice. size_t heapBytes() const { return codeCap_ + (ctrlArena_ ? kArenaBytes : 0); } - /// How much of the string pool the current program uses. Its ceiling is kStringPool. - uint16_t stringBytes() const { return stringLen_; } - /// Write "1700 B - controls 2/8" into `out`: how big the compiled program is, and the ONE /// budget it is closest to exhausting. /// @@ -245,9 +244,24 @@ class MoonLive { // NEXT compile: a pointer would be dangling by then. Truncated to a prefix, which is enough to // tell two members apart in the only case that matters, and bounded so a long name cannot run // off the end of a record that carries its length instead of a terminator. + // Indexed by MEMBER, not by arena byte. A class declares at most kMaxCtrls of them, so a row + // per byte was 8x more rows than can ever exist: 768 bytes of a 1440-byte engine, held by value + // inside every scripted module and constructed on the main task's stack by ModuleFactory's + // probe. The offset is stored alongside, which is what the byte-indexed form was really using. static constexpr uint8_t kSeedNameLen = 12; - uint32_t seeded_ = 0; - char seededName_[kArenaBytes][kSeedNameLen] = {}; + struct SeededMember { uint8_t offset = 0; char name[kSeedNameLen] = {}; }; + // A uint64_t, and the table is sized to the SCRIPT's region rather than the whole arena. This + // was a uint32_t when kCtrlBytes was 16; the byte budget then grew to 64 and the mask did not, + // so `1u << off` for a member at offset 32 or beyond was undefined behaviour and in practice + // aliased mod 32: that member was never recorded as seeded (snapping back to its initializer on + // every recompile, losing the live value this exists to keep) while corrupting the bit of the + // member it aliased. A static_assert now ties the two together so the next widening cannot + // repeat it. The rows above kCtrlBytes were dead: a system variable is never seeded from a + // declaration. + static_assert(kCtrlBytes <= 64, "seeded_ is a 64-bit mask, one bit per script arena byte"); + uint64_t seeded_ = 0; + SeededMember seededName_[kMaxCtrls] = {}; + uint8_t seededCount_ = 0; void* code_ = nullptr; // allocExec block holding the emitted machine code size_t codeCap_ = 0; // its capacity (for freeExec) diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index bd083ed2..8f04f2d5 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -616,7 +616,10 @@ struct Parser { if (align > 1 && (at % align) != 0) at = uint16_t(at + (align - at % align)); const uint16_t need = uint16_t(ctrlWidth(type)); if (at + need > kCtrlBytes) { fail("the class declares more member data than the arena holds"); return; } - members[memberCount] = {name, 0, 255, static_cast(def), + // 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 + // observes through setRGB, because the error is always a multiple of 256. + members[memberCount] = {name, 0, 255, static_cast(def), static_cast(nameLen), type, static_cast(at), 1}; memberBytes = static_cast(at + need); diff --git a/src/core/moonlive/MoonLiveSpill.cpp b/src/core/moonlive/MoonLiveSpill.cpp index 7fc13ddc..d21f4fc9 100644 --- a/src/core/moonlive/MoonLiveSpill.cpp +++ b/src/core/moonlive/MoonLiveSpill.cpp @@ -58,9 +58,15 @@ uint8_t sourcesOf(const IrInst& in, VReg* out) { case IrOp::AddImm: case IrOp::Spill: out[0] = in.a; return 1; case IrOp::LoadCtrl: out[0] = kArg4; return 1; // reads the arena pointer - // A member STORE reads two things: the arena pointer and the value being written. + // A member STORE reads the VALUE being written, and nothing else. The arena pointer is + // deliberately NOT reported, for the same reason LoadIdx/StoreIdx do not report it: the + // rewriter below writes sources back POSITIONALLY, so listing kArg4 first shifts the value + // into `b` and leaves `a` holding kArg4's register. Both lowerings read the value from + // `op.a`, so every member assignment would store whatever that register held, the moment + // 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] = kArg4; out[1] = in.a; return 2; + case IrOp::StoreCtrl16: out[0] = in.a; return 1; case IrOp::LoadCtrl16: 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] diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 43878c8a..7fd72dbc 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -115,11 +115,7 @@ class MoonLiveEffect : public EffectBase { void setScript(const char* name) { script_.setName(name); } private: - // Default script β€” random pixels: each tick lights one random light in a random RGB color. - // A live, always-visible starting example (and a good demo-reel slot). The index random16(256) - // covers a typical grid; setRGB bounds-guards it (an index past the light count is skipped, and - // 0Γ—0 is safe), so most ticks land on a real light and the demo stays visibly lit. - // Publish one system variable into its arena slot, saturating to the uint8 a slot holds β€” a + // Publish one system variable into its arena slot, saturating to the uint8 a slot holds: a // layer wider than 255 reports 255 rather than wrapping to a small number and drawing garbage. void writeSysVar(uint8_t offset, uint16_t value) { if (uint8_t* slot = script_.engine().controlSlot(offset)) diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h index c8eabd31..674d661f 100644 --- a/src/light/moonlive/MoonLiveLayout.h +++ b/src/light/moonlive/MoonLiveLayout.h @@ -93,10 +93,12 @@ class MoonLiveLayout : public LayoutBase { LayoutBase::release(); } - /// Replace the script. The next prepare() compiles it β€” the path a UI edit takes. - /// A control write lands DIRECTLY in script_ (addText binds the buffer), so setScript() is not - /// called and nothing would clear the compiled-hash β€” compile() would early-return and keep - /// running the previous script under a new name. Clearing it here covers both paths. + /// Nothing to do on a control write, and that is the point. + /// + /// A control write lands DIRECTLY in the name buffer, so this override used to exist to clear a + /// cached hash that the write would otherwise leave stale, keeping the previous script running + /// under a new name. compile() now re-derives from the FILE every time, comparing its content + /// hash, so a changed name and changed contents are both noticed without anything to clear. void onControlChanged(const char* name) override { // Nothing to invalidate: compile() re-derives from the FILE every time, comparing a content // hash, so a control write that lands directly in the name buffer is noticed on its own. diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h index 9bcc6d26..c329c6e0 100644 --- a/src/light/moonlive/MoonLiveModifier.h +++ b/src/light/moonlive/MoonLiveModifier.h @@ -111,9 +111,9 @@ class MoonLiveModifier : public ModifierBase { if (uint8_t* sh = self->script_.engine().controlSlot(moonlive::kSysHeight)) *sh = clamp255(box_.y); if (uint8_t* sd = self->script_.engine().controlSlot(moonlive::kSysDepth)) *sd = clamp255(box_.z); - // One light's worth of destination. The script addresses it as index 0 today; the - // index argument is real (setXYZ(index, x, y, z), the same shape as setRGB), so a - // script written against a future `for` loop uses the identical call. + // One light's worth of destination, which is why setXYZ(x, y, z) names no slot: a modifier + // is handed a single coordinate per call and can write nothing else. (setRGB keeps its + // index because an effect picks a pixel out of a whole buffer.) uint8_t out[3] = {*sx, *sy, *sz}; // seeded with the input, so a script that writes // nothing leaves the coordinate untouched // The fold moment: run `modifyLogical` if the script defined one, and leave the coordinate diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index 94d2287b..bbf726f8 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -36,8 +36,8 @@ class MoonLiveScript { /// its grid, a layout is not), which is why it is a parameter rather than a member. bool sync(const SysVarTable& sysvars, MoonModule& owner) { // Cheapest question first: does the file still hash to what is loaded? This runs on every - // prepare sweep, and a file write now triggers one, so it must not cost a compile. It reads - // through a small stack buffer and allocates nothing. + // prepare sweep, and a file write now triggers one, so it must not cost a compile. One read + // answers it, against a compile's read plus parse, codegen and exec-block allocation. uint32_t fileHash = 0; const bool readable = scriptFileHash(name_, fileHash); if (readable && engine_.ok() && compiledHash_ != 0 && fileHash == compiledHash_) return false; diff --git a/src/light/moonlive/MoonLiveScriptFile.h b/src/light/moonlive/MoonLiveScriptFile.h index 038e7c0f..dd373d1b 100644 --- a/src/light/moonlive/MoonLiveScriptFile.h +++ b/src/light/moonlive/MoonLiveScriptFile.h @@ -98,27 +98,19 @@ inline constexpr size_t kMaxScriptName = 40; /// /// Returns true when the script compiled. On any failure `err` names it, in the words a user needs: /// which file, and what was wrong with it. -/// FNV-1a, in its two halves so a whole-buffer hash and a chunked one cannot drift apart. The -/// chunked form is what lets scriptFileHash walk a file through a small stack buffer instead of -/// holding all of it. -inline constexpr uint32_t kScriptHashSeed = 2166136261u; -inline uint32_t scriptHashChunk(uint32_t h, const char* s, size_t len) { - for (size_t i = 0; i < len; i++) { h ^= static_cast(s[i]); h *= 16777619u; } - return h; -} - /// FNV-1a over the script text. A caller that must know "did this change" keeps 4 bytes rather than -/// a second copy of the source β€” which is the whole reason the text is not resident any more. +/// a second copy of the source, which is the whole reason the text is not resident any more. inline uint32_t scriptHash(const char* s, size_t len) { - return scriptHashChunk(kScriptHashSeed, s, len); + uint32_t h = 2166136261u; + for (size_t i = 0; i < len; i++) { h ^= static_cast(s[i]); h *= 16777619u; } + return h; } -/// The hash of `/`'s CURRENT text, without compiling and without allocating. +/// The hash of `/`'s CURRENT text, without compiling it. /// -/// Answers "has the file changed since I compiled it" for the cost of a read. The bindings ask this -/// on every prepare sweep, which a file write now triggers, so it runs far more often than a -/// compile does: it reads through a small stack buffer with fsReadAt rather than the whole-file -/// allocation compileScriptFile makes, because the answer is 4 bytes and the text is not wanted. +/// Answers "has the file changed since I compiled it" for the cost of ONE read, which is what a +/// binding asks on every prepare sweep. It costs the same whole-file read compileScriptFile makes +/// and skips everything after: the parse, the codegen, and the exec-block allocation. /// /// False when the file is missing, unreadable or outside the accepted bounds, which the caller /// treats as "not the thing I compiled" and lets compileScriptFile report properly. Reporting the diff --git a/src/ui/app.js b/src/ui/app.js index 44c0ce90..087b5d38 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -4798,6 +4798,10 @@ function fmMountEditor(host, relPath, opts = {}) { status.textContent = "saving…"; const r = await fmSaveFrom(body, path); status.textContent = r.message; + // A failed write (no space, a vanished path) must not be silent. The modal shows it on its + // status line; a host that supplied its own hidden one gets an alert, because the work is + // still unsaved and the dot alone does not say why. + if (!r.ok && statusEl && statusEl.hidden) alert(r.message); if (r.ok) { setDirty(false); if (onSaved) onSaved(path); } }; diff --git a/src/ui/style.css b/src/ui/style.css index b6b643fa..72c02223 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1708,16 +1708,20 @@ body.cards-resizing { .fm-editor-save.dirty::after { content: ""; display: inline-block; width: 6px; height: 6px; margin-left: 6px; - border-radius: 50%; background: currentColor; vertical-align: middle; -} -/* On the icon button the dot rides the corner instead, since there is no text to sit beside. */ -.fm-tool.fm-editor-save { position: relative; } -.fm-tool.fm-editor-save.dirty::after { + border-radius: 50%; background: currentcolor; vertical-align: middle; +} +/* On an ICON button the dot rides the corner instead, since there is no text to sit beside. Both + button shapes are covered: the card mounts a .card-btn, the File Manager modal a .fm-tool. */ +.fm-tool.fm-editor-save, +.card-btn.fm-editor-save { position: relative; } +.fm-tool.fm-editor-save.dirty::after, +.card-btn.fm-editor-save.dirty::after { position: absolute; top: 2px; right: 2px; margin: 0; width: 7px; height: 7px; background: var(--accent); } /* Nothing to save reads as nothing to do, rather than a button that silently ignores a click. */ -.fm-tool.fm-editor-save:disabled { opacity: 0.4; } +.fm-tool.fm-editor-save:disabled, +.card-btn.fm-editor-save:disabled { opacity: 0.4; } /* The capture toggles inside a pad popup: what a preset carries. A compact two-column grid so the four of them cost one popup row rather than four. */ diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index 25a10751..8fa57aa6 100644 --- a/test/scenarios/light/scenario_MoonLive_pipeline.json +++ b/test/scenarios/light/scenario_MoonLive_pipeline.json @@ -128,7 +128,7 @@ "desktop-macos": { "tick_us": [ 5, - 21 + 83 ], "free_heap": [ 0, @@ -140,7 +140,7 @@ ], "at": [ "2026-08-09", - "2026-08-18" + "2026-08-19" ] } } diff --git a/test/unit/core/unit_Control_filepath.cpp b/test/unit/core/unit_Control_filepath.cpp index 417dbd1e..b1942a1d 100644 --- a/test/unit/core/unit_Control_filepath.cpp +++ b/test/unit/core/unit_Control_filepath.cpp @@ -19,7 +19,7 @@ namespace { // What a module declares: where its files live and which of them to offer. Borrowed by the // descriptor, so it has to outlive the control, exactly like addSelect's options array. -const char* const kScriptPick[3] = {"/moonlive", ".mle", nullptr}; +const mm::FilePathPick kScriptPick = {"/moonlive", ".mle", nullptr}; } // namespace TEST_CASE("a file-path control carries the directory and extension the module declared") { @@ -42,7 +42,7 @@ TEST_CASE("a file-path control carries the directory and extension the module de TEST_CASE("a file-path control with no directory offers no picker rather than a broken one") { char path[41] = ""; mm::ControlList controls; - controls.addFilePath("file", path, sizeof(path)); // no pair: an editor with a fixed path + controls.addFilePath("file", path, sizeof(path)); // no picker: an editor with a fixed path mm::JsonSink sink; mm::writeControlMetadata(sink, controls[0]); const std::string meta = sink.data(); @@ -50,7 +50,7 @@ TEST_CASE("a file-path control with no directory offers no picker rather than a } TEST_CASE("a file-path control listing every file omits the extension filter") { - static const char* const anyFile[2] = {"/presets", nullptr}; + static const mm::FilePathPick anyFile = {"/presets", nullptr, nullptr}; char path[41] = ""; mm::ControlList controls; controls.addFilePath("file", path, sizeof(path), anyFile); diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 8aa93713..05f403e0 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -966,6 +966,23 @@ TEST_CASE("setRGB still names the light it writes") { eng.free(); } + +// A wide member's INITIALIZER must survive to the arena. It was cast to a byte on the way in, so +// `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") { + moonlive::MoonLive eng; + REQUIRE(eng.compile("class T {\n" + " uint16_t phase = 1000;\n" + " tick() { if (phase == 1000) { setRGB(0, 55, 0, 0); } }\n" + "}\n", kCtrlTable, kSys)); + uint8_t px[3] = {}; + eng.run(px, 1, 3, 0); + CHECK(px[0] == 55); + eng.free(); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT β€” every case above needs compile() to SUCCEED, so // they all gate on the JIT: on a target with no backend (x86-64 desktop today) // the helpers they call are compiled out with it.