diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45a6fa94..0cf47f8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,16 @@ on: - 'src/platform/esp32/platform_esp32_improv.cpp' - 'test/python/**' - 'test/js/**' + # A MoonLive script IS the subject of test_scripts_are_cpp.py: editing one is exactly when + # the "this is a subset of C++" claim needs re-checking. + - 'moonlive/**' + - 'src/light/moonlive/MoonLiveBuiltins_light.h' + # The sanitizer jobs below compile the WHOLE C++ suite, so they have to run when any of it + # changes. Without these, a C++ change could break them with no run at all, and the fix could + # not prove itself either: exactly what happened to a missing in a unit test. + - 'src/**' + - 'test/unit/**' + - 'CMakeLists.txt' - '.github/workflows/test.yml' push: branches: @@ -50,8 +60,18 @@ jobs: # against Python-Markdown's real toc slugify. `wled` is the frenck/python-wled # library HA's WLED integration uses; test_wled_json_shape.py parses the # device /json vector through its Device.from_dict to pin the wire contract. + # The MoonLive scripts compile as C++ FIRST, and by name: it is the one suite whose subject is + # the shipped content rather than the tooling, so "all 34 scripts are valid C++" should be + # readable in the log instead of folded into a single count. Named here rather than split into + # its own step so the compiler still runs exactly once. + - name: every MoonLive script is valid C++ + run: uv run --with pytest pytest test/python/test_scripts_are_cpp.py -v + # The rest, quietly. This directory is excluded because the step above already ran it: a bare + # `pytest test/python` here would compile all 34 scripts a second time. - name: pytest - run: uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q + run: > + uv run --with pytest --with pyserial --with markdown --with wled + pytest test/python -q --ignore=test/python/test_scripts_are_cpp.py js: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index f7c92e7f..fb2668a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l | platform boundary | `uv run moondeck/check/check_platform_boundary.py` | `src/`, except `src/platform/` | | hot-path discipline | `uv run moondeck/check/check_nonblocking.py --incremental` | `src/` | | ESP32 firmware fresh | `uv run moondeck/check/check_esp32_built.py --firmware ` | `src/`, `esp32/`, `CMakeLists.txt`, `library.json`, except `src/platform/desktop/` | -| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/` | +| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/`, `moonlive/` | | host tests (JS) | `node --test "test/js/**/*.test.mjs"` | `mooninstaller/`, `test/js/`, `src/ui/` | | desktop build (zero warnings) 🐒 | `cmake --build build` | `src/`, `test/`, `CMakeLists.txt`, `library.json` | | unit tests 🐒 | `ctest --test-dir build --output-on-failure --no-tests=error -C Release` | same as the desktop build | diff --git a/CMakeLists.txt b/CMakeLists.txt index 94cc565e..dde8a826 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -198,7 +198,7 @@ add_custom_target(build_info_gen ALL add_custom_command( OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake - DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake + DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/vendor/prism.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake COMMENT "Embedding UI files" ) add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h) diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index 3da9a10e..51051490 100644 --- a/docs/moonmodules/light/MoonLiveLayout.md +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -21,8 +21,8 @@ class GridLayout { } void placeLights() { - for (y = 0; y < rows; y = y + 1) { - for (x = 0; x < cols; x = x + 1) { + for (int y = 0; y < rows; y = y + 1) { + for (int x = 0; x < cols; x = x + 1) { addLight(x, y, 0); } } @@ -40,17 +40,17 @@ A few shapes that are one line here and a new class otherwise: ```c // a strand that runs right to left -for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); } +for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); } // a diagonal -for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); } +for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); } // two rows, stacked -for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); } +for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); } // a circle: lights and grid cells are not the same number // (`count` and `radius` are members, surfaced by addControl in defineControls) -for (i = 0; i < count; i = i + 1) { +for (int 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); } @@ -83,8 +83,8 @@ A serpentine (every other row reversed) is what `if` makes expressible, and it i ```c byte odd = 0; -for (y = 0; y < rows; y = y + 1) { - for (x = 0; x < cols; x = x + 1) { +for (int y = 0; y < rows; y = y + 1) { + for (int x = 0; x < cols; x = x + 1) { if (odd == 0) { addLight(x, y, 0); } else { addLight(cols - 1 - x, y, 0); } } diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index c5b3048b..1f9ca6cd 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -54,6 +54,22 @@ uv run moondeck/test/test_desktop.py Runs `./build//test/mm_tests -s` (doctest with all test cases shown) β€” same per-host build dir as the desktop build above. +### test_host + +Run the host test suites: the Python ones and the JS ones. + +```bash +uv run moondeck/test/test_host.py # both +uv run moondeck/test/test_host.py --python # just Python +uv run moondeck/test/test_host.py --js # just JS +``` + +The tests the C++ binary cannot reach: the cross-language contracts (the Improv frame's wire format, +WLED's `/json` shape), the MoonDeck scripts themselves, the browser code under `src/ui`, and the +claim that every shipped MoonLive script is valid C++ (`test_scripts_are_cpp.py` hands each one to a +real compiler). The commit gate and CI run the same two commands; this is the card in front of them. +JS reports SKIP rather than failing when node is absent, since a Python-only bench is a normal setup. + ### run_desktop Launch the desktop executable as a detached background process and exit. The app keeps running across other MoonDeck scripts and outlives MoonDeck itself β€” the same model as flashing an ESP32, where the device runs independently of this console. diff --git a/moondeck/check/check_prose.py b/moondeck/check/check_prose.py index 09df9f9e..af06c640 100755 --- a/moondeck/check/check_prose.py +++ b/moondeck/check/check_prose.py @@ -36,6 +36,7 @@ "docs/tests/", # generated from test comments (fix the test, not the page) "docs/moonmodules/", # partly generated technical pages "src/platform/desktop/vendor/", # upstream single-header code (miniaudio): not our prose + "src/ui/vendor/", # upstream browser code (Prism): not our prose either "moondeck/check/check_prose.py", # the detector: its rule table spells the very patterns ) diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index fb6f9108..f2d9bc97 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -31,6 +31,15 @@ "script": "test/test_desktop.py", "needs_module": true }, + { + "id": "test_host", + "tab": "desktop", + "group": "test", + "label": "Host Tests", + "speed": "medium", + "help": "test_host", + "script": "test/test_host.py" + }, { "id": "scenario_pipeline", "tab": "desktop", diff --git a/moondeck/test/test_host.py b/moondeck/test/test_host.py new file mode 100644 index 00000000..735cfbb1 --- /dev/null +++ b/moondeck/test/test_host.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Run the host test suites: the Python ones, the JS ones, or both. + +These are the tests that live outside the C++ binary, and they cover what it cannot reach: the +cross-language contracts (the Improv frame's wire format, WLED's /json shape), the MoonDeck scripts +themselves, the browser code in src/ui, and the claim that every shipped MoonLive script is valid +C++. The commit gate and CI both run them; this is the same command with a card in front of it. + + uv run moondeck/test/test_host.py # both suites + uv run moondeck/test/test_host.py --python # just Python + uv run moondeck/test/test_host.py --js # just JS + +The Python deps ride in a PEP-723 block per test file, so they are named here rather than +installed: `uv run --with` resolves them per run and leaves no environment behind. +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +# Named here rather than in a requirements file: each is a test's own dependency (pyserial for the +# flash tests, wled for the /json shim, markdown for the docs checks), and `uv run --with` is how +# every other script in MoonDeck reaches one. +PY_DEPS = ("pytest", "pyserial", "markdown", "wled") + + +def run(cmd, label): + print(f"\n=== {label} ===", flush=True) + r = subprocess.run(cmd, cwd=ROOT, check=False) # the caller collects the code; a raise would skip the other suite + return r.returncode + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + # Mutually exclusive: each flag means "ONLY this suite", so passing both asks for two + # contradictory things. Neither flag still means both suites, which is the common case. + only = ap.add_mutually_exclusive_group() + only.add_argument("--python", action="store_true", help="run only the Python suite") + only.add_argument("--js", action="store_true", help="run only the JS suite") + args = ap.parse_args() + both = not (args.python or args.js) + + rc = 0 + if both or args.python: + cmd = ["uv", "run"] + for d in PY_DEPS: + cmd += ["--with", d] + cmd += ["pytest", "test/python", "-q"] + rc |= run(cmd, "Python (test/python)") + + if both or args.js: + # node, not uv: the JS suite is the browser code's own runner. Reported rather than failed + # when node is absent, because a Python-only bench is a normal setup and a missing runtime + # is "this does not apply here", which is what SKIP means. + if shutil.which("node") is None: + print("\n=== JS (test/js) ===\nSKIP: node is not on PATH", flush=True) + else: + # Expanded HERE, not by node and not by a shell. The call passes a list with no + # shell=True, so a literal `test/js/**/*.test.mjs` would reach node and depend on its + # own glob support, which older runtimes lack. Resolving the paths in Python makes the + # command work on any node, and names the files it ran. + tests = sorted(str(f.relative_to(ROOT)) for f in (ROOT / "test/js").rglob("*.test.mjs")) + if not tests: + print("\n=== JS (test/js) ===\nSKIP: no test files found", flush=True) + else: + rc |= run(["node", "--test", *tests], "JS (test/js)") + + print("\nDONE" if rc == 0 else "\nFAILED", flush=True) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moonlive/README.md b/moonlive/README.md index 1a0b2a4c..dcbd06fc 100644 --- a/moonlive/README.md +++ b/moonlive/README.md @@ -17,7 +17,7 @@ class CrosshairEffect { void defineControls() { addControl("bpm", bpm, 1, 240); } - void column() { for (y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } } + void column() { for (int y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } } void tick() { fill(0, 0, 0); column(); } } ``` @@ -26,6 +26,16 @@ These are real calls, not pasted-in text: the callee gets its own frame when it lets one helper call another and lets a function recurse. A function takes no arguments yet, so a helper is parameterized through the class's members. `effects/crosshair.mle` is the worked example. +**A script is C++, and a compiler checks that.** Every shipped script compiles under +`c++ -std=c++20 -fsyntax-only` (`test/python/test_scripts_are_cpp.py`), so the language cannot drift +into a dialect one feature at a time: an editor highlights a script correctly, a reader brings their +C++ intuition, and a script stands a good chance in any engine that speaks the same subset. + +One shape difference is deliberate, and the test bridges exactly that one: a class here needs no +`public:` and no trailing semicolon. + +Anything else a compiler rejects is a divergence, and the test is where it surfaces. + **Every function declares what it returns**, the way the compiled module a script stands in for does: `void tick()` beside `void tick() override`. Three types, which is all the language has values for: @@ -83,6 +93,10 @@ int phase = 900; // a value a byte cannot hold byte heat[16]; // sixteen elements, all zero to begin with ``` +**Every variable is declared, including a loop's counter.** A member states its type, an assignment +to a name that was never declared is refused, and a `for` writes `for (int i = 0; ...)`. One rule +with no exception, and the same line C++ would take. + 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 diff --git a/moonlive/effects/aim.mle b/moonlive/effects/aim.mle index fc8e5ab9..9c239283 100644 --- a/moonlive/effects/aim.mle +++ b/moonlive/effects/aim.mle @@ -22,7 +22,7 @@ class AimEffect { void tick() { fill(bright, bright, bright); - for (i = 0; i < height; i = i + 1) { + for (int i = 0; i < height; i = i + 1) { lean = div(spread * i, height); p = pan + lean - div(spread, 2); if (p < 0) { p = 0; } diff --git a/moonlive/effects/balls.mle b/moonlive/effects/balls.mle index 9c6d022b..c1cdadf8 100644 --- a/moonlive/effects/balls.mle +++ b/moonlive/effects/balls.mle @@ -21,8 +21,8 @@ class BallsEffect { } void drawBall() { - for (dy = 0; dy < 21; dy = dy + 1) { - for (dx = 0; dx < 21; dx = dx + 1) { + for (int dy = 0; dy < 21; dy = dy + 1) { + for (int dx = 0; dx < 21; dx = dx + 1) { if ((dx - radius) * (dx - radius) + (dy - radius) * (dy - radius) <= radius * radius) { setPaletteColor(px + dx, py + dy, b * 64, 255 - scale(((dx - radius) * (dx - radius) @@ -41,7 +41,7 @@ class BallsEffect { if (width < radius * 2 + 2) { radius = 0; } if (height < radius * 2 + 2) { radius = 0; } - for (i = 0; i < 4; i = i + 1) { + for (int i = 0; i < 4; i = i + 1) { b = i; if (b < count) { px = beatsin(bpm + b * 7, t, width - radius * 2 - 1); diff --git a/moonlive/effects/chase.mle b/moonlive/effects/chase.mle index 1debf31e..85c5dcda 100644 --- a/moonlive/effects/chase.mle +++ b/moonlive/effects/chase.mle @@ -26,7 +26,7 @@ class ChaseEffect { fill(0, 0, 0); n = width * height * depth; head = scale(beat(bpm, t), n); - for (i = 0; i < n; i = i + 1) { + for (int i = 0; i < n; i = i + 1) { d = head - i; if (d < 0) { d = d + n; } if (d < spread) { diff --git a/moonlive/effects/crosshair.mle b/moonlive/effects/crosshair.mle index e6e2a208..e99efa72 100644 --- a/moonlive/effects/crosshair.mle +++ b/moonlive/effects/crosshair.mle @@ -18,15 +18,15 @@ class CrosshairEffect { } void column() { - for (y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); } + for (int y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); } } void row() { - for (x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); } + for (int x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); } } void center() { - for (i = 0; i < spread + spread + 1; i = i + 1) { + for (int i = 0; i < spread + spread + 1; i = i + 1) { d = i - spread; if (cx + d >= 0) { if (cx + d < width) { setRGB(cy * width + cx + d, 255, 255, 255); } } if (cy + d >= 0) { if (cy + d < height) { setRGB((cy + d) * width + cx, 255, 255, 255); } } diff --git a/moonlive/effects/dot.mle b/moonlive/effects/dot.mle index 9b6bf27b..7cf0a4c9 100644 --- a/moonlive/effects/dot.mle +++ b/moonlive/effects/dot.mle @@ -13,7 +13,7 @@ class DotEffect { void tick() { fill(0, 0, 0); - for (x = 0; x < width; x = x + 1) { + for (int x = 0; x < width; x = x + 1) { setRGB(x + scale(beat(bpm, t), height) * width, 0, 255, 0); } } diff --git a/moonlive/effects/ember.mle b/moonlive/effects/ember.mle index 64ffacb7..70d8a5f4 100644 --- a/moonlive/effects/ember.mle +++ b/moonlive/effects/ember.mle @@ -17,15 +17,15 @@ class EmberEffect { } void tick() { - for (i = 0; i < 16; i = i + 1) { + for (int i = 0; i < 16; i = i + 1) { if (heat[i] > cool) { heat[i] = heat[i] - cool; } else { heat[i] = 0; } } - for (j = 0; j < 16; j = j + 1) { + for (int j = 0; j < 16; j = j + 1) { if (random16(256) < scale(spark, 65535 - beat(cycle, t))) { heat[j] = 255; } } - for (k = 0; k < 16; k = k + 1) { + for (int k = 0; k < 16; k = k + 1) { setRGB(k, paletteR(heat[k], heat[k]), paletteG(heat[k], heat[k]), paletteB(heat[k], heat[k])); } diff --git a/moonlive/effects/fractal.mle b/moonlive/effects/fractal.mle index a59e6bd5..f7fbb584 100644 --- a/moonlive/effects/fractal.mle +++ b/moonlive/effects/fractal.mle @@ -30,8 +30,8 @@ class FractalEffect { - toFixed(sin(beat(bpm, t) * 2) - 32768) / 5120 * toFixed(seed) / 3277) * toFixed(870 + noise(t / 4, 0, 0)) / 1000; - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { cx = uvX(x, width, height) * toFixed(zoom) / 40; if (seed == 0) { cx = cx - 0.55; } diff --git a/moonlive/effects/gradient.mle b/moonlive/effects/gradient.mle index 9a59ab30..e347f939 100644 --- a/moonlive/effects/gradient.mle +++ b/moonlive/effects/gradient.mle @@ -9,7 +9,7 @@ class GradientEffect { void tick() { n = width * height * depth; - for (i = 0; i < n; i = i + 1) { + for (int i = 0; i < n; i = i + 1) { setRGB(i, div(i * 255, n), 255 - div(i * 255, n), 60); } } diff --git a/moonlive/effects/metal.mle b/moonlive/effects/metal.mle index 5969ba52..9cd7c77d 100644 --- a/moonlive/effects/metal.mle +++ b/moonlive/effects/metal.mle @@ -22,8 +22,8 @@ class MetalEffect { } void tick() { - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { ux = uvX(x, width, height); uy = uvY(y, width, height); diff --git a/moonlive/effects/noise.mle b/moonlive/effects/noise.mle index d3342ce1..30ea1cde 100644 --- a/moonlive/effects/noise.mle +++ b/moonlive/effects/noise.mle @@ -14,8 +14,8 @@ class NoiseEffect { } void tick() { - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { setPaletteColor(x, y, noise(x * zoom, y * zoom, scale(t, speed * 280)), 255); } } diff --git a/moonlive/effects/octopus.mle b/moonlive/effects/octopus.mle index fd13b212..094cc98b 100644 --- a/moonlive/effects/octopus.mle +++ b/moonlive/effects/octopus.mle @@ -19,8 +19,8 @@ class OctopusEffect { cx = scale(32768, width); cy = scale(32768, height); - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { setPaletteColor(x, y, polarR(x - cx, y - cy) * 8 + scale(beat(speed, t), 256), scale(sin(polarA(x - cx, y - cy) * branches diff --git a/moonlive/effects/plasma.mle b/moonlive/effects/plasma.mle index 30dcfe71..655016e6 100644 --- a/moonlive/effects/plasma.mle +++ b/moonlive/effects/plasma.mle @@ -14,8 +14,8 @@ class PlasmaEffect { } void tick() { - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { setRGB(y * width + x, scale(sin(x * zoom * 8 + beat(bpm, t)), 256), scale(sin(y * zoom * 8 + beat(bpm, t)), 256), diff --git a/moonlive/effects/ripples.mle b/moonlive/effects/ripples.mle index 5e6e6240..791d5783 100644 --- a/moonlive/effects/ripples.mle +++ b/moonlive/effects/ripples.mle @@ -14,8 +14,8 @@ class RipplesEffect { } void tick() { - for (y = 0; y < height; y = y + 1) { - for (x = 0; x < width; x = x + 1) { + for (int y = 0; y < height; y = y + 1) { + for (int x = 0; x < width; x = x + 1) { setRGB(y * width + x, scale(sin(((x - beatsin(bpm, t, width - 1)) * (x - beatsin(bpm, t, width - 1)) + (y - beatsin(bpm + 4, t, height - 1)) * (y - beatsin(bpm + 4, t, height - 1))) diff --git a/moonlive/effects/sparkle.mle b/moonlive/effects/sparkle.mle index b3949944..d461a798 100644 --- a/moonlive/effects/sparkle.mle +++ b/moonlive/effects/sparkle.mle @@ -21,7 +21,7 @@ class SparkleEffect { void tick() { fade(fadeAmt); n = width * height * depth; - for (i = 0; i < density; i = i + 1) { + for (int i = 0; i < density; i = i + 1) { p = random16(256); if (p > hueSpread) { p = hueSpread; } setRGB(random16(n), paletteR(p, 255), paletteG(p, 255), paletteB(p, 255)); diff --git a/moonlive/effects/spectrum.mle b/moonlive/effects/spectrum.mle index 95f9b3a9..a794216a 100644 --- a/moonlive/effects/spectrum.mle +++ b/moonlive/effects/spectrum.mle @@ -26,7 +26,7 @@ class SpectrumEffect { bars = width; if (width < 2) { bars = height; } - for (x = 0; x < bars; x = x + 1) { + for (int x = 0; x < bars; x = x + 1) { b = div(x * 16, bars); mag = div(audioBand(b) * gain, 100); if (mag > 255) { mag = 255; } @@ -36,7 +36,7 @@ class SpectrumEffect { } top = div(mag * height, 256); if (width < 2) { top = 0; } - for (y = 0; y < top; y = y + 1) { + for (int y = 0; y < top; y = y + 1) { setRGB((height - 1 - y) * width + x, paletteR(b * 16, 255), paletteG(b * 16, 255), paletteB(b * 16, 255)); } diff --git a/moonlive/effects/sweep.mle b/moonlive/effects/sweep.mle index de653a25..5d99128a 100644 --- a/moonlive/effects/sweep.mle +++ b/moonlive/effects/sweep.mle @@ -23,7 +23,7 @@ class SweepEffect { } void tick() { - for (i = 0; i < height; i = i + 1) { + for (int i = 0; i < height; i = i + 1) { spread = 0; dir = 1; if (formation == 1) { diff --git a/moonlive/layouts/diagonal.mll b/moonlive/layouts/diagonal.mll index dc2cbab9..36b4f846 100644 --- a/moonlive/layouts/diagonal.mll +++ b/moonlive/layouts/diagonal.mll @@ -12,7 +12,7 @@ class DiagonalLayout { } void placeLights() { - for (i = 0; i < count; i = i + 1) { + for (int i = 0; i < count; i = i + 1) { addLight(i, i, 0); } } diff --git a/moonlive/layouts/grid.mll b/moonlive/layouts/grid.mll index 8cd79a86..f11db6c1 100644 --- a/moonlive/layouts/grid.mll +++ b/moonlive/layouts/grid.mll @@ -14,8 +14,8 @@ class GridLayout { } void placeLights() { - for (y = 0; y < rows; y = y + 1) { - for (x = 0; x < cols; x = x + 1) { + for (int y = 0; y < rows; y = y + 1) { + for (int x = 0; x < cols; x = x + 1) { addLight(x, y, 0); } } diff --git a/moonlive/layouts/lattice.mll b/moonlive/layouts/lattice.mll index d21529bf..3c5ed2bb 100644 --- a/moonlive/layouts/lattice.mll +++ b/moonlive/layouts/lattice.mll @@ -16,9 +16,9 @@ class LatticeLayout { } void placeLights() { - for (z = 0; z < layers; z = z + 1) { - for (y = 0; y < rows; y = y + 1) { - for (x = 0; x < cols; x = x + 1) { + for (int z = 0; z < layers; z = z + 1) { + for (int y = 0; y < rows; y = y + 1) { + for (int x = 0; x < cols; x = x + 1) { addLight(x, y, z); } } diff --git a/moonlive/layouts/reversed-row.mll b/moonlive/layouts/reversed-row.mll index ca09c80e..106b76c9 100644 --- a/moonlive/layouts/reversed-row.mll +++ b/moonlive/layouts/reversed-row.mll @@ -12,7 +12,7 @@ class ReversedRowLayout { } void placeLights() { - for (i = 0; i < cols; i = i + 1) { + for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); } } diff --git a/moonlive/layouts/ring.mll b/moonlive/layouts/ring.mll index 01151392..72d46826 100644 --- a/moonlive/layouts/ring.mll +++ b/moonlive/layouts/ring.mll @@ -14,7 +14,7 @@ class RingLayout { } void placeLights() { - for (i = 0; i < count; i = i + 1) { + for (int 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); } diff --git a/moonlive/layouts/rose.mll b/moonlive/layouts/rose.mll index 71fb765f..bd4b70e6 100644 --- a/moonlive/layouts/rose.mll +++ b/moonlive/layouts/rose.mll @@ -14,7 +14,7 @@ class RoseLayout { } void placeLights() { - for (i = 0; i < 256; i = i + 1) { + for (int i = 0; i < 256; i = i + 1) { addLight(radius - scale(sin(i * turn(256) * petals), radius + 1) + scale(cos(i * turn(256)), 2 * scale(sin(i * turn(256) * petals), radius + 1) + 1), diff --git a/moonlive/layouts/two-rows.mll b/moonlive/layouts/two-rows.mll index 8cc27dae..7c6001e7 100644 --- a/moonlive/layouts/two-rows.mll +++ b/moonlive/layouts/two-rows.mll @@ -12,10 +12,10 @@ class TwoRowsLayout { } void placeLights() { - for (i = 0; i < cols; i = i + 1) { + for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } - for (i = 0; i < cols; i = i + 1) { + for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 1, 0); } } diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 437dd6fd..4df5bd1b 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -267,6 +267,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { else if (std::strcmp(path, "/app.js") == 0) serveFile(conn, "app.js", "application/javascript"); else if (std::strcmp(path, "/install-picker.js") == 0) serveFile(conn, "install-picker.js", "application/javascript"); else if (std::strcmp(path, "/semver.js") == 0) serveFile(conn, "semver.js", "application/javascript"); + else if (std::strcmp(path, "/prism.js") == 0) serveFile(conn, "prism.js", "application/javascript"); else if (std::strcmp(path, "/preview3d.js") == 0) serveFile(conn, "preview3d.js", "application/javascript"); else if (std::strcmp(path, "/preview-adapt.js") == 0) serveFile(conn, "preview-adapt.js", "application/javascript"); else if (std::strcmp(path, "/migrate.js") == 0) serveFile(conn, "migrate.js", "application/javascript"); @@ -1019,6 +1020,7 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file else if (std::strcmp(filename, "app.js") == 0) { data = ui::appJs; dataLen = ui::appJsLen; gzipped = true; } else if (std::strcmp(filename, "install-picker.js") == 0) { data = ui::installPickerJs; dataLen = ui::installPickerJsLen; gzipped = true; } else if (std::strcmp(filename, "semver.js") == 0) { data = ui::semverJs; dataLen = ui::semverJsLen; gzipped = true; } + else if (std::strcmp(filename, "prism.js") == 0) { data = ui::prismJs; dataLen = ui::prismJsLen; gzipped = true; } else if (std::strcmp(filename, "preview3d.js") == 0) { data = ui::preview3dJs; dataLen = ui::preview3dJsLen; gzipped = true; } else if (std::strcmp(filename, "preview-adapt.js") == 0) { data = ui::previewAdaptJs; dataLen = ui::previewAdaptJsLen; gzipped = true; } else if (std::strcmp(filename, "migrate.js") == 0) { data = ui::migrateJs; dataLen = ui::migrateJsLen; gzipped = true; } @@ -2106,6 +2108,22 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c sendResponse(conn, 200, "application/json", "{\"ok\":true}"); } +/// What a replaced module should be called: the requested name, the old one, or neither. +/// +/// Three cases, in order. A name the CALLER asked for wins: it knows what the slot now holds, and a +/// card swapped to a different script must not stay labeled after the old one. Otherwise a CUSTOM +/// name is kept, so a scenario id or a name a user chose survives a type swap. Otherwise null, and +/// the fresh module keeps the default name its own type gave it: a Multiply replaced by a +/// Checkerboard reads as "Checkerboard", not as a mislabeled "Multiply". +/// +/// Returns null for "leave it alone", never an empty string, so a caller cannot blank a name. +const char* HttpServerModule::replacementName(const char* requested, const char* current, + const char* oldDefault) { + if (requested && requested[0] != 0) return requested; + if (current && oldDefault && std::strcmp(current, oldDefault) != 0) return current; + return nullptr; +} + void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const char* moduleName, const char* body) { auto* mod = findModuleByName(moduleName); if (!mod) { @@ -2131,6 +2149,12 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const sendResponse(conn, 400, "application/json", "{\"error\":\"missing type\"}"); return; } + // An optional name for the replacement, the counterpart of `id` on create. Without it a replace + // keeps whatever the slot was called, which is right when the type is the only thing changing + // and wrong when the caller knows what the slot now holds: swapping a card to a different + // MoonLive script leaves it labeled after the old one. + char wantName[32] = {}; + mm::json::parseString(body, "name", wantName, sizeof(wantName)); // Find the child's index within the parent. uint8_t index = 0; @@ -2165,13 +2189,12 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const // the old name was just the old type's factory display name ("Multiply" for // a MultiplyModifier), let the fresh module keep its own factory name // ("Checkerboard"): otherwise a Multiplyβ†’Checkerboard replace leaves a - // Checkerboard mislabelled "Multiply". `fresh` already arrives with its + // Checkerboard mislabeled "Multiply". `fresh` already arrives with its // correct default name from ModuleFactory::create, so we only override for a // custom name; then re-run uniqueness so two same-type siblings don't collide. - const char* oldDefault = ModuleFactory::displayNameFor(mod->typeName(), mod->role()); - if (std::strcmp(mod->name(), oldDefault) != 0) { - fresh->setName(mod->name()); // custom name: preserve the slot identity - } + const char* keep = replacementName(wantName, mod->name(), + ModuleFactory::displayNameFor(mod->typeName(), mod->role())); + if (keep) fresh->setName(keep); // Swap in place; replaceChildAt returns the old module, which we own. MoonModule* old = parent->replaceChildAt(index, fresh); diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h index 7e7e7efd..bf4559e1 100644 --- a/src/core/HttpServerModule.h +++ b/src/core/HttpServerModule.h @@ -258,6 +258,11 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { /// "content-length:"; the case-sensitive strstr it replaces silently read a length of 0 and /// committed EMPTY files with a 200). Public + static so it's unit-testable without a socket. static const char* findHeaderCI(const char* hay, const char* needle); + /// What a replaced module should be called: requested name, else a custom one, else null + /// ("keep the fresh module's own default"). Static and public so the rule is unit-testable: + /// it decides what a card is labeled after a swap, which is not something to discover in the UI. + static const char* replacementName(const char* requested, const char* current, + const char* oldDefault); /// Apply a WLED `{on?, bri?}` state body onto the Drivers `on` / `brightness` controls through /// the shared apply-core (`on` and `bri` independent β€” off preserves the level). The transport- diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index 8728776c..d36fa3be 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -17,6 +17,12 @@ void MoonLive::freeCode() { fn_ = nullptr; anim_ = nullptr; ctrl_ = nullptr; + // Likewise the error position. Only a PARSE failure has one, and it is set by the caller right + // after this returns; the later failures (no control memory, codegen refused) have no offset of + // their own, and without this they would report the position of whatever failed last. An editor + // would then mark a line that has nothing to do with the error on screen. + errorPos_ = 0; + hasErrorPos_ = false; // The entry table describes code that no longer exists. Left behind, entry() would hand a // binding an address into a freed block: the same stale-state trap the control arena has. entryCount_ = 0; @@ -50,6 +56,7 @@ void* MoonLive::place(const uint8_t* staged, size_t len) { codeCap_ = cap; codeLen_ = len; error_ = ""; + errorPos_ = 0; return block; } @@ -99,7 +106,18 @@ bool MoonLive::compile(const char* source, const BuiltinTable& table, const SysV // through that name, so a broken script silently unbound the user's own sliders. CompileResult cr = compileSource(source, table, sysvars, staging.p, staging.n, nullptr, nullptr, strings_, CompileResult::kStringPool); - if (!cr.ok) { freeCode(); error_ = cr.error; return false; } // surface the parse diagnostic + // The diagnostic AND where it happened: an editor can only mark the line if it is told one, + // and the parser has already computed the offset (Parser::fail records lex.col()). + // errorCol is ONE-based (Lexer::col() adds 1), and every consumer counts from zero: the editor + // slices the source up to this offset to find the line. Converted here, at the boundary between + // the compiler's convention and everyone else's, rather than in each reader. + if (!cr.ok) { + freeCode(); + error_ = cr.error; + errorPos_ = cr.errorCol > 0 ? static_cast(cr.errorCol - 1) : 0; + hasErrorPos_ = true; + return false; + } // Allocate the control arena (fixed address) and seed new slots, BEFORE publishing the control // set β€” ensureArena reads the previous controlCount_ to know which slots are new. // Seeded from the MEMBERS, not the controls: a member the UI never shows still has an diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index f3b6a5b2..e5113fa5 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -87,6 +87,15 @@ class MoonLive { uint8_t entryCount() const { return entryCount_; } const char* error() const { return error_; } + /// WHERE the last compile failed: a character offset into the source, 0 when it did not. + /// The editor turns it into a line to mark; the parser already knows it, and throwing it + /// away meant a user was told what was wrong but never where. + uint16_t errorPos() const { return errorPos_; } + /// Whether errorPos() means anything. Zero is a VALID offset (a failure on the first character), + /// so the value cannot also stand for "no position": only a parse failure has one, while the + /// later failures (no control memory, codegen refused) do not. + bool hasErrorPos() const { return hasErrorPos_; } + // The hot path: run the compiled routine over the host's buffer. `t` is the host's // elapsed() ms; a static routine ignores it, an animated one derives its color from // it. No-op if !ok() (a failed compile renders nothing). The emitted routines write @@ -355,6 +364,8 @@ class MoonLive { AnimFn anim_ = nullptr; // animated fill (4-arg, reads t), or nullptr CtrlFn ctrl_ = nullptr; // front-end-compiled routine (5-arg, reads the controls arena) const char* error_ = ""; + uint16_t errorPos_ = 0; + bool hasErrorPos_ = false; // The functions the script defined, with their offsets into `code_`, and their names owned here // for the same reason the control names are: a CompileResult's `name` points into source text diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 8088e48e..87f1f983 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -325,7 +325,7 @@ struct Parser { outFixed = true; return true; } - fail("this mixes a whole number and a fixed value: write toFixed(x) or toInt(x)"); + fail("mixes whole and fixed: write toFixed(x) or toInt(x)"); return false; } @@ -1035,7 +1035,7 @@ struct Parser { // program := { decl } { stmt }. Declarations (control vars) come first, then one-or-more // call statements. (Multi-statement now: a script has decl lines AND a statement line.) /// stmt := call ";" | forStmt - /// forStmt := "for" "(" ident "=" expr ";" ident "<" expr ";" ident "=" expr ")" "{" {stmt} "}" + /// forStmt := "for" "(" "int" ident "=" expr ";" ident "<" expr ";" ident "=" expr ")" "{" {stmt} "}" /// /// C-style deliberately: it is the form a script author already knows, and the third clause is /// what a serpentine layout needs (`i = i + 2`, or counting down) without inventing more syntax. @@ -1059,7 +1059,17 @@ struct Parser { lex.advance(); // consume `for` if (!expect(Tok::LParen, "expected '(' after for")) return false; - // --- init: ident = expr --- + // --- init: "int" ident = expr --- + // The counter is DECLARED, like every other variable in the language: a member carries its + // type and an assignment to an undeclared name is refused, so a counter that appeared out of + // nowhere was the one exception left. `int` is also the only type it could be (a fixed init + // is rejected below), so the word adds no meaning for the compiler: it is here because it is + // what C++ writes and what a script author types by habit. + if (!atKeyword("int", 3)) { + fail("a loop counter is declared: for (int i = 0; ...)"); + return false; + } + lex.advance(); // consume `int` if (lex.kind != Tok::Ident) { fail("expected a loop variable"); return false; } // Two slots per loop: the counter and the limit. Both must outlive the body, and both live // in the frame β€” nesting depth is now bounded by frame slots, not by the register file. @@ -1256,14 +1266,14 @@ struct Parser { const int li = findLocal(name, nameLen); const int mi = li >= 0 ? -1 : findMember(name, nameLen); if (mi >= 0 && members[mi].count > 1) { - fail("an array is assigned one element at a time: write name[i] = value"); + fail("assign one element: name[i] = value"); return false; } if (li < 0 && mi < 0) { if (sysvars.find(name, nameLen)) { - fail("a system variable is read-only: the engine writes it before every call"); + fail("a system variable is read-only"); } else { - fail("no member or loop variable of that name: declare it in the class body"); + fail("not declared: add it to the class body"); } return false; } diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index c4ac5830..d633c421 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -22,6 +22,15 @@ namespace mm::moonlive { /// re-read the file each time. Same question, one answer. class MoonLiveScript { public: + /// The longest status this module reports, " @" included. + /// + /// Sized for the longest diagnostic the compiler emits plus the suffix. At 48 the longer + /// messages truncated, and once the offset moved into this string a truncation also cost the + /// editor the position it marks the failing line from: the errors hardest to read were exactly + /// the ones that lost their explanation AND their highlight. Public because a unit test pins it + /// against the compiler's own messages, which is what stops the two drifting apart again. + static constexpr size_t kMaxStatus = 72; + /// Let a binding that owns a particle pool size it from the script's defineControls(). Null for /// a binding with no particles, which is every binding but the effect today. void setPoolSizer(PoolSizeFn fn, void* ctx) { sizePool_ = fn; poolCtx_ = ctx; } @@ -84,7 +93,18 @@ class MoonLiveScript { owner.setStatus(statusBuf_, MoonModule::Severity::Status); compileFailed_ = false; } else { - owner.setStatus(err, MoonModule::Severity::Error); + // "message @": the message a user reads, and the position the editor marks. + // One string because status IS the channel a module reports through, and a second + // control for the number would be a field every non-scripted module carries for nothing. + // The suffix is machine-read, so it stays a fixed shape rather than a sentence. + if (engine_.hasErrorPos()) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "%s @%u", + err ? err : "compile failed", + static_cast(engine_.errorPos())); + owner.setStatus(statusBuf_, MoonModule::Severity::Error); + } else { + owner.setStatus(err, MoonModule::Severity::Error); + } // Forget what the LAST script said it was. A failed compile has already interned its // strings into the same pool from offset zero, so a tags_ kept from the previous // program now points at whatever those bytes became, and the card would show it. @@ -242,7 +262,7 @@ class MoonLiveScript { const char* tags_ = nullptr; // Backing store for the status line: MoonModule::setStatus keeps a POINTER, so the text has to // outlive the call. The same module-owned pattern NetworkModule uses. - char statusBuf_[48] = {}; + char statusBuf_[kMaxStatus] = {}; // The script's FILE NAME, inside the shared script directory. Empty on a fresh card: it reports // "no script" until one is named, rather than every new module compiling the same default. char name_[kMaxScriptName + 1] = ""; diff --git a/src/ui/app.js b/src/ui/app.js index aed6a756..266ad3ae 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -212,6 +212,7 @@ function connectWs() { // already stored above, so updateValues() below still shows fresh values; the structural // render happens on the next full state once the interaction ends. if (userIsEditing()) { updateValues(); preview.setTargetFps(previewTargetFps(state)); return; } + restoreSelectedRoot(); // before the render that reads it: this may be the FIRST state renderCards(); // a full state may add/remove/reshape cards (structural resync): full render // The nav is built from the same tree, so a structural change (a module added or removed) // must rebuild it too, or the sidebar keeps entries the state no longer has. AFTER @@ -343,13 +344,11 @@ async function init() { const snap = await resp.json(); if (snap && Array.isArray(snap.modules)) { state = snap; - const savedSel = lsRead(LS_SELECTED, null); - if (state.modules.length > 0) { - const exists = savedSel && state.modules.some(m => m.name === savedSel); - // Default to the first root AS LISTED, not as scheduled: otherwise a - // device with no saved selection opens on a card that is not the one the - // nav highlights at the top. - selectedModule = exists ? savedSel : navRoots(state.modules)[0].name; + restoreSelectedRoot(); + // Default to the first root AS LISTED, not as scheduled: otherwise a device with no + // saved selection opens on a card that is not the one the nav highlights at the top. + if (!selectedModule && state.modules.length > 0) { + selectedModule = navRoots(state.modules)[0].name; } renderNav(); renderCards(); @@ -615,10 +614,17 @@ async function listSetField(moduleName, ctrlName, id, field, value) { } } -async function addModule(type, parentName) { - if (!type) return; +/// Create a module, optionally under a chosen name, and return the name it got. +/// +/// `id` names the new module. The endpoint treats it as IDEMPOTENT (a module of that name already +/// there is success, not a rename), and answers that case without a `name`: so a caller that wants +/// a fresh module reads the absence of a name as "taken" and asks again with another. Returns null +/// when nothing was created, for either reason. +async function addModule(type, parentName, id) { + if (!type) return null; const body = {type: type}; if (parentName) body.parent_id = parentName; + if (id) body.id = id; let name = null; try { const r = await fetch("/api/modules", { @@ -626,8 +632,11 @@ async function addModule(type, parentName) { headers: {"Content-Type": "application/json"}, body: JSON.stringify(body) }); - name = (await r.json()).name; // the created module's final name (post-disambiguation) + name = (await r.json()).name || null; // the created module's final name; absent if taken } catch {} + // A name already in use: the caller decides whether to retry, and re-fetching state here would + // cost a round trip per attempt. + if (id && !name) return null; // Select the new module's tab BEFORE the re-render so renderCards shows it active (the tab strip // reads selectedTabs[parent]); then scroll it into view and focus its first control so a keyboard // user lands on it. Without this the view stays on the previously-active tab and the new module @@ -638,6 +647,7 @@ async function addModule(type, parentName) { } await refetchState(); if (name) focusModule(name); + return name; } // Bring a module's card into view and focus its first control (added via the "+" flow). @@ -675,14 +685,24 @@ async function moveModuleTo(name, toIndex) { // swap a module for another type at the same position. The replacement starts // with its own default control values: a clean swap, not a value carry-over. -async function replaceModule(name, newType) { +/// Swap a module for another type, optionally renaming it. +/// +/// `newName` is for a caller that knows what the slot now holds: replacing one script with another +/// leaves a card labeled after the old script unless the name travels with it. Omitted, the device +/// keeps a custom name and refreshes a default one, which is what a plain type swap wants. +async function replaceModule(name, newType, newName) { if (!newType) return; + const body = {type: newType}; + if (newName) body.name = newName; await fetch("/api/modules/" + encodeURIComponent(name) + "/replace", { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({type: newType}) + body: JSON.stringify(body) }); - refetchState(); + // AWAITED, because a caller may address the slot straight afterwards: the replace can rename it + // (a module still carrying its old type's default name takes the new type's), and only the + // refreshed state says what it is called now. + await refetchState(); } async function rebootDevice() { @@ -869,6 +889,37 @@ function selectModule(name) { closeNavDrawer(); } +/// The module holding `name` as a child, or null at the top level. +/// +/// Used after a replace to resolve a slot whose name was disambiguated: the parent bounds the +/// search to the siblings the swap happened among. +/// Restore the root the user was last on, once the tree is known. +/// +/// Called from BOTH state arrivals, because either can be first: the WebSocket full state and the +/// /api/state fetch race on load, and only the fetch used to consult this. When the socket won, the +/// selection stayed null and renderCards fell back to the first root, so a refresh landed on Control +/// instead of the Layer or File Manager the user left open. Intermittent exactly as a race is, and +/// self-correcting after any nav click, which sets the selection in memory. +/// +/// A saved name that is no longer in the tree is ignored, leaving the fallback to pick. +function restoreSelectedRoot() { + if (selectedModule) return; // an explicit choice this session wins + if (!state || !Array.isArray(state.modules) || !state.modules.length) return; + const saved = lsRead(LS_SELECTED, null); + if (saved && state.modules.some(m => m.name === saved)) selectedModule = saved; +} + +function findParentOf(name, modules) { + if (!modules) modules = state.modules; + for (const m of modules) { + const kids = m.children || []; + if (kids.some(k => k.name === name)) return m; + const found = findParentOf(name, kids); + if (found) return found; + } + return null; +} + function findModule(name, modules) { if (!modules) modules = state.modules; for (const m of modules) { @@ -1378,6 +1429,71 @@ function surfaceBreak() { return br; } +/// The live editors on each module's script, by module name. +/// +/// A card's status row and its script editor are built independently, so neither can reach the +/// other directly; this is the seam between them. A SET per module, because a module can have two +/// editors open on the same file at once (the card's pane and the modal it expands into) and both +/// have to mark the failing line. +const mlEditors = new Map(); + +/// Register an editor under a module, and return its own removal. +/// +/// Handing back the unregister rather than exposing a remove(name, ed) keeps the two halves from +/// drifting: the modal in particular learns its module from whoever opened it, and cannot then +/// unregister under a different name. +function mlEditorAdd(name, ed) { + if (!name) return () => {}; + if (!mlEditors.has(name)) mlEditors.set(name, new Set()); + mlEditors.get(name).add(ed); + return () => { + const set = mlEditors.get(name); + if (!set) return; + set.delete(ed); + if (!set.size) mlEditors.delete(name); + }; +} + +/// The editors on a module that are still on the page. +/// +/// The sweep runs from setStatusText, which a card only calls when it HAS a status: a module that +/// never reports one leaves its detached editors in the map. They are inert (no timers, no DOM +/// work: painting is driven by the editor's own events), so this is housekeeping rather than a +/// leak, and the entries go when the module does. +/// +/// renderCards rebuilds every card by clearing its host, which orphans an inline editor without +/// ever calling dispose: registering on create with no counterpart leaked one editor per re-render, +/// and each leaked one kept re-running the highlighter on detached DOM every time a status arrived. +/// Testing the DOM is what makes that impossible to get wrong, since it asks the only question that +/// matters (is this editor still showing?) rather than trusting every teardown path to report. +function mlLiveEditors(name) { + const set = mlEditors.get(name); + if (!set) return []; + for (const ed of set) if (!ed.isMounted()) set.delete(ed); + if (!set.size) { mlEditors.delete(name); return []; } + return [...set]; +} + +/// Write a module's status, and mark the line it names in every editor showing that script. +/// +/// Both render paths (createCard and updateModuleControls) come here, because a rule that lives in +/// only one of them is a rule that applies half the time. A compile failure arrives as +/// "message @"; an EDITOR turns that into a line and column, since only it holds the text +/// the offset counts into. Each returns the same rewritten text, so which one supplies it does not +/// matter; a module with no editor open shows the raw status unchanged. +function setStatusText(valEl, mod) { + // Every editor on this module marks the line; each returns the same rewritten text, since they + // hold the same file. Keeping the first answer rather than the last says that plainly: the + // string does not depend on which editor supplied it. + // Every editor on this module marks the line and returns the SAME rewritten text, since they + // hold the same file: so mark them all, and take any one answer. + let text = mod.status; + for (const ed of mlLiveEditors(mod.name)) text = ed.markError(mod.status); + // setText, not a bare assignment: this runs on every state push, and rewriting an unchanged + // node throws away a selection the user may be holding on it. + setText(valEl, text); +} + function createCard(mod, depth) { const card = document.createElement("div"); card.className = "card"; @@ -1561,7 +1677,7 @@ function createCard(mod, depth) { const val = document.createElement("span"); val.className = "status-value"; val.dataset.sev = mod.severity || "status"; - val.textContent = mod.status; + setStatusText(val, mod); row.appendChild(label); row.appendChild(val); controlsHost.appendChild(row); @@ -2613,6 +2729,11 @@ function createControl(moduleName, moduleType, ctrl) { // module recompiles or reloads on its own. The browser sends nothing extra. const editor = fmMountEditor(pane, pathOf(ctrl.value), { sizeKey: key, + // The status this module is ALREADY reporting, so a card built while its script is + // broken shows the marked line straight away rather than waiting for a recompile. + // The EDITOR applies it once the file has loaded: marking at construction would + // convert the offset against an empty textarea and put every error on line 1. + initialStatus: (findModule(moduleName) || {}).status || "", saveButton: saveBtn, statusEl, // Editing a factory script FORKS it: the read came from the library directory, but @@ -2633,6 +2754,15 @@ function createControl(moduleName, moduleType, ctrl) { fillPicker().then(() => { picker.value = sel; refreshDelLabel(); }); }, }); + mlEditorAdd(moduleName, editor); + // Apply the status the card is ALREADY showing: registration only catches the next one, + // so a card rendered while its script is broken would report the error with no line + // marked until something recompiled. The modal does the same on open. + { + const row = document.querySelector( + `[data-status-mid="${cssEscape(moduleName)}"] .status-value`); + if (row) editor.markError(row.textContent); + } // Re-read after the modal closes: it edits the same file through the same endpoints, so // whatever it saved is what this pane should now show. @@ -2690,7 +2820,7 @@ function createControl(moduleName, moduleType, ctrl) { await editor.save(); if (editor.isDirty()) { alert("Not opening: this script still has unsaved changes."); return; } const p = await scriptPathOf(picker.value); - await openFileEditor(p); + await openFileEditor(p, undefined, moduleName); await editor.load(p); }); @@ -4161,7 +4291,7 @@ function updateValues() { const val = document.createElement("span"); val.className = "status-value"; val.dataset.sev = mod.severity || "status"; - val.textContent = mod.status; + setStatusText(val, mod); statusRow.appendChild(label); statusRow.appendChild(val); // Insert before first .control-row, or append. @@ -4172,7 +4302,7 @@ function updateValues() { statusRow.style.display = ""; const val = statusRow.querySelector(".status-value"); if (val) { - setText(val, mod.status); + setStatusText(val, mod); const sev = mod.severity || "status"; if (val.dataset.sev !== sev) val.dataset.sev = sev; } @@ -4586,6 +4716,12 @@ function graphemes(s) { // All emoji for a type: role first, then dimensional (effects only), then each // curated tag emoji from tags(). Deduplicated, order preserved. +// A script is marked; a compiled module is not. Two chips in the picker filter on this, and the +// compiled one matches by ABSENCE, so adding it costs no tag on ninety existing modules. +const SCRIPTED_EMOJI = "\u{1F4DD}"; // πŸ“ the module runs a MoonLive script +// Not the gear: that is already the `generic` role's emoji, and reusing it drew the chip twice. +const COMPILED_EMOJI = "\u{1F4E6}"; // πŸ“¦ chip only: never a tag any module carries + function emojiTagsFor(t) { const out = []; const seen = new Set(); @@ -4601,34 +4737,183 @@ function emojiTagsFor(t) { // - replace: pick a type to swap parentMod for, at the same position. // They differ only in the role filter and the commit action; the search box, // list, and keyboard nav are shared. +/// The MoonLive module type that runs a script of this role. +/// +/// Role, not extension: the picker knows what it is offering, and the catalog is already grouped +/// the same way. Null for a role with no scripted form. +function mlTypeForRole(role) { + return role === "effect" ? "MoonLiveEffect" + : role === "layout" ? "MoonLiveLayout" + : role === "modifier" ? "MoonLiveModifier" : null; +} + +/// Every shipped script, as picker rows, for the roles a parent accepts. +/// +/// One row per script rather than one "MoonLive" row: to a user, `dot` is a thing to add exactly +/// as `DemoReel` is, and which of the two is compiled is a property, not a category. The scripted +/// marker rides on the row so the merged list still says which is which, and `script` carries the +/// file the row would load. +async function mlScriptItems(roles) { + const cat = await mlFetchCatalog().catch(() => null); + if (!cat) return []; // no catalog: the picker still offers every type + // What the device already holds, so a row can say when picking it costs a download. One listing + // for every role: the scripts share a directory. + const local = new Set((await fmFetchDir(cat.dir).catch(() => [])).map(e => e.name)); + const out = []; + for (const role of roles) { + if (!mlTypeForRole(role)) continue; + const g = cat[role + "s"] || {}; // "effect" -> catalog group "effects" + (g.names || []).forEach((n, i) => { + const tags = (g.tags && g.tags[i]) || ""; + const isRemote = !local.has(n); + out.push({ + name: n, + remote: isRemote, + // Without the extension: it is the file's business, not the reader's, and it keeps + // the row sorting next to the compiled modules rather than in a block of ".mle". + displayName: (isRemote ? "\u2601 " : "") + n.replace(/\.ml[elm]$/i, ""), + role, + // The scripted marker is what makes the row's kind visible, so it is added rather + // than assumed: a script whose own tags happen to omit it still reads correctly. + tags: tags.includes(SCRIPTED_EMOJI) ? tags : SCRIPTED_EMOJI + tags, + dim: (g.dim && g.dim[i]) || 0, + script: n, + }); + }); + } + return out; +} + +/// Make sure a picked script is ON the device, downloading it if it is only in the catalog. +/// +/// Creating or replacing a module before the file exists leaves a card reporting "script not found", +/// so this runs first on both paths. Returns false when the download failed and the caller must not +/// proceed; it has already told the user why. +async function mlEnsureLocal(item) { + if (!item.remote) return true; + try { + await mlDownloadScript(item.script, item.role + "s"); + return true; + } catch (e) { + alert("could not download " + item.script + ": " + (e && e.message ? e.message : e)); + return false; + } +} + +/// Add a module for a picked row, whether it is a compiled type or a script. +/// +/// A script becomes a MoonLive module holding it, named after the script: the user picked `dot`, so +/// the card says `dot`. `id` is how POST /api/modules names a module, and it is deliberately +/// idempotent (an existing name is success, not a rename), so a collision is retried with a suffix +/// rather than silently landing on the module already there. +/// Point a card at a script, and make sure the card catches up. +/// +/// The re-render is the point. A card is built BEFORE its script is set (created or replaced first, +/// pointed at the file second), so its picker reads "(none)" and its editor is blank until something +/// rebuilds it. A script that COMPILES hides this: defining controls changes the module's schema, +/// which fires a full resync. A script that FAILS defines nothing, the schema signature is unchanged, +/// and only the status text arrives, leaving a card that contradicts itself: an error about a script +/// it claims not to have, correct again after a manual refresh. +async function setCardScript(moduleName, script) { + await sendControl(moduleName, "script", script); + await refetchState(); +} + +async function addPickedType(item, parentName) { + if (!item.script) return addModule(item.name, parentName); + const type = mlTypeForRole(item.role); + if (!type) return; + if (!await mlEnsureLocal(item)) return; + // The cloud marker is a property of the row, not of the name the card takes. + const base = item.displayName.replace(/^\u2601\s*/, ""); + for (let n = 1; n <= 20; n++) { + const id = n === 1 ? base : `${base}-${n}`; + const created = await addModule(type, parentName, id); + if (created) { + await setCardScript(created, item.script); + return created; + } + } + // Twenty siblings of one name, all taken. Unlikely, but silence would leave the user clicking + // create and watching nothing happen, which is the one outcome worse than an error. + alert(`could not add ${base}: twenty modules of that name already exist here`); +} + function openTypePicker(parentMod, anchorEl) { const roles = rolesAcceptedBy(parentMod); // One candidate = no choice to make, so don't stage a picker to ask a question with one answer: // "+" on Effects just adds a Layer. (Same filter openPicker uses, so the two can't disagree about - // what the candidates are.) + // what the candidates are.) Counted over the TYPES alone: the scripts arrive asynchronously, and + // a role with one type but many scripts is still a real choice, which the await below sees. const candidates = availableTypes.filter(t => roles.includes(t.role)); - if (candidates.length === 1) { - addModule(candidates[0].name, parentMod.name); - return; - } - openPicker(anchorEl, { - roles, - actionLabel: "create", - commit: (type) => addModule(type, parentMod.name) + mlScriptItems(roles).then((scripts) => { + if (candidates.length === 1 && !scripts.length) { + addModule(candidates[0].name, parentMod.name); + return; + } + openPicker(anchorEl, { + items: [...candidates, ...scripts], + actionLabel: "create", + commit: (name, item) => addPickedType(item, parentMod.name), + }); }); } // Replace mode: filter to the target module's own role (effect ↔ effect), and // pre-select the module's CURRENT type so the cursor lands on it (not the first row). function openReplacePicker(targetMod, anchorEl) { - openPicker(anchorEl, { - roles: [targetMod.role], - actionLabel: "replace", - currentType: targetMod.type, - commit: (type) => replaceModule(targetMod.name, type) + const roles = [targetMod.role]; + // Scripts here for the same reason they are in the add picker: swapping an effect for a script + // is the same question as adding one, and a list that offers scripts in one place and not the + // other makes the distinction visible again exactly where it should not be. + mlScriptItems(roles).then((scripts) => { + openPicker(anchorEl, { + items: [...availableTypes.filter(t => roles.includes(t.role)), ...scripts], + actionLabel: "replace", + currentType: targetMod.type, + commit: (name, item) => replacePickedType(targetMod, item), + }); }); } +/// Replace a module with a picked row, whether it is a compiled type or a script. +/// +/// A script replaces in place: the module becomes the MoonLive type for its role and then loads the +/// file, so the slot keeps its position in the layer. It is also RENAMED, like the add path: a card +/// is named after what it runs, so swapping what it runs renames it. +async function replacePickedType(targetMod, item) { + if (!item) return; + // A replace ALWAYS renames: a card is named after what it runs, so swapping what it runs renames + // it. Simple and predictable, and it is why the device's replace takes a name at all: left to + // itself it preserves any name that is not the old type's default, which kept a card auto-named + // "MoonLive-3" labeled that after it became a Lissajous. + const picked = item.displayName.replace(/^\u2601\s*/, ""); + if (!item.script) return replaceModule(targetMod.name, item.name, picked); + const type = mlTypeForRole(item.role); + if (!type) return; + if (!await mlEnsureLocal(item)) return; + // Named after the script, the same as adding one: a card running dot.mle is called `dot` however + // it got there, unless the user named it something of their own. The device disambiguates a + // collision, so the name asked for is not always the name given. + // WHERE the slot sits, captured BEFORE the replace. Afterwards the module answers to its new + // name and the old one is gone, so nothing can be found by it: the position is the only handle + // that survives. Looking the parent up afterwards found nothing, and the name asked for could + // match a DIFFERENT card when it was already taken, which sent the script to the wrong module. + const parent = findParentOf(targetMod.name); + const index = parent ? (parent.children || []).findIndex(k => k.name === targetMod.name) : -1; + const parentName = parent ? parent.name : null; + + await replaceModule(targetMod.name, type, picked); + + // The slot at that position, in the refreshed state. A replace swaps a child IN PLACE, so the + // index is stable and the parent keeps its own name; the device may have suffixed the name it + // was given ("dot-2") to keep it unique, which is exactly what the position answers. + const after = parentName ? findModule(parentName) : null; + const atIndex = after && index >= 0 && after.children ? after.children[index] : null; + const slot = atIndex ? atIndex.name : (findModule(picked) ? picked : null); + if (slot) await setCardScript(slot, item.script); +} + function openPicker(anchorEl, opts) { // Close any existing picker // Its MODAL, not just the inner block: removing only the picker would leave an open @@ -4645,8 +4930,11 @@ function openPicker(anchorEl, opts) { // name so the list is scannable regardless of registration order (localeCompare: // case-insensitive, locale-aware). const source = opts.items || availableTypes.filter(t => opts.roles.includes(t.role)); - const filtered = [...source] - .sort((a, b) => (a.displayName || a.name).localeCompare(b.displayName || b.name)); + // Sorted on the NAME, not on any marker in front of it: a row prefixed with the cloud glyph is + // still that script alphabetically, and prefixed rows would otherwise collect in a block of + // their own instead of sitting where the reader looks for them. + const sortKey = (t) => (t.displayName || t.name).replace(/^[^\p{L}\p{N}]+/u, ""); + const filtered = [...source].sort((a, b) => sortKey(a).localeCompare(sortKey(b))); const picker = document.createElement("div"); picker.className = "type-picker"; @@ -4676,12 +4964,18 @@ function openPicker(anchorEl, opts) { if (!chipSeen.has(ch)) { chipSeen.add(ch); present.push(ch); } } } + // The compiled chip is not a tag anything carries, so it cannot be discovered from the rows the + // way every other chip is: it is offered when the list actually holds both kinds, which is the + // only situation where filtering by kind means anything. + if (chipSeen.has(SCRIPTED_EMOJI) && filtered.some(t => !emojiTagsFor(t).includes(SCRIPTED_EMOJI))) { + present.push(COMPILED_EMOJI); + } // Grouped rather than in first-seen order, so the row reads as the legend does: the scripted // marker, then what a module IS (role, then dimension), then where it came from, then what it // can do. A chip whose category is unknown falls in the last group rather than vanishing, so a // new emoji is visible before anyone remembers to classify it. const CHIP_GROUPS = [ - ["\u{1F4DD}"], // MoonLive: scripted, first + [SCRIPTED_EMOJI, COMPILED_EMOJI], // what a row IS: scripted or compiled Object.values(ROLE_EMOJI), // type Object.values(DIM_EMOJI), // dimension ["\u{1F4AB}", "\u{1F319}", "\u{1F419}", "\u26A1\uFE0F"], // origin @@ -4734,6 +5028,7 @@ function openPicker(anchorEl, opts) { picker.appendChild(actions); let selectedType = null; + let selectedItem = null; // the row itself: commit needs more than its name // Types matching the search box AND all active emoji chips. The query matches // against both the raw typeName ("RainbowEffect") and the displayName @@ -4748,7 +5043,12 @@ function openPicker(anchorEl, opts) { } if (activeChips.size > 0) { const has = new Set(emojiTagsFor(t)); - for (const chip of activeChips) if (!has.has(chip)) return false; + for (const chip of activeChips) { + // The compiled chip matches what carries NO scripted marker, since a compiled + // module has no tag of its own: the two kind chips are each other's opposite. + const ok = chip === COMPILED_EMOJI ? !has.has(SCRIPTED_EMOJI) : has.has(chip); + if (!ok) return false; + } } return true; }); @@ -4780,15 +5080,17 @@ function openPicker(anchorEl, opts) { list.querySelectorAll(".selected").forEach(x => x.classList.remove("selected")); item.classList.add("selected"); selectedType = t.name; + selectedItem = t; createBtn.disabled = false; }); item.addEventListener("dblclick", () => { - opts.commit(t.name); + opts.commit(t.name, t); closePicker(); }); list.appendChild(item); }); - selectedType = matches.length > 0 ? matches[selIdx].name : null; + selectedItem = matches.length > 0 ? matches[selIdx] : null; + selectedType = selectedItem ? selectedItem.name : null; createBtn.disabled = !selectedType; // Scroll the pre-selected row into view (it may be below the fold for a long list). const selEl = list.querySelector(".type-picker-item.selected"); @@ -4805,19 +5107,21 @@ function openPicker(anchorEl, opts) { if (idx < items.length - 1) { sel?.classList.remove("selected"); items[idx + 1].classList.add("selected"); - selectedType = filteredAt(idx + 1)?.name; + selectedItem = filteredAt(idx + 1); + selectedType = selectedItem?.name; } } else if (e.key === "ArrowUp") { e.preventDefault(); if (idx > 0) { sel?.classList.remove("selected"); items[idx - 1].classList.add("selected"); - selectedType = filteredAt(idx - 1)?.name; + selectedItem = filteredAt(idx - 1); + selectedType = selectedItem?.name; } } else if (e.key === "Enter") { e.preventDefault(); if (selectedType) { - opts.commit(selectedType); + opts.commit(selectedType, selectedItem); closePicker(); } } else if (e.key === "Escape") { @@ -4831,7 +5135,7 @@ function openPicker(anchorEl, opts) { createBtn.addEventListener("click", () => { if (selectedType) { - opts.commit(selectedType); + opts.commit(selectedType, selectedItem); closePicker(); } }); @@ -6084,27 +6388,134 @@ async function fmCreateFile(dir, name, content = "") { // // `onSaved(relPath)` fires after each successful save. Returns a handle so a caller can point the // same pane at a different file without rebuilding it. +/// Prism's C++ grammar plus the three type names MoonLive adds. +/// +/// `byte`, `fixed` and `string` are the language's own aliases (a C++ reader would meet uint8_t, +/// a Q16.16 int and a const char*), so a stock C++ grammar leaves them unpainted beside the `int` +/// next to them. Extending rather than writing a grammar: everything else in a script IS C++, which +/// is what test_scripts_are_cpp.py holds each shipped script to. +/// +/// Built once and cached: the highlighter runs on every keystroke. +let mlGrammarCache = null; +function mlGrammar() { + if (mlGrammarCache) return mlGrammarCache; + // A COPY of the C++ grammar with one rule replaced: its keyword pattern, widened to also match + // MoonLive's three type names. Prism.languages.insertBefore was the other route and it is the + // wrong tool here (it rebuilds the language in place and expects a rule object, not a bare + // RegExp), which silently produced a grammar that highlighted nothing at all. + const cpp = Prism.languages.cpp; + mlGrammarCache = Object.assign({}, cpp, { + keyword: [/\b(?:byte|fixed|string)\b/].concat(cpp.keyword || []), + }); + return mlGrammarCache; +} + function fmMountEditor(host, relPath, opts = {}) { // `savePath(readPath)` lets a caller WRITE somewhere other than it read. The script picker uses // it: a factory script is read from the read-only library directory, and editing it must create // the user's own copy rather than overwrite what shipped. Defaults to writing back where it // read, which is what every other caller wants. - const { expectedSize, onSaved, sizeKey, saveButton, statusEl, savePath } = opts; + const { expectedSize, onSaved, onDispose, sizeKey, saveButton, statusEl, savePath, + initialStatus } = opts; const wrap = document.createElement("div"); wrap.className = "fm-editor-pane"; // The footer carries Save and the status line, UNLESS the host supplies both: a card already has // a toolbar of file actions, so they belong there, and an empty strip under the box is a gap // rather than a layout. - const ownFooter = !saveButton || !statusEl; + // Highlighting is for SCRIPTS: a .mle/.mll/.mlm is MoonLive, which is C++, so Prism's own C++ + // grammar paints it with nothing of ours to maintain. A .json or a .txt edits as plain text. + const hlOn = /\.(mle|mll|mlm)$/i.test(relPath || ""); + + // The highlight layer sits BEHIND a transparent textarea, both sharing one box and one set of + // font metrics: a textarea cannot color its own text, and this is the standard way around that. + // The textarea keeps every editing behavior (caret, selection, undo, IME); the
 only paints.
     wrap.innerHTML =
