diff --git a/docs/assets/how-to/multi-board/qConControl.jpg b/docs/assets/how-to/multi-board/qConControl.jpg
new file mode 100644
index 00000000..7dcc46b3
Binary files /dev/null and b/docs/assets/how-to/multi-board/qConControl.jpg differ
diff --git a/docs/how-to/multi-board-installation.md b/docs/how-to/multi-board-installation.md
index 414ad98b..7592ec93 100644
--- a/docs/how-to/multi-board-installation.md
+++ b/docs/how-to/multi-board-installation.md
@@ -1,9 +1,22 @@
-# Building a multi-device installation
+# The StadBeest: a multi-device installation
-> Saw the StadBeest at the Museumnacht? [Leave a message](https://tally.so/r/68X5xO), or join us on [Discord](https://discord.gg/TC8NSUSCdV) or [Reddit](https://reddit.com/r/moonmodules).
+**Meet the StadBeest**, a creature made of what the city throws away.
+Its body is a chandelier on ten legs of light, its eyes sit in 3D-printed light bulbs, and its mouth is an ordinary WiFi lamp.
+It listens to the music around it, takes a step and looks around on every beat, and picks its own colors and moods through the night.
+A handful of small computers run it together on MoonLight, free lighting software made by volunteers.
+
+
+
+**What did you think of it?** A few words are enough, and we read every one.
+
+[:material-message-text: Leave us a message](https://tally.so/r/68X5xO){ .md-button .md-button--primary } [:fontawesome-brands-discord: Discord](https://discord.gg/TC8NSUSCdV){ .md-button } [:fontawesome-brands-reddit: Reddit](https://reddit.com/r/moonmodules){ .md-button }
+
+**Curious how it is made, or want to build your own?** Read on for the whole recipe, from the hardware to the code.
+
+---
An installation is several MoonLight devices that act as one piece: one creature, one sculpture, one room.
-The worked example is the StadBeest, a walking beast whose legs, eyes and mouth run on four devices.
+The worked example is the StadBeest, whose legs, eyes and mouth run on devices of their own.
Each section names a choice that makes it work; borrow what fits your own piece.
> **Start simple.** The StadBeest shows most of what MoonLight can do in one piece, so it can look overwhelmingly complex.
@@ -15,8 +28,6 @@ Each section names a choice that makes it work; borrow what fits your own piece.
>
> Start with the first, see it run, and add the next step only when you miss it. The autopilot, the desk, the looks and the mouth came last, one at a time.
-
-
> New here? Start with **[Install & first light](../gettingstarted.md)** and **[your first script](../tutorials/first-script.md)**. What follows assumes devices that run MoonLight on one network.
---
@@ -170,14 +181,15 @@ A slot assigned by hand keeps its target, which is how the look switches and the
| `fader1` | `Drivers.brightness` | `Drivers.brightness` | `Drivers.brightness` |
| `fader2` | `bpm` | `bpm` | `bpm` |
| `fader3` | `sensitivity` | `sensitivity` | `sensitivity` |
-| `fader4` and on | the look's next numbers | the look's next numbers | `rest` |
+| `fader4` to `fader7` | the look's next numbers | the look's next numbers | `rest` |
+| `fader8` | autopilot `seconds` | | |
| `encoder1` | `Drivers.palette` | `Drivers.palette` | `Drivers.palette` |
| `encoder2` and on | the look's choices, such as the orb's `shape` | the same | |
A slot holds the value of the control it drives, so `fader2` at 33 means `bpm` 33.
Each look is an effect of its own on every device, named after its script without the `stadbeest-` prefix, and `switch3` to `switch5` turn them on and off through their `enabled`.
So one switch changes the look on the legs and on both eyes at once, and the desk follows it.
-Assign a slot by hand from the card, or with `POST /api/control` and `{"module":"Control","control":"switch3Target","value":"walk.enabled"}`.
+Assign a slot by hand from the Control card.
---
@@ -246,9 +258,9 @@ A [MoonLiveService](../moonmodules/core/services.md#moonliveservice) on the lead
- **Scenes:** every `seconds`, three minutes by default, it picks a new scene.
A scene moves on to the next look, walking, the orb or the thrusters, by switching `switch3` to `switch5`.
- It picks a built-in palette, or for the thrusters [`stadbeest-fire.mlp`](https://github.com/MoonModules/MoonLight/blob/main/moonlive/palettes/stadbeest-fire.mlp), a mood from calm to wild, how much the music counts and `pulse` on or off.
+ It picks a built-in palette, in a third of the thruster scenes [`stadbeest-fire.mlp`](https://github.com/MoonModules/MoonLight/blob/main/moonlive/palettes/stadbeest-fire.mlp), a mood from calm to wild, how much the music counts and `pulse` on or off.
That palette is computed every frame: louder music burns hotter, a beat flares the hot end white, and the flames flicker.
- A palette has the same number on every device, since every factory palette keeps its place in the list whether the device holds it or not: `stadbeest-fire` is 64.
+ A palette has the same number on every device, so the eyes and the mouth pick the same one.
- **Breathing:** within a scene the energy rises and falls once a minute, through `fader2`, the tempo of whatever look runs.
- **Through the surface:** it writes the shared slots with `setControl("fader2", value)`, exactly as a person moves a fader.
The legs follow on the leader, and the eyes and the mouth over OSC.
@@ -277,7 +289,8 @@ The browser is the bridge between desk and device, so the laptop keeps the leade
| APC40 | the StadBeest |
|---|---|
-| track faders 1-8 | brightness, then the look's `bpm`, `sensitivity` and its own numbers |
+| track faders 1-7 | brightness, then the look's `bpm`, `sensitivity` and its own numbers |
+| track fader 8 | the autopilot's `seconds`, how long a scene lasts |
| track knobs 1-8 | the palette, then the look's choices and the numbers past the faders |
| activator 1 | on and off |
| activator 2 | `pulse`, legs and eyes together |
@@ -304,7 +317,10 @@ The bridge is a cable over RTP-MIDI, the standard for MIDI on a network, and kee
So the desk works on the leader as if plugged into it.
The leader greets it, moves its motors, lights its pads with its presets and holds a motor still under a hand.
A Mackie desk's display shows the leader's display line: the last change for five seconds, then the device's name.
-An APC40 mkII takes its power from USB, which this board's port does not supply, so it stays on the laptop or goes through an active, powered USB hub. A hub needs the firmware's hub support, `CONFIG_USB_HOST_HUBS_SUPPORTED`, switched on in the S3 images.
+
+
+
+An APC40 mkII takes its power from USB, which this board's port does not supply, so it stays on the laptop or goes through an active, powered USB hub.
---
diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md
index b9ab8403..51b72dc8 100644
--- a/docs/moonmodules/core/services.md
+++ b/docs/moonmodules/core/services.md
@@ -188,7 +188,7 @@ A device's own page, such as `http://192.168.1.158`, is not a secure origin, so
2. Enter the device's address, such as `http://192.168.1.158`, set the flag to Enabled, and click Relaunch.
3. Reload the device's page: Chrome now asks to use MIDI devices.
-The decoding follows Mackie Control: a fader is 14-bit pitch bend on its own channel, its touch sensor a note from 0x68, a knob a relative turn on a control change from 0x10, and SELECT a note from 0x18. The master fader has no slot on the surface and is ignored. The way back uses the same messages: pitch bend moves a motor, a SELECT note at full velocity lights its button, and a control change from 0x30 fills a knob's ring. The device keeps what the desk should show and pushes it up to 25 times a second; the browser sends only what changed, so a page opened later moves the motors to where the surface already is.
+The decoding follows Mackie Control: a fader is 14-bit pitch bend on its own channel, its touch sensor a note from 0x68, a knob a relative turn on a control change from 0x10, and SELECT a note from 0x18. The master fader has no slot on the surface and is ignored. The way back uses the same messages: pitch bend moves a motor, a SELECT note at full velocity lights its button, and a control change from 0x30 fills a knob's ring over the range of what the knob drives, so it is full at that control's top. The device keeps what the desk should show and pushes it up to 25 times a second; the browser sends only what changed, so a page opened later moves the motors to where the surface already is.
**The Akai APC40 mkII profile.** The browser greets the desk with the SysEx that puts it in Alternate Ableton Live mode, where every light is the host's. Chrome asks once to allow SysEx for it. Track faders 1-8 move the surface's faders and track knobs 1-8 set its encoders, each reading 0 to 127. Activator buttons 1-8 flip its switches. The 40 clip pads apply presets 1-40, the top-left pad being preset 1 as on the Control card. The way back lights each activator while its switch is on and fills each knob's ring like a meter. Each pad is dark when empty, dim white when a preset is stored, and green for the one applied. The faders have no motors. Every message is in [the APC40 reference](../../reference/hardware/control-surfaces.md#akai-apc40-mkii).
diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md
index afc4e45e..e4c89ea8 100644
--- a/docs/reference/metrics/code.md
+++ b/docs/reference/metrics/code.md
@@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code
Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows.
-**1620 finding(s)** across 318 file(s). 2.88% of all lines are duplicated, 3735 lines.
+**1620 finding(s)** across 318 file(s). 2.88% of all lines are duplicated, 3733 lines.
## By rule
@@ -103,7 +103,7 @@ The next function worth simplifying is at the top of each file's list.
| `test/unit/light/unit_Effects_container.cpp` | 10 | `lines 188-199, also in test/unit/light/unit_Effects_container.cpp:139`, duplicated block 12 |
| `src/core/services/InfraredService.h` | 9 | `lines 113-135, also in src/core/services/GamepadService.h:108`, duplicated block 23 |
| `src/core/services/OscModule.h` | 9 | `OscModule::handle`, deeply nested 5 |
-| `src/core/system/ControlModule.h` | 9 | `ControlModule::driveSwitch`, complex function 15 |
+| `src/core/system/ControlModule.h` | 9 | `ControlModule::moveListRow`, complex function 15 |
| `src/light/drivers/PanelCardDriver.h` | 9 | `lines 187-195, also in src/light/drivers/NetworkSendDriver.h:210`, duplicated block 9 |
| `test/unit/light/unit_ParallelLedDriver_swap.cpp` | 9 | `lines 176-184, also in test/unit/light/unit_ParallelLedDriver_swap.cpp:112`, duplicated block 9 |
| `src/core/system/WiFiModule.h` | 8 | `lines 352-359, also in src/light/drivers/FixtureProfilesModule.h:245`, duplicated block 8 |
@@ -132,6 +132,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 |
| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1760 |
| `src/light/layers/BlendMap.h` | 6 | `blendMap`, complex function 35 |
+| `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 557-564, also in src/platform/desktop/moonlive_asm_x86_64.cpp:515`, duplicated block 8 |
| `src/platform/esp32/platform_esp32_fs.cpp` | 6 | `lines 211-221, also in src/platform/esp32/platform_esp32_fs.cpp:161`, duplicated block 11 |
| `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 |
| `test/unit/core/unit_AccessPointModule.cpp` | 6 | `lines 340-348, also in test/unit/core/unit_AccessPointModule.cpp:311`, duplicated block 9 |
@@ -155,8 +156,6 @@ The next function worth simplifying is at the top of each file's list.
| `src/light/layouts/CarLightsLayout.h` | 5 | `lines 49-65, also in src/light/layouts/TorontoBarGourdsLayout.h:52`, duplicated block 17 |
| `src/light/layouts/RingLayout.h` | 5 | `lines 85-100, also in src/light/layouts/CarLightsLayout.h:95`, duplicated block 16 |
| `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 |
-| `src/platform/desktop/moonlive_asm_x86_64.cpp` | 5 | `lines 570-577, also in src/platform/desktop/moonlive_asm_x86_64.cpp:528`, duplicated block 8 |
-| `src/platform/esp32/moonlive_asm_riscv.cpp` | 5 | `lines 40-47, also in src/platform/desktop/moonlive_asm_arm64.cpp:34`, duplicated block 8 |
| `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 |
| `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 |
| `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 |
@@ -184,6 +183,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/light/util/RtspSession.h` | 4 | `rtsp::parseTransport`, complex function 28 |
| `src/main.cpp` | 4 | `mm_main`, complex function 27 |
| `src/platform/desktop/main_desktop.cpp` | 4 | `main`, deeply nested 5 |
+| `src/platform/esp32/moonlive_asm_riscv.cpp` | 4 | `lines 297-303, also in src/platform/esp32/moonlive_asm_xtensa.cpp:481`, duplicated block 7 |
| `test/js/ui-mooncloud.test.mjs` | 4 | `lines 6-17, also in test/js/installer-s31-webflash.test.mjs:11`, duplicated block 12 |
| `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:159`, duplicated block 19 |
| `test/unit/light/unit_BouncingBallsEffect.cpp` | 4 | `lines 75-88, also in test/unit/light/unit_RubiksCubeEffect.cpp:103`, duplicated block 14 |
@@ -208,7 +208,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/light/effects/PongEffect.h` | 3 | `PongEffect::render`, complex function 15 |
| `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 |
| `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 86 files`, name spread 86 |
-| `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 34-41, also in src/platform/esp32/moonlive_asm_riscv.cpp:40`, duplicated block 8 |
+| `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 35-40, also in src/platform/esp32/moonlive_asm_riscv.cpp:34`, duplicated block 6 |
| `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 |
| `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 |
| `test/unit/light/unit_MoonLiveLayout.cpp` | 3 | `lines 685-935, also in test/unit/light/unit_MoonLiveLayout.cpp:403`, duplicated block 251 |
@@ -346,7 +346,7 @@ The next function worth simplifying is at the top of each file's list.
| `src/platform/esp32/platform_config.h` | 1 | `lines 236-250, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 |
| `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 |
| `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 |
-| `src/platform/platform.h` | 1 | `platform.h`, large file 1302 |
+| `src/platform/platform.h` | 1 | `platform.h`, large file 1301 |
| `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 |
| `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 |
| `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 |
@@ -360,7 +360,7 @@ The next function worth simplifying is at the top of each file's list.
| `test/js/ui-visibility.test.mjs` | 1 | `lines 7-16, also in test/js/ui-ws-scheme.test.mjs:13`, duplicated block 10 |
| `test/python/test_check_docgen.py` | 1 | `test_check_docgen.py`, large file 1596 |
| `test/scenario_runner.cpp` | 1 | `scenario_runner.cpp`, large file 1075 |
-| `test/unit/core/unit_ControlModule.cpp` | 1 | `unit_ControlModule.cpp`, large file 2082 |
+| `test/unit/core/unit_ControlModule.cpp` | 1 | `unit_ControlModule.cpp`, large file 2152 |
| `test/unit/core/unit_Control_apply_absent_key.cpp` | 1 | `lines 92-103, also in test/unit/core/unit_HttpServerModule_apply.cpp:40`, duplicated block 12 |
| `test/unit/core/unit_moonlive_compiler.cpp` | 1 | `unit_moonlive_compiler.cpp`, large file 1865 |
| `test/unit/core/unit_moonlive_fill.cpp` | 1 | `unit_moonlive_fill.cpp`, large file 1380 |
diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md
index 0e6bd723..d0bf9091 100644
--- a/docs/reference/metrics/docgen.md
+++ b/docs/reference/metrics/docgen.md
@@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do
Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks.
-**0 error(s)** and **4287 warning(s)** across 413 page(s).
+**0 error(s)** and **4286 warning(s)** across 413 page(s).
**5460 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says.
@@ -17,7 +17,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of
| Rule | Errors | Warnings |
|---|---:|---:|
| split paragraphs | 0 | 1791 |
-| over-wide comment lines | 0 | 1583 |
+| over-wide comment lines | 0 | 1582 |
| appendix sections nothing refers to | 0 | 369 |
| multi-line comment blocks | 0 | 317 |
| over-long sentences | 0 | 82 |
@@ -31,7 +31,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier
| Summary page | Errors | Warnings |
|---|---:|---:|
| `(tests, no card)` | 0 | 2064 |
-| `platform/index.md` | 0 | 744 |
+| `platform/index.md` | 0 | 743 |
| `light/effects.md` | 0 | 403 |
| `core/system.md` | 0 | 203 |
| `core/supporting.md` | 0 | 181 |
@@ -71,7 +71,7 @@ By rule, warnings: 1360 over-wide comment lines, 271 split paragraphs, 197 multi
### platform/index.md
-**0 error(s)** and **744 warning(s)** across 31 file(s).
+**0 error(s)** and **743 warning(s)** across 31 file(s).
| Findings | File | |
|---:|---|---|
@@ -80,14 +80,14 @@ By rule, warnings: 1360 over-wide comment lines, 271 split paragraphs, 197 multi
| 68 | `src/platform/esp32/moonlive_asm_xtensa.cpp` | warning |
| 67 | `src/platform/esp32/platform_esp32_i80.cpp` | warning |
| 61 | `src/platform/esp32/platform_esp32_rmt.cpp` | warning |
-| 43 | `src/platform/desktop/moonlive_asm_arm64.cpp` | warning |
+| 42 | `src/platform/desktop/moonlive_asm_arm64.cpp` | warning |
| 33 | `src/platform/esp32/platform_esp32_parlio.cpp` | warning |
| 32 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning |
| 23 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning |
| 20 | `src/platform/esp32/platform_esp32_improv.cpp` | warning |
| 1-19 each | *21 more warning files, 146 findings* | |
-By rule, warnings: 398 split paragraphs, 218 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to.
+By rule, warnings: 398 split paragraphs, 217 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to.
### light/effects.md
diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md
index 0b49f327..2f2798e2 100644
--- a/docs/reference/metrics/prose.md
+++ b/docs/reference/metrics/prose.md
@@ -9,11 +9,11 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec
docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a
`#` in a script are counted beside a `///` in a header.
-**2950 finding(s)** across 370 file(s).
+**2946 finding(s)** across 370 file(s).
| rule | findings |
|---|---|
-| `MoonLight.EmDash` | 1532 |
+| `MoonLight.EmDash` | 1528 |
| `MoonLight.Weasel` | 688 |
| `MoonLight.SentenceLength` | 430 |
| `MoonLight.Temporal` | 215 |
diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json
index ef90700d..7401f233 100644
--- a/docs/reference/metrics/repo-health.json
+++ b/docs/reference/metrics/repo-health.json
@@ -1,38 +1,38 @@
{
- "commit": "4c61c409",
+ "commit": "48ab0058",
"flash": {
- "esp32s3-n16r8": 2335504,
+ "esp32s3-n16r8": 2336304,
"esp32": 2226256,
"esp32p4rev1-eth": 2177776,
"esp32p4rev1-eth-wifi": 2358896,
- "esp32s3-n8r8": 2335504,
- "esp32s31": 2594384,
+ "esp32s3-n8r8": 2336304,
+ "esp32s31": 2595200,
"esp32-16mb": 2060368,
"esp32-eth": 1681040,
"esp32-wrover": 1843760,
"qemu": 1383648,
"esp32p4rev3-eth": 2203712,
- "esp32s3-zero": 2218288,
+ "esp32s3-zero": 2219120,
"esp32-pico": 2107168,
- "desktop-macos": 2270488,
+ "desktop-macos": 2270440,
"desktop-windows": 1925120,
- "esp32c3": 2334528
+ "esp32c3": 2335072
},
"measured": {
"esp32p4rev1-eth": "2026-10-07",
- "esp32s31": "2026-10-09",
+ "esp32s31": "2026-10-10",
"esp32": "2026-10-09",
"esp32-pico": "2026-09-09",
- "esp32s3-n16r8": "2026-10-09",
- "esp32s3-n8r8": "2026-10-09",
- "esp32s3-zero": "2026-10-09",
+ "esp32s3-n16r8": "2026-10-10",
+ "esp32s3-n8r8": "2026-10-10",
+ "esp32s3-zero": "2026-10-10",
"esp32-16mb": "2026-09-09",
"esp32p4rev1-eth-wifi": "2026-09-30",
"esp32-eth": "2026-09-11",
"esp32p4rev3-eth": "2026-10-08",
- "desktop-macos": "2026-10-09",
+ "desktop-macos": "2026-10-10",
"desktop-windows": "2026-09-30",
- "esp32c3": "2026-10-09"
+ "esp32c3": "2026-10-10"
},
"perf": {
"esp32": {
@@ -44,8 +44,8 @@
"desktop-macos": {
"p50": 7,
"p95": 324,
- "n": 29,
- "last": "2026-10-09"
+ "n": 30,
+ "last": "2026-10-10"
},
"esp32s3-n16r8": {
"p50": 2073,
@@ -108,10 +108,10 @@
},
"State_one_document_adds_an_effect_with_its_controls": {
"desktop-macos": {
- "p50": 2,
+ "p50": 4,
"p95": 387,
- "n": 28,
- "last": "2026-10-09"
+ "n": 29,
+ "last": "2026-10-10"
},
"esp32s3-n16r8": {
"p50": 2505,
@@ -487,7 +487,7 @@
"p50": 7,
"p95": 15,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"desktop-windows": {
"p50": 4,
@@ -513,7 +513,7 @@
"p50": 7,
"p95": 10,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"desktop-windows": {
"p50": 4,
@@ -527,7 +527,7 @@
"p50": 1,
"p95": 2,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"desktop-windows": {
"p50": 4,
@@ -553,7 +553,7 @@
"p50": 7,
"p95": 18,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"esp32s3-n16r8": {
"p50": 9080,
@@ -579,7 +579,7 @@
"p50": 267,
"p95": 355,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
}
},
"Layouts_resize_reallocates_live": {
@@ -587,7 +587,7 @@
"p50": 10,
"p95": 19,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"desktop-windows": {
"p50": 93,
@@ -613,7 +613,7 @@
"p50": 7,
"p95": 11,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"esp32s3-n16r8": {
"p50": 10350,
@@ -639,7 +639,7 @@
"p50": 7,
"p95": 74,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
}
}
},
@@ -651,13 +651,13 @@
"p50": 7,
"p95": 10,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
},
"Layouts_resize_reallocates_live": {
"p50": 10,
"p95": 19,
"n": 32,
- "last": "2026-10-09"
+ "last": "2026-10-10"
}
}
},
@@ -681,16 +681,16 @@
}
},
"loc": {
- "core": 28229,
+ "core": 28271,
"light": 31027,
- "platform": 17948,
+ "platform": 17930,
"ui": 12307,
- "test": 64507,
+ "test": 64597,
"moondeck": 30600
},
"comments": {
"core": {
- "lines": 6157,
+ "lines": 6165,
"ratio": 0.242
},
"light": {
@@ -698,7 +698,7 @@
"ratio": 0.269
},
"platform": {
- "lines": 3660,
+ "lines": 3659,
"ratio": 0.228
},
"ui": {
@@ -706,7 +706,7 @@
"ratio": 0.298
},
"test": {
- "lines": 6963,
+ "lines": 6968,
"ratio": 0.125
},
"moondeck": {
@@ -715,19 +715,19 @@
}
},
"tests": {
- "cases": 2394,
+ "cases": 2398,
"scenarios": 30
},
"docs": {
"md_files": 150,
- "md_lines": 32267,
+ "md_lines": 32271,
"plans_files": 44,
- "backlog_lines": 3318,
+ "backlog_lines": 3319,
"lessons_lines": 532,
"claude_md_lines": 283
},
"complexity": {
- "functions": 4385,
+ "functions": 4389,
"over_threshold": 331,
"worst_ccn": 128
}
diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md
index 2928e52c..d488dc2e 100644
--- a/docs/reference/metrics/repo-health.md
+++ b/docs/reference/metrics/repo-health.md
@@ -1,6 +1,6 @@
# Repo health
-Measured at `4c61c409`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
+Measured at `48ab0058`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
@@ -8,23 +8,23 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Metric | v6.0.0 | Now | Change |
|---|---:|---:|---:|
-| flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+13.0%) |
+| flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) |
| flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) |
| flash esp32p4rev1-eth | 2,022 KB | 2,127 KB | +105.0 KB (+5.2%) |
| flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) |
-| flash esp32s3-n16r8 | 2,111 KB | 2,281 KB | +169.3 KB (+8.0%) |
-| flash esp32s3-n8r8 | 2,038 KB | 2,281 KB | +242.5 KB (+11.9%) |
-| flash esp32s3-zero | 1,977 KB | 2,166 KB | +189.5 KB (+9.6%) |
-| flash esp32s31 | 2,395 KB | 2,534 KB | +138.2 KB (+5.8%) |
+| flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) |
+| flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) |
+| flash esp32s3-zero | 1,977 KB | 2,167 KB | +190.4 KB (+9.6%) |
+| flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) |
| tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 µs | 7 µs | +0 (+0.0%) |
| tick Layouts_resize_reallocates_live (desktop-macos) | 12 µs | 10 µs | -2 (-16.7%) |
-| lines core | 22,299 | 28,229 | +5,930 (+26.6%) |
+| lines core | 22,299 | 28,271 | +5,972 (+26.8%) |
| lines light | 31,054 | 31,027 | -27 (-0.1%) |
-| lines platform | 16,798 | 17,948 | +1,150 (+6.8%) |
+| lines platform | 16,798 | 17,930 | +1,132 (+6.7%) |
| lines ui | 11,394 | 12,307 | +913 (+8.0%) |
-| lines test | 57,759 | 64,507 | +6,748 (+11.7%) |
+| lines test | 57,759 | 64,597 | +6,838 (+11.8%) |
| lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) |
-| unit tests | 2,120 | 2,394 | +274 (+12.9%) |
+| unit tests | 2,120 | 2,398 | +278 (+13.1%) |
| scenarios | 21 | 30 | +9 (+42.9%) |
Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one.
@@ -33,21 +33,21 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b
| Target | Flash | Capacity | Used | Built |
|---|---:|---:|---:|:--:|
-| desktop-macos | 2,217 KB (+16 B) ⚠ | - | - | yes |
-| desktop-windows | 1,880 KB | - | - | **STALE 9d** |
-| esp32 | 2,174 KB | 2,496 KB | 87% | carried 0d |
-| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 30d** |
-| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 28d** |
-| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 30d** |
+| desktop-macos | 2,217 KB (−48 B) ✓ | - | - | yes |
+| desktop-windows | 1,880 KB | - | - | **STALE 10d** |
+| esp32 | 2,174 KB | 2,496 KB | 87% | carried 1d |
+| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 31d** |
+| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 29d** |
+| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 31d** |
| esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) |
-| esp32c3 | 2,280 KB (+224 B) ⚠ | 2,496 KB | 91% | yes |
-| esp32p4rev1-eth | 2,127 KB | 4,096 KB | 52% | carried 2d |
-| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 9d** |
-| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 1d |
-| esp32s3-n16r8 | 2,281 KB | 4,096 KB | 56% | yes |
-| esp32s3-n8r8 | 2,281 KB | 3,072 KB | 74% | yes |
-| esp32s3-zero | 2,166 KB | 2,496 KB | 87% | yes |
-| esp32s31 | 2,534 KB | 4,096 KB | 62% | yes |
+| esp32c3 | 2,280 KB (+544 B) ⚠ | 2,496 KB | 91% | yes |
+| esp32p4rev1-eth | 2,127 KB | 4,096 KB | 52% | carried 3d |
+| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 10d** |
+| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 2d |
+| esp32s3-n16r8 | 2,282 KB (+800 B) ⚠ | 4,096 KB | 56% | yes |
+| esp32s3-n8r8 | 2,282 KB (+800 B) ⚠ | 3,072 KB | 74% | yes |
+| esp32s3-zero | 2,167 KB (+832 B) ⚠ | 2,496 KB | 87% | yes |
+| esp32s31 | 2,534 KB (+816 B) ⚠ | 4,096 KB | 62% | yes |
| qemu | 1,351 KB | - | - | carried (age?) |
`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table.
@@ -56,19 +56,19 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b
| Target | Net | New | Removed | Grown | Shrunk | Other | Since |
|---|---:|---:|---:|---:|---:|---:|---|
-| esp32c3 | +0.2 KB | +0.1 KB | -0.2 KB | +0.3 KB | -0.1 KB | +0.1 KB | `4c61c409` |
-| esp32s3-n16r8 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4c61c409` |
-| esp32s3-n8r8 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4c61c409` |
-| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4c61c409` |
-| esp32s31 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4c61c409` |
+| esp32c3 | +0.5 KB | +1.0 KB | +0.0 KB | +0.6 KB | -0.5 KB | -0.5 KB | `48ab0058` |
+| esp32s3-n16r8 | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.2 KB | +0.1 KB | `48ab0058` |
+| esp32s3-n8r8 | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.2 KB | +0.1 KB | `48ab0058` |
+| esp32s3-zero | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.1 KB | +0.1 KB | `48ab0058` |
+| esp32s31 | +0.8 KB | +1.1 KB | -0.2 KB | +0.8 KB | -0.5 KB | -0.4 KB | `48ab0058` |
Largest moves:
-- **esp32c3**: `mm::platform::pwmStart(unsigned long, unsigned char&)` -198 B, `mm::platform::pwmStart(unsigned char)` +126 B, `mm::DistortionWavesEffect::defineControls()` -64 B, `mm::PwmLightDriver::tick()` +56 B, `mm::FixedPointEffect::tick()` +48 B
-- **esp32s3-n16r8**: none
-- **esp32s3-n8r8**: none
-- **esp32s3-zero**: none
-- **esp32s31**: none
+- **esp32c3**: `mm::moonlive::lowerWith(mm::mo` +504 B, `mm::moonlive::lowerWith(mm::mo` +504 B, `unsigned int mm::moonlive::lowerWith(mm::mo` +504 B, `mm::moonlive::lowerWith(mm::mo` +504 B, `unsigned int mm::moonlive::lowerWith blinkAt + 300) { blinkAt = t + 1000 + random16(2000) * 3; }
+ int closing = 150 - polarR(t - blinkAt - 150, 0);
+ closing = div(closing + polarR(closing, 0), 2);
+ return div(closing * div(height, 2), 150);
+ }
+
void tick() {
fill(0, 0, 0);
// The same shape as the legs' orb, from the same controls: disc, ball or egg, its height along the axis as a share of its width.
@@ -54,14 +62,14 @@ class StadBeestEyeOrbEffect {
// It rises and falls through the legs' whole height, squared so the low end stays slow to set.
rise = mod(rise + (t - lastT) * speed * speed, 65536 * 1280);
lastT = t;
- h = div((span - 1) * sin(div(rise, 1280)), 65535);
+ h = div(143 * sin(div(rise, 1280)), 65535);
int cx = div(width, 2);
for (int y = 0; y < height; y = y + 1) {
- // Each light of the disc stands for `span / height` lights of the legs, in the plane through the ring's middle.
- int dy = div(y * span, height) - h;
+ // Each light of the disc stands for 144 / height lights of the legs, whose height is 144, in the plane through the ring's middle.
+ int dy = div(y * 144, height) - h;
for (int x = 0; x < width; x = x + 1) {
- int dx = div((x - cx) * span, height);
+ int dx = div((x - cx) * 144, height);
// Along the axis, and the squared distance from it.
int u = div(nx * dx + ny * dy, 32768);
int rho = div(u * u * 256, bb) + div((dx * dx + dy * dy - u * u) * 256, aa);
@@ -74,5 +82,13 @@ class StadBeestEyeOrbEffect {
if (bright > 0) { setPaletteColor(x, y, mod(div(polarA(dx, dy), 256) + div(t, 40) + div(y * 64, height), 256), bright); }
}
}
+ // The lids, over what is drawn: the rows beyond the opening go dark.
+ int closed = lids();
+ for (int y = 0; y < closed; y = y + 1) {
+ for (int x = 0; x < width; x = x + 1) {
+ setRGB(y * width + x, 0, 0, 0);
+ setRGB((height - 1 - y) * width + x, 0, 0, 0);
+ }
+ }
}
}
diff --git a/moonlive/effects/stadbeest-sauron.mle b/moonlive/effects/stadbeest-sauron.mle
index 089fdac8..a6372e29 100644
--- a/moonlive/effects/stadbeest-sauron.mle
+++ b/moonlive/effects/stadbeest-sauron.mle
@@ -1,4 +1,4 @@
-// StadBeestSauron: the eye wreathed in flame, a burning iris around a black slit that searches left and right. With music it turns on the beat, its flames reach out with the volume and its slit narrows on every beat.
+// StadBeestSauron: the eye wreathed in flame, a burning iris around a black slit that searches left and right and blinks. With music it turns on the beat, its flames reach out with the volume and its slit narrows on every beat.
class StadBeestSauronEffect {
byte bpm = 12;
@@ -14,6 +14,7 @@ class StadBeestSauronEffect {
int to = 0; // where it turns to
bool wasOnBeat = false; // whether the last frame was on a beat, so one beat counts once
byte flash = 0; // the beat's flash, fading
+ int blinkAt = 3000; // when the next blink starts, in ms
int dimensions() { return 2; }
@@ -47,6 +48,7 @@ class StadBeestSauronEffect {
int heard = audioBeat();
if (sensitivity == 0) { heard = 0; }
flash = flash - div(flash + 15, 16);
+ if (t > blinkAt + 300) { blinkAt = t + 1500 + random16(6000); } // the next blink, a few seconds on
// A rising edge is a beat, unless it follows the last one too closely.
if (heard > wasOnBeat) {
if (t - lastBeat > 250) {
@@ -77,6 +79,8 @@ class StadBeestSauronEffect {
// An almond nearly as wide as the disc and half as high, the slit inside it narrowing on a beat and widening with the volume.
int hw = div(rim * 15, 16);
int hh = div(rim * 9, 16);
+ // A blink closes the almond and opens it again within 300 ms.
+ if (t > blinkAt) { hh = 8 + div((hh - 8) * polarR(t - blinkAt - 150, 0), 150); }
// At least half a light either side of the gaze, so the slit always covers a column of lights.
int sw = 8 + div(slit * rim, 1024) + div(loud * rim, 8192);
int pul = 256;
diff --git a/moonlive/services/stadbeest-autopilot.mls b/moonlive/services/stadbeest-autopilot.mls
index 22073fd3..cc67f507 100644
--- a/moonlive/services/stadbeest-autopilot.mls
+++ b/moonlive/services/stadbeest-autopilot.mls
@@ -1,10 +1,11 @@
-// StadBeestAutopilot: runs the StadBeest unattended. Every few minutes a new scene moves on to the next look (walking, the orb, the thrusters) and picks a palette (stadbeest-fire for the thrusters), a mood, how much the music counts and whether it flashes on the beat; within a scene the energy breathes once a minute. It moves the control surface, so the legs follow here and the eyes over OSC, each on the look's own controls through automap.
+// StadBeestAutopilot: runs the StadBeest unattended. Every few minutes a new scene moves on to the next look (walking, the orb, the thrusters) and picks a palette (stadbeest-fire in a third of the thruster scenes), a mood, how much the music counts and whether it flashes on the beat; within a scene the energy breathes once a minute. It moves the control surface, so the legs follow here and the eyes over OSC, each on the look's own controls through automap.
class StadBeestAutopilot {
bool run = true;
byte seconds = 180;
int scene = -1; // the scene running, counted from boot
+ int sceneAt = -1000000; // when it started, in ms, so a new length moves where it ends rather than restarting the count
int look = 0; // the scene's look: 0 walking, 1 the orb, 2 the thrusters, one switch each
int mood = 128; // the scene's energy, 0 calm to 255 wild
int palette = 0; // the scene's palette
@@ -43,12 +44,12 @@ class StadBeestAutopilot {
void tick20ms() {
if (run == 0) { return; }
- int now = div(t, seconds * 1000);
- if (now != scene) {
- scene = now;
+ if (t - sceneAt >= seconds * 1000) {
+ scene = scene + 1;
+ sceneAt = t;
look = mod(scene, 3);
palette = random16(60); // any built-in palette
- if (look == 2) { palette = 64; } // the thrusters and the burning eye in stadbeest-fire, its place in the palette list on every device
+ if (look == 2) { if (random16(3) == 0) { palette = 64; } } // a third of the thruster scenes burn in stadbeest-fire, its place in the palette list on every device
mood = random16(256);
pulseOn = random16(2);
hearing = 80 + random16(150);
diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h
index 48ef3b0d..5e54f74a 100644
--- a/src/core/moonlive/MoonLiveIr.h
+++ b/src/core/moonlive/MoonLiveIr.h
@@ -139,10 +139,13 @@ constexpr uint8_t idxCount(int32_t p) { return uint8_t((p >> 16) & 0xff); }
/// Branch targets one IR program may use.
static constexpr uint8_t kIrLabels = 40;
-// THE COST IS STACK: doubling these would pass 2 KB, so move them to the heap instead.
+// THE COST IS STACK: a label is a two-byte code position, since no program passes the 16 KB code cap, so 96 of them take what 48 four-byte ones did.
/// Labels and fixups an assembler's tables hold.
-static constexpr uint8_t kAsmLabels = 48;
+static constexpr uint8_t kAsmLabels = 96;
static constexpr uint8_t kAsmFixups = 96;
+/// Where a label is bound in the code, or kUnbound before it is.
+using CodePos = uint16_t;
+static constexpr CodePos kUnbound = 0xFFFF;
// What is live at once rather than a total, since a block hands its slots back at its brace.
/// How many frame slots one program may hold at once.
diff --git a/src/core/services/MidiService.h b/src/core/services/MidiService.h
index e00bde4a..a82cf180 100644
--- a/src/core/services/MidiService.h
+++ b/src/core/services/MidiService.h
@@ -102,6 +102,7 @@ class MidiService : public MoonModule, public ControlSurface {
/// Show one surface control on the desk, in the profile's messages.
void sendValue(SurfaceControl kind, uint8_t index, uint8_t value) override {
+ if (kind == SurfaceControl::Encoder) value = ringShare(index, value);
uint8_t m[3];
uint8_t slot;
if (profile == kProfileApc40 ? !apcMessage(kind, index, value, m, slot) : !mackieMessage(kind, index, value, m, slot)) return;
@@ -110,6 +111,15 @@ class MidiService : public MoonModule, public ControlSurface {
if (connected_ && greeted_) sendChangedSlots();
}
+ /// An encoder's value as its share of what its target takes, on 0..255, so a ring is full at the target's top whatever its range.
+ static uint8_t ringShare(uint8_t index, uint8_t value) {
+ int lo = 0, hi = 255;
+ auto* c = ControlModule::active();
+ if (!c || !c->targetRange(SurfaceControl::Encoder, index, lo, hi) || hi <= lo) return value;
+ const int64_t v = value < lo ? lo : (value > hi ? hi : value);
+ return static_cast((v - lo) * 255 / (static_cast(hi) - lo)); // wide, since an int target's range can span the whole int
+ }
+
/// Show the Control card's display line on a Mackie desk's LCD, at once when the desk is there and on its greeting otherwise.
void sendDisplay(const char* text) override {
mm::formatTo(lcdText_, sizeof(lcdText_), "%s", text ? text : "");
diff --git a/src/core/services/MoonLiveService.h b/src/core/services/MoonLiveService.h
index fa771b31..4ec7b76e 100644
--- a/src/core/services/MoonLiveService.h
+++ b/src/core/services/MoonLiveService.h
@@ -76,6 +76,9 @@ class MoonLiveService : public MoonModule {
MoonModule::defineControls();
}
+ /// The script's controls exist once prepare has compiled it, so a saved value waits for that.
+ bool declaresControlsAtPrepare() const override { return true; }
+
/// Only naming a different script recompiles, a value change updating a byte the tick reads.
bool affectsPrepare(const char* controlName) const override {
return std::strcmp(controlName, "script") == 0;
diff --git a/src/core/system/ControlModule.h b/src/core/system/ControlModule.h
index a43a1401..ef2d6aa5 100644
--- a/src/core/system/ControlModule.h
+++ b/src/core/system/ControlModule.h
@@ -354,6 +354,7 @@ class ControlModule : public MoonModule, public ListSource {
if (std::strstr(controlName, "Target") != nullptr) {
rebuildControls();
followTargets();
+ resendEncoders();
return;
}
for (uint8_t i = 0; i < kFaderCount; i++) {
@@ -545,7 +546,16 @@ class ControlModule : public MoonModule, public ListSource {
}
for (uint8_t b = 0; b < 3; b++)
for (; next[b] < kAutoSlots; next[b]++) changed |= setAuto(banks[b][next[b]], nullptr, nullptr);
- if (changed) rebuildControls(); // the cards show what each slot drives now
+ if (!changed) return;
+ rebuildControls(); // the cards show what each slot drives now
+ followTargets();
+ resendEncoders();
+ }
+
+ /// Show every encoder again, since a ring is scaled by what its knob drives, which just changed.
+ void resendEncoders() {
+ for (uint8_t s = 0; s < surfaceCount_; s++)
+ for (uint8_t i = 0; i < kEncoderCount; i++) surfaces_[s]->sendValue(SurfaceControl::Encoder, i, encoders_[i]);
}
/// Automap after every rebuild walk, which is when the focus and a script's controls can change.
@@ -553,28 +563,24 @@ class ControlModule : public MoonModule, public ListSource {
/// Drives whatever `switchTarget` declares.
void driveSwitch(uint8_t index) {
- const char* target = switchTarget(index);
- if (!target || index >= kSwitchCount) return;
- const char* dot = std::strchr(target, '.');
- if (!dot) return;
+ if (index >= kSwitchCount) return;
+ char module[24];
+ const char* name = splitTarget(switchTarget(index), module);
+ if (!name) return;
auto* sched = Scheduler::instance();
if (!sched) return;
- char module[24];
- const size_t n = std::min(static_cast(dot - target), sizeof(module) - 1);
- std::memcpy(module, target, n);
- module[n] = '\0';
// A button takes every write as a press, so a switch drives one on the way down only.
- const ControlDescriptor* tc = findControl(module, dot + 1);
+ const ControlDescriptor* tc = findControl(module, name);
if (tc && tc->type == ControlType::Button && !switches_[index]) return;
// An unchanged target is not rewritten, as in driveSurface; `enabled` is no declared control yet reads back the same way, and rewriting it rebuilds the tree.
int32_t current = 0;
- if ((!tc || tc->type != ControlType::Button) && sched->getControlWide(module, dot + 1, current)
+ if ((!tc || tc->type != ControlType::Button) && sched->getControlWide(module, name, current)
&& (current != 0) == switches_[index]) return;
char body[32];
std::snprintf(body, sizeof(body), "{\"value\":%s}", switches_[index] ? "true" : "false");
- sched->setControl(module, dot + 1, body);
+ sched->setControl(module, name, body);
// On or off rather than 1 or 0, and a module's `enabled` named by its module, since every such switch drives `enabled`.
- writeStrip("%s %s", std::strcmp(dot + 1, "enabled") == 0 ? module : dot + 1, switches_[index] ? "on" : "off");
+ writeStrip("%s %s", std::strcmp(name, "enabled") == 0 ? module : name, switches_[index] ? "on" : "off");
}
/// Read every bound control back, so a surface FOLLOWS what it drives.
@@ -601,22 +607,48 @@ class ControlModule : public MoonModule, public ListSource {
return nullptr;
}
+ /// What a surface control drives, as "Module.control", or null when it drives nothing yet.
+ const char* targetOf(SurfaceControl kind, uint8_t index) const {
+ return kind == SurfaceControl::Switch ? switchTarget(index)
+ : kind == SurfaceControl::Encoder ? encoderTarget(index)
+ : surfaceTarget(index);
+ }
+
+ /// Split "Module.control" into the module's name, written to `module`, and the control's, returned; null when there is none.
+ static const char* splitTarget(const char* target, char (&module)[24]) {
+ const char* dot = target ? std::strchr(target, '.') : nullptr;
+ if (!dot) return nullptr;
+ const size_t n = std::min(static_cast(dot - target), sizeof(module) - 1);
+ std::memcpy(module, target, n);
+ module[n] = '\0';
+ return dot + 1;
+ }
+
+ /// The values a control takes, from `lo` to `hi`, a choice counted to its last option.
+ static void rangeOf(const ControlDescriptor& c, int& lo, int& hi) {
+ lo = c.min;
+ hi = (c.type == ControlType::Select || c.type == ControlType::Palette) ? static_cast(c.max) - 1
+ : static_cast(c.max);
+ }
+
+ /// The values what a surface control drives takes; false when it drives nothing.
+ bool targetRange(SurfaceControl kind, uint8_t index, int& lo, int& hi) const {
+ char module[24];
+ const char* name = splitTarget(targetOf(kind, index), module);
+ const ControlDescriptor* tc = name ? findControl(module, name) : nullptr;
+ if (tc) rangeOf(*tc, lo, hi);
+ return tc != nullptr;
+ }
+
/// One control's read-back.
bool pullTarget(SurfaceControl kind, uint8_t index, uint8_t& value) {
- const char* target = kind == SurfaceControl::Switch ? switchTarget(index)
- : kind == SurfaceControl::Encoder ? encoderTarget(index)
- : surfaceTarget(index);
- if (!target) return false; // unassigned: nothing to follow
- const char* dot = std::strchr(target, '.');
- if (!dot) return false;
+ char module[24];
+ const char* name = splitTarget(targetOf(kind, index), module);
+ if (!name) return false; // unassigned: nothing to follow
auto* sched = Scheduler::instance();
if (!sched) return false;
- char module[24];
- const size_t n = std::min(static_cast(dot - target), sizeof(module) - 1);
- std::memcpy(module, target, n);
- module[n] = '\0';
uint8_t live = 0;
- if (!sched->getControl(module, dot + 1, live)) return false;
+ if (!sched->getControl(module, name, live)) return false;
if (live == value) return false;
value = live;
return true;
@@ -624,34 +656,28 @@ class ControlModule : public MoonModule, public ListSource {
/// Write a surface control's value onto whatever it targets.
void driveSurface(SurfaceControl kind, uint8_t index) {
- const char* target = kind == SurfaceControl::Encoder ? encoderTarget(index)
- : surfaceTarget(index);
- if (!target) return; // unassigned
- const char* dot = std::strchr(target, '.');
- if (!dot) return;
+ char module[24];
+ const char* name = splitTarget(targetOf(kind, index), module);
+ if (!name) return; // unassigned
auto* sched = Scheduler::instance();
if (!sched) return;
- char module[24];
- const size_t n = std::min(static_cast(dot - target), sizeof(module) - 1);
- std::memcpy(module, target, n);
- module[n] = '\0';
uint8_t value = kind == SurfaceControl::Encoder ? encoders_[index] : faders_[index];
// Clamped to the TARGET's range before writing:
- if (const ControlDescriptor* tc = findControl(module, dot + 1)) {
- const int hi = (tc->type == ControlType::Select || tc->type == ControlType::Palette)
- ? static_cast(tc->max) - 1 : static_cast(tc->max);
+ if (const ControlDescriptor* tc = findControl(module, name)) {
+ int lo = 0, hi = 255;
+ rangeOf(*tc, lo, hi);
if (value > hi) value = static_cast(hi < 0 ? 0 : hi);
- if (value < tc->min) value = tc->min;
+ if (value < lo) value = static_cast(lo);
}
// And the control itself holds what it wrote, so the next read agrees with the target.
if (kind == SurfaceControl::Encoder) encoders_[index] = value; else faders_[index] = value;
// An unchanged target is not rewritten: an OSC echo of a sent value would otherwise read as a player taking a self-playing game's control.
int32_t current = 0;
- if (sched->getControlWide(module, dot + 1, current) && current == value) return;
+ if (sched->getControlWide(module, name, current) && current == value) return;
char body[32];
std::snprintf(body, sizeof(body), "{\"value\":%u}", static_cast(value));
- sched->setControl(module, dot + 1, body);
- showOnStrip(module, dot + 1, value);
+ sched->setControl(module, name, body);
+ showOnStrip(module, name, value);
followTargets(); // siblings on the same target update now, not at the next pass
}
diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h
index 76f4725a..123e2c3e 100644
--- a/src/light/drivers/ParallelLedDriver.h
+++ b/src/light/drivers/ParallelLedDriver.h
@@ -539,9 +539,9 @@ class ParallelLedDriver : public DriverBase {
const uint64_t mask = maskLo32 | (static_cast(maskHi32) << 32); // constant shift: cheap
if (shift) {
// DATA WORDS ONLY: the pulse-start and tail are frame constants, prefilled once.
- const uint32_t segT1 = platform::cycleCount(); // TEMP DIAGNOSTIC
+ const uint32_t segT1 = platform::cycleCount(); // the instrument's split: gather above, transpose below
encodeWs2812ShiftData(wire, mask, physPins_, latchBit_, outputsPerPin(), outCh, out);
- const uint32_t segT2 = platform::cycleCount(); // TEMP DIAGNOSTIC
+ const uint32_t segT2 = platform::cycleCount();
dbgSegGatherCy += segT1 - segT0; // memset+mask+gather (since segT0 / previous row's end)
dbgSegEmitCy += segT2 - segT1; // the transpose+emit
dbgSegRows += 1;
diff --git a/src/platform/desktop/moonlive_asm_arm64.cpp b/src/platform/desktop/moonlive_asm_arm64.cpp
index c3d51b09..4d2be9a5 100644
--- a/src/platform/desktop/moonlive_asm_arm64.cpp
+++ b/src/platform/desktop/moonlive_asm_arm64.cpp
@@ -31,20 +31,6 @@ constexpr bool armScratchOutsideMap() {
static_assert(armScratchOutsideMap(), "a scratch register is also a vreg — calls will corrupt it");
-Label HostAssembler::newLabel() {
- if (labelCount_ == 0) for (auto& p : labelPos_) p = -1;
- if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; } // same overflow signal as emit32
- Label l = labelCount_++;
- labelPos_[l] = -1;
- return l;
-}
-void HostAssembler::bind(Label l) { if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
-
-// Record a pending branch fixup, guarding the fixed table, a script with too many branches sets overflow_ rather than writing past fixups_ (the same failure path as a full code buffer).
-void HostAssembler::addFixup(size_t at, Label label, FixKind kind) {
- if (fixupCount_ >= kMaxFixups) { overflow_ = true; return; }
- fixups_[fixupCount_++] = {at, label, kind};
-}
void HostAssembler::emit32(uint32_t w) {
if (!buf_ || len_ + 4 > kCap) { overflow_ = true; return; }
@@ -294,8 +280,8 @@ void HostAssembler::patchBranches() {
if (!buf_ || overflow_) return;
for (uint8_t i = 0; i < fixupCount_; i++) {
const Fixup& f = fixups_[i];
+ if (labelPos_[f.label] == kUnbound) continue; // unbound label: leave the branch as-is (overflow_ already failed the compile)
int32_t target = labelPos_[f.label];
- if (target < 0) continue; // unbound label — leave the branch as-is (overflow_ already failed the compile)
int32_t rel = (target - static_cast(f.at)) >> 2; // PC-relative, /4
uint32_t w; std::memcpy(&w, buf_ + f.at, 4);
if (f.kind == FixKind::Call) {
diff --git a/src/platform/desktop/moonlive_asm_host.h b/src/platform/desktop/moonlive_asm_host.h
index c5443b39..1ac93b3d 100644
--- a/src/platform/desktop/moonlive_asm_host.h
+++ b/src/platform/desktop/moonlive_asm_host.h
@@ -77,10 +77,17 @@ class HostAssembler {
void emitBytes(const uint8_t* p, size_t n);
// --- labels ---
+ // One definition for both host ISAs, since a table running out is the same failure as a full code buffer.
/// A fresh label, to be bound once and branched to any number of times.
- Label newLabel();
+ Label newLabel() {
+ if (labelCount_ == 0) for (auto& p : labelPos_) p = kUnbound;
+ if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; }
+ const Label l = labelCount_++;
+ labelPos_[l] = kUnbound;
+ return l;
+ }
/// Mark l's position = current offset.
- void bind(Label l);
+ void bind(Label l) { if (len_ >= kUnbound) overflow_ = true; else if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
// --- the call frame ---
/// Open a frame with room for `slots` spilled values, parking a frame pointer at its base.
@@ -175,7 +182,10 @@ class HostAssembler {
enum class FixKind : uint8_t { Branch, Call };
struct Fixup { size_t at; Label label; FixKind kind = FixKind::Branch; };
/// Bounds-checked.
- void addFixup(size_t at, Label label, FixKind kind = FixKind::Branch);
+ void addFixup(size_t at, Label label, FixKind kind = FixKind::Branch) {
+ if (fixupCount_ >= kMaxFixups) { overflow_ = true; return; }
+ fixups_[fixupCount_++] = {at, label, kind};
+ }
// Heap rather than a member array, which put 2 KB on the stack and overflowed a classic ESP32.
uint8_t* buf_;
@@ -185,8 +195,8 @@ class HostAssembler {
// Frame size, 0 without a prologue, so a teardown cannot disagree with its setup about the stack.
uint16_t frameBytes_ = 0;
- // Label positions (-1 = unbound) and pending branch fixups.
- int32_t labelPos_[kMaxLabels];
+ // Label positions (kUnbound before binding) and pending branch fixups.
+ CodePos labelPos_[kMaxLabels];
uint8_t labelCount_ = 0;
Fixup fixups_[kMaxFixups];
uint8_t fixupCount_ = 0;
diff --git a/src/platform/desktop/moonlive_asm_x86_64.cpp b/src/platform/desktop/moonlive_asm_x86_64.cpp
index 5dd2ecc9..db369f08 100644
--- a/src/platform/desktop/moonlive_asm_x86_64.cpp
+++ b/src/platform/desktop/moonlive_asm_x86_64.cpp
@@ -112,19 +112,6 @@ static uint8_t xr(Reg r) { return kX64Reg[r < kRegCount ? r : kRegCount - 1]; }
static_assert(kX64Reg[kRegCount - 1] == x64::RAX,
"rax must be the last vreg — call() relies on saving it before using it as the fn-target scratch");
-Label HostAssembler::newLabel() {
- if (labelCount_ == 0) for (auto& p : labelPos_) p = -1;
- if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; }
- Label l = labelCount_++;
- labelPos_[l] = -1;
- return l;
-}
-void HostAssembler::bind(Label l) { if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
-
-void HostAssembler::addFixup(size_t at, Label label, FixKind kind) {
- if (fixupCount_ >= kMaxFixups) { overflow_ = true; return; }
- fixups_[fixupCount_++] = {at, label, kind};
-}
// Emit bytes through the existing path, which owns the bounds check and the overflow flag; instructions here are variable-length, so a small local buffer holds any single one.
// There is no fixed-word emitter on this architecture: the header declares one for the sibling, where a fixed word is the natural unit.
@@ -746,8 +733,8 @@ void HostAssembler::patchBranches() {
if (!buf_ || overflow_) return;
for (uint8_t i = 0; i < fixupCount_; i++) {
const Fixup& f = fixups_[i];
+ if (labelPos_[f.label] == kUnbound) continue; // unbound label: leave the branch as-is (overflow_ already failed the compile)
int32_t target = labelPos_[f.label];
- if (target < 0) continue; // unbound label — leave alone
if (f.kind == FixKind::Call) {
// `call rel32` at [f.at]: opcode E8 at f.at, imm32 at f.at+1. rel is relative to the byte AFTER the imm32 field, so subtract (f.at + 5).
int32_t rel = target - (int32_t(f.at) + 5);
diff --git a/src/platform/esp32/moonlive_asm_riscv.cpp b/src/platform/esp32/moonlive_asm_riscv.cpp
index 53250967..9effd6fc 100644
--- a/src/platform/esp32/moonlive_asm_riscv.cpp
+++ b/src/platform/esp32/moonlive_asm_riscv.cpp
@@ -38,11 +38,11 @@ void RiscvAssembler::emit32(uint32_t w) {
}
Label RiscvAssembler::newLabel() {
- if (labelCount_ == 0) for (auto& p : labelPos_) p = -1;
+ if (labelCount_ == 0) for (auto& p : labelPos_) p = kUnbound;
if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; } // same overflow signal as emit32
- Label l = labelCount_++; labelPos_[l] = -1; return l;
+ Label l = labelCount_++; labelPos_[l] = kUnbound; return l;
}
-void RiscvAssembler::bind(Label l) { if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
+void RiscvAssembler::bind(Label l) { if (len_ >= kUnbound) overflow_ = true; else if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
// A call to a function in this block, the script-to-script call, linking the return address into the standard register.
// The callee's prologue saves it into its own frame, which is what lets calls nest and therefore recurse.
@@ -299,8 +299,8 @@ void RiscvAssembler::patchBranches() {
if (!buf_ || overflow_) return;
for (uint8_t i = 0; i < fixupCount_; i++) {
const Fixup& f = fixups_[i];
- if (labelPos_[f.label] < 0) continue; // unbound label — leave as-is (overflow_ already failed the compile)
- int32_t off = labelPos_[f.label] - static_cast(f.at);
+ if (labelPos_[f.label] == kUnbound) continue; // unbound label: leave as-is (overflow_ already failed the compile)
+ int32_t off = static_cast(labelPos_[f.label]) - static_cast(f.at);
uint32_t w; std::memcpy(&w, buf_ + f.at, 4);
if (f.kind == FixKind::Branch) {
// The short branch reaches a few kilobytes and no further, past which the mask below truncates the offset silently and the branch lands on whatever the low bits name.
diff --git a/src/platform/esp32/moonlive_asm_riscv.h b/src/platform/esp32/moonlive_asm_riscv.h
index 71dd0703..0f9a0623 100644
--- a/src/platform/esp32/moonlive_asm_riscv.h
+++ b/src/platform/esp32/moonlive_asm_riscv.h
@@ -161,7 +161,7 @@ class RiscvAssembler {
// Frame size, 0 without a prologue, so a teardown cannot disagree with its setup about the stack.
uint16_t frameBytes_ = 0;
- int32_t labelPos_[kMaxLabels];
+ CodePos labelPos_[kMaxLabels];
uint8_t labelCount_ = 0;
struct Fixup { size_t at; Label label; FixKind kind = FixKind::Branch; };
Fixup fixups_[kMaxFixups];
diff --git a/src/platform/esp32/moonlive_asm_xtensa.cpp b/src/platform/esp32/moonlive_asm_xtensa.cpp
index bab475b5..77bb0199 100644
--- a/src/platform/esp32/moonlive_asm_xtensa.cpp
+++ b/src/platform/esp32/moonlive_asm_xtensa.cpp
@@ -180,11 +180,11 @@ void XtensaAssembler::spillLoad(Reg r, uint8_t slot) {
}
Label XtensaAssembler::newLabel() {
- if (labelCount_ == 0) for (auto& p : labelPos_) p = -1;
+ if (labelCount_ == 0) for (auto& p : labelPos_) p = kUnbound;
if (labelCount_ >= kMaxLabels) { overflow_ = true; return 0; } // same overflow signal as emit
- Label l = labelCount_++; labelPos_[l] = -1; return l;
+ Label l = labelCount_++; labelPos_[l] = kUnbound; return l;
}
-void XtensaAssembler::bind(Label l) { if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
+void XtensaAssembler::bind(Label l) { if (len_ >= kUnbound) overflow_ = true; else if (l < kMaxLabels) labelPos_[l] = static_cast(len_); }
// Record a pending branch fixup, guarding the fixed table (overflow_ rather than an OOB write).
void XtensaAssembler::addFixup(size_t at, Label label, FixKind kind) {
@@ -483,11 +483,11 @@ void XtensaAssembler::patchBranches() {
if (!buf_ || overflow_) return;
for (uint8_t i = 0; i < fixupCount_; i++) {
const Fixup& f = fixups_[i];
- if (labelPos_[f.label] < 0) continue; // unbound label — leave as-is (overflow_ already failed the compile)
+ if (labelPos_[f.label] == kUnbound) continue; // unbound label: leave as-is (overflow_ already failed the compile)
uint32_t enc = 0;
if (f.kind == FixKind::Jump) {
// `j`: the displacement is relative to the byte AFTER the instruction and occupies bits 6..23: eighteen signed bits, so it reaches any script the code buffer can hold. Range-checked: refusing beats silently retargeting a jump.
- const int32_t off = labelPos_[f.label] - (static_cast(f.at) + 4);
+ const int32_t off = static_cast(labelPos_[f.label]) - (static_cast(f.at) + 4);
if (off < -131072 || off > 131071) { overflow_ = true; return; }
enc = 0x06u | ((static_cast(off) & 0x3ffffu) << 6);
} else {
@@ -496,7 +496,7 @@ void XtensaAssembler::patchBranches() {
// It stays because the alternative to noticing is a call landing mid-instruction, which the assembler rejects outright.
// The indirect form has no such rule, which is why a call INTO a block always worked while one inside it did not.
const int32_t base = static_cast(f.at & ~size_t(3));
- const int32_t byteOff = labelPos_[f.label] - (base + 4);
+ const int32_t byteOff = static_cast(labelPos_[f.label]) - (base + 4);
if ((byteOff & 3) != 0) { overflow_ = true; return; }
const int32_t off = byteOff >> 2;
if (off < -131072 || off > 131071) { overflow_ = true; return; }
diff --git a/src/platform/esp32/moonlive_asm_xtensa.h b/src/platform/esp32/moonlive_asm_xtensa.h
index 04c8fd1f..d5019d90 100644
--- a/src/platform/esp32/moonlive_asm_xtensa.h
+++ b/src/platform/esp32/moonlive_asm_xtensa.h
@@ -166,7 +166,7 @@ class XtensaAssembler {
size_t len_ = 0;
bool overflow_ = false;
- int32_t labelPos_[kMaxLabels];
+ CodePos labelPos_[kMaxLabels];
uint8_t labelCount_ = 0;
Fixup fixups_[kMaxFixups];
uint8_t fixupCount_ = 0;
diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json
index 9a03ba56..2594e95e 100644
--- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json
+++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json
@@ -178,10 +178,10 @@
"p95": 324,
"min": 5,
"max": 462,
- "n": 29,
- "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8]
+ "n": 30,
+ "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json
index eeb29fc6..e1e18998 100644
--- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json
+++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json
@@ -69,14 +69,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 2,
+ "p50": 4,
"p95": 387,
"min": 1,
"max": 555,
- "n": 28,
- "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7]
+ "n": 29,
+ "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json
index 593581de..00007c32 100644
--- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json
+++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json
@@ -107,9 +107,9 @@
"min": 6,
"max": 17,
"n": 32,
- "samples": [6, 7, 7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8]
+ "samples": [7, 7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
index 754a6e42..af5c1ade 100644
--- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
+++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json
@@ -54,10 +54,10 @@
"p95": 7,
"min": 1,
"max": 7,
- "n": 18,
- "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6]
+ "n": 19,
+ "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -86,11 +86,11 @@
"p50": 1,
"p95": 7,
"min": 1,
- "max": 7,
+ "max": 8,
"n": 32,
- "samples": [1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6]
+ "samples": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
@@ -118,9 +118,9 @@
"min": 6,
"max": 46,
"n": 32,
- "samples": [6, 7, 7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7]
+ "samples": [7, 7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json
index 47479bb7..ebeb465e 100644
--- a/test/scenarios/light/scenario_Effects_swap_while_running.json
+++ b/test/scenarios/light/scenario_Effects_swap_while_running.json
@@ -86,9 +86,9 @@
"min": 1,
"max": 2,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
@@ -168,9 +168,9 @@
"min": 1,
"max": 4,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json
index 122faabe..6a7eb405 100644
--- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json
+++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json
@@ -68,9 +68,9 @@
"min": 6,
"max": 81,
"n": 32,
- "samples": [7, 7, 6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8]
+ "samples": [7, 6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -149,9 +149,9 @@
"min": 4,
"max": 32,
"n": 32,
- "samples": [4, 4, 4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5]
+ "samples": [4, 4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -226,9 +226,9 @@
"min": 6,
"max": 19,
"n": 32,
- "samples": [7, 7, 7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7]
+ "samples": [7, 7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -313,9 +313,9 @@
"min": 6,
"max": 18,
"n": 32,
- "samples": [7, 7, 6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7]
+ "samples": [7, 6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -406,9 +406,9 @@
"min": 6,
"max": 31,
"n": 32,
- "samples": [6, 7, 6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
+ "samples": [7, 6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -495,9 +495,9 @@
"min": 3,
"max": 25,
"n": 32,
- "samples": [3, 3, 3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5]
+ "samples": [3, 3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -600,9 +600,9 @@
"min": 3,
"max": 20,
"n": 32,
- "samples": [4, 3, 3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5]
+ "samples": [3, 3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json
index de286202..35709635 100644
--- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json
+++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json
@@ -92,9 +92,9 @@
"min": 8,
"max": 24,
"n": 32,
- "samples": [9, 8, 8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9]
+ "samples": [8, 8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -136,9 +136,9 @@
"min": 13,
"max": 37,
"n": 32,
- "samples": [15, 15, 15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16]
+ "samples": [15, 15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -224,9 +224,9 @@
"min": 18,
"max": 46,
"n": 32,
- "samples": [21, 20, 20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21]
+ "samples": [20, 20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -264,9 +264,9 @@
"min": 121,
"max": 225,
"n": 32,
- "samples": [124, 123, 122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124]
+ "samples": [123, 122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -304,9 +304,9 @@
"min": 262,
"max": 454,
"n": 32,
- "samples": [275, 270, 264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267]
+ "samples": [270, 264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -338,12 +338,12 @@
"tick_us": {
"p50": 56,
"p95": 85,
- "min": 54,
+ "min": 51,
"max": 100,
"n": 32,
- "samples": [56, 56, 56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56]
+ "samples": [56, 56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -367,9 +367,9 @@
"min": 51,
"max": 103,
"n": 32,
- "samples": [52, 52, 52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55]
+ "samples": [52, 52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -400,12 +400,12 @@
"tick_us": {
"p50": 52,
"p95": 205,
- "min": 51,
+ "min": 48,
"max": 281,
"n": 32,
- "samples": [51, 51, 51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52]
+ "samples": [51, 51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
}
diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json
index 2ce74766..1e6691db 100644
--- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json
+++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json
@@ -82,9 +82,9 @@
"min": 6,
"max": 23,
"n": 32,
- "samples": [7, 6, 6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7]
+ "samples": [6, 6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
@@ -164,9 +164,9 @@
"min": 9,
"max": 22,
"n": 32,
- "samples": [10, 10, 10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11]
+ "samples": [10, 10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
@@ -253,9 +253,9 @@
"min": 6,
"max": 14,
"n": 32,
- "samples": [6, 7, 6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7]
+ "samples": [7, 6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json
index 03378b5c..8feca06b 100644
--- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json
+++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json
@@ -67,9 +67,9 @@
"min": 6,
"max": 19,
"n": 32,
- "samples": [6, 6, 6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7]
+ "samples": [6, 6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -156,9 +156,9 @@
"min": 3,
"max": 11,
"n": 32,
- "samples": [3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4]
+ "samples": [3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -238,9 +238,9 @@
"min": 3,
"max": 9,
"n": 32,
- "samples": [3, 3, 3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4]
+ "samples": [3, 3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -319,9 +319,9 @@
"min": 2,
"max": 6,
"n": 32,
- "samples": [2, 2, 2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3]
+ "samples": [2, 2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -406,9 +406,9 @@
"min": 3,
"max": 11,
"n": 32,
- "samples": [3, 3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4]
+ "samples": [3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -495,9 +495,9 @@
"min": 3,
"max": 9,
"n": 32,
- "samples": [3, 4, 3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4]
+ "samples": [4, 3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -582,9 +582,9 @@
"min": 6,
"max": 20,
"n": 32,
- "samples": [6, 6, 6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7]
+ "samples": [6, 6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
},
"esp32s3-n16r8": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json
index 04aabb65..992e642e 100644
--- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json
+++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json
@@ -107,9 +107,9 @@
"min": 6,
"max": 202,
"n": 32,
- "samples": [6, 6, 202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7]
+ "samples": [6, 202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
},
@@ -175,9 +175,9 @@
"min": 6,
"max": 205,
"n": 32,
- "samples": [6, 7, 205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
+ "samples": [7, 205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6]
},
- "last_updated": "2026-10-09"
+ "last_updated": "2026-10-10"
}
}
}
diff --git a/test/unit/core/moonlive_device_codegen.inc b/test/unit/core/moonlive_device_codegen.inc
index 0c95502d..77f5f32e 100644
--- a/test/unit/core/moonlive_device_codegen.inc
+++ b/test/unit/core/moonlive_device_codegen.inc
@@ -187,26 +187,28 @@ TEST_CASE("an unencodable script is refused, not truncated, on " MM_ISA_NAME) {
// The assembler is a LOCAL in lowerWith, and lowerWith runs on the render task, so its size is
// stack a 12 KB task has to have spare while a script compiles. Its two fixed tables dominate it:
-// 4 bytes per label and 8 per fixup, so raising kAsmLabels/kAsmFixups is a stack decision wearing a
-// capacity costume. 16/32 put lowerWith at 480 bytes on the classic ESP32 and 48/96 puts it at
-// 1120, which is the largest frame on the compile chain (2128 bytes nested).
+// 2 bytes per label (a code position, under the 16 KB code cap) and 8 per fixup, so raising
+// kAsmLabels/kAsmFixups is a stack decision wearing a capacity costume. 48 four-byte labels and 96
+// fixups put lowerWith at 1120 bytes on the classic ESP32, the largest frame on the compile chain
+// (2128 bytes nested); 96 two-byte labels take the same table bytes.
//
// This bound is a TRIPWIRE, not a target: it exists so that raising the tables is a deliberate act
// with a number attached, rather than something noticed when a board bootloops. A P4 has already
// been lost to an oversized frame on this path. If this fails, either the growth is wrong, or the
// tables belong on the heap next to the code buffer, which was moved off the stack for this reason.
TEST_CASE("the " MM_ISA_NAME " assembler stays small enough to build on a render task") {
- // Asserted in TABLE ENTRIES rather than bytes, because bytes here are the host's: these backends
- // are compiled on a 64-bit machine to run their real emitter, a Fixup holds a size_t, and
- // sizeof() therefore overstates the device object. The entry counts are the same everywhere and
- // they are what actually gets raised, so they are the honest thing to pin.
+ // Asserted in the tables' DEVICE bytes rather than sizeof(), because sizeof() here is the host's:
+ // these backends are compiled on a 64-bit machine to run their real emitter, a Fixup holds a
+ // size_t, and the host object overstates the device one. A label is a CodePos everywhere and a
+ // device Fixup is 8 bytes, so the device bytes follow from the entry counts.
//
// The device figure, measured on the classic ESP32 image rather than derived: lowerWith's frame
- // is 480 bytes at 16/32 and 1120 at 48/96: 4 bytes per label and 8 per fixup. Re-measure with
- // `xtensa-esp-elf-objdump -d` and grep the `entry a1, N` of lowerWith after changing either.
- INFO(MM_ISA_NAME " assembler = " << sizeof(MM_ISA_ASM) << " bytes on this 64-bit host");
- CHECK(mm::moonlive::kAsmLabels <= 64);
- CHECK(mm::moonlive::kAsmFixups <= 128);
+ // is 1120 bytes with these tables at 960 bytes. Re-measure with `xtensa-esp-elf-objdump -d` and
+ // grep the `entry a1, N` of lowerWith after changing either.
+ constexpr size_t kDeviceFixupBytes = 8;
+ const size_t tableBytes = mm::moonlive::kAsmLabels * sizeof(mm::moonlive::CodePos) + mm::moonlive::kAsmFixups * kDeviceFixupBytes;
+ INFO(MM_ISA_NAME " assembler = " << sizeof(MM_ISA_ASM) << " bytes on this 64-bit host, tables " << tableBytes << " on a device");
+ CHECK(tableBytes <= 1280); // 64 four-byte labels and 128 fixups, the allowance before labels shrank
}
// A class whose functions call each other has to place each function where a call can REACH it.
diff --git a/test/unit/core/unit_ControlModule.cpp b/test/unit/core/unit_ControlModule.cpp
index 7e55b7c2..abd682e2 100644
--- a/test/unit/core/unit_ControlModule.cpp
+++ b/test/unit/core/unit_ControlModule.cpp
@@ -1612,6 +1612,33 @@ TEST_CASE("a MIDI desk shows the surface: fader motors, SELECT lights and knob r
midi.release();
}
+// A ring shows a knob's value as its share of what the knob drives, so it is full at the target's top whatever that target's range.
+TEST_CASE("a MIDI desk's knob ring fills over the range of what the knob drives") {
+ Device d;
+ mm::MidiService midi;
+ midi.defineControls();
+ REQUIRE(d.scheduler.setControl("Control", "encoder2Target", "{\"value\":\"Drivers.motionHold\"}") == mm::Scheduler::SetControlResult::Ok);
+ REQUIRE(d.scheduler.setControl("Control", "encoder2", "{\"value\":240}") == mm::Scheduler::SetControlResult::Ok); // motionHold's top
+ midi.prepare();
+ CHECK(deskSlot(midi, 17) == "b0312b"); // encoder2's ring, all eleven lights
+ midi.release();
+}
+
+// A knob given another target keeps its value, so its ring is sent again for the new range.
+TEST_CASE("a MIDI desk's knob ring follows a new target with the same value") {
+ Device d;
+ mm::MidiService midi;
+ midi.defineControls();
+ REQUIRE(d.scheduler.setControl("Drivers", "brightness", "{\"value\":200}") == mm::Scheduler::SetControlResult::Ok);
+ REQUIRE(d.scheduler.setControl("Drivers", "motionHold", "{\"value\":200}") == mm::Scheduler::SetControlResult::Ok);
+ REQUIRE(d.scheduler.setControl("Control", "encoder2Target", "{\"value\":\"Drivers.brightness\"}") == mm::Scheduler::SetControlResult::Ok);
+ midi.prepare();
+ CHECK(deskSlot(midi, 17) == "b03128"); // 200 of 255: eight lights
+ REQUIRE(d.scheduler.setControl("Control", "encoder2Target", "{\"value\":\"Drivers.motionHold\"}") == mm::Scheduler::SetControlResult::Ok);
+ CHECK(deskSlot(midi, 17) == "b03129"); // 200 of 240: nine
+ midi.release();
+}
+
// A desk that reports its motorized fader's position back must land on the value it was sent, or the two would chase each other.
TEST_CASE("a fader position sent to a MIDI desk decodes back to the same value") {
Device d;
@@ -1972,6 +1999,49 @@ TEST_CASE("a Mackie desk shared over the network shows the display line of the d
mm::platform::setTestUsbMidiDesk(false);
}
+// A quick turn arrives as several knob messages in one USB read and one network packet, so each must keep its direction on the way.
+TEST_CASE("a Mackie desk shared over the network turns a knob down as well as up") {
+ Device d;
+ mm::platform::setTestUsbMidiDesk(true);
+ mm::MidiService bridge;
+ bridge.source = mm::MidiService::kSourceUsb;
+ bridge.share = true;
+ bridge.port = 26104;
+ bridge.prepare();
+ mm::MidiService midi;
+ midi.defineControls();
+ midi.source = mm::MidiService::kSourceNetwork;
+ midi.port = 26204;
+ auto& cs = midi.controls();
+ for (uint8_t i = 0; i < cs.count(); i++)
+ if (std::strcmp(cs[i].name, "host") == 0) std::strcpy(static_cast(cs[i].ptr), "127.0.0.1:26104");
+ midi.prepare();
+ const auto exchange = [&] {
+ midi.tick20ms();
+ bridge.tick20ms();
+ sentToDesk();
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
+ };
+ for (int i = 0; i < 200 && std::string(bridge.status()).find("shared with") != 0; i++) exchange();
+ REQUIRE(std::string(bridge.status()).find("shared with") == 0);
+
+ const uint8_t start = surfaceValue(d, "encoder1");
+ const uint8_t up[1][4] = {{0x0B, 0xB0, 0x10, 0x0A}};
+ mm::platform::injectTestUsbMidi(up, 1);
+ for (int i = 0; i < 200 && surfaceValue(d, "encoder1") == start; i++) exchange();
+ const uint8_t raised = surfaceValue(d, "encoder1");
+ CHECK(raised > start);
+ const uint8_t down[5][4] = {{0x0B, 0xB0, 0x10, 0x41}, {0x0B, 0xB0, 0x10, 0x41}, {0x0B, 0xB0, 0x10, 0x42},
+ {0x0B, 0xB0, 0x10, 0x41}, {0x0B, 0xB0, 0x10, 0x41}};
+ mm::platform::injectTestUsbMidi(down, 5);
+ for (int i = 0; i < 200 && surfaceValue(d, "encoder1") == raised; i++) exchange();
+ for (int i = 0; i < 20; i++) exchange();
+ CHECK(surfaceValue(d, "encoder1") == raised - 6);
+ midi.release();
+ bridge.release();
+ mm::platform::setTestUsbMidiDesk(false);
+}
+
// A Mackie desk's LCD shows what the Control card's display shows, centered on its top row.
TEST_CASE("a Mackie desk's LCD shows the Control card's display line") {
Device d;
diff --git a/test/unit/core/unit_MoonLiveService.cpp b/test/unit/core/unit_MoonLiveService.cpp
index 78081257..a5d83eeb 100644
--- a/test/unit/core/unit_MoonLiveService.cpp
+++ b/test/unit/core/unit_MoonLiveService.cpp
@@ -11,6 +11,7 @@
#include "core/module/Scheduler.h"
#include "core/module/MoonModule.h"
#include "core/system/FilesystemModule.h"
+#include "core/module/StateDocument.h"
#include "platform/platform.h"
#include
@@ -129,6 +130,23 @@ TEST_CASE("a scripted service declares its own controls, which a user can set")
CHECK(sawThreshold);
}
+// A script's controls exist only once it compiles, so a saved value for one lands after the compile rather than being dropped.
+TEST_CASE("a saved value of a script's own control survives the boot") {
+ Rig rig;
+ writeScript("t_saved.mls",
+ "class Saved {\n"
+ " byte seconds = 180;\n"
+ " void defineControls() { addControl(\"seconds\", seconds, 1, 255); }\n"
+ " void tick20ms() { }\n"
+ "}\n");
+ const StateDocumentResult r = applyStateDocument(rig.scheduler, R"({"Script": {"script": "t_saved.mls", "seconds": 85}})", StateSource::Stored);
+ REQUIRE(r.ok);
+ rig.scheduler.prepareTree();
+ int32_t seconds = 0;
+ REQUIRE(rig.scheduler.getControlWide("Script", "seconds", seconds));
+ CHECK(seconds == 85);
+}
+
// A script names its own choices, so a setting with a few modes reads as words on the card rather than as numbers on a slider.
TEST_CASE("a script declares a dropdown with addSelect, its value the index of the name picked") {
Rig rig;
diff --git a/test/unit/light/unit_Effects_framerate.cpp b/test/unit/light/unit_Effects_framerate.cpp
index 97490e76..819ac021 100644
--- a/test/unit/light/unit_Effects_framerate.cpp
+++ b/test/unit/light/unit_Effects_framerate.cpp
@@ -39,6 +39,8 @@ double meanLit(Make make, int fps, int seconds) {
MoonModule* effect = make();
effect->defineControls();
layer.addChild(effect);
+ // Set before setup, which seeds an effect's random draws from the clock: unset, it reads the real clock and every run draws differently.
+ platform::setTestNowMs(100000u);
layouts.applyState();
layer.applyState();