Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,36 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul

## Unreleased (`next-iteration`)

### A module declares every control with `addControl` (2026-08-24)

`addUint8`, `addUint16`, `addInt16`, `addInt32` and `addBool` are replaced by one overloaded
`addControl(name, variable, min, max)`. The widget follows the variable's own type, which the
compiler already knows, so the name no longer repeats a width the declaration states:

```cpp
controls_.addUint8("speed", speed_, 1, 255); // before
controls_.addControl("speed", speed_, 1, 255); // after
```

This is the same call a MoonLive script makes, which is the point: someone who has written a
script can read a compiled module, and someone who has read a module can write a script.

The **widget-specific** adders keep their names — `addPin`, `addSelect`, `addPalette`, `addText`,
`addTextArea`, `addFilePath`, `addPassword`, `addIPv4`, `addReadOnly`, `addReadOnlyInt`,
`addProgress`, `addList`, `addButton`. Those name a widget rather than a width, and the intent is
not recoverable from the C++ type: `uint8_t` backs a slider, a dropdown *and* a palette picker, and
an `int8_t` silently becoming a Pin would register as a claimed GPIO in the pin map. `addControl`
on an `int8_t` is deliberately deleted, with a diagnostic naming the two real options.

**Action: *nothing* for a device.** No control name, type, range, wire format or persisted value
changes — a renamed call produces a byte-identical descriptor, which is why nothing on the device
can notice.

**Action for a third-party module: *recompile*.** Rename the five calls to `addControl`; the
arguments are unchanged. A missed one is a compile error, never a silent behaviour change: the
overloads bind by exact reference type, so a call that compiles produces the widget it always did.


### Desktop settings move to a per-user directory (2026-08-23)

The desktop build wrote its configuration to `build/.config`, resolved against whatever directory the process happened to start in. That is a source-checkout layout, and it shipped: a downloaded binary either could not write there at all, failing every save and logging one line per save, or it wrote settings that belonged to that *folder* rather than to the user, so moving the executable lost them.
Expand Down
4 changes: 2 additions & 2 deletions docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co
second, uint8 and uint16). Only the re-read after a slot write is still wrong.