+        '
' + + '' + '' + - (ownFooter - ? '
' + - (statusEl ? '' : ' ') + - (saveButton ? '' : ' ') + - '
' - : ''); + '
' + + '
' + + '
' + + (statusEl ? '' : ' ') + + ' ' + + (saveButton ? '' : ' ') + + '
'; const body = wrap.querySelector(".fm-editor-body"); + if (!hlOn) wrap.querySelector(".fm-editor-stack").classList.add("plain"); + let errorLine = -1; // 0-based, -1 for none: set by markError below + + /// A character offset as the line and column a person counts in, both 1-based. + /// + /// The device reports an OFFSET, because that is what the parser has; nobody reads a script by + /// offset. The editor holds the same text, so it is the one place that can do the conversion. + const lineColAt = (off) => { + const upto = body.value.slice(0, Math.max(0, Math.min(off, body.value.length))); + const nl = upto.lastIndexOf("\n"); + return { line: upto.split("\n").length, col: upto.length - nl }; + }; + const hl = wrap.querySelector(".fm-editor-hl"); + const hlCode = hl.querySelector("code"); + const errBand = hl.querySelector(".fm-editor-err"); + + /// Repaint the layer under the caret, and keep it aligned. + /// + /// Only for a MoonLive script: Prism is given the C++ grammar because that is what the language + /// is (test/python/test_scripts_are_cpp.py holds every shipped script to it), so `class`, the + /// types and the comments light up with no grammar of our own. Any other file edits as plain + /// text, which is what a .json or a .txt should look like. + /// + /// A trailing newline gets a space: a
 collapses the last empty line where a textarea
