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/backlog/backlog-light.md b/docs/backlog/backlog-light.md index e98a6cea..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,48 @@ 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 + 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 c946b2f2..e8d84fd2 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. @@ -298,19 +313,73 @@ 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. 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, + 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.** 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. 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 @@ -379,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. @@ -405,12 +482,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 @@ -419,22 +511,131 @@ 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. - -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. +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: + + - **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. + + **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 + 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 + +`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.** 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 @@ -448,7 +649,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). @@ -482,12 +683,15 @@ 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. -4. **The same script at the host's real budget and a squeezed one renders identical pixels.** The +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. + 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 @@ -498,12 +702,62 @@ 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. -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. +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. + + 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. + + 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 | + + 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 + 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/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..e3ae8a59 --- /dev/null +++ b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md @@ -0,0 +1,276 @@ +# 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,18 @@ 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, + 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 + ``, 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 42e43a2f..1a27db45 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -19,28 +19,44 @@ 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. -- **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: +- `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 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 + + defineControls() { + addUint8("speed", speed, 0, 99); + addUint8("hue", hue, 0, 255); + } - tick() { setRGB(speed, 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. - 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. + 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. + + `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 @@ -54,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. @@ -67,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` | @@ -86,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. @@ -97,7 +113,50 @@ 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`. + +## 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 diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index 23bd6b93..45e45ad7 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. @@ -73,17 +77,37 @@ 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 | |---|---| | `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..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,16 +36,23 @@ 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 -**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 @@ -53,7 +60,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/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/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 d9cadece..b4d1bd4a 100644 --- a/moondeck/moonlive/emit_isa.cpp +++ b/moondeck/moonlive/emit_isa.cpp @@ -53,17 +53,26 @@ 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.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() : 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, 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); 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..7c6408ed 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(); } @@ -22,9 +24,56 @@ 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 +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. + +**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.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.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. @@ -33,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 93% rename from moonlive/effects/crosshair.mlv rename to moonlive/effects/crosshair.mle index 2c85f271..7b7a14fd 100644 --- a/moonlive/effects/crosshair.mlv +++ b/moonlive/effects/crosshair.mle @@ -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/ember.mle b/moonlive/effects/ember.mle new file mode 100644 index 00000000..a6efbed1 --- /dev/null +++ b/moonlive/effects/ember.mle @@ -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/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 89% rename from moonlive/effects/lines.mlv rename to moonlive/effects/lines.mle index cb1caaf4..0aea1ac0 100644 --- a/moonlive/effects/lines.mlv +++ b/moonlive/effects/lines.mle @@ -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.mle similarity index 87% rename from moonlive/effects/plasma.mlv rename to moonlive/effects/plasma.mle index 85e11cd0..ee15e748 100644 --- a/moonlive/effects/plasma.mlv +++ b/moonlive/effects/plasma.mle @@ -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/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 90% rename from moonlive/effects/ripples.mlv rename to moonlive/effects/ripples.mle index 71c87b8c..96fb8328 100644 --- a/moonlive/effects/ripples.mlv +++ b/moonlive/effects/ripples.mle @@ -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.mll similarity index 71% rename from moonlive/layouts/diagonal.mlv rename to moonlive/layouts/diagonal.mll index 42fba6a6..a5128188 100644 --- a/moonlive/layouts/diagonal.mlv +++ b/moonlive/layouts/diagonal.mll @@ -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.mll similarity index 68% rename from moonlive/layouts/grid.mlv rename to moonlive/layouts/grid.mll index 1b97b395..53c9d307 100644 --- a/moonlive/layouts/grid.mlv +++ b/moonlive/layouts/grid.mll @@ -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.mll similarity index 73% rename from moonlive/layouts/lattice.mlv rename to moonlive/layouts/lattice.mll index 8356396e..2ae74bfb 100644 --- a/moonlive/layouts/lattice.mlv +++ b/moonlive/layouts/lattice.mll @@ -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.mll similarity index 70% rename from moonlive/layouts/reversed-row.mlv rename to moonlive/layouts/reversed-row.mll index 3b451f64..f98f101f 100644 --- a/moonlive/layouts/reversed-row.mlv +++ b/moonlive/layouts/reversed-row.mll @@ -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.mll similarity index 75% rename from moonlive/layouts/ring.mlv rename to moonlive/layouts/ring.mll index 73c63390..f1c5a930 100644 --- a/moonlive/layouts/ring.mlv +++ b/moonlive/layouts/ring.mll @@ -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.mll similarity index 87% rename from moonlive/layouts/rose.mlv rename to moonlive/layouts/rose.mll index 00532d94..84b12650 100644 --- a/moonlive/layouts/rose.mlv +++ b/moonlive/layouts/rose.mll @@ -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.mll similarity index 80% rename from moonlive/layouts/two-rows.mlv rename to moonlive/layouts/two-rows.mll index 7dadd22d..44fe7aff 100644 --- a/moonlive/layouts/two-rows.mlv +++ b/moonlive/layouts/two-rows.mll @@ -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/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 63% rename from moonlive/modifiers/shift.mlv rename to moonlive/modifiers/shift.mlm index e4f85276..61ecfcaa 100644 --- a/moonlive/modifiers/shift.mlv +++ b/moonlive/modifiers/shift.mlm @@ -2,9 +2,13 @@ // 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); + 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..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. @@ -105,6 +113,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; + // 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); } + }; + + 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..72c02223 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1647,12 +1647,82 @@ 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 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, +.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. */ .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_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/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json index ce095d4e..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, - 19 + 83 ], "free_heap": [ 0, @@ -140,7 +140,7 @@ ], "at": [ "2026-08-09", - "2026-08-16" + "2026-08-19" ] } } @@ -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", @@ -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" ] } } @@ -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 5d760d75..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,14 +29,23 @@ #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 // 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 +72,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; @@ -124,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); } @@ -201,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 @@ -233,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. @@ -249,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(); @@ -266,8 +281,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 fed9b042..c4333e0b 100644 --- a/test/unit/core/moonlive_structural.inc +++ b/test/unit/core/moonlive_structural.inc @@ -176,8 +176,8 @@ 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", - mmScript("uint8_t n = 8; // @control 1..64\n" + {"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", mmScript("for (x = 0; x < 4; x = x + 1) { setRGB(x, width, random16(256), 0); }\n"), 1}, @@ -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_Control_filepath.cpp b/test/unit/core/unit_Control_filepath.cpp new file mode 100644 index 00000000..b1942a1d --- /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 mm::FilePathPick kScriptPick = {"/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 picker: 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 mm::FilePathPick anyFile = {"/presets", nullptr, 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_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_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_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp index 6f8cd6c5..932b5bdf 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"}, @@ -375,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)); @@ -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..05f403e0 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -188,9 +188,81 @@ 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 +// 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 +277,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 } @@ -364,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")); @@ -404,4 +476,513 @@ 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(); +} + + +// 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(); +} + + +// 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. 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/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 b436e20c..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" @@ -57,8 +58,8 @@ std::vector place(const char* script) { TEST_CASE("the default script lays out a grid, one light per cell") { // The shape almost every panel is, and the script that ships: a nested loop calling addLight. const std::vector p = place( - mmScriptAs("placeLights", "uint8_t cols = 4; // @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 +74,8 @@ TEST_CASE("the light count is known before any coordinate is asked for") { // placeLights. A count that came from the walk would arrive too late to be useful. MoonLiveLayout l; l.defineControls(); - l.setScript(mmWriteScript(mmScriptAs("placeLights", "uint8_t cols = 5; // @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 +112,7 @@ TEST_CASE("a scripted layout allocates nothing, like every other layout") { TEST_CASE("a script places lights wherever it likes, which is the point of scripting one") { // A strand that runs right to left: one line here, a new C++ class otherwise. const std::vector p = place( - mmScriptAs("placeLights", "uint8_t cols = 4; // @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 +157,18 @@ TEST_CASE("editing the script changes the fixture") { TEST_CASE("the scripts the documentation shows all compile") { const char* fromDocs[] = { // the default - mmScriptAs("placeLights", "uint8_t cols = 16; // @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 +227,7 @@ TEST_CASE("a subtraction feeding a loop bound produces the whole value") { CHECK(l.lightCount() == 6); // And a subtraction inside the placement, where the coordinate is the observable. - std::vector p = place(mmScriptAs("placeLights", "uint8_t cols = 4; // @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 +246,29 @@ 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 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; // @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 +381,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 +407,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. @@ -470,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. @@ -537,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(); @@ -561,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 @@ -569,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); @@ -595,9 +612,140 @@ 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); #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 +} + +// --- 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 ca464ea5..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; } @@ -89,7 +99,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 @@ -101,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[] = { @@ -111,12 +122,12 @@ 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 " "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 +160,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 +245,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 +279,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"