**Reproduce** (in `unit_moonlive_fill.cpp`, which has `kCtrlTable`/`kSys` to hand): compile
`class T { uint16_t big = 5; defineControls() { addUint16("big", big, 0, 1000); } tick() {
`class T { int big = 5; defineControls() { addControl("big", big, 0, 1000); } tick() {
setRGB(0, big, 0, 0); } }`, `run(..., kEntryTick)` → 5, write the slot to 7, run again → still 5.

**Impact: desktop only, nothing ships broken.** `addUint16` is hardware-verified on both ISAs
**Impact: desktop only, nothing ships broken.** A wide control is hardware-verified on both ISAs
(S3 Xtensa and S31 RISC-V drive ember's `cycle` to 2000 and back). What is missing is DESKTOP
coverage of the live-edit loop — the path users touch most — so no host test can pin it and the
next regression there would surface only on a board. Add the runtime assertion together with the
Expand Down
4 changes: 2 additions & 2 deletions docs/backlog/livescripts-analysis-bottom-up.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@ Decisions from the design discussion that produced this survey. These are *direc
1. **Execution = native, the standout.** Build our own **native-codegen** engine (ESPLiveScript-class speed, near-100%) — the differentiator; projectMM should stand out with something not done before (a native live-compiler bound to a real module system). *Not* a slow interpreter, *not* WASM-as-flagship.
2. **No dead-ends, start small + beautiful (the LED-driver method).** Ship **one ISA backend first — Xtensa (classic ESP32 + S3)** — as a complete, blazing first increment, then grow. The **IR seam** (front-end → typed IR → per-ISA backend) is the no-dead-end guarantee: RISC-V (P4), ARM (Teensy), x86/ARM64 (desktop) each become a *new backend behind the unchanged IR*, never a rewrite. WASM/WAMR is the **per-target fallback** so no target is ever blocked.
3. **The IR must NOT cost speed (hard constraint).** It is a *compile-time* representation that lowers to the *same* native instructions ESPLiveScript hand-emits — **zero per-pixel runtime overhead**, no interpreted layer. Equally fast as hpwit on Xtensa is non-negotiable; prove it by diffing generated instructions for a hot loop against hand-written Xtensa, and keep an `__asm__` escape hatch for the very hottest paths (as ESPLiveScript has).
4. **Source language = a C-subset, "as close as possible" to the precompiled effect, with pragmatic simplifications.** The effect *body* (types like `uint8_t`/`uint32_t`/`lengthType`, nested `for`, integer + 64-bit math, `static_cast`, `RGB`, `hsvToRgb`, buffer writes) ports **near-verbatim** from a file like `RipplesEffect.h` (our reference effect — it exercises the hard cases: `float` trig `std::sqrt`/`std::sin`/`std::floor`, `std::memset`, 3D with `depth()`, two controls). The C++ *file/class ceremony* that buys nothing in a script (`#pragma`/`#include`/`namespace`, and — accepted as a pragmatic simplification — `class : public EffectBase`/`override`/the `controls_.addUint8` host-object dance) is **dropped or lightened**: the engine supplies the module scaffolding around the script. Target: porting an existing effect is the loop body verbatim + a handful of lines changed, *not* a rewrite, and *not* implementing a full C++ object model (inheritance/vtables/host-method binding) in the engine. **Not** a JS-subset (the ARTI-FX surface): JS's double-everything number model is the slow path *and* further from our C++ codebase, so it's worse on both speed and portability of existing effects.
5. **Controls = minimal ceremony.** A scripted control is a near-plain top-level variable (e.g. `uint8_t speed = 60;` with a range annotation); the engine derives the MoonModule control + UI + persistence. Lighter than today's explicit `controls_.addUint8(...)`, copy-paste-friendly. (Exact annotation syntax is the top-down's call.)
4. **Source language = a C-subset, "as close as possible" to the precompiled effect, with pragmatic simplifications.** The effect *body* (types like `uint8_t`/`uint32_t`/`lengthType`, nested `for`, integer + 64-bit math, `static_cast`, `RGB`, `hsvToRgb`, buffer writes) ports **near-verbatim** from a file like `RipplesEffect.h` (our reference effect — it exercises the hard cases: `float` trig `std::sqrt`/`std::sin`/`std::floor`, `std::memset`, 3D with `depth()`, two controls). The C++ *file/class ceremony* that buys nothing in a script (`#pragma`/`#include`/`namespace`, and — accepted as a pragmatic simplification — `class : public EffectBase`/`override`/the `controls_.addControl` host-object dance) is **dropped or lightened**: the engine supplies the module scaffolding around the script. Target: porting an existing effect is the loop body verbatim + a handful of lines changed, *not* a rewrite, and *not* implementing a full C++ object model (inheritance/vtables/host-method binding) in the engine. **Not** a JS-subset (the ARTI-FX surface): JS's double-everything number model is the slow path *and* further from our C++ codebase, so it's worse on both speed and portability of existing effects.
5. **Controls = minimal ceremony.** A scripted control is a near-plain top-level variable (e.g. `uint8_t speed = 60;` with a range annotation); the engine derives the MoonModule control + UI + persistence. Lighter than today's explicit `controls_.addControl(...)`, copy-paste-friendly. (Exact annotation syntax is the top-down's call.)
6. **Safety = staged, climb the tiers, don't pay upfront.** Ship the **cheap** tier first — array **bounds-checking** (a compare-branch per indexed access, low single-digit %, removable in a trusted/fast mode) + **watchdog / instruction budget** (kill a runaway loop, near-free). The **expensive** true-memory-sandbox tier (a script physically can't touch memory outside its arena — what WASM gives free, native can't cheaply) is **deferred**, reachable via the IR→WASM fallback only if a public script editor in the field shows the cheap tier isn't enough. Decided this way because the price of full sandboxing upfront isn't worth paying before evidence demands it.
7. **MoonModule-first.** A scripted module **is** a MoonModule (role, controls, `loop()`, generic UI, lifecycle, robustness, live-reconfig). The script ⇄ MoonModule binding (reach the `Buffer`/`AudioFrame`/LUT via the producer/consumer pull pattern, no copy) is the projectMM value-add to design — no prior art copies cleanly.
8. **General in core + specific in light.** One engine serves a domain-neutral core script (e.g. transform sensor data) *and* a scripted layout / effect / modifier / driver. **Effect is the first role.** `RipplesEffect.h` is the *reference* effect for the language design (it stresses float trig + 3D + memset), but it is **too complex for the hello-world spike** — the first running script must be trivial (e.g. fill the buffer one color, or a single moving dot), proving the engine end-to-end before any real effect. Ripples is the *graduation* target, not the spike. For how an effect is structured for a newcomer, the [MoonLight effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/) is a good read (a sibling project's step-by-step). The simple→Ripples progression is itself the start-small-grow staging applied to the demo.
Expand Down
12 changes: 6 additions & 6 deletions docs/backlog/livescripts-analysis-top-down.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The same shape gives `MoonLiveLayout` (role `Layout`, emits coordinates), `MoonL

So the binding overrides the same hooks any compiled module does; the only difference is that each one delegates to the compiled `MoonLive` instead of hand-written C++.

**Crucially, all of these lifecycle methods live in the *binding* (`MoonLiveEffect`, `src/light/moonlive/`), not in the engine.** `onBuildControls`/`onBuildState`/`onUpdate`/`teardown`, `EffectBase`, `ModuleRole`, `controls_` — every projectMM type — sit on the binding side of the §3.9 seam. The engine (`MoonLive`, `src/core/moonlive/`) sees none of them; the binding reaches it only through a **neutral public API**: `compile(source)`, `run()`, `free()`, `declaredControls()` → a plain list of `{name, type, min, max, default}` structs the engine owns, and `allocForSize(w, h, d)` → plain ints. The binding *translates* — it reads the engine's neutral `declaredControls()` and calls projectMM's `controls_.addUint8(...)`; it maps a grid resize to `allocForSize`. **The engine never takes a `ControlList`, a `Buffer`, or any projectMM type** — so the rich MoonModule lifecycle is entirely a property of the binding, and the engine stays the domain-neutral core §3.9 describes. (This is the seam working as intended: a different host writes its own binding with its own lifecycle against the same neutral engine API.)
**Crucially, all of these lifecycle methods live in the *binding* (`MoonLiveEffect`, `src/light/moonlive/`), not in the engine.** `onBuildControls`/`onBuildState`/`onUpdate`/`teardown`, `EffectBase`, `ModuleRole`, `controls_` — every projectMM type — sit on the binding side of the §3.9 seam. The engine (`MoonLive`, `src/core/moonlive/`) sees none of them; the binding reaches it only through a **neutral public API**: `compile(source)`, `run()`, `free()`, `declaredControls()` → a plain list of `{name, type, min, max, default}` structs the engine owns, and `allocForSize(w, h, d)` → plain ints. The binding *translates* — it reads the engine's neutral `declaredControls()` and calls projectMM's `controls_.addControl(...)`; it maps a grid resize to `allocForSize`. **The engine never takes a `ControlList`, a `Buffer`, or any projectMM type** — so the rich MoonModule lifecycle is entirely a property of the binding, and the engine stays the domain-neutral core §3.9 describes. (This is the seam working as intended: a different host writes its own binding with its own lifecycle against the same neutral engine API.)

### 3.4 The host binding — script ⇄ MoonModule (decision 7, the value-add)

Expand All @@ -160,11 +160,11 @@ The binding is generated *around* the script body — the script never writes `#
A control is a near-plain top-level variable with a range annotation; the engine derives the `MoonModule` control + UI + persistence:

```c
uint8_t speed = 50; // @control 0..99 → controls_.addUint8("speed", …, 0, 99)
byte speed = 50; // @control 0..99 → controls_.addControl("speed", …, 0, 99)
uint8_t interval = 128; // @control 1..254
```

The front-end collects annotated top-level vars during parsing and the engine exposes them as a neutral `declaredControls()` list (`{name, type, min, max, default}` — no projectMM type); the *binding* reads that list and calls the normal `controls_.add(...)` the rest of projectMM uses (§3.3) — so a scripted control is indistinguishable from a compiled one in the UI, persistence, and the live-reconfig sweep, while the engine stays projectMM-agnostic. Lighter than today's explicit `onBuildControls` + `addUint8` (the engine writes that for you), and copy-paste-friendly: the `uint8_t speed = 50;` line is *already* how RipplesEffect.h declares it. (Exact annotation syntax — `@control`, a trailing comment convention, or a `slider(0,99)` initializer — is settled in the spike; the principle is "declare the var, get the control".)
The front-end collects annotated top-level vars during parsing and the engine exposes them as a neutral `declaredControls()` list (`{name, type, min, max, default}` — no projectMM type); the *binding* reads that list and calls the normal `controls_.add(...)` the rest of projectMM uses (§3.3) — so a scripted control is indistinguishable from a compiled one in the UI, persistence, and the live-reconfig sweep, while the engine stays projectMM-agnostic. Lighter than today's explicit `onBuildControls` + `addControl` (the engine writes that for you), and copy-paste-friendly: the `uint8_t speed = 50;` line is *already* how RipplesEffect.h declares it. (Exact annotation syntax — `@control`, a trailing comment convention, or a `slider(0,99)` initializer — is settled in the spike; the principle is "declare the var, get the control".)

### 3.6 Live reconfig + tick-atomic hot-swap (decision: sync)

Expand Down Expand Up @@ -239,7 +239,7 @@ A C-subset, not full C++, not JS. The type model is exactly what real effects us
### 5.2 What's dropped vs lightened (the pragmatic simplifications)

- **Dropped** (file ceremony, zero value in a script): `#pragma once`, `#include`, `namespace`. The engine supplies the surrounding module.
- **Lightened** (the C++ object model): no `class : public EffectBase`, no `override`, no `controls_.addUint8(...)` host-object dance. The engine synthesizes the `MoonLiveEffect` wrapper (§3.3) around the script body; the role/`dimensions`/controls come from light annotations (§3.5) and the script's `loop()`.
- **Lightened** (the C++ object model): no `class : public EffectBase`, no `override`, no `controls_.addControl(...)` host-object dance. The engine synthesizes the `MoonLiveEffect` wrapper (§3.3) around the script body; the role/`dimensions`/controls come from light annotations (§3.5) and the script's `loop()`.
- **Kept verbatim** (the part you iterate on): types, the `loop()` body, all the math, `static_cast`, `RGB c = hsvToRgb(...)`, the loops.

**Why not full C++:** supporting `class`/inheritance/`override`/host-method-binding means implementing a C++ object model (vtables, member-reference binding) in the engine — build cost up front, and the object machinery is the very "object graph in the hot path" the architecture forbids. The wrapper has no runtime value; let the engine write it.
Expand All @@ -259,8 +259,8 @@ class RipplesEffect : public EffectBase { // ← dropped (engine su
uint8_t speed = 50; // ← kept (becomes a control)
uint8_t interval = 128;
void onBuildControls() override { // ← dropped (derived from the vars)
controls_.addUint8("speed", speed, 0, 99);
controls_.addUint8("interval", interval, 1, 254);
controls_.addControl("speed", speed, 0, 99);
controls_.addControl("interval", interval, 1, 254);
}
void loop() override { // ← KEPT VERBATIM (the body)
uint8_t* buf = buffer(); … std::memset(buf, 0, nrOfLights()*cpl);
Expand Down
40 changes: 20 additions & 20 deletions docs/metrics/repo-health.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"commit": "f985a366",
"commit": "6b9d520d",
"flash": {
"esp32s3-n16r8": 1820752,
"desktop": 1231192,
"esp32s3-n16r8": 1821104,
"desktop": 1247704,
"esp32": 1764416,
"esp32p4rev1-eth": 1653696,
"esp32p4rev1-eth-wifi": 1933472,
Expand All @@ -16,58 +16,58 @@
},
"perf": {
"desktop": {
"tick_us": 259,
"fps": 3861
"tick_us": 131,
"fps": 7633
},
"esp32": {
"tick_us": 2151,
"fps": 464
}
},
"loc": {
"core": 20117,
"light": 25873,
"platform": 15128,
"core": 20194,
"light": 25878,
"platform": 15161,
"ui": 7047,
"test": 46792,
"moondeck": 21847
"test": 46933,
"moondeck": 21848
},
"comments": {
"core": {
"lines": 7929,
"ratio": 0.426
"lines": 7988,
"ratio": 0.428
},
"light": {
"lines": 10271,
"lines": 10275,
"ratio": 0.438
},
"platform": {
"lines": 5379,
"lines": 5389,
"ratio": 0.39
},
"ui": {
"lines": 1874,
"ratio": 0.282
},
"test": {
"lines": 8658,
"ratio": 0.212
"lines": 8694,
"ratio": 0.213
},
"moondeck": {
"lines": 3529,
"lines": 3530,
"ratio": 0.185
}
},
"tests": {
"cases": 1565,
"cases": 1571,
"scenarios": 23
},
"docs": {
"md_files": 192,
"md_lines": 28089,
"md_lines": 28136,
"plans_files": 98,
"backlog_lines": 4451,
"lessons_lines": 592,
"lessons_lines": 606,
"claude_md_lines": 136
},
"complexity": {
Expand Down
Loading
Loading