+    /// keeps it, and without this the two drift by one line at the end of a file.
+    const paintHighlight = () => {
+        if (!hlOn) return;
+        const src = body.value;
+        hlCode.textContent = src.endsWith("\n") ? src + " " : src;
+        if (window.Prism && Prism.languages.cpp) {
+            hlCode.innerHTML = Prism.highlight(hlCode.textContent, mlGrammar(), "cpp");
+        }
+        // The failing line, marked with a BAND positioned over it rather than by wrapping its text.
+        // Wrapping meant splitting Prism's serialized HTML on newlines, which cuts any token that
+        // spans lines (a block comment is one element) in half and leaves unbalanced tags for the
+        // parser to re-balance, shifting the coloring of everything after it. A band cannot touch
+        // the markup at all, and it lands on the same line because both use the same line height.
+        errBand.hidden = errorLine < 0;
+        if (errorLine >= 0) errBand.style.top = `calc(${errorLine} * 1.5em)`;
+        syncHighlightScroll();
+    };
+    /// Move the paint under the text by TRANSFORM rather than by scrolling it: a scrollTop the
+    /// element cannot reach (it has overflow:hidden) is silently clamped, which is what left the
+    /// last lines of a file unreachable.
+    /// Mark the line a compile failed on, and return the status with a readable position.
+    ///
+    /// Converts the device's offset against the text this editor HOLDS, so it must not run before
+    /// the file has loaded: every position would resolve to line 1.
+    const markErrorAt = (statusText) => {
+        errorLine = -1;
+        let shown = statusText || "";
+        // Two forms, because this is called with both: the raw device status "message @" on
+        // every update, and an ALREADY rewritten "message (line L, col C)" when a second editor
+        // opens on a module whose status was converted before it existed.
+        const at = /@(\d+)\s*$/.exec(shown);
+        const lc = /\(line (\d+), col \d+\)\s*$/.exec(shown);
+        if (at) {
+            const p = lineColAt(Number(at[1]));
+            errorLine = p.line - 1;
+            // The offset is replaced, not appended to: line and column is the only half a person can
+            // act on, and the editor marks the line anyway.
+            shown = shown.slice(0, at.index).trimEnd() + ` (line ${p.line}, col ${p.col})`;
+        } else if (lc) {
+            errorLine = Number(lc[1]) - 1;
+        }
+        paintHighlight();
+        return shown;
+    };
+
+    const syncHighlightScroll = () => {
+        // The band follows VERTICALLY only: it spans the full width, so a horizontal shift would
+        // just walk it off the box while the line it marks stays put.
+        hlCode.style.transform = `translate(${-body.scrollLeft}px, ${-body.scrollTop}px)`;
+        errBand.style.transform = `translateY(${-body.scrollTop}px)`;
+    };
     const status = statusEl || wrap.querySelector(".fm-editor-status");
     const saveBtn = saveButton || wrap.querySelector(".fm-editor-save");
     host.appendChild(wrap);
@@ -6126,14 +6537,43 @@ function fmMountEditor(host, relPath, opts = {}) {
     // 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";
+    const stack = wrap.querySelector(".fm-editor-stack");
+    if (typeof savedH === "number" && savedH > 0) stack.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);
+    taObserver.observe(stack);
+
+    // Resize by dragging the grip.
+    //
+    // Ours rather than the browser's `resize`, which cannot work here: the textarea is positioned
+    // over the whole stack including the corner the native gesture starts in, so the press lands on
+    // the text and the drag never begins. The handle is a real element above both layers, and
+    // setPointerCapture keeps the drag alive once the pointer leaves those few pixels.
+    const grip = wrap.querySelector(".fm-editor-grip");
+    let dragFrom = 0, dragH = 0;
+    grip.addEventListener("pointerdown", (e) => {
+        dragFrom = e.clientY;
+        dragH = stack.getBoundingClientRect().height;
+        grip.setPointerCapture(e.pointerId);
+        e.preventDefault();                    // no text selection while dragging
+    });
+    grip.addEventListener("pointermove", (e) => {
+        if (!dragFrom) return;
+        // No floor here: the stack's own min-height clamps it, so one rule owns the minimum.
+        stack.style.height = `${dragH + (e.clientY - dragFrom)}px`;
+    });
+    const endDrag = (e) => {
+        if (!dragFrom) return;
+        dragFrom = 0;
+        if (grip.hasPointerCapture(e.pointerId)) grip.releasePointerCapture(e.pointerId);
+        // The ResizeObserver above persists the new height; nothing to store here.
+    };
+    grip.addEventListener("pointerup", endDrag);
+    grip.addEventListener("pointercancel", endDrag);
 
     // Blur, Cmd+S and the Save button all call save(), and a blur fires when the button takes
     // focus: so without this guard one edit issues overlapping POSTs of the same file. `dirty`
@@ -6166,7 +6606,20 @@ function fmMountEditor(host, relPath, opts = {}) {
         return saving;
     };
 
-    body.addEventListener("input", () => { if (!body.readOnly) setDirty(true); });
+    // Where the caret IS, so a reported line and column can be found by moving to it. Updated from
+    // every event that can move a caret; `selectionchange` alone does not fire on a plain textarea
+    // in every browser, so the input and key/click events cover it.
+    const caretEl = wrap.querySelector(".fm-editor-caret");
+    const showCaret = () => {
+        if (!caretEl) return;
+        const at = lineColAt(body.selectionStart);
+        caretEl.textContent = `Ln ${at.line}, Col ${at.col}`;
+    };
+    ["input", "click", "keyup", "select", "focus"].forEach(e => body.addEventListener(e, showCaret));
+
+    body.addEventListener("input", () => { if (!body.readOnly) setDirty(true); paintHighlight(); });
+    // Scroll is not an input event: the layer has to follow the box it sits under.
+    body.addEventListener("scroll", () => syncHighlightScroll());
     body.addEventListener("blur", save);
     body.addEventListener("keydown", (e) => {
         if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "S")) { e.preventDefault(); save(); }
@@ -6184,12 +6637,26 @@ function fmMountEditor(host, relPath, opts = {}) {
         loadAbort = ac;
         path = p;
         setDirty(false);
-        if (!path) { body.value = ""; body.readOnly = true; saveBtn.disabled = true; status.textContent = ""; return; }
+        if (!path) {
+            body.value = ""; body.readOnly = true; saveBtn.disabled = true; status.textContent = "";
+            // The mark and the caret readout belong to text that is no longer there: left alone, an
+            // empty pane keeps a red band over nothing and a stale Ln/Col.
+            errorLine = -1;
+            paintHighlight();
+            showCaret();
+            return;
+        }
         const r = await fmLoadInto(body, path, size, ac.signal);
         if (r.aborted || ac !== loadAbort) return;   // a newer load started: that one owns the pane
         body.readOnly = r.readOnly;
         saveBtn.disabled = r.readOnly;
+        paintHighlight();          // the file just arrived: paint what it says
+        showCaret();
         status.textContent = r.message;
+        // A compile error the module was ALREADY reporting when this editor was built. Marked here
+        // rather than at construction, because markError converts an offset against the text: run
+        // before the file arrives and every position resolves to line 1.
+        if (initialStatus) { markErrorAt(initialStatus); initialStatus = null; }
     };
     load(path, expectedSize);
 
@@ -6200,13 +6667,22 @@ function fmMountEditor(host, relPath, opts = {}) {
         // Resolves once any in-flight save has completed, so the caller can safely re-read.
         save,
         isDirty: () => dirty,
-        dispose: () => { taObserver.disconnect(); wrap.remove(); },
+        /// Still on the page? A card rebuild detaches an inline editor without disposing it.
+        isMounted: () => wrap.isConnected,
+        /// Mark the line a compile failed on, from the module's own status.
+        ///
+        /// The device reports "message @", an offset into the source it compiled, which is
+        /// the only position anyone has: the parser records it and nothing else can reconstruct it.
+        /// Passing "" or a status with no @ clears the mark, so a fixed script stops being flagged
+        /// the moment it compiles.
+        markError: markErrorAt,
+        dispose: () => { taObserver.disconnect(); wrap.remove(); if (onDispose) onDispose(); },
     };
 }
 
 // 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) {
+async function openFileEditor(relPath, expectedSize, moduleName) {
     const dlg = document.createElement("dialog");
     dlg.className = "fm-editor";
     dlg.innerHTML =
@@ -6216,7 +6692,19 @@ async function openFileEditor(relPath, expectedSize) {
         '';
     dlg.querySelector(".fm-editor-path").textContent = relPath;
     document.body.appendChild(dlg);
-    const ed = fmMountEditor(dlg, relPath, { expectedSize });
+    // Registered under the module that opened it, exactly as the card's pane is: the status row is
+    // rendered by the card either way, and setStatusText marks every editor on that module. Without
+    // a module name (the File Manager's own rows) nothing registers and the modal simply highlights.
+    let unregister = () => {};
+    const ed = fmMountEditor(dlg, relPath, { expectedSize, onDispose: () => unregister() });
+    unregister = mlEditorAdd(moduleName, ed);
+    // Mark it NOW, from the status already on the card: registration only catches the next update,
+    // and a compile failure that happened before the modal opened would otherwise show unmarked
+    // until the module recompiles. The row is the card's, so it holds the same text either way.
+    if (moduleName) {
+        const row = document.querySelector(`[data-status-mid="${cssEscape(moduleName)}"] .status-value`);
+        if (row) ed.markError(row.textContent);
+    }
     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.
diff --git a/src/ui/embed_ui.cmake b/src/ui/embed_ui.cmake
index 10075d07..78a7c4e4 100644
--- a/src/ui/embed_ui.cmake
+++ b/src/ui/embed_ui.cmake
@@ -53,6 +53,7 @@ gzip_file_hex("app.js" APP_JS)
 gzip_file_hex("style.css" STYLE_CSS)
 gzip_file_hex("install-picker.js" INSTALL_PICKER_JS)
 gzip_file_hex("semver.js" SEMVER_JS)
+gzip_file_hex("vendor/prism.js" PRISM_JS)
 gzip_file_hex("preview3d.js" PREVIEW3D_JS)
 gzip_file_hex("preview-adapt.js" PREVIEW_ADAPT_JS)
 gzip_file_hex("migrate.js" MIGRATE_JS)
@@ -78,6 +79,7 @@ hex_to_c_array("${APP_JS}" "appJs" APP_ARRAY)
 hex_to_c_array("${STYLE_CSS}" "styleCss" STYLE_ARRAY)
 hex_to_c_array("${INSTALL_PICKER_JS}" "installPickerJs" INSTALL_PICKER_ARRAY)
 hex_to_c_array("${SEMVER_JS}" "semverJs" SEMVER_ARRAY)
+hex_to_c_array("${PRISM_JS}" "prismJs" PRISM_ARRAY)
 hex_to_c_array("${PREVIEW3D_JS}" "preview3dJs" PREVIEW3D_ARRAY)
 hex_to_c_array("${PREVIEW_ADAPT_JS}" "previewAdaptJs" PREVIEW_ADAPT_ARRAY)
 hex_to_c_array("${MIGRATE_JS}" "migrateJs" MIGRATE_ARRAY)
@@ -88,6 +90,7 @@ string(LENGTH "${APP_JS}" APP_HEX_LEN)
 string(LENGTH "${STYLE_CSS}" STYLE_HEX_LEN)
 string(LENGTH "${INSTALL_PICKER_JS}" INSTALL_PICKER_HEX_LEN)
 string(LENGTH "${SEMVER_JS}" SEMVER_HEX_LEN)
+string(LENGTH "${PRISM_JS}" PRISM_HEX_LEN)
 string(LENGTH "${PREVIEW3D_JS}" PREVIEW3D_HEX_LEN)
 string(LENGTH "${PREVIEW_ADAPT_JS}" PREVIEW_ADAPT_HEX_LEN)
 string(LENGTH "${MIGRATE_JS}" MIGRATE_HEX_LEN)
@@ -97,6 +100,7 @@ math(EXPR APP_LEN "${APP_HEX_LEN} / 2")
 math(EXPR STYLE_LEN "${STYLE_HEX_LEN} / 2")
 math(EXPR INSTALL_PICKER_LEN "${INSTALL_PICKER_HEX_LEN} / 2")
 math(EXPR SEMVER_LEN "${SEMVER_HEX_LEN} / 2")
+math(EXPR PRISM_LEN "${PRISM_HEX_LEN} / 2")
 math(EXPR PREVIEW3D_LEN "${PREVIEW3D_HEX_LEN} / 2")
 math(EXPR PREVIEW_ADAPT_LEN "${PREVIEW_ADAPT_HEX_LEN} / 2")
 math(EXPR MIGRATE_LEN "${MIGRATE_HEX_LEN} / 2")
@@ -114,6 +118,8 @@ file(APPEND "${OUT}" "constexpr uint8_t installPickerJs[] = {${INSTALL_PICKER_AR
 file(APPEND "${OUT}" "constexpr size_t installPickerJsLen = ${INSTALL_PICKER_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t semverJs[] = {${SEMVER_ARRAY}};\n")
 file(APPEND "${OUT}" "constexpr size_t semverJsLen = ${SEMVER_LEN};\n")
+file(APPEND "${OUT}" "constexpr uint8_t prismJs[] = {${PRISM_ARRAY}};\n")
+file(APPEND "${OUT}" "constexpr size_t prismJsLen = ${PRISM_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t preview3dJs[] = {${PREVIEW3D_ARRAY}};\n")
 file(APPEND "${OUT}" "constexpr size_t preview3dJsLen = ${PREVIEW3D_LEN};\n")
 file(APPEND "${OUT}" "constexpr uint8_t previewAdaptJs[] = {${PREVIEW_ADAPT_ARRAY}};\n")
diff --git a/src/ui/index.html b/src/ui/index.html
index f92b997e..beb37a2e 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -64,6 +64,9 @@
             
         
     
+    
+    
     
 
 
diff --git a/src/ui/style.css b/src/ui/style.css
index 426d1e67..a5db8322 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -1748,19 +1748,109 @@ body.cards-resizing {
 .fm-editor-path { font-family: ui-monospace, monospace; font-size: 0.9rem; color: var(--fg-muted); }
 .fm-editor-x { background: none; border: none; color: var(--fg-muted); cursor: pointer; font-size: 1rem; }
 .fm-editor-x:hover { color: var(--fg); }
+/* The editor is TWO layers in one box: a 
 that paints the syntax and a textarea that does the
+   editing, the textarea on top with transparent text so the paint shows through its caret and
+   selection. They must agree on every metric that positions a glyph, so the font, size, line
+   height, padding and whitespace handling are declared once and shared: any drift between them and
+   the colors slide off the characters. */
+/* The STACK carries the height and the resize grip, not the textarea inside it. When the textarea
+   owned its own height it could end up shorter than the stack, and every click below its real
+   bottom landed on the stack instead of the text: the editor stopped responding partway down. */
+.fm-editor-stack {
+    position: relative; display: flex; flex: 1 1 auto;
+    min-height: 120px; overflow: hidden;
+}
+/* The resize handle, ours rather than the browser's `resize`. The native gesture cannot work here:
+   the textarea is positioned over the whole stack, corner included, so the press that would start
+   the drag lands on the text instead. This is a real element above both layers, so it both draws
+   the familiar corner strokes and receives the pointer. */
+.fm-editor-grip {
+    position: absolute; right: 0; bottom: 0; width: 18px; height: 18px;
+    z-index: 3; cursor: ns-resize; touch-action: none;
+    background:
+        linear-gradient(135deg, transparent 42%, var(--fg-muted) 42%, var(--fg-muted) 56%, transparent 56%),
+        linear-gradient(135deg, transparent 70%, var(--fg-muted) 70%, var(--fg-muted) 84%, transparent 84%);
+    background-size: 12px 12px; background-position: right 3px bottom 3px;
+    background-repeat: no-repeat;
+    opacity: 0.75;
+}
+.fm-editor-grip:hover { opacity: 1; }
+.fm-editor-body, .fm-editor-hl {
+    margin: 0; padding: 12px 14px; border: none;
+    font-family: ui-monospace, monospace; font-size: 0.85rem; line-height: 1.5;
+    white-space: pre; overflow: auto; tab-size: 4;
+}
+.fm-editor-hl {
+    position: absolute; inset: 0; pointer-events: none;
+    background: var(--bg-1); color: var(--fg);
+    /* The layer never scrolls itself: the textarea owns the scroll and this follows by transform,
+       so the two cannot disagree about a scroll position. `overflow: hidden` also stops it from
+       claiming a scrollbar and shrinking its own content box below the textarea's. */
+    overflow: hidden;
+}
 .fm-editor-body {
-    flex: 1 1 auto; min-height: 240px; margin: 0; padding: 12px 14px;
-    border: none; resize: vertical; font-family: ui-monospace, monospace;
-    font-size: 0.85rem; line-height: 1.5; background: var(--bg-1); color: var(--fg);
+    position: absolute; inset: 0; width: 100%; height: 100%; resize: none;
+    background: transparent; color: transparent; caret-color: var(--fg);
+}
+/* A textarea leaves its RIGHT padding out of scrollWidth where a 
 includes it, so the two
+   boxes drift by one padding once a line is wider than the box: the caret then sits a character
+   away from the glyph under it. The paint carries its padding on the inner  instead, and the
+   layer itself has none, which makes the two geometries identical. */
+.fm-editor-hl { padding: 0; }
+.fm-editor-hl > code {
+    display: block; padding: 12px 14px;
+    font: inherit; line-height: inherit; white-space: pre; tab-size: 4;
+}
+/* The selection has to stay visible through transparent text. */
+.fm-editor-body::selection { background: var(--accent-soft, rgba(122,162,247,0.35)); color: transparent; }
+/* A file with no highlighting (a .json, a .txt) paints nothing, so the textarea shows its own text
+   and the empty layer stays out of the way. The class is set by the editor, which knows the
+   extension: deriving it from the DOM would guess at what the JS already decided. */
+/* The token colors, in the app's OWN palette rather than one of Prism's stock themes: those ship
+   their own background and their own idea of a foreground, and a card that reads as part of this UI
+   is worth more than a familiar-looking editor. Only the roles a MoonLive script actually produces
+   are named; anything else inherits --fg and simply reads as text.
+
+   Comments are muted rather than colored: a script's comments are long by design (one header line
+   and a note per control), and coloring them makes the file harder to scan, not easier. */
+.fm-editor-hl .token.comment { color: var(--fg-muted); font-style: italic; }
+.fm-editor-hl .token.keyword { color: var(--accent); }
+.fm-editor-hl .token.class-name { color: var(--yellow); }
+.fm-editor-hl .token.function { color: #7ec8e3; }
+.fm-editor-hl .token.string { color: var(--green); }
+.fm-editor-hl .token.number { color: #e08a5a; }
+/* The line a compile failed on: a band UNDER the text, not a wrapper around it. Wrapping meant
+   cutting Prism's markup at a newline, which breaks any token spanning lines; a band positioned by
+   line height cannot disturb the markup at all. It sits behind the tokens, so the syntax colors on
+   that line stay readable: the mark says WHERE, the status line says what. */
+.fm-editor-err {
+    position: absolute; left: 0; right: 0; height: 1.5em;
+    margin-top: 12px;                    /* the paint layer's own top padding, so line 0 lines up */
+    background: var(--red-soft, rgba(247,118,142,0.16));
+    box-shadow: inset 2px 0 0 var(--red, #f7768e);
+    pointer-events: none;
 }
+.fm-editor-hl .token.operator,
+.fm-editor-hl .token.punctuation { color: var(--fg-muted); }
+
+.fm-editor-stack.plain .fm-editor-body { background: var(--bg-1); color: var(--fg); }
+.fm-editor-stack.plain .fm-editor-hl { display: none; }
 .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;
+    display: flex; align-items: center; justify-content: space-between; gap: 10px;
     padding: 10px 14px; border-top: 1px solid var(--border);
 }
+/* A footer holding only the caret readout carries no rule: the row belongs to the editor, but a
+   host that supplied its own save button and status line has nothing here to separate from. */
+.fm-editor-foot:not(:has(.fm-editor-save)):not(:has(.fm-editor-status)) {
+    border-top: none; padding: 4px 14px 6px;
+}
+/* The caret readout: quiet, and on tabular figures so the numbers do not jitter as they change. */
+.fm-editor-caret {
+    margin-left: auto; color: var(--fg-muted);
+    font-family: ui-monospace, monospace; font-size: 0.75rem;
+    font-variant-numeric: tabular-nums; white-space: nowrap;
+}
 .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
@@ -1772,8 +1862,11 @@ body.cards-resizing {
     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; }
+/* The starting height, which the grip then overrides per editor (stored in mm_textareaSizes). On
+   the STACK, not the textarea: the stack owns the height, so a floor on the textarea did nothing.
+   A card is one of several on screen, so it starts shorter than the modal. */
+.fm-editor-stack { height: 240px; }
+.control-fileedit .fm-editor-stack { height: 200px; }
 .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
diff --git a/src/ui/vendor/prism.js b/src/ui/vendor/prism.js
new file mode 100644
index 00000000..138264c3
--- /dev/null
+++ b/src/ui/vendor/prism.js
@@ -0,0 +1,27 @@
+// Prism 1.29.0, vendored: core + clike + c + cpp, the minimum that highlights a MoonLive
+// script. Vendored rather than fetched from a CDN because a rig at a venue is on an isolated
+// network: the editor must look the same there as at a desk. 12 KB against app.js's 333 KB.
+// Upstream: https://prismjs.com  Regenerate by concatenating those four components.
+//
+// Prism is redistributed here under its own MIT license, reproduced in full because the code below
+// is a copy of that work rather than a reference to it:
+//
+//   MIT LICENSE
+//
+//   Copyright (c) 2012 Lea Verou
+//
+//   Permission is hereby granted, free of charge, to any person obtaining a copy of this software
+//   and associated documentation files (the "Software"), to deal in the Software without
+//   restriction, including without limitation the rights to use, copy, modify, merge, publish,
+//   distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
+//   Software is furnished to do so, subject to the following conditions:
+//
+//   The above copyright notice and this permission notice shall be included in all copies or
+//   substantial portions of the Software.
+//
+//   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
+//   BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+//   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+//   DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/};Prism.languages.c=Prism.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),Prism.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),Prism.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},Prism.languages.c.string],char:Prism.languages.c.char,comment:Prism.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:Prism.languages.c}}}}),Prism.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete Prism.languages.c.boolean;!function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n="\\b(?!)\\w+(?:\\s*\\.\\s*\\w+)*\\b".replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp("(\\b(?:class|concept|enum|struct|typename)\\s+)(?!)\\w+".replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp('(\\b(?:import|module)\\s+)(?:"(?:\\\\(?:\r\n|[^])|[^"\\\\\r\n])*"|<[^<>\r\n]*>|'+"(?:\\s*:\\s*)?|:\\s*".replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(Prism);
\ No newline at end of file
diff --git a/test/js/ui-live-patch-text.test.mjs b/test/js/ui-live-patch-text.test.mjs
index b4ac7a4e..b5bc7d5d 100644
--- a/test/js/ui-live-patch-text.test.mjs
+++ b/test/js/ui-live-patch-text.test.mjs
@@ -39,7 +39,8 @@ function functionBody(name) {
 // back twice after being "fixed" because the audit was per-function: the status bar and the tab
 // dot are patched on the same tick and were missed. Adding a function to updateValues without
 // adding it here is the gap this list closes.
-for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot"]) {
+for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot",
+                  "setStatusText"]) {
     test(`${fn} writes text only through setText, so a selection survives the patch`, () => {
         const body = functionBody(fn);
         const offenders = body
@@ -114,7 +115,7 @@ for (const fn of ["updateValues", "updateModuleControls", "updateStatusBar", "ap
 // calls into (our own, not DOM builtins) is one this file checks.
 test("every function on the patch path is covered by this file", () => {
     const checked = ["updateValues", "updateModuleControls", "updateStatusBar", "applyTabDot",
-                     "updateTabDot", "setText", "setUrlDisplay"];
+                     "updateTabDot", "setText", "setUrlDisplay", "setStatusText"];
     const keywords = ["if", "for", "while", "switch", "catch", "return", "typeof"];
     const dom = ["querySelector", "querySelectorAll", "createElement", "appendChild",
                  "insertBefore", "toggle", "setAttribute", "getAttribute", "remove", "closest",
diff --git a/test/js/ui-picker-scripts.test.mjs b/test/js/ui-picker-scripts.test.mjs
new file mode 100644
index 00000000..df0c52d3
--- /dev/null
+++ b/test/js/ui-picker-scripts.test.mjs
@@ -0,0 +1,171 @@
+// The module picker offers scripts and compiled modules as one list.
+//
+// To a user, adding `dot` and adding `DemoReel` are the same gesture: one is a MoonLive script and
+// the other is compiled, which is a property of the row rather than a category of its own. So the
+// picker merges them, sorts them together, and marks the scripted ones. These tests pin the three
+// things that make that readable: the marker, the ordering, and the two filter chips.
+//
+// Read out of app.js rather than imported: the file is a browser script with no module boundary,
+// and the alternative (a DOM harness driving the real picker) proved far slower to steer than the
+// logic is to check directly.
+//
+// Run: `node --test test/js`.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+const src = readFileSync(new URL("../../src/ui/app.js", import.meta.url), "utf8");
+
+/// A top-level function's source, by brace matching, `async` prefix included.
+function fnSource(name) {
+    const at = src.indexOf(`function ${name}(`);
+    assert.notEqual(at, -1, `${name} not found in app.js`);
+    const from = src.startsWith("async ", at - 6) ? at - 6 : at;
+    const open = src.indexOf("{", at);
+    let depth = 0;
+    for (let i = open; i < src.length; i++) {
+        if (src[i] === "{") depth++;
+        else if (src[i] === "}" && --depth === 0) return src.slice(from, i + 1);
+    }
+    assert.fail(`unbalanced braces in ${name}`);
+}
+
+const SCRIPTED = "\u{1F4DD}";
+const COMPILED = "\u{1F4E6}";      // matches the source: NOT the gear, which is the generic role
+
+const CATALOG = {
+    dir: "/.moonlive",
+    effects: { names: ["balls.mle", "aim.mle"], tags: ["\u{1F4AB}", "\u{1F4AB}"], dim: [2, 2] },
+    layouts: { names: ["grid.mll"], tags: [""], dim: [2] },
+    modifiers: { names: [], tags: [], dim: [] },
+};
+
+/// mlScriptItems, wired to a fixed catalog and a device holding only balls.mle.
+function scriptItems(roles) {
+    const build = new Function(
+        "SCRIPTED_EMOJI", "COMPILED_EMOJI", "mlFetchCatalog", "fmFetchDir",
+        `${fnSource("mlTypeForRole")}\n${fnSource("mlScriptItems")}\nreturn mlScriptItems;`);
+    return build(SCRIPTED, COMPILED,
+                 async () => CATALOG,
+                 async () => [{ name: "balls.mle" }])(roles);
+}
+
+test("a script becomes a picker row named after the file, without its extension", async () => {
+    const items = await scriptItems(["effect"]);
+    assert.deepEqual(items.map(i => i.script).sort(), ["aim.mle", "balls.mle"]);
+    const balls = items.find(i => i.script === "balls.mle");
+    // The name a card would take, and the file it would load: the user picks "balls", not "balls.mle".
+    assert.equal(balls.displayName, "balls");
+    assert.equal(balls.role, "effect");
+});
+
+test("every script row carries the scripted marker, whatever its own tags say", async () => {
+    const items = await scriptItems(["effect"]);
+    for (const i of items) assert.ok(i.tags.includes(SCRIPTED), `${i.script} is unmarked`);
+});
+
+test("a script the device does not hold is marked as a download before it is chosen", async () => {
+    const items = await scriptItems(["effect"]);
+    const aim = items.find(i => i.script === "aim.mle");     // absent from the device
+    const balls = items.find(i => i.script === "balls.mle"); // present
+    assert.equal(aim.remote, true);
+    assert.equal(balls.remote, false);
+    assert.ok(aim.displayName.startsWith("☁"), "a remote row says so before it is picked");
+});
+
+test("only the roles a parent accepts are offered", async () => {
+    const effects = await scriptItems(["effect"]);
+    assert.ok(effects.every(i => i.role === "effect"));
+    // A role with no scripted form contributes nothing rather than throwing.
+    assert.deepEqual(await scriptItems(["driver"]), []);
+});
+
+test("scripts and compiled modules sort as one alphabetical list, markers ignored", async () => {
+    // The picker's own sort key, which strips a leading marker so a prefixed row still sorts by its
+    // name: without this the cloud rows collect in a block instead of sitting where they are looked for.
+    const sortKey = (t) => (t.displayName || t.name).replace(/^[^\p{L}\p{N}]+/u, "");
+    const compiled = [
+        { name: "DemoReel", displayName: "DemoReel", role: "effect", tags: "" },
+        { name: "SolidEffect", displayName: "Solid", role: "effect", tags: "" },
+    ];
+    const merged = [...compiled, ...(await scriptItems(["effect"]))]
+        .sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
+    assert.deepEqual(merged.map(m => sortKey(m)), ["aim", "balls", "DemoReel", "Solid"]);
+});
+
+test("the two kind chips partition the list, the compiled one matching by absence", async () => {
+    const compiled = [{ name: "DemoReel", displayName: "DemoReel", role: "effect", tags: "" }];
+    const merged = [...compiled, ...(await scriptItems(["effect"]))];
+    // A compiled module carries no marker of its own, so its chip matches what LACKS the scripted
+    // one. That is what keeps ninety existing modules from needing a new tag.
+    const isScripted = (m) => (m.tags || "").includes(SCRIPTED);
+    const scripted = merged.filter(isScripted);
+    const notScripted = merged.filter(m => !isScripted(m));
+    assert.equal(scripted.length, 2);
+    assert.equal(notScripted.length, 1);
+    assert.equal(scripted.length + notScripted.length, merged.length, "the chips partition the list");
+});
+
+test("the picker's chip groups lead with the two kinds", () => {
+    // Order is the legend's: what a row IS, then role, then dimension, then origin. A reader scanning
+    // the chip row should meet scripted/compiled first, because that is the coarsest split.
+    const groups = src.slice(src.indexOf("const CHIP_GROUPS = ["));
+    const firstGroup = groups.slice(0, groups.indexOf("]", groups.indexOf("[", 20)) + 1);
+    assert.ok(firstGroup.includes("SCRIPTED_EMOJI") && firstGroup.includes("COMPILED_EMOJI"),
+              "the first chip group is the scripted/compiled pair");
+});
+
+test("a replace always renames the card, whichever kind of row was picked", () => {
+    // A card is named after what it RUNS, so swapping what it runs renames it. Both branches of
+    // replacePickedType pass a name: the compiled one was the bug (a card auto-named "MoonLive-3"
+    // stayed "MoonLive-3" after becoming a Lissajous), because the device preserves any name that is
+    // not the old type's default and cannot tell a generated name from a chosen one.
+    const body = fnSource("replacePickedType");
+    const compiled = body.slice(body.indexOf("if (!item.script)"));
+    assert.match(compiled.slice(0, 120), /replaceModule\([^)]*picked\)/,
+                 "the compiled branch must pass the picked name");
+    assert.match(body.slice(body.indexOf("mlTypeForRole")), /replaceModule\([^)]*picked\)/,
+                 "the script branch must pass the picked name");
+    // And the name is the row's, with the cloud marker stripped: that glyph says a download is
+    // coming, it is not part of what the card is called.
+    assert.match(body, /displayName\.replace\(\/\^\\u2601/,
+                 "the cloud marker is stripped before the name is used");
+});
+
+test("pointing a card at a script re-renders it, so a failing script still shows its source", () => {
+    // A card is built BEFORE its script is set: created or replaced first, pointed at the file
+    // second. Something has to rebuild it, or the picker keeps reading "(none)" over an empty editor.
+    //
+    // A script that COMPILES hides the gap, which is why this needs pinning: defining controls
+    // changes the module's schema, the device fires a full resync, and the card is rebuilt for free.
+    // A script that FAILS defines nothing, the schema signature is unchanged, so only the status text
+    // arrives and the card ends up reporting an error about a script it claims not to have. Worse, the
+    // editor holds no text, so the error's offset converts against nothing and reads "line 1, col 1".
+    const body = fnSource("setCardScript");
+    assert.match(body, /sendControl\([^)]*"script"/, "it sets the script control");
+    assert.match(body, /refetchState\(\)/, "and re-renders, which is the whole point");
+    assert.ok(body.indexOf("sendControl") < body.indexOf("refetchState"),
+              "the re-render must come after the value it is meant to show");
+
+    // Both entry points go through it: a fix in one path only is the bug waiting to come back.
+    assert.match(fnSource("addPickedType"), /setCardScript\(/, "the add path uses it");
+    assert.match(fnSource("replacePickedType"), /setCardScript\(/, "the replace path uses it");
+});
+
+test("a replaced card is found by POSITION, so a name collision still reaches the right module", () => {
+    // The device keeps names unique, so replacing a card with a script whose name is already taken
+    // in that layer gives it a suffix ("dot-2"). Everything after the replace therefore has to find
+    // the slot WITHOUT its old name, which no longer exists, and without assuming it got the name it
+    // asked for: looking it up by the requested name matched the OTHER card and sent the script
+    // there, and looking the parent up by the old name found nothing at all, so the script was never
+    // applied. A replace swaps a child in place, so its index is the one handle that survives.
+    const body = fnSource("replacePickedType");
+    const replaceAt = body.indexOf("await replaceModule(");
+    assert.ok(body.indexOf("findParentOf(targetMod.name)") < replaceAt,
+              "the parent must be captured BEFORE the replace, while the old name still resolves");
+    assert.ok(body.indexOf("findIndex(") < replaceAt,
+              "and so must the index, for the same reason");
+    assert.match(body.slice(replaceAt), /children\[index\]/,
+                 "afterwards the slot is read at that index, not by a name that may not be its own");
+});
diff --git a/test/js/ui-selected-root.test.mjs b/test/js/ui-selected-root.test.mjs
new file mode 100644
index 00000000..8d604396
--- /dev/null
+++ b/test/js/ui-selected-root.test.mjs
@@ -0,0 +1,84 @@
+// The root a user was last on survives a refresh, whichever state arrives first.
+//
+// Two payloads race on load: the WebSocket full state and the /api/state fetch. Both set `state` and
+// both trigger the first render, so either can be the one that decides what is on screen. Only the
+// fetch used to consult the saved selection, so when the socket won, the selection stayed null and
+// renderCards fell back to the first root: a refresh with a Layer or the File Manager open landed on
+// Control instead. Intermittent exactly as a race is, and it healed after any nav click, which sets
+// the selection in memory and hides the bug for the rest of the session.
+//
+// So this pins the rule at its source: both arrival paths restore before the render that reads it.
+//
+// Run: `node --test test/js`.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+const src = readFileSync(new URL("../../src/ui/app.js", import.meta.url), "utf8");
+
+/// A top-level function's source, by brace matching.
+function fnSource(name) {
+    const at = src.indexOf(`function ${name}(`);
+    assert.notEqual(at, -1, `${name} not found in app.js`);
+    const open = src.indexOf("{", at);
+    let depth = 0;
+    for (let i = open; i < src.length; i++) {
+        if (src[i] === "{") depth++;
+        else if (src[i] === "}" && --depth === 0) return src.slice(at, i + 1);
+    }
+    assert.fail(`unbalanced braces in ${name}`);
+}
+
+/// restoreSelectedRoot, given a tree and what localStorage holds.
+function restore({ modules, saved, already = null }) {
+    const build = new Function("state", "lsRead", "LS_SELECTED", "selectedModuleIn", `
+        let selectedModule = selectedModuleIn;
+        ${fnSource("restoreSelectedRoot")}
+        restoreSelectedRoot();
+        return selectedModule;`);
+    return build({ modules }, () => saved, "mm_selectedRoot", already);
+}
+
+const TREE = [{ name: "Control" }, { name: "Layouts" }, { name: "File Manager" }, { name: "System" }];
+
+test("the saved root is restored, so a refresh returns to where the user was", () => {
+    assert.equal(restore({ modules: TREE, saved: "File Manager" }), "File Manager");
+    assert.equal(restore({ modules: TREE, saved: "Layouts" }), "Layouts");
+});
+
+test("a choice made this session is not overwritten by what was saved earlier", () => {
+    // The user clicked System a moment ago; a later full state must not send them back.
+    assert.equal(restore({ modules: TREE, saved: "File Manager", already: "System" }), "System");
+});
+
+test("a saved root that is no longer in the tree is ignored, leaving the fallback to choose", () => {
+    // A module deleted since the last visit, or a config from another device.
+    assert.equal(restore({ modules: TREE, saved: "Gone" }), null);
+    assert.equal(restore({ modules: TREE, saved: null }), null);
+});
+
+test("an empty or absent tree leaves the selection alone rather than guessing", () => {
+    assert.equal(restore({ modules: [], saved: "File Manager" }), null);
+});
+
+test("both state arrivals restore the selection before the render that reads it", () => {
+    // The actual bug: the WebSocket handler set `state` and rendered without consulting the saved
+    // root, so whichever payload won the race decided whether the user's tab survived. Checking the
+    // call sites, because a correct restoreSelectedRoot that only one path calls is the bug itself.
+    const wsAt = src.indexOf("if (!Array.isArray(data.modules)) return;");
+    assert.notEqual(wsAt, -1, "the websocket full-state handler moved");
+    // To the RENDER, not to the first setTargetFps: the mid-edit early-return above it mentions the
+    // same call, and slicing there cut the block off before the lines under test.
+    const wsBlock = src.slice(wsAt, src.indexOf("renderNav()", wsAt));
+    const restoreAt = wsBlock.indexOf("restoreSelectedRoot()");
+    const renderAt = wsBlock.indexOf("renderCards()");
+    assert.notEqual(restoreAt, -1, "the websocket path must restore the saved root");
+    assert.ok(restoreAt < renderAt, "restore must run BEFORE the render that reads the selection");
+
+    // And the fetch path, which is the one that always had it.
+    const httpAt = src.indexOf("const snap = await resp.json();");
+    assert.notEqual(httpAt, -1, "the /api/state fetch moved");
+    const httpBlock = src.slice(httpAt, src.indexOf("renderCards()", httpAt));
+    assert.ok(httpBlock.includes("restoreSelectedRoot()"), "the fetch path must restore too");
+});
diff --git a/test/python/test_scripts_are_cpp.py b/test/python/test_scripts_are_cpp.py
new file mode 100644
index 00000000..29120fbf
--- /dev/null
+++ b/test/python/test_scripts_are_cpp.py
@@ -0,0 +1,116 @@
+"""Every shipped MoonLive script is valid C++.
+
+MoonLive is documented as a subset of C++, and that claim is worth more than a description: it is
+what lets someone read a script without learning a language, and what keeps the grammar from
+drifting into a dialect one feature at a time. A claim nothing checks is a claim that decays.
+
+So each script is wrapped in a generated prelude and handed to a real C++ compiler. The prelude
+declares the vocabulary (the builtins, the system variables, the type aliases) and nothing else:
+the SCRIPT's own text is compiled unmodified except for the one shape difference the language
+deliberately has, applied mechanically here and listed in the language reference:
+
+  - a class needs `public:` and a trailing semicolon in C++
+
+Anything else a compiler rejects is a real divergence, and this test is where it surfaces.
+
+The prelude is GENERATED from the engine's own builtin table rather than hand-written, so a builtin
+added to the language cannot leave this test asserting against a stale vocabulary. Signatures are
+`int` throughout: this is a syntax check (`-fsyntax-only`), never a run, so the shapes that matter
+are the arity and the name.
+
+Skips when no C++ compiler is on PATH, which is the case in a bare CI container. Run:
+`uv run --with pytest pytest test/python/test_scripts_are_cpp.py -q`.
+"""
+
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+BUILTINS = ROOT / "src" / "light" / "moonlive" / "MoonLiveBuiltins_light.h"
+SYSVARS = ("t", "width", "height", "depth", "xPos", "yPos", "zPos")
+
+CXX = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++")
+pytestmark = pytest.mark.skipif(CXX is None, reason="no C++ compiler on PATH")
+
+
+def builtins():
+    """Every builtin the light vocabulary registers: (name, argc).
+
+    Read from the registration table itself. A hand-kept list here would pass while the engine
+    moved on, which is the exact failure this test exists to prevent one level down.
+    """
+    text = BUILTINS.read_text(encoding="utf-8")
+    found = re.findall(r't\.add\(\{"([A-Za-z0-9_]+)",\s*(\d+)', text)
+    assert found, "no builtins parsed: the registration shape changed"
+    # A name can register twice (an overload by arity); keep the widest, since a call with fewer
+    # arguments still matches a declaration with more only if defaults exist, which these lack.
+    widest: dict[str, int] = {}
+    for name, argc in found:
+        widest[name] = max(widest.get(name, 0), int(argc))
+    return sorted(widest.items())
+
+
+def prelude() -> str:
+    lines = [
+        "#include ",
+        "using byte = uint8_t;",
+        "using fixed = int32_t;",
+        "using string = const char*;",
+    ]
+    for v in SYSVARS:
+        lines.append(f"inline int {v} = 0;")
+    for name, argc in builtins():
+        args = ", ".join(["int"] * argc)
+        lines.append(f"int {name}({args});")
+    # toFixed/toInt are KEYWORDS, not builtins: the compiler recognizes them inline so each costs
+    # one shift instruction rather than a host call (MoonLiveCompiler.cpp), so the registration
+    # table above does not carry them.
+    lines.append("int toFixed(int); int toInt(int);")
+    # addControl binds a member BY REFERENCE and takes its label as a string, so the generated
+    # int-only declaration cannot express it. Both member widths, spelled out.
+    lines.append("void addControl(string, int&, int, int);")
+    lines.append("void addControl(string, byte&, int, int);")
+    lines.append("void addControl(string, bool&);")
+    return "\n".join(lines) + "\n"
+
+
+def as_cpp(script: str) -> str:
+    """One script, as the C++ it claims to be."""
+    src = re.sub(r"^(class \w+ \{)", r"\1\npublic:", script, count=1, flags=re.M)
+    return src.rstrip() + ";\n"
+
+
+def scripts():
+    out = sorted((ROOT / "moonlive").rglob("*.ml*"))
+    assert out, "no scripts found: this test would pass without checking anything"
+    return out
+
+
+@pytest.mark.parametrize("script", scripts(), ids=lambda p: p.name)
+def test_script_compiles_as_cpp(script, tmp_path):
+    cpp = tmp_path / "probe.cpp"
+    (tmp_path / "prelude.h").write_text(prelude(), encoding="utf-8")
+    cpp.write_text('#include "prelude.h"\n' + as_cpp(script.read_text(encoding="utf-8")),
+                   encoding="utf-8")
+    r = subprocess.run([CXX, "-std=c++20", "-fsyntax-only", str(cpp)],
+                       capture_output=True, text=True, cwd=tmp_path)
+    assert r.returncode == 0, f"{script.name} is not valid C++:\n{r.stderr}"
+
+
+def test_the_check_can_fail(tmp_path):
+    """A control: a script with a real C++ error must be rejected.
+
+    Without this the suite above is indistinguishable from one where the compiler never ran, which
+    is the failure mode a green test cannot show on its own.
+    """
+    (tmp_path / "prelude.h").write_text(prelude(), encoding="utf-8")
+    cpp = tmp_path / "bad.cpp"
+    cpp.write_text('#include "prelude.h"\nclass T {\npublic:\n  void tick() { nosuchcall(1); }\n};\n',
+                   encoding="utf-8")
+    r = subprocess.run([CXX, "-std=c++20", "-fsyntax-only", str(cpp)],
+                       capture_output=True, text=True, cwd=tmp_path)
+    assert r.returncode != 0, "the compiler accepted an undeclared call: this check proves nothing"
diff --git a/test/scenarios/light/scenario_MoonLive_pipeline.json b/test/scenarios/light/scenario_MoonLive_pipeline.json
index cec0e461..7435409c 100644
--- a/test/scenarios/light/scenario_MoonLive_pipeline.json
+++ b/test/scenarios/light/scenario_MoonLive_pipeline.json
@@ -359,7 +359,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 24; i = i + 1) { addLight(i, 0, 0); } } }\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 24; i = i + 1) { addLight(i, 0, 0); } } }\n"
     },
     {
       "name": "layout-a-row",
@@ -817,7 +817,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 4; i = i + 1) { addLight(i, i\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 4; i = i + 1) { addLight(i, i\n"
     },
     {
       "name": "break-the-layout-resync",
@@ -968,7 +968,7 @@
       "description": "Write the script file the card points at, the way the editor saves it.",
       "op": "write_file",
       "path": "/moonlive/sc-row.mll",
-      "value": "class L { placeLights() { for (i = 0; i < 12; i = i + 1) { addLight(i, 0, 0); } } }\n"
+      "value": "class L { placeLights() { for (int i = 0; i < 12; i = i + 1) { addLight(i, 0, 0); } } }\n"
     },
     {
       "name": "recover-the-layout-resync",
diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc
index 03e82fe8..194bcbdd 100644
--- a/test/unit/core/moonlive_device_codegen.inc
+++ b/test/unit/core/moonlive_device_codegen.inc
@@ -47,8 +47,8 @@ const char* kGridLayout =
     "  byte cols = 16;\n"
     "  byte rows = 16;\n"
     "  void tick() {\n"
-    "    for (y = 0; y < rows; y = y + 1) {\n"
-    "      for (x = 0; x < cols; x = x + 1) {\n"
+    "    for (int y = 0; y < rows; y = y + 1) {\n"
+    "      for (int x = 0; x < cols; x = x + 1) {\n"
     "        addLight(x, y, 0);\n"
     "      }\n"
     "    }\n"
@@ -62,7 +62,7 @@ const char* kEffectSimple =
     "class SimpleEffect {\n  void tick() { setRGB(0, 255, 0, 0); }\n}\n";
 const char* kEffectLoop =
     "class LoopEffect {\n"
-    "  void tick() { for (i = 0; i < 64; i = i + 1) { setRGB(i, i, 255 - i, 60); } }\n"
+    "  void tick() { for (int i = 0; i < 64; i = i + 1) { setRGB(i, i, 255 - i, 60); } }\n"
     "}\n";
 
 /// Compile for THIS TU's backend. `sysvars` is the ONE light vocabulary; the role-named
@@ -132,7 +132,7 @@ TEST_CASE("a scripted effect compiles for " MM_ISA_NAME ", straight-line and loo
 // that pushes any backend back over the edge shows up here first.
 TEST_CASE("fill plus a loop on " MM_ISA_NAME " emits what it currently can") {
     bool ok = false;
-    CHECK(emitLen(mmScript("fill(0,0,0);\nfor (y = 0; y < height; y = y + 1) { setRGB(y, 255, 0, 0); }"),
+    CHECK(emitLen(mmScript("fill(0,0,0);\nfor (int y = 0; y < height; y = y + 1) { setRGB(y, 255, 0, 0); }"),
                   mm::moonlive::effectSysVars(), ok) == MM_GOLD_FILLLOOP_LEN);
 }
 
diff --git a/test/unit/core/moonlive_structural.inc b/test/unit/core/moonlive_structural.inc
index c66179e0..544a923b 100644
--- a/test/unit/core/moonlive_structural.inc
+++ b/test/unit/core/moonlive_structural.inc
@@ -32,8 +32,8 @@ TEST_CASE("emitted " MM_ISA_NAME " code keeps every frame offset inside the fram
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
         {"a bare write",      mmScript("setRGB(0, 255, 0, 0);\n"), 1},
-        {"a loop",            mmScript("for (i = 0; i < 3; i = i + 1) { }\n"), 0},
-        {"a call in a loop",  mmScript("for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
+        {"a loop",            mmScript("for (int i = 0; i < 3; i = i + 1) { }\n"), 0},
+        {"a call in a loop",  mmScript("for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
         {"the shipped grid",  kGridLayout, 0},
         // Several routines in one block, which is what a class emits. Every function after the
         // first is what a checker reading only the prologue at byte 0 would never look at.
@@ -101,11 +101,11 @@ TEST_CASE("emitted " MM_ISA_NAME " code keeps every frame offset inside the fram
 TEST_CASE("emitted " MM_ISA_NAME " code branches only to instruction boundaries") {
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
-        {"one loop",     mmScript("for (i = 0; i < 3; i = i + 1) { }\n"), 0},
+        {"one loop",     mmScript("for (int i = 0; i < 3; i = i + 1) { }\n"), 0},
         {"nested loops", kGridLayout, 0},
         // Long enough that a short-displacement branch would have to be relaxed to reach.
         {"a long loop body",
-         mmScript("for (i = 0; i < 8; i = i + 1) {\n"
+         mmScript("for (int i = 0; i < 8; i = i + 1) {\n"
          "  addLight(i, 0, 0); addLight(i, 1, 0); addLight(i, 2, 0); addLight(i, 3, 0);\n"
          "  addLight(i, 4, 0); addLight(i, 5, 0); addLight(i, 6, 0); addLight(i, 7, 0);\n"
          "}\n"), 0},
@@ -169,18 +169,18 @@ TEST_CASE("emitted " MM_ISA_NAME " code reads no register a call destroyed") {
     struct Case { const char* name; const char* src; int binding; };
     const Case cases[] = {
         {"one call",            mmScript("setRGB(0, random16(256), 0, 0);\n"), 1},
-        {"a call in a loop",    mmScript("for (i = 0; i < 4; i = i + 1) { setRGB(i, random16(256), 0, 0); }\n"), 1},
+        {"a call in a loop",    mmScript("for (int i = 0; i < 4; i = i + 1) { setRGB(i, random16(256), 0, 0); }\n"), 1},
         {"nested calls",        mmScript("setRGB(random16(256), random16(256), random16(256), random16(256));\n"), 1},
-        {"a call in a layout",  mmScript("for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
+        {"a call in a layout",  mmScript("for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }\n"), 0},
         {"the shipped grid",    kGridLayout, 0},
         // 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},
+         mmScript("for (int x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
         {"member bound + call in body",
          mmScript("byte n = 8;\n"
-         "for (x = 0; x < n; x = x + 1) { setRGB(x, random16(256), 0, 0); }\n"), 1},
+         "for (int 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},
+         mmScript("for (int x = 0; x < 4; x = x + 1) { setRGB(x, width, random16(256), 0); }\n"), 1},
     };
     for (const auto& c : cases) {
         bool ok = false;
diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp
index 7e9318d8..13a69db3 100644
--- a/test/unit/core/unit_HttpServerModule_apply.cpp
+++ b/test/unit/core/unit_HttpServerModule_apply.cpp
@@ -8,6 +8,7 @@
 #include "core/JsonSink.h"
 
 #include 
+#include     // std::string: named explicitly, GCC does not pull it in transitively
 
 // Pins the transport-free apply-core that HttpServerModule exposes β€” applyAddModule
 // / applySetControl / applyClearChildren / applyOp. These are the operations the
@@ -588,6 +589,27 @@ TEST_CASE("a burst of file writes costs one re-derive, not one per file") {
     CHECK(root->prepared == before + 1);
 }
 
+TEST_CASE("a replaced module is named by the caller, then by its old custom name, then by its type") {
+    using H = mm::HttpServerModule;
+
+    // A caller that knows what the slot now holds names it. This is what keeps a card swapped to a
+    // different MoonLive script from staying labeled after the old script: the UI asks for the new
+    // script's name, and it wins over both defaults.
+    CHECK(std::string(H::replacementName("dot", "balls", "MoonLive")) == "dot");
+    CHECK(std::string(H::replacementName("dot", "MoonLive", "MoonLive")) == "dot");
+
+    // No request: a name the user or a scenario chose survives a type swap, so the slot keeps its
+    // identity and callers can still address it.
+    CHECK(std::string(H::replacementName(nullptr, "MOD", "Multiply")) == "MOD");
+    CHECK(std::string(H::replacementName("", "MOD", "Multiply")) == "MOD");
+
+    // No request and no custom name: null means "leave it", so the fresh module keeps the default
+    // its OWN type gave it. Without this a Multiply replaced by a Checkerboard would read as a
+    // mislabeled "Multiply".
+    CHECK(H::replacementName(nullptr, "Multiply", "Multiply") == nullptr);
+    CHECK(H::replacementName("", "Multiply", "Multiply") == nullptr);
+}
+
 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).
diff --git a/test/unit/core/unit_moonlive_codegen_x86_64.cpp b/test/unit/core/unit_moonlive_codegen_x86_64.cpp
index 7630f78b..113359d2 100644
--- a/test/unit/core/unit_moonlive_codegen_x86_64.cpp
+++ b/test/unit/core/unit_moonlive_codegen_x86_64.cpp
@@ -556,8 +556,8 @@ TEST_CASE("x86_64: two sequential call-bearing loops stay under the density boun
     // backend's density canary.
     const char* src =
         "class T { void tick() { "
-        "for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } "
-        "for (i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); } "
+        "for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } "
+        "for (int i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); } "
         "} }\n";
     uint8_t out[2048];
     auto r = mm::moonlive::compileSource(src, mm::moonlive::lightBuiltins(),
diff --git a/test/unit/core/unit_moonlive_compiler.cpp b/test/unit/core/unit_moonlive_compiler.cpp
index 8e213a84..14a4f38a 100644
--- a/test/unit/core/unit_moonlive_compiler.cpp
+++ b/test/unit/core/unit_moonlive_compiler.cpp
@@ -376,9 +376,9 @@ TEST_CASE("a script cannot declare a name the engine already defines") {
     const Case refused[] = {
         {mmScript("byte width = 16;\nsetRGB(0, 0, 0, 0);"), "a control named width"},
         {mmScript("byte t = 5;\nsetRGB(0, 0, 0, 0);"),                        "a control named t"},
-        {mmScript("for (xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"),
+        {mmScript("for (int xPos = 0; xPos < 4; xPos = xPos + 1) { setRGB(xPos, 0, 0, 0); }"),
                                                                         "a loop variable named xPos"},
-        {mmScript("for (height = 0; height < 4; height = height + 1) { setRGB(0, 0, 0, 0); }"),
+        {mmScript("for (int height = 0; height < 4; height = height + 1) { setRGB(0, 0, 0, 0); }"),
                                                                         "a loop variable named height"},
     };
     for (const Case& c : refused) {
@@ -417,13 +417,13 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
     // unit_moonlive_codegen_x86_64.cpp's canary.
     uint8_t out[1024];
     auto r = moonlive::compileSource(
-        mmScript("for (i = 0; i < 2; i = i + 1) { for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } }"),
+        mmScript("for (int i = 0; i < 2; i = i + 1) { for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } }"),
         kTable, kSys, out, sizeof(out));
     CHECK_FALSE(r.ok);
     CHECK(std::string(r.error) == "loop variable already in use");
     // Distinct names nest fine β€” the check must not refuse the ordinary case it exists to protect.
     auto ok = moonlive::compileSource(
-        mmScript("for (yy = 0; yy < 2; yy = yy + 1) { for (xx = 0; xx < 2; xx = xx + 1) { addLight(xx, yy, 0); } }"),
+        mmScript("for (int yy = 0; yy < 2; yy = yy + 1) { for (int xx = 0; xx < 2; xx = xx + 1) { addLight(xx, yy, 0); } }"),
         kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(ok.ok);
@@ -433,7 +433,7 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
     // Sequential loops REUSE a name legitimately: the first has left scope by the time the second
     // 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); }"),
+        mmScript("for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); } for (int i = 0; i < 2; i = i + 1) { addLight(i, 1, 0); }"),
         kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(seq.ok);
@@ -445,21 +445,76 @@ TEST_CASE("a nested loop cannot reuse the enclosing loop's variable") {
 // The emitted loop tests and advances its OWN counter whatever name the condition and step clauses
 // write, so a mistyped name used to compile clean and run as though it said the right thing β€” a
 // wrong fixture with no diagnostic anywhere. Found by review.
+TEST_CASE("a compile error reports the exact offset the editor turns into a line and column") {
+    uint8_t out[512];
+    // The offset is the ONLY position anyone has: the editor slices the source up to it to find the
+    // failing line, and marks that line in the paint layer. It is therefore ZERO-based, counted in
+    // characters from the start of the whole source, while Lexer::col() is one-based; the conversion
+    // happens where the two conventions meet. An off-by-one here marks the wrong line whenever a
+    // failure lands on the first character of one.
+    const char* src =
+        "class T {\n"                       // line 1, offsets 0..9
+        "  byte b = 1;\n"                   // line 2, offsets 10..24
+        "  void tick() { fill(0, 0, 0; }\n"  // line 3: the missing ')' is here
+        "}\n";
+    auto r = moonlive::compileSource(src, kTable, kSys, out, sizeof(out));
+    REQUIRE_FALSE(r.ok);
+    CHECK(std::string(r.error) == "expected ')'");
+
+    // What the light domain publishes, and what the editor reads back.
+    moonlive::MoonLive eng;
+    CHECK_FALSE(eng.compile(src, kTable, kSys));
+    REQUIRE(eng.hasErrorPos());
+    const size_t at = eng.errorPos();
+    REQUIRE(at < std::strlen(src));
+
+    // The line and column a reader counts, derived the way the editor derives them.
+    const std::string upto(src, at);
+    const size_t line = std::count(upto.begin(), upto.end(), '\n') + 1;
+    const size_t nl = upto.rfind('\n');
+    const size_t col = upto.size() - (nl == std::string::npos ? 0 : nl + 1) + 1;
+    INFO("reported offset " << at << " -> line " << line << ", col " << col);
+    // The EXACT position, not merely somewhere on the right line: an off-by-one in the one-based
+    // to zero-based conversion keeps the line and still lands inside it, so a loose assertion
+    // passes while the editor marks a character that is not the one the parser choked on.
+    CHECK(line == 3);
+    CHECK(col == 29);
+    CHECK(src[at] == ';');   // the ';' written where a ')' belongs
+}
+
+TEST_CASE("a for loop declares its counter, as every other variable in the language does") {
+    uint8_t out[512];
+    // The rule the language already held everywhere else: a member carries its type and an
+    // assignment to an undeclared name is refused, so a counter appearing out of nowhere was the
+    // last exception. Requiring `int` also makes the loop header identical to the C++ one, which is
+    // what test/python/test_scripts_are_cpp.py compiles every shipped script as.
+    auto bare = moonlive::compileSource(
+        mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        kTable, kSys, out, sizeof(out));
+    CHECK_FALSE(bare.ok);
+    CHECK(std::string(bare.error) == "a loop counter is declared: for (int i = 0; ...)");
+
+    auto declared = moonlive::compileSource(
+        mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        kTable, kSys, out, sizeof(out));
+    CHECK(declared.ok);
+}
+
 TEST_CASE("a for loop's condition and step must name the loop variable") {
     uint8_t out[512];
     struct Case { const char* src; const char* err; const char* what; };
     const Case refused[] = {
-        {mmScript("for (i = 0; j < 3; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; j < 3; i = i + 1) { addLight(i, 0, 0); }"),
          "the condition must test the loop variable", "a typo in the condition"},
-        {mmScript("for (i = 0; i < 3; j = j + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; i < 3; j = j + 1) { addLight(i, 0, 0); }"),
          "the step must advance the loop variable",   "a typo in the step"},
         // Plain names, not x/y: those are system variables in this table and would be refused a
         // step earlier, hiding what this case is about.
-        {mmScript("for (a = 0; a < 4; a = a + 1) { for (b = 0; a < 4; b = b + 1) { addLight(b, a, 0); } }"),
+        {mmScript("for (int a = 0; a < 4; a = a + 1) { for (int b = 0; a < 4; b = b + 1) { addLight(b, a, 0); } }"),
          "the condition must test the loop variable", "an inner loop testing the OUTER variable"},
         // The step is re-lexed from the source it was skipped over, and an expression parser stops
         // at the first token it cannot use β€” so trailing junk was silently dropped.
-        {mmScript("for (i = 0; i < 3; i = i + 1 garbage) { addLight(i, 0, 0); }"),
+        {mmScript("for (int i = 0; i < 3; i = i + 1 garbage) { addLight(i, 0, 0); }"),
          "unexpected token in the for's step", "trailing junk after the step expression"},
     };
     for (const Case& c : refused) {
@@ -469,7 +524,7 @@ TEST_CASE("a for loop's condition and step must name the loop variable") {
         CHECK(std::string(r.error) == c.err);
     }
     // The ordinary loop is untouched.
-    auto ok = moonlive::compileSource(mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+    auto ok = moonlive::compileSource(mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
                                       kTable, kSys, out, sizeof(out));
 #if MM_MOONLIVE_HAS_HOST_JIT
     CHECK(ok.ok);
@@ -740,7 +795,7 @@ TEST_CASE("a brightness that went below zero renders black rather than full") {
 // The loop guard deliberately stayed UNSIGNED when comparisons went signed: a loop counter is a
 // count, and `for (i = 0; i < width; ...)` must run whatever a signed reading would make of it.
 TEST_CASE("a loop over a count still runs every step after comparisons became signed") {
-    auto px = render(mmScript("for (i = 0; i < 4; i = i + 1) { setRGB(i, 9, 0, 0); }"), 4);
+    auto px = render(mmScript("for (int i = 0; i < 4; i = i + 1) { setRGB(i, 9, 0, 0); }"), 4);
     CHECK(px[0] == 9);
     CHECK(px[3 * 3] == 9);
 }
@@ -1148,7 +1203,7 @@ TEST_CASE("an array element refuses a value of the wrong type") {
 TEST_CASE("a loop header refuses a fixed value in any of its three clauses") {
     moonlive::MoonLive eng;
     CHECK_FALSE(eng.compile("class T { fixed f = 3.0;\n"
-                            "  void tick() { for (i = 0; i < f; i = i + 1) { setRGB(0, 1, 0, 0); } } }",
+                            "  void tick() { for (int i = 0; i < f; i = i + 1) { setRGB(0, 1, 0, 0); } } }",
                             kTable, kSys));
     eng.free();
 }
@@ -1310,7 +1365,7 @@ TEST_CASE("return leaves tick() early, and the statements after it do not run")
 
 // A return inside a loop leaves the FUNCTION, not just the iteration: the classic early-out.
 TEST_CASE("return inside a loop leaves the whole function") {
-    auto buf = render(mmScript("for (i = 0; i < 4; i = i + 1) {"
+    auto buf = render(mmScript("for (int i = 0; i < 4; i = i + 1) {"
                                "  setRGB(i, 9, 0, 0);"
                                "  if (i >= 1) { return; }"
                                "}"), 4);
diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp
index 9e476e23..2fac38a3 100644
--- a/test/unit/core/unit_moonlive_fill.cpp
+++ b/test/unit/core/unit_moonlive_fill.cpp
@@ -158,7 +158,7 @@ static moonlive::SysVarTable kSys = moonlive::modifierSysVars();
 TEST_CASE("a loop counter survives a call in the body") {
     moonlive::MoonLive eng;
     // random16 is a Call; `i` and the limit `w` are both live around it.
-    REQUIRE(eng.compile(mmScript("byte w = 8;\nfor (i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"),
+    REQUIRE(eng.compile(mmScript("byte w = 8;\nfor (int i = 0; i < w; i = i + 1) { setRGB(i, random16(200), 200, 0); }"),
                         kCtrlTable, kSys));
     uint8_t buf[8 * 3] = {};
     eng.run(buf, 8, 3, 0);
@@ -592,7 +592,7 @@ TEST_CASE("a loop variable can be assigned in the loop body") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T {\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 8; i = i + 1) {\n"
+                        "    for (int i = 0; i < 8; i = i + 1) {\n"
                         "      i = i + 1;\n"          // skips every other light
                         "      setRGB(i, 99, 0, 0);\n"
                         "    }\n"
@@ -678,7 +678,7 @@ TEST_CASE("an if inside a for runs the body every iteration") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T {\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 6; i = i + 1) {\n"
+                        "    for (int 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"
@@ -987,8 +987,8 @@ TEST_CASE("an array element written in one loop is read in the next") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte heat[8];\n"
                         "  void 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"
+                        "    for (int i = 0; i < 8; i = i + 1) { heat[i] = i * 10; }\n"
+                        "    for (int j = 0; j < 8; j = j + 1) { setRGB(j, heat[j], 0, 0); }\n"
                         "  }\n"
                         "}\n", kCtrlTable, kSys));
     uint8_t px[24] = {};
@@ -1004,7 +1004,7 @@ TEST_CASE("array contents survive from one tick to the next") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte acc[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { acc[i] = acc[i] + 5; setRGB(i, acc[i], 0, 0); }\n"
+                        "    for (int 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] = {};
@@ -1027,9 +1027,9 @@ TEST_CASE("an out-of-range array index is clamped, not written past the end") {
     REQUIRE(eng.compile("class T {\n"
                         "  byte a[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { a[i] = 1; }\n"
+                        "    for (int 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"
+                        "    for (int j = 0; j < 4; j = j + 1) { setRGB(j, a[j], 0, 0); }\n"
                         "  }\n"
                         "}\n", kCtrlTable, kSys));
     uint8_t px[12] = {};
@@ -1088,7 +1088,7 @@ TEST_CASE("a int array holds per-element values above 255") {
     REQUIRE(eng.compile("class T {\n"
                         "  int v[4];\n"
                         "  void tick() {\n"
-                        "    for (i = 0; i < 4; i = i + 1) { v[i] = 300 + i; }\n"
+                        "    for (int 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"
@@ -1246,7 +1246,7 @@ TEST_CASE("a shape's outside stays dark once the distance passes its edge") {
     moonlive::MoonLive eng;
     // Sweep the distance from inside the edge to well outside it, one light each.
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (i = 0; i < 8; i = i + 1) {"
+                        "  for (int i = 0; i < 8; i = i + 1) {"
                         "    setRGB(i, scale(smoothstep(0, 400, 400 - i * 100), 256), 0, 0);"
                         "  } } }", kCtrlTable, kSys));
     uint8_t px[8 * 3] = {};
@@ -1264,7 +1264,7 @@ TEST_CASE("a shape's outside stays dark once the distance passes its edge") {
 TEST_CASE("smoothstep is a soft ramp rather than a hard threshold") {
     moonlive::MoonLive eng;
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (i = 0; i < 8; i = i + 1) {"
+                        "  for (int i = 0; i < 8; i = i + 1) {"
                         "    setRGB(i, scale(smoothstep(0, 800, i * 100), 256), 0, 0);"
                         "  } } }", kCtrlTable, kSys));
     uint8_t px[8 * 3] = {};
@@ -1294,8 +1294,8 @@ TEST_CASE("a circle drawn through uv stays circular on a wide panel") {
     // conversion: toInt() alone discards the fraction, which on this grid rounds every cell to
     // the same handful of integers and lights the lot.
     REQUIRE(eng.compile("class T { void tick() {"
-                        "  for (y = 0; y < 8; y = y + 1) {"
-                        "    for (x = 0; x < 32; x = x + 1) {"
+                        "  for (int y = 0; y < 8; y = y + 1) {"
+                        "    for (int x = 0; x < 32; x = x + 1) {"
                         "      if (polarR(toInt(uvX(x, 32, 8) * 1024), "
                         "                 toInt(uvY(y, 32, 8) * 1024)) < 650) {"
                         "        setRGB(y * 32 + x, 255, 0, 0);"
@@ -1334,7 +1334,7 @@ TEST_CASE("uv places the grid center at the origin, with the left half negative"
 TEST_CASE("blending two shapes with smin produces one surface, not two") {
     // Two circles far enough apart that a plain union leaves a gap between them.
     const char* src = "class T { int k = 0; void tick() {"
-                      "  for (x = 0; x < 16; x = x + 1) {"
+                      "  for (int x = 0; x < 16; x = x + 1) {"
                       "    if (smin(polarR(x - 4, 0) - 2, polarR(x - 11, 0) - 2, k) < 0) {"
                       "      setRGB(x, 255, 0, 0); } } } }";
     moonlive::MoonLive hard;
diff --git a/test/unit/core/unit_moonlive_spill.cpp b/test/unit/core/unit_moonlive_spill.cpp
index 7a39142f..b60e719e 100644
--- a/test/unit/core/unit_moonlive_spill.cpp
+++ b/test/unit/core/unit_moonlive_spill.cpp
@@ -73,7 +73,7 @@ int litCount(const std::vector& b) {
 TEST_CASE("a script renders identical pixels at a squeezed register budget as at the full one") {
     // Enough live values that a nine-register budget cannot hold them all: three independent
     // colour components plus two loop-carried values.
-    const char* src = mmScript("for (i = 0; i < 6; i = i + 1) { setRGB(i, i + 1, i + 2, i + 3); }");
+    const char* src = mmScript("for (int i = 0; i < 6; i = i + 1) { setRGB(i, i + 1, i + 2, i + 3); }");
 
     bool fullOk = false, tightOk = false;
     auto full = renderAt(src, 8, nullptr, fullOk);
@@ -91,8 +91,8 @@ TEST_CASE("a script renders identical pixels at a squeezed register budget as at
 // value β€” placing lights twice, or not at all. Nested, so the extension has to apply innermost-first.
 TEST_CASE("a nested loop at a squeezed budget places every light exactly once") {
     const char* src =
-        mmScript("for (i = 0; i < 4; i = i + 1) {\n"
-        "  for (j = 0; j < 4; j = j + 1) {\n"
+        mmScript("for (int i = 0; i < 4; i = i + 1) {\n"
+        "  for (int j = 0; j < 4; j = j + 1) {\n"
         "    setRGB(i * 4 + j, 200, 100, 50);\n"
         "  }\n"
         "}\n");
@@ -123,7 +123,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards"
     // and at a squeezed budget it is one of the values that has nowhere to live but a slot.
     const char* src =
         mmScript("byte idx = 5;\n"
-        "for (i = 0; i < 3; i = i + 1) {\n"
+        "for (int i = 0; i < 3; i = i + 1) {\n"
         "  setRGB(idx + i, random16(1) + 111, i + 1, 222);\n"
         "}\n");
 
@@ -149,7 +149,7 @@ TEST_CASE("a spilled value survives a host call and is still correct afterwards"
 TEST_CASE("a declared control still reads live at a squeezed budget") {
     const char* src =
         mmScript("byte pos = 0;\n"
-        "for (i = 0; i < 2; i = i + 1) {\n"
+        "for (int i = 0; i < 2; i = i + 1) {\n"
         "  setRGB(pos + i, 10, 20, 30);\n"
         "}\n");
     uint8_t code[moonlive::kCodeCap];
@@ -183,8 +183,8 @@ TEST_CASE("an impossible register budget refuses the compile instead of emitting
     // A script whose live values genuinely exceed the budgets below, so each really does have to
     // spill and really does have nowhere to put the result.
     const char* src =
-        mmScript("for (i = 0; i < 4; i = i + 1) {\n"
-        "  for (j = 0; j < 4; j = j + 1) {\n"
+        mmScript("for (int i = 0; i < 4; i = i + 1) {\n"
+        "  for (int j = 0; j < 4; j = j + 1) {\n"
         "    setRGB(i * 4 + j, 200, 100, 50);\n"
         "  }\n"
         "}\n");
@@ -297,7 +297,7 @@ TEST_CASE("a system variable read in a loop survives a host call in that loop")
     // means a loop that runs PAST width still writes 0 there, so the past-width check could not
     // detect the runaway it is named for. A constant 7 fixes both while keeping the call, which is
     // the ingredient this test exists for.
-    const char* src = mmScript("for (x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 7, 0); }\n");
+    const char* src = mmScript("for (int x = 0; x < width; x = x + 1) { setRGB(x, random16(256), 7, 0); }\n");
 
     // At the host's full budget AND at a squeezed one: Xtensa has ten registers where arm64 has
     // fourteen, so the squeezed run is the closest a host test gets to the pressure the device is
diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp
index f66051c1..641c5eeb 100644
--- a/test/unit/light/unit_MoonLiveLayout.cpp
+++ b/test/unit/light/unit_MoonLiveLayout.cpp
@@ -60,8 +60,8 @@ TEST_CASE("the default script lays out a grid, one light per cell") {
     const std::vector p = place(
         mmScriptAs("placeLights", "byte cols = 4;\n"
         "byte rows = 2;\n"
-        "for (yy = 0; yy < rows; yy = yy + 1) {"
-        "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"));
+        "for (int yy = 0; yy < rows; yy = yy + 1) {"
+        "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"));
     REQUIRE(p.size() == 8);
     CHECK(p[0] == Coord3D{0, 0, 0});
     CHECK(p[3] == Coord3D{3, 0, 0});
@@ -76,8 +76,8 @@ TEST_CASE("the light count is known before any coordinate is asked for") {
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 5;\n"
                 "byte rows = 3;\n"
-                "for (yy = 0; yy < rows; yy = yy + 1) {"
-                "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
+                "for (int yy = 0; yy < rows; yy = yy + 1) {"
+                "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
     l.prepare();
     CHECK(l.lightCount() == 15);           // answered without anyone calling placeLights
 }
@@ -86,7 +86,7 @@ TEST_CASE("the count and the coordinates always agree, because one script produc
     // The property SphereLayout names: count and emit run the same code, so they cannot drift.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
 
     std::vector seen;
@@ -102,7 +102,7 @@ TEST_CASE("a scripted layout allocates nothing, like every other layout") {
     // have. The script calls out per light instead, so the only heap here is the compiled program.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4096; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4096; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 4096);
     // dynamicBytes is the JIT'd program only β€” no coordinate storage grows with the light count.
@@ -113,7 +113,7 @@ TEST_CASE("a script places lights wherever it likes, which is the point of scrip
     // A strand that runs right to left: one line here, a new C++ class otherwise.
     const std::vector p = place(
         mmScriptAs("placeLights", "byte cols = 4;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
+        "for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
     REQUIRE(p.size() == 4);
     CHECK(p[0] == Coord3D{3, 0, 0});
     CHECK(p[3] == Coord3D{0, 0, 0});
@@ -121,7 +121,7 @@ TEST_CASE("a script places lights wherever it likes, which is the point of scrip
 
 TEST_CASE("a script can place a shape no rectangular layout can express") {
     // A diagonal β€” light i at (i, i).
-    const std::vector p = place(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i, 0); }"));
+    const std::vector p = place(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i, 0); }"));
     REQUIRE(p.size() == 4);
     CHECK(p[0] == Coord3D{0, 0, 0});
     CHECK(p[3] == Coord3D{3, 3, 0});
@@ -132,7 +132,7 @@ TEST_CASE("a broken script leaves an empty fixture rather than taking the pipeli
     // fixture reports no lights, the module carries the diagnostic, and the device keeps running.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
     l.prepare();
     CHECK(l.lightCount() == 0);
     CHECK(l.severity() == MoonModule::Severity::Error);
@@ -142,11 +142,11 @@ TEST_CASE("editing the script changes the fixture") {
     // The live-edit loop: the same module, a new script, a different physical shape.
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 4);
 
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 2; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 2);
 }
@@ -159,19 +159,19 @@ TEST_CASE("the scripts the documentation shows all compile") {
         // the default
         mmScriptAs("placeLights", "byte cols = 16;\n"
         "byte rows = 16;\n"
-        "for (yy = 0; yy < rows; yy = yy + 1) {"
-        "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"),
+        "for (int yy = 0; yy < rows; yy = yy + 1) {"
+        "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }"),
         // right to left
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"),
+        "for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"),
         // a diagonal
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"),
+        "for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }"),
         // two rows, stacked
         mmScriptAs("placeLights", "byte cols = 8;\n"
-        "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }"),
+        "for (int 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); }"),
+        mmScriptAs("placeLights", "for (int i = 0; i < 2; i = i + 1) { addLight(print(i), 0, 0); }"),
     };
     for (const char* s : fromDocs) {
         MoonLiveLayout l;
@@ -191,7 +191,7 @@ TEST_CASE("the scripts the documentation shows all compile") {
 TEST_CASE("a layout answers count and coordinates every time it is asked") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
 
     CHECK(l.lightCount() == 6);
@@ -222,13 +222,13 @@ TEST_CASE("a subtraction feeding a loop bound produces the whole value") {
     MoonLiveLayout l;
     l.defineControls();
     // 10 - 4 must be 6 lights. A widened -1 makes the bound enormous and the count is not 6.
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 10 - 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 10 - 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 6);
 
     // And a subtraction inside the placement, where the coordinate is the observable.
     std::vector p = place(mmScriptAs("placeLights", "byte cols = 4;\n"
-                                   "for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }"));
+                                   "for (int 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
     CHECK(p[3] == Coord3D{0, 0, 0});      // 4 - 1 - 3
@@ -247,13 +247,13 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
     MoonLiveLayout l;
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 16;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
+                "for (int 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", "byte cols = 8;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }")));
+                "for (int i = 0; i < cols; i = i + 1) { addLight(i, 1, 0); }")));
     l.prepare();
     CHECK(l.lightCount() == 16);
 
@@ -262,15 +262,15 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
     // pad must take its own 4 rather than inherit the 16 the user had dialed into cols.
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte pad = 4;\n"
                 "byte cols = 7;\n"
-                "for (i = 0; i < pad; i = i + 1) { addLight(i, 2, 0); }")));
+                "for (int 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", "byte cols = 16;\n"
                 "byte rows = 3;\n"
-                "for (yy = 0; yy < rows; yy = yy + 1) {"
-                "  for (xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
+                "for (int yy = 0; yy < rows; yy = yy + 1) {"
+                "  for (int xx = 0; xx < cols; xx = xx + 1) { addLight(xx, yy, 0); } }")));
     l.prepare();
     CHECK(l.lightCount() == 48);          // 16 inherited, rows 3 its own
 }
@@ -284,8 +284,8 @@ TEST_CASE("a scripted control keeps its live value when the script is edited") {
 // the behaviour: a nested loop places every light of the grid it describes.
 TEST_CASE("a nested loop lays out a full grid, on every target's register budget") {
     const std::vector p = place(
-        mmScriptAs("placeLights", "for (yy = 0; yy < 3; yy = yy + 1) {"
-        "  for (xx = 0; xx < 5; xx = xx + 1) { addLight(xx, yy, 0); } }"));
+        mmScriptAs("placeLights", "for (int yy = 0; yy < 3; yy = yy + 1) {"
+        "  for (int xx = 0; xx < 5; xx = xx + 1) { addLight(xx, yy, 0); } }"));
     REQUIRE(p.size() == 15);               // 3 rows x 5 columns, none dropped
     CHECK(p[0]  == Coord3D{0, 0, 0});
     CHECK(p[4]  == Coord3D{4, 0, 0});      // end of the first row
@@ -305,7 +305,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
     SUBCASE("through a call β€” addLight") {
         MoonLiveLayout l;
         l.defineControls();
-        l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, i, 0); }")));
+        l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, i, 0); }")));
         l.prepare();
         CHECK(l.lightCount() == 6);      // a clobbered counter gives some other number
     }
@@ -315,7 +315,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
         // β€” addLight is a Call and takes a different path β€” so this drives the emitted code and
         // checks every light was written, which is what a wrong counter changes.
         uint8_t code[4096];
-        auto r = moonlive::compileSource(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { setRGB(i, 200, 0, 0); }"),
+        auto r = moonlive::compileSource(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { setRGB(i, 200, 0, 0); }"),
                                          moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
         REQUIRE(r.ok);
         void* blk = platform::allocExec(r.len);
@@ -339,7 +339,7 @@ TEST_CASE("a loop counter survives the body that uses it") {
 TEST_CASE("a stray character in a for header is rejected, not spun on") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i @ 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i @ 1) { addLight(i, 0, 0); }")));
     l.prepare();                                    // must return β€” a hang fails by timeout
     CHECK(l.severity() == MoonModule::Severity::Error);
     CHECK(l.lightCount() == 0);
@@ -357,7 +357,7 @@ TEST_CASE("two threads can run scripts at once without stealing each other's sin
         MoonLiveLayout l;
         l.defineControls();
         char src[128];
-        std::snprintf(src, sizeof(src), mmScriptAs("placeLights", "for (i = 0; i < %d; i = i + 1) { addLight(i, 0, 0); }"), cols);
+        std::snprintf(src, sizeof(src), mmScriptAs("placeLights", "for (int i = 0; i < %d; i = i + 1) { addLight(i, 0, 0); }"), cols);
         l.setScript(mmWriteScript(src));
         l.prepare();
         for (int r = 0; r < reps; r++)
@@ -384,7 +384,7 @@ TEST_CASE("two threads can run scripts at once without stealing each other's sin
 TEST_CASE("a disabled scripted layout stops reporting the memory it freed") {
     MoonLiveLayout l;
     l.defineControls();
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     REQUIRE(l.dynamicBytes() > 0);
     l.release();
@@ -395,14 +395,14 @@ TEST_CASE("a scripted layout reports every heap byte it holds, compiled or not")
     MoonLiveLayout l;
     l.defineControls();
     l.setScript(mmWriteScript(mmScriptAs("placeLights", "byte cols = 4;\n"
-                "for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
+                "for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); }")));
     l.prepare();
     const size_t compiled = l.dynamicBytes();
     CHECK(compiled > 0);
     CHECK(l.lightCount() == 4);
 
     // A broken script frees the code but keeps the arena, so the figure drops without reaching zero.
-    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
+    l.setScript(mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, i")));   // unclosed
     l.prepare();
     CHECK(l.severity() == MoonModule::Severity::Error);
     CHECK(l.dynamicBytes() < compiled);      // the code block is gone
@@ -426,7 +426,7 @@ TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") {
         "class GrowLayout {\n"
         "  byte cols = 4;\n"
         "  void defineControls() { addControl(\"cols\", cols, 1, 64); }\n"
-        "  void placeLights() { for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); } }\n"
+        "  void placeLights() { for (int 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
@@ -471,13 +471,13 @@ TEST_CASE("a layout that changes size mid-build cannot overrun the mapping") {
 TEST_CASE("naming a different script through the control actually swaps the program") {
     MoonLiveLayout l;
     l.defineControls();
-    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(four);
     l.prepare();
     REQUIRE(l.lightCount() == 4);
 
     // Write the OTHER script the way the API does: straight into the bound control buffer.
-    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
     const auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -516,7 +516,7 @@ TEST_CASE("a layout whose script is missing reports it without retrying forever"
 
     // A working script after a failed one must still compile β€” the give-up is per script name, not
     // permanent, or fixing a typo would need a reboot.
-    const char* good = mmScriptAs("placeLights", "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+    const char* good = mmScriptAs("placeLights", "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     l.setScript(mmWriteScript(good));
     l.prepare();
     // The COUNT needs an emitting backend; the give-up-is-per-name behaviour above does not, so
@@ -548,7 +548,7 @@ TEST_CASE("a layout that starts empty still compiles the first script it is give
     // Write the control the way the UI does β€” straight into the bound buffer, then
     // onControlChanged β€” because addText binds `script_` directly and setScript() is NOT called on
     // that path. That is exactly how a device sets a script, and where the latch survived.
-    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
     auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -644,8 +644,8 @@ TEST_CASE("a serpentine layout places every light exactly once") {
         "byte cols = 4;\n"
         "byte rows = 3;\n"
         "byte odd = 0;\n"
-        "for (y = 0; y < rows; y = y + 1) {\n"
-        "  for (x = 0; x < cols; x = x + 1) {\n"
+        "for (int y = 0; y < rows; y = y + 1) {\n"
+        "  for (int 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"
@@ -665,7 +665,7 @@ 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); }"));
+        "for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(name);
     l.prepare();
     CHECK(l.lightCount() == 3);
@@ -674,7 +674,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     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); }");
+        "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, edited.c_str(), edited.size()));
 
     l.prepare();
@@ -722,7 +722,7 @@ TEST_CASE("a broken script fixed in place compiles, without being renamed") {
     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); }");
+        "for (int 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
@@ -773,7 +773,7 @@ TEST_CASE("a disabled scripted module publishes no controls bound to freed memor
         "class T {\n"
         "  byte cols = 7;\n"
         "  void defineControls() { addControl(\"cols\", cols, 1, 64); }\n"
-        "  void placeLights() { for (x = 0; x < cols; x = x + 1) { addLight(x, 0, 0); } }\n"
+        "  void placeLights() { for (int x = 0; x < cols; x = x + 1) { addLight(x, 0, 0); } }\n"
         "}\n"));
     l.prepare();
 
@@ -806,13 +806,13 @@ TEST_CASE("a disabled scripted module publishes no controls bound to freed memor
 TEST_CASE("naming a different script through the control actually swaps the program") {
     MoonLiveLayout l;
     l.defineControls();
-    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* four = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 4; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(four);
     l.prepare();
     REQUIRE(l.lightCount() == 4);
 
     // Write the OTHER script the way the API does: straight into the bound control buffer.
-    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* nine = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 9; i = i + 1) { addLight(i, 0, 0); }"));
     const auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -851,7 +851,7 @@ TEST_CASE("a layout whose script is missing reports it without retrying forever"
 
     // A working script after a failed one must still compile β€” the give-up is per script name, not
     // permanent, or fixing a typo would need a reboot.
-    const char* good = mmScriptAs("placeLights", "for (i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
+    const char* good = mmScriptAs("placeLights", "for (int i = 0; i < 5; i = i + 1) { addLight(i, 0, 0); }");
     l.setScript(mmWriteScript(good));
     l.prepare();
     // The COUNT needs an emitting backend; the give-up-is-per-name behaviour above does not, so
@@ -883,7 +883,7 @@ TEST_CASE("a layout that starts empty still compiles the first script it is give
     // Write the control the way the UI does β€” straight into the bound buffer, then
     // onControlChanged β€” because addText binds `script_` directly and setScript() is NOT called on
     // that path. That is exactly how a device sets a script, and where the latch survived.
-    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
+    const char* name = mmWriteScript(mmScriptAs("placeLights", "for (int i = 0; i < 6; i = i + 1) { addLight(i, 0, 0); }"));
     auto& cs = l.controls();
     for (uint8_t i = 0; i < cs.count(); i++)
         if (cs[i].name && std::strcmp(cs[i].name, "script") == 0)
@@ -979,8 +979,8 @@ TEST_CASE("a serpentine layout places every light exactly once") {
         "byte cols = 4;\n"
         "byte rows = 3;\n"
         "byte odd = 0;\n"
-        "for (y = 0; y < rows; y = y + 1) {\n"
-        "  for (x = 0; x < cols; x = x + 1) {\n"
+        "for (int y = 0; y < rows; y = y + 1) {\n"
+        "  for (int 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"
@@ -1000,7 +1000,7 @@ 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); }"));
+        "for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"));
     l.setScript(name);
     l.prepare();
     CHECK(l.lightCount() == 3);
@@ -1009,7 +1009,7 @@ TEST_CASE("editing a script's text recompiles it, without renaming the file") {
     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); }");
+        "for (int i = 0; i < 7; i = i + 1) { addLight(i, 0, 0); }");
     REQUIRE(mm::platform::fsWriteAtomic(path, edited.c_str(), edited.size()));
 
     l.prepare();
@@ -1057,7 +1057,7 @@ TEST_CASE("a broken script fixed in place compiles, without being renamed") {
     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); }");
+        "for (int 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
diff --git a/test/unit/light/unit_MoonLiveModifier.cpp b/test/unit/light/unit_MoonLiveModifier.cpp
index fa9bf17e..fbd3d037 100644
--- a/test/unit/light/unit_MoonLiveModifier.cpp
+++ b/test/unit/light/unit_MoonLiveModifier.cpp
@@ -344,7 +344,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(xPos, yPos, zPos);")));
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int 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);
@@ -357,7 +357,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(xPos, yPos, zPos);")));
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int 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);
@@ -369,7 +369,7 @@ TEST_CASE("a loop over an empty range runs its body no times") {
 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); } }"
+    m.setScript(mmWriteScript(mmScriptAs("modifyLogical", "for (int a = 0; a < 2; a = a + 1) { for (int b = 0; b < 2; b = b + 1) { print(a); } }"
                 " setXYZ(xPos, yPos, zPos);")));
     m.prepare();
     CHECK(m.severity() != MoonModule::Severity::Error);
@@ -394,7 +394,7 @@ TEST_CASE("a loop in an effect script paints every light it walks") {
     layer.setChannelsPerLight(3);
     auto* fx = new MoonLiveEffect();
     fx->defineControls();
-    fx->setScript(mmWriteScript(mmScript("for (i = 0; i < 8; i = i + 1) { setRGB(i, i, 0, 0); }")));
+    fx->setScript(mmWriteScript(mmScript("for (int i = 0; i < 8; i = i + 1) { setRGB(i, i, 0, 0); }")));
     layer.addChild(fx);
     layouts.applyState();
     layer.applyState();
diff --git a/test/unit/light/unit_MoonLiveMotion.cpp b/test/unit/light/unit_MoonLiveMotion.cpp
index 071dbd4e..e8d43ef7 100644
--- a/test/unit/light/unit_MoonLiveMotion.cpp
+++ b/test/unit/light/unit_MoonLiveMotion.cpp
@@ -95,7 +95,7 @@ TEST_CASE("a script aims each head with setPan and setTilt") {
     HeadRig rig;
     rig.run("class Aim {"
             "  void tick() {"
-            "    for (i = 0; i < height; i = i + 1) {"
+            "    for (int i = 0; i < height; i = i + 1) {"
             "      setPan(i, 10 + i * 20);"
             "      setTilt(i, 200 - i * 20);"
             "    }"
@@ -115,7 +115,7 @@ TEST_CASE("setPan on a light with no motion channel writes nothing") {
     strip.run("class Aim {"
               "  void tick() {"
               "    fill(0, 0, 0);"
-              "    for (i = 0; i < height; i = i + 1) { setPan(i, 255); setTilt(i, 255); }"
+              "    for (int i = 0; i < height; i = i + 1) { setPan(i, 255); setTilt(i, 255); }"
               "  }"
               "}");
 
@@ -210,7 +210,7 @@ TEST_CASE("an audio script runs on a device with no audio, and paints nothing")
     rig.run("class A {"
             "  void tick() {"
             "    fill(0, 0, 0);"
-            "    for (i = 0; i < height; i = i + 1) {"
+            "    for (int i = 0; i < height; i = i + 1) {"
             "      setRGB(i, audioLevel(), audioBand(i), audioBeat() * 255);"
             "    }"
             "  }"
@@ -315,7 +315,7 @@ TEST_CASE("a script that declares D1 is extruded across the width") {
         "  int dimensions() { return 1; }"
         "  void tick() {"
         "    fill(0, 0, 0);"
-        "    for (y = 0; y < height; y = y + 1) { setRGB(y * width, 200, 0, 0); }"
+        "    for (int y = 0; y < height; y = y + 1) { setRGB(y * width, 200, 0, 0); }"
         "  }"
         "}"));
     layouts.applyState();
diff --git a/test/unit/light/unit_MoonLiveScripts.cpp b/test/unit/light/unit_MoonLiveScripts.cpp
index 4613a21c..e0fe697d 100644
--- a/test/unit/light/unit_MoonLiveScripts.cpp
+++ b/test/unit/light/unit_MoonLiveScripts.cpp
@@ -18,6 +18,7 @@
 #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 "light/moonlive/MoonLiveScript.h"       // kMaxStatus: the status line a failure reports through
 #include "light/moonlive/script_catalog.h"       // generated: what the device offers
 
 #include 
@@ -163,10 +164,10 @@ TEST_CASE("the shipped catalog names every script in moonlive/") {
 TEST_CASE("every script reads the same system-variable vocabulary") {
     struct Case { const char* src; bool ok; const char* what; };
     const Case cases[] = {
-        {mmScript("for (y = 0; y < 2; y = y + 1) { for (x = 0; x < 3; x = x + 1) { addLight(x, y, 0); } }"),
+        {mmScript("for (int y = 0; y < 2; y = y + 1) { for (int x = 0; x < 3; x = x + 1) { addLight(x, y, 0); } }"),
          true,  "x and y are ordinary loop counters, in EVERY role: they are the names an author "
                 "reaches for, which is why the coordinate is xPos/yPos/zPos instead"},
-        {mmScript("for (i = 0; i < width; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("for (int 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(width - 1 - xPos, yPos, zPos);"),
@@ -215,11 +216,11 @@ TEST_CASE("a comment is whitespace, wherever it appears") {
     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,
+        {mmScript("for (int 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("// @control 1..64 is just text now\naddLight(1, 2, 3);"), true,
          "the old annotation is an ordinary comment"},
-        {mmScript("byte n = 4; // anything at all !!\nfor (i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"),
+        {mmScript("byte n = 4; // anything at all !!\nfor (int i = 0; i < n; i = i + 1) { addLight(i, 0, 0); }"),
          true, "a comment after a member declaration"},
     };
     for (const Case& c : cases) {
@@ -240,10 +241,10 @@ TEST_CASE("a comment changes nothing about what a script does") {
     // with no code emitted, "same length" is two zeroes and proves nothing.
 #if MM_MOONLIVE_HAS_HOST_JIT
     moonlive::MoonLive bare, commented;
-    CHECK(bare.compile(mmScript("for (i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
+    CHECK(bare.compile(mmScript("for (int i = 0; i < 3; i = i + 1) { addLight(i, 0, 0); }"),
                        moonlive::lightBuiltins(), moonlive::modifierSysVars()));
     CHECK(commented.compile(mmScript("// place three lights in a row\n"
-                            "for (i = 0; i < 3; i = i + 1) {\n"
+                            "for (int i = 0; i < 3; i = i + 1) {\n"
                             "  addLight(i, 0, 0);   // one per step\n"
                             "}"),
                             moonlive::lightBuiltins(), moonlive::modifierSysVars()));
@@ -295,7 +296,7 @@ TEST_CASE("noise is smooth across neighbouring points, and varies across the fie
     // One light per sample: light i gets the noise at x = i * 64, so the 32 lights walk 8 whole
     // cells (256 units each) and the buffer IS a real slice of the field, not a corner of one cell.
     auto r = moonlive::compileSource(
-        mmScript("for (i = 0; i < 32; i = i + 1) { setRGB(i, noise(i * 64, 0, 0), 0, 0); }"),
+        mmScript("for (int i = 0; i < 32; i = i + 1) { setRGB(i, noise(i * 64, 0, 0), 0, 0); }"),
         moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
     REQUIRE(r.ok);
     void* blk = platform::allocExec(r.len);
@@ -336,7 +337,7 @@ TEST_CASE("mod wraps a sweep, so an animation repeats instead of running off the
     uint8_t code[4096];
     auto r = moonlive::compileSource(
         mmScript("byte w = 16;\n"
-        "for (yy = 0; yy < w; yy = yy + 1) { setRGB(yy * w + mod(t, w), 255, 0, 0); }"),
+        "for (int 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);
     void* blk = platform::allocExec(r.len);
@@ -370,10 +371,10 @@ TEST_CASE("sequential loops reuse the same register, so a script is not billed p
     // Four loops, each with a call in the body β€” comfortably over budget if counters accumulate.
     auto r = moonlive::compileSource(
         mmScript("byte w = 16;\n"
-        "for (a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n"
-        "for (b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n"
-        "for (c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n"
-        "for (d = 0; d < w; d = d + 1) { setRGB(d, 255, 255, 0); }"),
+        "for (int a = 0; a < w; a = a + 1) { setRGB(a, 255, 0, 0); }\n"
+        "for (int b = 0; b < w; b = b + 1) { setRGB(b, 0, 255, 0); }\n"
+        "for (int c = 0; c < w; c = c + 1) { setRGB(c, 0, 0, 255); }\n"
+        "for (int d = 0; d < w; d = d + 1) { setRGB(d, 255, 255, 0); }"),
         moonlive::lightBuiltins(), moonlive::modifierSysVars(), code, sizeof(code));
     if (!r.ok) INFO(r.error);
     // What this pins is REGISTER REUSE, which the front-end does on every host β€” but proving it
@@ -396,6 +397,45 @@ TEST_CASE("sequential loops reuse the same register, so a script is not billed p
 //
 // Read from the .md files rather than pasted here: a pasted copy stops being the documented one the
 // first time someone edits the real page.
+TEST_CASE("every compile error fits the status line whole, its position included") {
+    // A failure reaches the UI as ONE string, " @": the sentence a user reads and
+    // the position the editor marks the failing line from. MoonLiveScript formats it into a fixed
+    // buffer, so a message longer than that buffer loses its explanation, and a slightly longer one
+    // eats the offset and the line marking silently stops working. Both happened.
+    //
+    // Read from the SOURCE rather than a list kept here: a hand-kept copy would agree with the
+    // buffer while the compiler moved on, which is the drift this exists to prevent.
+    const std::filesystem::path repo =
+        std::filesystem::path(__FILE__).parent_path().parent_path().parent_path().parent_path();
+    size_t longest = 0;
+    const char* longestText = "";
+    for (const char* rel : {"src/core/moonlive/MoonLiveCompiler.cpp",
+                            "src/core/moonlive/MoonLiveCompiler.h"}) {
+        std::ifstream in(repo / rel);
+        REQUIRE(in.good());
+        std::string line;
+        while (std::getline(in, line)) {
+            // Both shapes a diagnostic is written in: fail("...") and a kName = "..." constant.
+            for (const char* lead : {"fail(\"", "= \""}) {
+                size_t at = 0;
+                while ((at = line.find(lead, at)) != std::string::npos) {
+                    const size_t beg = at + std::strlen(lead);
+                    const size_t end = line.find('"', beg);
+                    if (end == std::string::npos) break;
+                    const size_t len = end - beg;
+                    if (len > longest) { longest = len; }
+                    at = end;
+                }
+            }
+        }
+    }
+    INFO("longest diagnostic is " << longest << " chars");
+    CHECK(longest >= 40);              // a control: a parse that found nothing would pass silently
+    // " @" + up to 5 digits + the terminator, against the buffer MoonLiveScript declares.
+    CHECK(longest + 8 <= mm::moonlive::MoonLiveScript::kMaxStatus);
+    (void)longestText;
+}
+
 TEST_CASE("every script example in the docs compiles") {
     const std::filesystem::path repo = scriptRoot().parent_path();
     const std::filesystem::path pages[] = {