Skip to content

Crash-safe module ops, modifier overhaul, Windows desktop port#13

Merged
ewowi merged 7 commits into
mainfrom
next-iteration
Jun 8, 2026
Merged

Crash-safe module ops, modifier overhaul, Windows desktop port#13
ewowi merged 7 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

This branch spans three themes that grew together over the iteration. All commit + pre-merge gates pass; both review rounds (👾 Reviewer over the whole branch, 🐇 CodeRabbit) are processed.

1. Crash-safe module lifecycle + scenario tooling

  • Delete/rebuild never crashes. Deleting a Layer used to leave drivers holding a dangling pointer (→ LoadProhibited boot loop). Drivers now re-resolve the active Layer from the Layers container at every buildState and tolerate null; Layers re-propagates Layouts to children. Regression-tested.
  • clear_children scenario op — a scenario clears a container and rebuilds its own canvas from any starting tree; replace_module over the live API; remove_module/delete_module aliased so the in-process and live runners can't diverge. Non-editable submodules (Board, Preview, Improv) are refused by delete/replace, enforced at the API.
  • scenario_AllEffects_grid_sizes — every effect (no modifier) across 16/32/64/128, measuring tick/FPS/heap/block.

2. Modifier overhaul

  • MultiplyModifier replaces MirrorModifier — per-axis tile (multiplyX/Y/Z, 1–64) + per-axis mirrorX/Y/Z. The kaleidoscope superset: a pure mirror is multiply-2-with-mirror. Effective multiplier clamps to the axis extent, so multiplyZ on a 2D layout is a no-op (not a blank).
  • CheckerboardModifier — a mask (size + invert) that drops lights via outCount=0, no ModifierBase contract change.
  • Fan-out limited only by memory — the LUT scratch buffer is sized dynamically to the modifier's fan-out (clamped to the box), not a fixed cap; OOM degrades to the identity LUT.
  • Layout status linesLayouts shows physical lights + bounding box, Layer shows the logical render box, via the MoonModule status slot (no new controls).
  • UI — "+ add module" rename; drag-reorder stopPropagation fix (a drop no longer bubbles to ancestor cards and undoes itself); documented insert-not-swap reorder semantics.

3. Windows desktop port

  • Windows desktop build + ESP-IDF-on-Windows path; CI Windows/macOS build fixes; MoonDeck port enum + installer hints. (Implemented + tested on a Windows machine.)

Notable bug fixes from hardware testing

  • uint16 overflow black-screen (no-PSRAM): a high fan-out logicalCount × maxMultiplier (and maxMultiplier itself) overflowed uint16, wrapping the LUT size to ~0 → black display. Both now compute in uint64 + saturate. Invisible on the uint32 desktop build — caught on the Olimex.
  • Device-created names with spaces (ensureUniqueName "Grid 2") were API-unaddressable. Root-caused: the disambiguating separator is now - (URL-safe), and the live runner URL-encodes names.

Verification

Commit gates green on every commit; pre-merge: Reviewer (1 blocker fixed), CodeRabbit (7 fixed / 3 skipped-with-reason), docs sync, carry-forward lesson, permission review. Modifier fixes verified on the Olimex (esp32-eth) hardware. ESP32 KPI captured; one live-perf snapshot deferred during a session where the board's Ethernet PHY was intermittently flapping (a documented hardware issue — firmware ruled out; backlog).

🤖 Generated with Claude Code

Make the device survive any add/delete/replace/reconfigure sequence: deleting a Layer no longer leaves drivers holding a dangling pointer (which previously crashed the device into a boot loop). Adds a scenario that sweeps every effect across four grid sizes on real hardware, the clear_children op that lets a scenario prepare its own canvas from any starting tree, and a "robust to any input" principle backed by the test framework.

KPI: 16384lights | PC:298KB | tick:368/110/83/1/109/109/52/35/310us(FPS:2717/9090/12048/1000000/9174/9174/19230/28571/3225) | ESP32:765KB | tick:485us(FPS:2061) | heap:229KB | src:64(11476) | test:42(6313) | lizard:46w

Core:
- Drivers: self-healing Layer link — setLayers(Layers*) re-resolves the active Layer at every buildState instead of pinning it once; passBufferToDrivers now clears driver layer_/sourceBuffer_ to null when no Layer is active (was an early-return that left a dangling pointer → the boot-loop crash). Added a layer() getter. setLayer(Layer*) kept for test rigs.
- Layers: onBuildState re-propagates layouts_ to child Layers, so a Layer added/rebuilt via the API wires up without re-running main.cpp.
- HttpServerModule: delete now refuses non-deletable submodules (userEditable()==false) — enforces at the API what was UI-only before.
- ImprovProvisioningModule: userEditable()==false (apparatus, not deletable; still disableable).
- main: bind Drivers to the Layers container (drops the manual setLayer wiring — the link self-heals now).

Light domain:
- (covered under Core — the Drivers/Layers changes live in src/light/ but are core pipeline behaviour)

Tests:
- scenario_runner: clear_children op (delete all deletable children of a container, respecting userEditable); remove_module/delete_module accepted as aliases; registered the 12 missing effects; Drivers props accept a Layers-container binding; Layers props wire layouts.
- run_live_scenario: clear_children + replace_module ops; remove_module/delete_module aliases; 15s timeout, retry on transient connection-reset and 404-during-buildState, tolerate non-deletable children in clear; optional steps skipped on a missing target.
- scenario_AllEffects_grid_sizes: new — every effect (no modifier) at 16/32/64/128, measuring tick/FPS/free-internal-heap/max-block; prepares its own canvas (clear + rebuild) so it runs from any device state; shrinks the grid before each effect swap.
- unit_PreviewDriver: regression test — deleting the active Layer leaves the driver null, not dangling, and an empty pipeline produces no frame (pins the crash fix).

Docs / CI:
- CLAUDE.md: "Robust to any input" principle + hard rule; commit-message format clarifies hard newlines between parts, no hard-wrap within a part.
- architecture.md: § Robustness (no-crash contract, resolve-at-buildState + tolerate-null patterns, the dangling-Layer worked example).
- performance.md: all-effects sweep tables (FPS, free internal heap, max block per effect × grid) for esp32-eth.
- backlog.md: Windows desktop port runbook (POSIX→Winsock port checklist, MoonDeck + web-installer already cross-platform, CI re-enable steps).
- README: "adding a new module needs zero UI code" (was "a new effect").
- docs/history: monthly activity digests for FastLED / WLED / WLED-MM / NightDriverStrip + a README index with the reusable digest prompt.

Reviews:
- 👾 Reviewer (pre-commit, on staged diff): confirmed the dangling-pointer fix correct and complete (all driver consumers null-tolerant, setLayers/setLayer coherent, persistence interaction sound). Flagged op-name divergence between the two runners (remove_module vs delete_module) — fixed: both accept both verbs as aliases. All other scope (domain boundary, abstractions, hot-path, spec conformance, bloat) clean.

KPI Details:
  PC tick: 368/110/83/1/109/109/52/35/310us (FPS: 2717/9090/12048/1000000/9174/9174/19230/28571/3225) (per scenario)
  ESP32 (esp32-eth) tick: 485us (FPS: 2061)  free internal: 229KB  maxBlock: 110KB  (canonical Noise+Mirror+ArtNet pipeline)
  Specs: 28 modules, 28 ok, 0 missing, 0 outdated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch next-iteration

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/core/HttpServerModule.cpp (1)

727-738: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce userEditable() in replace endpoint as well.

Line 727+ validates top-level replacement, but there is no userEditable() check in handleReplaceModule. That means protected apparatus modules can still be replaced through API even though the editability contract covers delete and replace.

Suggested fix
 void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const char* moduleName, const char* body) {
     auto* mod = findModuleByName(moduleName);
     if (!mod) {
         sendResponse(conn, 404, "application/json", "{\"error\":\"module not found\"}");
         return;
     }
     auto* parent = mod->parent();
     if (!parent) {
         sendResponse(conn, 400, "application/json", "{\"error\":\"top-level modules cannot be replaced\"}");
         return;
     }
+    if (!mod->userEditable()) {
+        sendResponse(conn, 400, "application/json", "{\"error\":\"module not replaceable\"}");
+        return;
+    }
     char typeName[32] = {};
     mm::json::parseString(body, "type", typeName, sizeof(typeName));

Also applies to: 758-763

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.cpp` around lines 727 - 738, The replace endpoint
currently skips the userEditable() guard; update handleReplaceModule to call
mod->userEditable() (or the module object's userEditable method) after resolving
mod/parent and before proceeding with replacement, and if it returns false send
a 403 JSON response (e.g. "{\"error\":\"module not editable\"}") and return;
apply the same check in the second replace path referenced around the 758-763
region so both code paths enforce the editability contract.
scripts/scenario/run_live_scenario.py (1)

600-604: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--update-contract path uses unhashable dict keys and can crash.

At Line 600 and Line 687, (step, target) is used as a dict key, but step is a Python dict (unhashable). This raises TypeError: unhashable type: 'dict' during contract update/rollback.

Suggested fix (key by step index instead of step dict)
-    for step in scenario.get("steps", []):
+    for step_idx, step in enumerate(scenario.get("steps", [])):
         step_name = step.get("name", "?")
         op = step.get("op", "")
         step_result = {"name": step_name, "op": op}
@@
             if update_contract:
                 # Preserve any per-step tolerance overrides already in place.
                 existing = step.get("contract", {}).get(target, {})
-                if (step, target) not in pending_contract_originals:
+                key = (step_idx, target)
+                if key not in pending_contract_originals:
                     # Deep enough copy: existing is a flat dict of scalars.
-                    pending_contract_originals[(step, target)] = (
+                    pending_contract_originals[key] = (
                         dict(existing) if existing else None
                     )
@@
-        for step in scenario.get("steps", []):
+        for step_idx, step in enumerate(scenario.get("steps", [])):
             contract = step.get("contract")
             if contract and target in contract:
-                if (step, target) in pending_contract_originals:
-                    orig = pending_contract_originals[(step, target)]
+                key = (step_idx, target)
+                if key in pending_contract_originals:
+                    orig = pending_contract_originals[key]
                     if orig is None:
                         del contract[target]
                     else:
                         contract[target] = orig

Also applies to: 687-688

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/scenario/run_live_scenario.py` around lines 600 - 604, The bug is
that pending_contract_originals is keyed by (step, target) where step is an
unhashable dict; change the key to use the step index instead (e.g., use the
loop index or an explicit step_index variable) so keys are hashable; update all
references that create or access pending_contract_originals (the lines that set
pending_contract_originals[(step, target)] and the rollback/access at lines
corresponding to 687-688) to use (step_index, target) consistently, ensuring any
places that previously passed the step dict now use the index or retrieve the
step dict from the steps list by that index.
src/main.cpp (1)

197-212: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the PreviewDriver wiring comment.

The code now binds Drivers through setLayers(layersContainer), so the nearby setLayer wording is no longer true.

📝 Suggested fix
-    // PreviewDriver reads the active Layer (via Drivers' setLayer wiring) for the
-    // light positions and the sparse driver buffer it streams; it owns its own
+    // PreviewDriver reads the active Layer resolved by Drivers from the bound
+    // Layers container for the light positions and sparse driver buffer it
+    // streams; it owns its own

As per coding guidelines, comments in src/**/*.{h,hpp,cpp,js,ts,tsx} should only be changed when they are outdated or factually wrong, and this one is now factually wrong after the wiring change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.cpp` around lines 197 - 212, The comment near PreviewDriver is
outdated: it still says "via Drivers' setLayer wiring" but the code now calls
drivers->setLayers(layersContainer); update the comment to reference the actual
wiring (e.g., "via the Drivers container's setLayers(layersContainer) wiring")
and ensure any other phrasing that implied setLayer or an externally-owned frame
is corrected to match PreviewDriver and Drivers (PreviewDriver,
Drivers::setLayers, layersContainer).

Source: Coding guidelines

test/scenario_runner.cpp (1)

528-583: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Purge deleted subtrees from ctx.modules, not just the direct child.

Both delete paths remove only the root entry before calling deleteTree(). If the removed module owns descendants, those descendant ids stay in ctx.modules pointing at freed objects, so a later set_control, remove_module, or re-add can operate on dangling memory instead of the rebuilt subtree.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/scenario_runner.cpp` around lines 528 - 583, The delete paths
(remove_module handling around symbols target, parent->removeChild,
target->teardown, mm::Scheduler::deleteTree and the clear_children loop for
childMod) only erase the root id from ctx.modules leaving descendant ids
pointing at freed memory; fix by purging all ctx.modules entries whose stored
pointer lies inside the deleted subtree before calling
mm::Scheduler::deleteTree. Implement this by iterating ctx.modules and erasing
any entry where the value is equal to or is a descendant of the subtree root
(e.g., add a helper isDescendant(subtreeRoot, node) or collect pointers from the
subtree and remove matching map entries) in both the remove_module branch (use
target as subtreeRoot) and the clear_children branch (use childMod as
subtreeRoot) prior to deleteTree().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Line 118: The ordered list prefix on the "Commit message format" item uses
"8." which triggers MD029; update the numeric prefix (the leading "8." in that
list item) to the "1." style expected by the markdownlint rule (or change all
ordered-list items in that block to use the "1." prefix) so the list follows the
1/1/1 ordered-list-prefix style; ensure the rest of the line text remains
unchanged (look for the "Commit message format" list entry and the leading
numeric prefix).

In `@docs/backlog/backlog.md`:
- Line 20: The doc comment incorrectly states the code uses writev in
TcpConnection::writeChunks; update the documentation to say the implementation
calls sendmsg(fd_, &msg, MSG_DONTWAIT) with an iovec[] in platform_desktop.cpp
(rather than writev) and explain the correct Windows mapping is WSASend with
WSABUF[] or a coalesced send while preserving the non-blocking partial-write
semantics.

---

Outside diff comments:
In `@scripts/scenario/run_live_scenario.py`:
- Around line 600-604: The bug is that pending_contract_originals is keyed by
(step, target) where step is an unhashable dict; change the key to use the step
index instead (e.g., use the loop index or an explicit step_index variable) so
keys are hashable; update all references that create or access
pending_contract_originals (the lines that set pending_contract_originals[(step,
target)] and the rollback/access at lines corresponding to 687-688) to use
(step_index, target) consistently, ensuring any places that previously passed
the step dict now use the index or retrieve the step dict from the steps list by
that index.

In `@src/core/HttpServerModule.cpp`:
- Around line 727-738: The replace endpoint currently skips the userEditable()
guard; update handleReplaceModule to call mod->userEditable() (or the module
object's userEditable method) after resolving mod/parent and before proceeding
with replacement, and if it returns false send a 403 JSON response (e.g.
"{\"error\":\"module not editable\"}") and return; apply the same check in the
second replace path referenced around the 758-763 region so both code paths
enforce the editability contract.

In `@src/main.cpp`:
- Around line 197-212: The comment near PreviewDriver is outdated: it still says
"via Drivers' setLayer wiring" but the code now calls
drivers->setLayers(layersContainer); update the comment to reference the actual
wiring (e.g., "via the Drivers container's setLayers(layersContainer) wiring")
and ensure any other phrasing that implied setLayer or an externally-owned frame
is corrected to match PreviewDriver and Drivers (PreviewDriver,
Drivers::setLayers, layersContainer).

In `@test/scenario_runner.cpp`:
- Around line 528-583: The delete paths (remove_module handling around symbols
target, parent->removeChild, target->teardown, mm::Scheduler::deleteTree and the
clear_children loop for childMod) only erase the root id from ctx.modules
leaving descendant ids pointing at freed memory; fix by purging all ctx.modules
entries whose stored pointer lies inside the deleted subtree before calling
mm::Scheduler::deleteTree. Implement this by iterating ctx.modules and erasing
any entry where the value is equal to or is a descendant of the subtree root
(e.g., add a helper isDescendant(subtreeRoot, node) or collect pointers from the
subtree and remove matching map entries) in both the remove_module branch (use
target as subtreeRoot) and the clear_children branch (use childMod as
subtreeRoot) prior to deleteTree().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9e54edc3-5303-46af-bf87-b9ac98bcfd74

📥 Commits

Reviewing files that changed from the base of the PR and between b23664d and ecd47ce.

📒 Files selected for processing (20)
  • CLAUDE.md
  • README.md
  • docs/architecture.md
  • docs/backlog/backlog.md
  • docs/history/FastLED.md
  • docs/history/NightDriverStrip.md
  • docs/history/README.md
  • docs/history/WLED-MM.md
  • docs/history/WLED.md
  • docs/performance.md
  • scripts/scenario/run_live_scenario.py
  • src/core/HttpServerModule.cpp
  • src/core/ImprovProvisioningModule.h
  • src/light/drivers/Drivers.h
  • src/light/layers/Layers.h
  • src/main.cpp
  • test/scenario_runner.cpp
  • test/scenarios/light/scenario_AllEffects_grid_sizes.json
  • test/scenarios/light/scenario_Layouts_mutation.json
  • test/unit/light/unit_PreviewDriver.cpp

Comment thread CLAUDE.md
**When "commit now" is received** — compile the commit message in this format and execute the commit:

8. Commit message format:
8. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix ordered-list prefix style to satisfy MD029.

Line 118 uses 8. while your markdownlint configuration expects 1/1/1 list-prefix style, so this keeps the warning active.

Suggested fix
-8. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line):
+1. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
8. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line):
1. Commit message format (the structure below uses hard newlines *between* its parts — title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps — same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line):
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 118-118: Ordered list item prefix
Expected: 1; Actual: 8; Style: 1/1/1

(MD029, ol-prefix)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 118, The ordered list prefix on the "Commit message
format" item uses "8." which triggers MD029; update the numeric prefix (the
leading "8." in that list item) to the "1." style expected by the markdownlint
rule (or change all ordered-list items in that block to use the "1." prefix) so
the list follows the 1/1/1 ordered-list-prefix style; ensure the rest of the
line text remains unchanged (look for the "Commit message format" list entry and
the leading numeric prefix).

Source: Linters/SAST tools

Comment thread docs/backlog/backlog.md Outdated
ewowi and others added 2 commits June 7, 2026 16:49
Address PR #13 review findings: two real latent bugs (a TypeError that killed the --update-contract path, and a use-after-free when deleting a module that has registered child ids) plus three accuracy/consistency fixes.

Core:
- HttpServerModule: handleReplaceModule now refuses non-editable submodules (userEditable()==false) with a 400, mirroring handleDeleteModule — the editability contract now holds across both endpoints (replacing Preview/Board/Improv was previously allowed).

Tests:
- scenario_runner: delete (remove_module/delete_module) and clear_children now purge ALL ctx.modules entries inside the deleted subtree before deleteTree, via a new purgeSubtree helper — previously only the root id was erased, leaving descendant ids (e.g. a Layer's effect child) pointing at freed memory.

Scripts / MoonDeck:
- run_live_scenario: pending_contract_originals keyed by step INDEX instead of the step dict — a dict is unhashable, so (step, target) raised TypeError on the --update-contract path (unreached by the gates, which is why it slipped through). Loop and rollback both use enumerate now.

Docs / CI:
- backlog.md: Windows port note now says writeChunks uses sendmsg(MSG_DONTWAIT) with iovec[] (not writev), with the correct WSASend/WSABUF mapping.
- main.cpp: corrected the PreviewDriver comment that still referenced "setLayer wiring" — it's the Drivers container's setLayers(layersContainer) now.

Reviews:
- 🐇 CodeRabbit: contract-originals unhashable dict key — fixed (key by step index).
- 🐇 CodeRabbit: delete/clear_children leave descendant ids dangling — fixed (purgeSubtree walks the subtree before freeing).
- 🐇 CodeRabbit: handleReplaceModule missing userEditable guard — fixed (400, mirrors delete; used 400 not 403 for consistency with the sibling guard).
- 🐇 CodeRabbit: backlog writev vs sendmsg — fixed (doc corrected).
- 🐇 CodeRabbit: stale main.cpp setLayer comment — fixed.
- 🐇 CodeRabbit: CLAUDE.md MD029 ("8." prefix) — declined: the number is a deliberate gate-step index (step 8 of the commit sequence), not a generic ordered list; renumbering to "1." would lose that meaning. A markdownlint override is the right fix if the lint noise matters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First-class Windows support for both the desktop binary (Winsock socket layer, MSVC /W4 /WX clean, .zip release artifact, CI build-windows job) and the ESP32 firmware build (UTF-8 locale shim, IDF venv layout, toolchain PATH including flat Windows tool dirs). A fresh Windows machine now flows git clone -> winget install -> MoonDeck -> Setup ESP-IDF -> Build end-to-end.

tick:873/714/251/247/127/77/2/129/4/229us(FPS:1145/1400/3984/4048/7874/12987/500000/7751/250000/4366) | ESP32:1067KB

ESP32 tick is absent from the one-liner because no ESP32 was wired to the Windows build host during this commit; gate 6 was verified via a clean esp32 firmware build, and the KPI ESP32 section reports flash/DRAM but not a live tick. A future hardware-attached run on Windows (or a fresh monitor.log from any other host) will refill it.

### Core
- platform_desktop.cpp: Winsock port via #ifdef _WIN32 plus thin shims at the top (sock/close_sock/open_sock/make_nonblocking/sockWouldBlock; the would-block check accepts both WSAEWOULDBLOCK and WSAETIMEDOUT). writeChunks toggles FIONBIO around WSASend to emulate MSG_DONTWAIT. A static RAII g_winsockInit calls WSAStartup(2,2) at library load so the test binaries get Winsock too, and fsWriteAtomic uses _fileno/_commit instead of fileno/fsync on Windows.
- main_desktop.cpp: signal() with one-shot semantics on Windows (no sigaction, no SIGPIPE/SIGBUS). _write(2, ...) replaces unistd ::write(STDERR_FILENO, ...) in the crash-handler safeWrite path. Added <cstdint> for uint16_t (was pulled in transitively via unistd.h on POSIX).
- Control.cpp / SystemModule.h: explicit static_casts for the narrowing sites MSVC catches at /W4 that GCC -Wall misses (legitimate, not noise).
- JsonSink.h: portable MM_PRINTF_FORMAT(fmt, va) macro that expands to __attribute__((format(printf))) on GCC/Clang and to nothing on MSVC, which previously parsed the bare attribute as an unknown override specifier and cascaded into 20+ false errors.

### UI
- embed_ui.cmake: now takes a PYTHON_CMD parameter so each caller picks the interpreter (desktop passes \${UV_EXECUTABLE};run;python, ESP32 passes \${Python3_EXECUTABLE}). Windows MSVC build hosts don't ship python3 on PATH, and ESP-IDF docker doesn't have uv -- the parameter avoids both traps.

### Scripts / MoonDeck
- build_desktop.py: --config Release on Windows so MSVC multi-config picks the right build type (CMAKE_BUILD_TYPE is ignored there).
- build_esp32.py: Windows venv layout (Scripts/python.exe), idf_env toolchain PATH walks both bin/-subdir layouts and flat ones (ninja/ccache/idf-exe live at the version-dir root on Windows), PYTHONUTF8=1 + PYTHONIOENCODING=utf-8 injected into idf env, idf_cmd routes through _idf_win_shim.py on Windows.
- _idf_win_shim.py (new): sets the C locale to en_US.UTF-8 via locale.setlocale BEFORE running idf.py via runpy.run_path. Necessary because IDF's _valid_unicode_config uses locale.getlocale() which Windows Python initialises to the system codepage (cp1252 on Dutch Windows etc.) regardless of PYTHONUTF8. sys.path is prepended with the idf tools dir so idf.py's sibling python_version_checker import resolves under runpy.
- setup_esp_idf.py: detects host, runs install.bat on Windows (shell=True) and install.sh elsewhere. Also runs git submodule update --init --recursive --depth 1 idempotently -- Windows users typically do a depth-1 clone which skips submodules, and install.bat then fails.
- package_desktop.py: utf-8 encoding for the README write (cp1252 rejects -> and -- on Windows).
- scenario/run_scenario.py: looks for mm_scenarios.exe in both test/Release/ and test/ (MSVC multi-config vs Ninja single-config).
- check_specs.py, collect_kpi.py, moondeck.py: encoding=\"utf-8\" on read_text() sites that bit on cp1252. collect_kpi.py also passes encoding=\"utf-8\" to subprocess capture (idf.py size emits box-drawing chars that crash cp1252).

### Tests
- unit_FilesystemModule_persistence.cpp: explicit f.close() before remove_all in the two tests that hold an std::ifstream open across the rmtree -- POSIX unlinks open files, Windows blocks.
- unit_MoonModule_movechild.cpp: <string> include -- MSVC STL doesn't pull it in via doctest.h, so std::string == const char* fails to find the doctest comparison overload.
- scenario_runner.cpp: .string().c_str() on std::filesystem::path because Windows path::c_str() returns wchar_t*.
- scenarios/*.json (10 files): auto-written observed[pc-windows] blocks from the scenario runner's drift-tracking persistence.

### Docs / CI
- CLAUDE.md: new \"Use \`uv\` for every Python invocation\" process rule with the ESP-IDF docker exception called out (IDF uses find_package(Python3) and its own venv, not uv).
- docs/building.md: Windows prereqs block (winget install, Developer PowerShell, MSVC multi-config binary location) and an ESP-IDF Windows section (PowerShell git clone, core.longpaths true, IDF version corrected from \"v6.0.0\" which didn't exist on upstream to \"v6.1-dev\" which matches CI).
- README.md: per-OS bullet list for the desktop downloads (macOS tar.gz + Gatekeeper note, Windows zip + SmartScreen note).
- CMakeLists.txt: find_program(UV_EXECUTABLE), ws2_32 link on Windows, _CRT_SECURE_NO_WARNINGS family for cl.exe's deprecated-standard-library warnings, /wd4702 /wd4244 /wd4267 (MSVC-only warnings GCC -Wall doesn't catch; kept the strict /WX otherwise).
- esp32/main/CMakeLists.txt: find_package(Python3 REQUIRED) and \${Python3_EXECUTABLE} for build_info.h and embed_ui -- works on Windows IDF venv (python.exe) and POSIX (python3) without per-host #ifdef.
- .github/workflows/release.yml: new build-windows job on windows-latest, both desktop jobs install uv via astral-sh/setup-uv@v3 and invoke package_desktop.py via uv run, release job's needs + Pages-stage download + final files block all include projectMM-*.zip alongside projectMM-*.tar.gz.
- .gitignore: /dist/ added so CI is the only authoritative producer of release artifacts (a stale dist/projectMM-windows-x64-vRC2.zip was unstaged in the process).

KPI Details:
  Desktop:
    Lights: 16,384
    Binary: 432 KB
    [doctest] test cases:  239 |  239 passed | 0 failed | 0 skipped
    tick: 873us, 714us, 251us, 247us, 127us, 77us, 2us, 129us, 4us, 229us (FPS: 1145, 1400, 3984, 4048, 7874, 12987, 500000, 7751, 250000, 4366) (per scenario)
    === 11 scenario(s), 11 passed, 0 failed ===
    Platform boundary: PASS
    Specs: Spec check: 28 modules, 28 ok, 0 missing, 0 outdated
  ESP32:
    Image: 1,199,889 bytes (35% partition free)
    Flash (code+data): 1067 KB
    DRAM: 38,312 / 180,736 (142,424 free)
  Code:
    64 source files (11642 lines)
    42 test files (6333 lines)
    41 specs, 11 scenarios
    Lizard: 0 warnings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/scenario_runner.cpp (1)

594-624: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Purge old descendant ids during replace_module to avoid dangling pointers.

Line 622 deletes old, but descendant ids from old can remain in ctx.modules. Later set_control/delete steps can dereference freed pointers.

Suggested fix
             mm::MoonModule* old = parent->replaceChildAt(index, fresh);
             fresh->onBuildControls();
             fresh->setup();
             fresh->onBuildState();
-            if (old) { old->teardown(); mm::Scheduler::deleteTree(old); }
-            ctx.modules[targetId] = fresh;
+            if (old) {
+                old->teardown();
+                ctx.purgeSubtree(old);  // drop old + descendant id mappings before free
+                mm::Scheduler::deleteTree(old);
+            }
+            ctx.modules[targetId] = fresh;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/scenario_runner.cpp` around lines 594 - 624, When replacing a module in
the replace_module branch, you must remove any ctx.modules entries that point to
the old module or any of its descendants before calling
mm::Scheduler::deleteTree(old); update the handler that uses
parent->replaceChildAt(...) / old to traverse the subtree rooted at old, collect
all scenario ids stored in ctx.modules whose pointer equals old or any
descendant, erase those keys from ctx.modules, then proceed with old->teardown()
and mm::Scheduler::deleteTree(old); this prevents dangling pointers when later
set_control/remove steps look up those ids.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/building.md`:
- Around line 91-94: The fenced code blocks under the "**macOS / Linux:**" and
the "**Windows** (PowerShell — run once with admin...)" headings are missing a
blank line before the opening ``` and need a blank line after the closing ``` to
satisfy markdownlint MD031; edit the docs/building.md content around the
"**macOS / Linux:**" and "**Windows**" sections (and the other affected block at
the second occurrence) to insert an empty line immediately before each opening
fenced code block and ensure one blank line follows each closing fence so the
code fences are separated from the preceding paragraph.

---

Outside diff comments:
In `@test/scenario_runner.cpp`:
- Around line 594-624: When replacing a module in the replace_module branch, you
must remove any ctx.modules entries that point to the old module or any of its
descendants before calling mm::Scheduler::deleteTree(old); update the handler
that uses parent->replaceChildAt(...) / old to traverse the subtree rooted at
old, collect all scenario ids stored in ctx.modules whose pointer equals old or
any descendant, erase those keys from ctx.modules, then proceed with
old->teardown() and mm::Scheduler::deleteTree(old); this prevents dangling
pointers when later set_control/remove steps look up those ids.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f46b1d4f-2870-447c-aa3f-b86b33af0847

📥 Commits

Reviewing files that changed from the base of the PR and between ecd47ce and 21b7259.

⛔ Files ignored due to path filters (5)
  • scripts/build/_idf_win_shim.py is excluded by !**/build/**
  • scripts/build/build_desktop.py is excluded by !**/build/**
  • scripts/build/build_esp32.py is excluded by !**/build/**
  • scripts/build/package_desktop.py is excluded by !**/build/**
  • scripts/build/setup_esp_idf.py is excluded by !**/build/**
📒 Files selected for processing (34)
  • .github/workflows/release.yml
  • .gitignore
  • CLAUDE.md
  • CMakeLists.txt
  • README.md
  • docs/backlog/backlog.md
  • docs/building.md
  • esp32/main/CMakeLists.txt
  • scripts/check/check_specs.py
  • scripts/check/collect_kpi.py
  • scripts/moondeck.py
  • scripts/scenario/run_live_scenario.py
  • scripts/scenario/run_scenario.py
  • src/core/Control.cpp
  • src/core/HttpServerModule.cpp
  • src/core/JsonSink.h
  • src/core/SystemModule.h
  • src/main.cpp
  • src/platform/desktop/main_desktop.cpp
  • src/platform/desktop/platform_desktop.cpp
  • src/ui/embed_ui.cmake
  • test/scenario_runner.cpp
  • test/scenarios/core/scenario_MoonModule_control_change.json
  • test/scenarios/light/scenario_AllEffects_grid_sizes.json
  • test/scenarios/light/scenario_GridLayout_grid_sizes.json
  • test/scenarios/light/scenario_GridLayout_resize.json
  • test/scenarios/light/scenario_Layer_base_pipeline.json
  • test/scenarios/light/scenario_Layer_buildup.json
  • test/scenarios/light/scenario_Layer_memory_1to1.json
  • test/scenarios/light/scenario_Layouts_mutation.json
  • test/scenarios/light/scenario_MirrorModifier_memory_lut.json
  • test/scenarios/light/scenario_MirrorModifier_pipeline.json
  • test/unit/core/unit_FilesystemModule_persistence.cpp
  • test/unit/core/unit_MoonModule_movechild.cpp

Comment thread docs/building.md
@ewowi

ewowi commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, you only have one review finding: #13 (review) ?

that is not a lot!

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the PR review details
gh pr view 13 --repo ewowi/projectMM --json reviews,files,additions,deletions,commits

# Also fetch the specific review comment
gh api repos/ewowi/projectMM/pulls/13/reviews

Length of output: 67109



@ewowi: Ha, fair point on the surface — but there were actually two review rounds on this PR, totalling 8 findings across both!

Round 1 (on the first commit, ecd47ce) — 6 findings:

# Severity Finding
1 🟠 Major handleReplaceModule missing userEditable() guard (outside-diff)
2 🟠 Major --update-contract path crashing with unhashable dict key (outside-diff)
3 🟠 Major purgeSubtree not called for descendant ids in delete/clear_children (outside-diff)
4 🟡 Minor Stale setLayer comment in main.cpp (outside-diff)
5 🟡 Minor CLAUDE.md MD029 list numbering issue (inline)
6 🟡 Minor backlog.md incorrectly documented writev instead of sendmsg (inline)

All 6 were addressed in the second commit — 5 fixed, 1 (CLAUDE.md MD029) deliberately declined with a good reason.

Round 2 (on the Windows port commit, 21b7259) — 2 findings:

# Severity Finding
7 🟡 Minor docs/building.md missing blank lines around fenced code blocks / MD031 (inline)
8 🟠 Major purgeSubtree still needed during replace_module to avoid dangling descendants (outside-diff)

The second round was lighter because: (a) 5 scripts/build/** files were excluded by your path filter, and (b) the Windows port commit was largely mechanical cross-platform plumbing that was structurally sound.

The real reason there weren't more findings is that this is a genuinely well-engineered PR — 239 unit tests, 11 scenarios, full CI gates, a regression test pinning the exact crash fix. Hard to argue with that! 🐇


🧠 Learnings used
Learnt from: CR
Repo: ewowi/projectMM PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-06-06T09:42:05.778Z
Learning: For PR merge, update `docs/performance.md` if the branch touches anything under `src/light/`, `src/core/Scheduler.h`, `src/core/HttpServerModule.cpp`, or platform code in the tick path

Learnt from: ewowi
Repo: ewowi/projectMM PR: 9
File: test/scenarios/light/scenario_Layer_buildup.json:54-90
Timestamp: 2026-06-03T13:02:28.887Z
Learning: In ewowi/projectMM, ArtNet-sink steps inside construct-mode scenarios (e.g., scenario_Layer_buildup.json, scenario_Layer_base_pipeline.json) are intentional and acceptable in the required test suite. Desktop ArtNet send is non-blocking (UDP write to kernel buffer, no recipient bound, no flake history). The in-process scenario runner (test/scenario_runner.cpp) downgrades min_pct FPS bounds to WARN; only absolute max_us / max_bytes budgets gate ctest. Combining construction verification with per-step timing/heap guardrails in a single scenario is a deliberate design choice to ensure "full pipeline assembles" at every intermediate state without duplicating construction steps.

Four follow-ups on the Windows-port commit, all in CI / review territory:

1. build-windows (CI exit 1): SystemModule.h includes core/build_info.h,
   generated by the build_info_gen custom target. Only the projectMM exe
   listed it as a dependency — mm_core did not — so on a clean CI build
   mm_tests (which links mm_core) raced the generator and tried to compile
   SystemModule.h before build_info.h existed. Adding build_info_gen to
   mm_core's add_dependencies ties the generation to every consumer.

2. build-macos (CI exit 2, `/bin/sh: run: command not found`): the desktop
   CMakeLists passed `-DPYTHON_CMD=${UV_EXECUTABLE};run;python` to the
   embed_ui.cmake -P invocation. On POSIX Makefile generators `make` reads
   the unescaped semicolons as shell command separators and splits the line
   into three commands. Backslash-escaping (`\;`) broke MSBuild instead.
   Rewriting embed_ui.cmake's interpreter resolution: callers pass either a
   ready PYTHON_CMD list (ESP32 keeps ${Python3_EXECUTABLE}) or a single
   UV_EXECUTABLE path (desktop), and embed_ui.cmake builds the
   `<uv> run python` list internally. No semicolons cross the command line.

3. CodeRabbit: building.md MD031 — blank lines added before the `\`\`\`sh`
   and `\`\`\`powershell` fences under the **macOS / Linux** and **Windows**
   bold labels.

4. CodeRabbit: scenario_runner.cpp replace_module — the remove_module and
   clear_children branches both call ctx.purgeSubtree(target) before
   deleteTree(target); replace_module did not. Any descendant of `old`
   registered in ctx.modules (e.g. an effect under a Layer) was left
   pointing at freed memory, and a later set_control / remove step
   addressing that id by name would read a dangling pointer. Mirroring the
   purge here closes the gap. The targetId mapping that purgeSubtree erases
   is re-registered to `fresh` on the existing next line.

build-esp32 (esp32-eth) also failed in the same CI run but with a transient
docker-registry timeout pulling espressif/idf:v6.1-dev — unrelated to this
diff; a re-run recovers.

Verified locally: clean Windows build green, 239/239 tests, 10/10 scenarios.

### Docs / CI
- CMakeLists.txt: add_dependencies(mm_core ui_embed build_info_gen) — every consumer of mm_core's headers now has the generated header ready. Also drop the semicolon-bearing -DPYTHON_CMD and pass -DUV_EXECUTABLE through instead.
- docs/building.md: blank line before the `\`\`\`sh` and `\`\`\`powershell` fences (MD031).

### UI
- embed_ui.cmake: interpreter resolution now accepts either an explicit PYTHON_CMD list or a UV_EXECUTABLE path, building the `<uv> run python` list in-script so the desktop CMakeLists doesn't have to escape semicolons through MSBuild vs make.

### Tests
- scenario_runner.cpp: ctx.purgeSubtree(old) in the replace_module branch before deleteTree(old), matching the pattern in remove_module and clear_children. Fixes a dangling pointer if a later step looks up a descendant id of the replaced module.
- scenarios/*.json (3 files): auto-written observed[pc-windows] drift from the validation run.

🐇 CodeRabbit: building.md MD031 blank-line-before-fence — fixed.
🐇 CodeRabbit: scenario_runner.cpp replace_module ctx.modules purge — fixed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ewowi and others added 2 commits June 7, 2026 21:20
Three follow-ups on the Windows desktop port, all caught by exercising the
flow on real hardware:

1. MoonDeck (scripts/moondeck.py): list_serial_ports' Windows branch
   required pyserial (brute-force open of COM0..COM255) but moondeck.py
   never declared the dep, so on Windows the ESP32 tab's port dropdown
   showed empty even with a healthy COM port present. Replaced the loop
   with a winreg read of HKLM\HARDWARE\DEVICEMAP\SERIALCOMM — that's the
   authoritative table the OS itself uses to advertise present COM ports,
   and what pyserial reads under the hood. Stdlib-only, no third-party
   dep, no brute-force probe. Verified locally: now returns ['COM3'] for
   a plugged-in Olimex Gateway.

2. Web installer (docs/install/index.html): Chrome Web Serial's
   auto-reset-into-bootloader sequence is unreliable on Windows due to a
   documented DTR/RTS timing issue (espressif/esptool#136, #790) — the
   browser dispatches setDTR and setRTS as separate IOCTLs and the ESP32's
   EN+IO0 toggle doesn't always land. The native esptool path through
   MoonDeck isn't affected. Added a `.windows-only` hint in both the
   connecting and error sections of the install modal telling Windows
   users to do the manual BOOT/RST press; toggled via a 3-line userAgent
   check at body-open. macOS / Linux users see no extra UI.

3. Doc cleanup post-Windows port:
   - backlog.md: removed the implemented "Windows desktop port" section.
     Release 2.0 list now lists "1.0 ships ESP32 + macOS arm64 + Windows
     x64" and adds Windows code-signing alongside macOS signing.
   - performance.md: renamed "Desktop (macOS, Apple Silicon)" section to
     "Desktop (64-bit)" and generalized the intro to per-host
     contract.pc-<os> blocks. Binary-size note shows a per-OS table
     (macOS 358 KB, Windows 432 KB).
   - install/README.md: workflow comment now mentions build-windows
     alongside build-macos / build-esp32.
   - testing.md: note above the raw ctest example explains the
     host-specific path replacement (uv-wrapped paths already handle it).
   - package_desktop.py: docstring now mentions both Gatekeeper and
     SmartScreen warnings.

Only gate 1 (spec check) applies per CLAUDE.md trigger rules (no src/,
no test/, no CMake, no esp32/). Spec check passes — 28 modules in sync.

### Scripts / MoonDeck
- moondeck.py: list_serial_ports now reads SERIALCOMM via the stdlib winreg module on Windows instead of brute-forcing COM0..COM255 through pyserial. Fixes empty port dropdown on Windows when a device is present. POSIX path (glob /dev/tty.usb*) unchanged.
- package_desktop.py: docstring mentions Windows SmartScreen alongside macOS Gatekeeper.

### UI
- install/index.html: new `.windows-only` hint in section-connecting and section-error explaining the manual BOOT/RST workaround for the documented Windows DTR/RTS timing issue. Tiny DOMContentLoaded handler toggles the hint visible only on Windows hosts. HTML comments link the upstream espressif/esptool issues for context.

### Docs / CI
- backlog.md: removed completed "Windows desktop port" section; added Windows code-signing to the Release 2.0 list (counterpart to macOS code-signing).
- performance.md: Desktop section is now host-neutral; per-OS binary sizes in a small table.
- install/README.md, testing.md: minor mentions of build-windows / per-host build dirs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces MirrorModifier with a richer MultiplyModifier (per-axis tile + mirror, the kaleidoscope superset) and adds a CheckerboardModifier mask, giving a second modifier to test modifier-swapping with. Modifier fan-out is now limited only by memory (dynamic scratch buffer), not a fixed cap. Also lands the layout status lines, the "add module" rename, the drag-reorder fix, and a no-PSRAM overflow fix that black-screened high fan-out multiplies.

ESP32 KPI line omitted: the Olimex Ethernet link was unstable this whole session (intermittent PHY flapping — see backlog "esp32-eth slow Ethernet bring-up"), so a live tick capture wasn't possible. The esp32-eth firmware build passed and the device was confirmed to boot and render this build; KPI to be captured next stable-network session.

Core:
- ModifierBase: maxMultiplier() widened uint8_t→nrOfLightsType; removed the fixed fan-out cap. The fan-out scratch buffer is now sized to the modifier's actual maxMultiplier() (heap, cold path) in Layer::rebuildLUT — any fan-out a modifier declares is emitted in full, limited only by memory (OOM degrades to the identity LUT).
- Layer: maxDest = logicalCount × maxMultiplier now computed in uint64_t before narrowing — on no-PSRAM boards (nrOfLightsType=uint16) 256×256 overflowed, wrapping maxDest to a near-empty LUT and blanking the display on high fan-out multiplies. Layer status shows the logical render box; Layouts status shows physical lights + bounding box (both via the MoonModule status slot, no new controls).
- HttpServerModule: replace now refuses non-editable submodules (mirrors delete); on a type-replace, a default-named module relabels to the new type's name while a custom/scenario id is preserved (ensureUniqueName runs after the swap so no spurious " 2").
- ImprovProvisioningModule: userEditable()==false (apparatus, not deletable).
- ModuleFactory: display-name examples updated to MultiplyModifier.

Light domain:
- MultiplyModifier (new, replaces MirrorModifier): controls multiplyX/Y/Z (1–64) + mirrorX/Y/Z (default all on). Tiles the logical image per axis; mirror-enabled axes fold (multiply 2 + mirror = the old Mirror). Effective multiplier clamps to the axis extent, so multiplyZ on a 2D (depth-1) layout is a no-op instead of blanking the layer (logD=0).
- CheckerboardModifier (new): size + invert; masks lights in "off" squares (outCount=0 — no ModifierBase contract change). MirrorModifier deleted.

UI:
- app.js: "+ add child" button → "+ add module"; drag-reorder drop handler now stopPropagation() so a drop doesn't bubble to ancestor cards and fire a second move that undoes the first; documented insert (not swap) reorder semantics; status row renders the module status slot.

Tests:
- unit_MultiplyModifier (new): tile/fold, pure-fold equivalence to old Mirror, multiplyZ-2D no-op, extent clamp, 8×8 emits 64 tiles, maxOut clamp.
- unit_CheckerboardModifier (new): identity dims, drop pattern both invert phases, size grouping.
- unit_Layer_sparse_mapping: regression — high fan-out Multiply (8×8×4) builds a full, in-range LUT (the uint16-overflow black-screen guard).
- unit_PreviewDriver: regression — deleting the active Layer leaves the driver null, not dangling.
- scenario_modifier_swap (new): replace_module cycles Multiply↔Checkerboard, asserts the pipeline stays live.
- scenario_AllEffects_grid_sizes (new): every effect (no modifier) across 16/32/64/128, tick/FPS/heap/block per (effect, size).
- Mirror→Multiply rename across scenario_runner, persistence/control-gate/layouts-toggle/sparse-mapping tests, and the Grid/MoonModule/Network scenarios; scenario_MirrorModifier_* renamed to scenario_MultiplyModifier_*.

Scripts / MoonDeck:
- run_live_scenario: clear_children + replace_module ops; remove_module/delete_module aliased; 15s timeout, retry on transient connection-reset and 404-during-buildState, tolerate non-deletable children; optional steps skipped on a missing target.

Docs / CI:
- New MultiplyModifier.md + CheckerboardModifier.md; deleted MirrorModifier.md. architecture.md § Modifiers corrected to present tense (first enabled modifier applies; chaining is planned). Layouts.md/Layer.md document the status lines + insert-reorder semantics. performance.md all-effects sweep tables. README "add a new module". backlog: Windows port runbook, boot-time buffer degradation, and the Ethernet investigation (firmware ruled out — intermittent build-independent PHY flapping). decisions.md modifier example updated. testing.md + regenerated tests docs.

Reviews:
- 🐇 CodeRabbit (prior round, in this branch): unhashable contract key, dangling descendant ids on delete, replace userEditable guard, writev→sendmsg doc, stale setLayer comment — all fixed; MD029 "8." declined (intentional gate-step numbering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main.cpp (1)

201-224: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider rewriting the forward-looking phrase in present tense.

Line 206 contains a parenthetical note: "(The composition follow-up will blend across N Layer buffers read from the same container.)" This is forward-looking language. Per the Principles audit gate guideline for src/, consider rewriting to present tense, such as: "(This wiring supports future blending across N Layer buffers from the same container.)" or providing a one-line justification for the forward reference.

The Drivers wiring change from setLayer(activeLayer()) to setLayers(container) is correct and enables rebuild-safety as documented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.cpp` around lines 201 - 224, The comment contains forward-looking
phrasing — change the parenthetical "(The composition follow-up will blend
across N Layer buffers read from the same container.)" to present-tense and
explanatory wording; update the comment near Drivers and
setLayers(layersContainer) to something like "(This wiring supports blending
across N Layer buffers read from the same container.)" or add a one-line
justification that the setLayers(layersContainer) change enables rebuild-safety
and multi-layer blending, referencing the Drivers container and setLayers to
clarify intent.

Source: Coding guidelines

test/scenario_runner.cpp (1)

543-564: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror API editability guards in remove_module and replace_module.

These branches currently allow operations on non-editable modules, but live API handlers reject them. That mismatch causes in-process/live divergence for the same scenario JSON.

💡 Suggested fix
         } else if (std::strcmp(op, "remove_module") == 0 || std::strcmp(op, "delete_module") == 0) {
@@
             auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr;
             if (!target || !target->parent()) {
                 std::printf("  -     %s — %s not found or top-level, skipped\n", name, targetId);
                 continue;
             }
+            if (!target->userEditable()) {
+                std::printf("  -     %s — %s not deletable, skipped\n", name, targetId);
+                continue;
+            }
@@
         } else if (std::strcmp(op, "replace_module") == 0) {
@@
             auto* target = ctx.modules.count(targetId) ? ctx.modules[targetId] : nullptr;
             if (!target || !target->parent()) {
                 std::printf("  ~     %s — %s not found or top-level, skipped\n", name, targetId);
                 continue;
             }
+            if (!target->userEditable()) {
+                std::printf("  ~     %s — %s not editable, skipped\n", name, targetId);
+                continue;
+            }

Also applies to: 596-637

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/scenario_runner.cpp` around lines 543 - 564, The remove_module and
replace_module branches currently operate on modules even when they are not
editable; update both branches to mirror the live API by checking the module
editability before mutating: after resolving target and parent, call the
module's editability accessor (e.g., target->isEditable() or target->editable())
and if it returns false, print the same "not found or top-level, skipped" style
message and continue without performing parent->removeChild, target->teardown,
ctx.purgeSubtree, mm::Scheduler::deleteTree, or the replace logic; apply the
same editable check to the replace_module branch so both remove_module and
replace_module use the same guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CMakeLists.txt`:
- Around line 74-80: Replace the bespoke -DUV_EXECUTABLE argument with the
repository-standard -DPYTHON_CMD when invoking embed_ui.cmake: update the
add_custom_command that calls ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake to pass
-DPYTHON_CMD set to the desktop form (${UV_EXECUTABLE};run;python) instead of
-DUV_EXECUTABLE, so embed_ui.cmake receives the expected PYTHON_CMD contract;
keep the rest of the add_custom_command (OUTPUT, -DUI_DIR, -DOUT, -P) unchanged.

In `@docs/architecture.md`:
- Line 296: The sentence uses future tense ("when it lands"); update the wording
in the Layer::rebuildLUT documentation to present-tense describing current
behavior: state that "modifier chaining is not implemented and order matters"
(e.g. "Modifier chaining is not implemented; order would matter — a
multiply-then-checkerboard mask differs from checkerboard-then-multiply"), and
keep the note that only the first enabled modifier is applied during
Layer::rebuildLUT and that static modifiers chain during LUT build while dynamic
modifiers chain during rendering, or alternatively move any roadmap text into
docs/backlog/.

In `@docs/moonmodules/light/modifiers/CheckerboardModifier.md`:
- Line 18: Rewrite the forward-looking sentence in CheckerboardModifier.md to
present tense: state that the Layer applies its first enabled modifier during
rebuildLUT and that modifier chaining (e.g., Checkerboard-then-Multiply vs
Multiply-then-Checkerboard) is not implemented; keep the note about
architecture.md § Modifiers and that the mask integrates without changing the
ModifierBase contract (since logical lights can map to zero physical positions).

In `@docs/moonmodules/light/modifiers/MultiplyModifier.md`:
- Around line 7-20: Update the MultiplyModifier docs to match the
implementation: change multiplyX/multiplyY/multiplyZ allowed ranges from "1–8"
to "1–64" (reflect the code's 1–64 bounds for multiply*), set the documented
default for mirrorZ to true (matching the modifier initialization), and
remove/replace the statement about a fixed fan-out cap of "≤ 8" (the
implementation no longer uses the fixed physicals[8] scratch); instead document
that fan-out is dynamic per the modifier contract and that
Layer::rebuildLUT/mapToPhysical enforce the runtime maxOut behavior rather than
a hard 8-entry stack buffer.

In `@src/light/layers/Layer.h`:
- Around line 310-317: The code allocates the per-light scratch array using
mod->maxMultiplier() (eg. MultiplyModifier’s conservative over-estimate), which
can vastly over-allocate; before computing fanout and calling platform::alloc,
clamp mod->maxMultiplier() to the actual physical light capacity (the
runtime/board physical light count used by this layer, e.g., the layer’s
physical light count/extent) so fanout = min(mod->maxMultiplier() > 0 ?
mod->maxMultiplier() : 1, physicalCapacity); then allocate physicals with that
clamped fanout; apply the same clamping change at the other occurrence around
line 334 to avoid over-allocation there as well (reference symbols:
mod->maxMultiplier(), fanout, physicals, platform::alloc, MultiplyModifier).

In `@src/light/modifiers/MultiplyModifier.h`:
- Around line 39-45: maxMultiplier() (and the similar product computation at
lines ~52-54) can overflow nrOfLightsType; compute the product using a wider
integer type (e.g., uint64_t or size_t) by promoting multiplyX, multiplyY,
multiplyZ into that wider type, check against
std::numeric_limits<nrOfLightsType>::max(), and return the capped value if it
would overflow, otherwise static_cast back to nrOfLightsType; apply the same
change to the other product calculation(s) in this file so all uses of
multiplyX/multiplyY/multiplyZ avoid truncation.
- Around line 29-31: MultiplyModifier.h currently initializes mirrorZ = true
which contradicts the documented/default pipeline contract (canonical multiply
defaults are mirror true/true/false). Change the default initialization of the
mirrorZ member in the MultiplyModifier class/struct to false (leave mirrorX and
mirrorY as true) so the default behavior matches the documented multiply 2/2/1
with XY-only folding; update any related constructors or default member
initializers in MultiplyModifier if they also set mirrorZ to true.

In `@src/ui/embed_ui.cmake`:
- Around line 19-24: Replace the silent fallback to "python3" with a hard
failure: if PYTHON_CMD is not defined and UV_EXECUTABLE is not provided, stop
configuration with an explicit error message; update the conditional logic
around the PYTHON_CMD/UV_EXECUTABLE check in src/ui/embed_ui.cmake (the block
currently setting PYTHON_CMD) so that when neither UV_EXECUTABLE nor PYTHON_CMD
exist you call message(FATAL_ERROR "...") with a clear instruction to supply
PYTHON_CMD or UV_EXECUTABLE, preserving the existing branch that uses
UV_EXECUTABLE when present.

In `@test/scenarios/core/scenario_MoonModule_control_change.json`:
- Around line 263-266: The JSON contains future-dated observed timestamps in the
"at" arrays (observed.at) — change both occurrences of "2026-06-08" to a
non-future date (<= 2026-06-07) so observed.at reflects an actual measurement;
update the two "at" arrays referenced (the observed.at entries around Line 265
and the other at Lines ~378-381) to a valid date (e.g., "2026-06-07" or an
earlier date) to keep drift history consistent.

---

Outside diff comments:
In `@src/main.cpp`:
- Around line 201-224: The comment contains forward-looking phrasing — change
the parenthetical "(The composition follow-up will blend across N Layer buffers
read from the same container.)" to present-tense and explanatory wording; update
the comment near Drivers and setLayers(layersContainer) to something like "(This
wiring supports blending across N Layer buffers read from the same container.)"
or add a one-line justification that the setLayers(layersContainer) change
enables rebuild-safety and multi-layer blending, referencing the Drivers
container and setLayers to clarify intent.

In `@test/scenario_runner.cpp`:
- Around line 543-564: The remove_module and replace_module branches currently
operate on modules even when they are not editable; update both branches to
mirror the live API by checking the module editability before mutating: after
resolving target and parent, call the module's editability accessor (e.g.,
target->isEditable() or target->editable()) and if it returns false, print the
same "not found or top-level, skipped" style message and continue without
performing parent->removeChild, target->teardown, ctx.purgeSubtree,
mm::Scheduler::deleteTree, or the replace logic; apply the same editable check
to the replace_module branch so both remove_module and replace_module use the
same guard.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dca5ec8d-6696-4634-b276-86ab56b94ad1

📥 Commits

Reviewing files that changed from the base of the PR and between 21b7259 and a60114f.

⛔ Files ignored due to path filters (1)
  • scripts/build/package_desktop.py is excluded by !**/build/**
📒 Files selected for processing (50)
  • CMakeLists.txt
  • README.md
  • docs/architecture.md
  • docs/backlog/backlog.md
  • docs/building.md
  • docs/history/decisions.md
  • docs/install/README.md
  • docs/install/index.html
  • docs/moonmodules/light/Layer.md
  • docs/moonmodules/light/Layouts.md
  • docs/moonmodules/light/effects/NoiseEffect.md
  • docs/moonmodules/light/effects/PlasmaEffect.md
  • docs/moonmodules/light/modifiers/CheckerboardModifier.md
  • docs/moonmodules/light/modifiers/MirrorModifier.md
  • docs/moonmodules/light/modifiers/MultiplyModifier.md
  • docs/performance.md
  • docs/testing.md
  • docs/tests/scenario-tests.md
  • docs/tests/unit-tests.md
  • scripts/moondeck.py
  • src/core/HttpServerModule.cpp
  • src/core/ModuleFactory.h
  • src/light/layers/Layer.h
  • src/light/layouts/Layouts.h
  • src/light/modifiers/CheckerboardModifier.h
  • src/light/modifiers/MirrorModifier.h
  • src/light/modifiers/ModifierBase.h
  • src/light/modifiers/MultiplyModifier.h
  • src/main.cpp
  • src/ui/app.js
  • src/ui/embed_ui.cmake
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/scenarios/core/scenario_MoonModule_control_change.json
  • test/scenarios/core/scenario_NetworkModule_mdns_toggle.json
  • test/scenarios/light/scenario_AllEffects_grid_sizes.json
  • test/scenarios/light/scenario_GridLayout_grid_sizes.json
  • test/scenarios/light/scenario_GridLayout_resize.json
  • test/scenarios/light/scenario_Layer_buildup.json
  • test/scenarios/light/scenario_Layouts_mutation.json
  • test/scenarios/light/scenario_MultiplyModifier_memory_lut.json
  • test/scenarios/light/scenario_MultiplyModifier_pipeline.json
  • test/scenarios/light/scenario_modifier_swap.json
  • test/unit/core/unit_FilesystemModule_persistence.cpp
  • test/unit/core/unit_MoonModule_control_change_gate.cpp
  • test/unit/light/unit_CheckerboardModifier.cpp
  • test/unit/light/unit_Layer_sparse_mapping.cpp
  • test/unit/light/unit_Layouts_toggle_cycle.cpp
  • test/unit/light/unit_MirrorModifier.cpp
  • test/unit/light/unit_MultiplyModifier.cpp
💤 Files with no reviewable changes (3)
  • docs/moonmodules/light/modifiers/MirrorModifier.md
  • test/unit/light/unit_MirrorModifier.cpp
  • src/light/modifiers/MirrorModifier.h

Comment thread CMakeLists.txt
Comment thread docs/architecture.md Outdated
Comment thread docs/moonmodules/light/modifiers/CheckerboardModifier.md Outdated
Comment thread docs/moonmodules/light/modifiers/MultiplyModifier.md Outdated
Comment thread src/light/layers/Layer.h
Comment on lines +310 to +317
// Per-light scratch buffer for the modifier's fan-out destinations. Sized
// to the modifier's actual maxMultiplier() (not a fixed cap) so any
// fan-out the modifier declares fits without truncation — the only limit
// is memory. Heap-allocated on the cold path (like boxToDriver); an OOM
// here degrades to the identity LUT, same cascade as the LUT build above.
const nrOfLightsType fanout = mod->maxMultiplier() > 0 ? mod->maxMultiplier() : 1;
auto* physicals = static_cast<nrOfLightsType*>(
platform::alloc(static_cast<size_t>(fanout) * sizeof(nrOfLightsType)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cap scratch fanout by physical bounds before allocation.

At Line 315, fanout uses raw mod->maxMultiplier(). For MultiplyModifier, that value is intentionally an over-estimate before extent clamping, so this can allocate far more than needed and force unnecessary degrade-to-identity on constrained boards.

💡 Suggested fix
-        const nrOfLightsType fanout = mod->maxMultiplier() > 0 ? mod->maxMultiplier() : 1;
+        nrOfLightsType fanout = mod->maxMultiplier();
+        if (fanout == 0) fanout = 1;
+        // mapToPhysical cannot emit more destinations than physical box cells.
+        if (fanout > boxCount) fanout = boxCount;
         auto* physicals = static_cast<nrOfLightsType*>(
             platform::alloc(static_cast<size_t>(fanout) * sizeof(nrOfLightsType)));

Also applies to: 334-334

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/layers/Layer.h` around lines 310 - 317, The code allocates the
per-light scratch array using mod->maxMultiplier() (eg. MultiplyModifier’s
conservative over-estimate), which can vastly over-allocate; before computing
fanout and calling platform::alloc, clamp mod->maxMultiplier() to the actual
physical light capacity (the runtime/board physical light count used by this
layer, e.g., the layer’s physical light count/extent) so fanout =
min(mod->maxMultiplier() > 0 ? mod->maxMultiplier() : 1, physicalCapacity); then
allocate physicals with that clamped fanout; apply the same clamping change at
the other occurrence around line 334 to avoid over-allocation there as well
(reference symbols: mod->maxMultiplier(), fanout, physicals, platform::alloc,
MultiplyModifier).

Comment on lines +29 to +31
bool mirrorX = true;
bool mirrorY = true;
bool mirrorZ = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Default mirrorZ conflicts with the documented/default pipeline contract.

Line 31 sets mirrorZ = true, but the documented canonical defaults are multiply 2/2/1 with mirror true/true/false (XY fold only). This changes 3D behavior by default and makes docs/tests drift from runtime behavior.

Suggested fix
-    bool mirrorZ = true;
+    bool mirrorZ = false;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool mirrorX = true;
bool mirrorY = true;
bool mirrorZ = true;
bool mirrorX = true;
bool mirrorY = true;
bool mirrorZ = false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/modifiers/MultiplyModifier.h` around lines 29 - 31,
MultiplyModifier.h currently initializes mirrorZ = true which contradicts the
documented/default pipeline contract (canonical multiply defaults are mirror
true/true/false). Change the default initialization of the mirrorZ member in the
MultiplyModifier class/struct to false (leave mirrorX and mirrorY as true) so
the default behavior matches the documented multiply 2/2/1 with XY-only folding;
update any related constructors or default member initializers in
MultiplyModifier if they also set mirrorZ to true.

Comment thread src/light/modifiers/MultiplyModifier.h Outdated
Comment thread src/ui/embed_ui.cmake Outdated
Comment on lines 263 to 266
"at": [
"2026-06-02",
"2026-06-05"
"2026-06-08"
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix future-dated observed timestamps (Line 265, Line 380).

"2026-06-08" is a future date relative to June 7, 2026. observed.at should reflect an actual measurement date to keep drift history trustworthy.

Also applies to: 378-381

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/scenarios/core/scenario_MoonModule_control_change.json` around lines 263
- 266, The JSON contains future-dated observed timestamps in the "at" arrays
(observed.at) — change both occurrences of "2026-06-08" to a non-future date (<=
2026-06-07) so observed.at reflects an actual measurement; update the two "at"
arrays referenced (the observed.at entries around Line 265 and the other at
Lines ~378-381) to a valid date (e.g., "2026-06-07" or an earlier date) to keep
drift history consistent.

Pre-merge round on PR #13. Fixes the Reviewer agent's blocker (a uint16 overflow in the modifier fan-out estimate), the still-valid CodeRabbit findings, and two bugs found testing on the Olimex (a high-fan-out memory degrade; device-created names with spaces being API-unaddressable). Plus documentation-sync, a carry-forward lesson, and a Windows build-robustness fix.

Core:
- Scheduler::ensureUniqueName: disambiguating suffix separator changed from space to '-' ("Grid-2"). A module name is a URL path segment in the module API (DELETE/replace/move /api/modules/<name>); a space needs encoding and broke the device's raw-path lookup, so a device-created "Grid 2" couldn't be deleted. '-' is URL-safe.

Light domain:
- MultiplyModifier::maxMultiplier(): compute the axis product in uint64 and saturate to the return type's max. Controls allow up to 64/axis, so 64×64×16 overflowed nrOfLightsType (uint16 on no-PSRAM) and wrapped — feeding the uint64 maxDest math in Layer::rebuildLUT an already-wrapped (possibly 0) value → empty LUT → black display / "buffer reduced" on the device. Verified on the Olimex: 8×8 + multiplyZ=4 holds dyn=34050, no degrade.
- Layer::rebuildLUT: the per-light fan-out scratch buffer is now clamped to boxCount (a logical light can't map to more positions than the box has cells) — avoids a pathological over-alloc when maxMultiplier() saturates high on a small grid.

UI / build:
- embed_ui.cmake: a standalone invocation with neither UV_EXECUTABLE nor PYTHON_CMD now fails with a clear configure error instead of falling back to bare `python3`, which doesn't exist on Windows (it's python/py) and would surface as a cryptic mid-build failure. The two real callers always pass one, so this only catches a misconfigured `cmake -P`.

Scripts / MoonDeck:
- run_live_scenario: URL-encode module names in delete/replace/clear/cleanup paths (a name with a space — legal from ensureUniqueName — otherwise raised "URL can't contain control characters").

Tests:
- scenario_runner: remove_module/replace_module now refuse non-editable submodules, mirroring the live API (handleDeleteModule/handleReplaceModule).
- unit_MultiplyModifier: maxMultiplier saturates (64×64×16 must not wrap to 0).
- unit_Scheduler_unique_names: assertions + seeds updated to the '-' separator.

Docs / CI:
- testing.md: documented the scenario op vocabulary that was missing — remove_module/delete_module (aliased), replace_module, clear_children, and the `optional` set_control flag.
- architecture.md § Modifiers, CheckerboardModifier.md, main.cpp comment: future-tense → present tense (first enabled modifier applies; chaining is on the backlog). MultiplyModifier.md: corrected control ranges (1–64), mirrorZ default (true), and the removed fixed-fan-out narrative. FilesystemModule.md: MirrorModifier → MultiplyModifier example.
- backlog: live full-suite state-leakage between scenarios (canvas-preparing vs canonical-tree-assuming mutate scenarios don't compose when chained — in-process suite is the gate).
- decisions.md: uint16 intermediate-overflow lesson + "a status/dimension assertion is not proof the pipeline renders" + "width-sensitive paths are invisible on the uint32 desktop build".
- regenerated tests docs.

Reviews:
- 👾 Reviewer (pre-merge, whole-branch): maxMultiplier() uint16 overflow — fixed; stale MultiplyModifier.md — fixed; confirmed clean on hot-path (scratch alloc is cold-path onBuildState), platform boundary, zero-destination (Checkerboard) handling, bloat. Accepted with note: a same-type sibling auto-named "Foo-2" replaced to another type keeps "Foo-2" (rare: needs two same-type siblings + a replace).
- 🐇 CodeRabbit: Layer.h fan-out over-alloc — fixed (clamp to boxCount); embed_ui.cmake python3 fallback on Windows — fixed (FATAL_ERROR); scenario_runner editable guard — fixed; future-tense docs (architecture/Checkerboard/MultiplyModifier/main) — fixed. Skipped: CMake -DUV_EXECUTABLE→-DPYTHON_CMD (the suggested semicolon-list is the exact make/MSBuild mangling the current single-value design avoids); mirrorZ default false (product owner set it true deliberately — no-op on 2D); future-dated observed `at` (it's today's date, auto-managed by the scenario wrapper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ewowi ewowi changed the title Crash-safe module delete/rebuild + all-effects perf sweep Crash-safe module ops, modifier overhaul, Windows desktop port Jun 8, 2026
@ewowi
ewowi merged commit 55991ea into main Jun 8, 2026
1 check passed
@ewowi
ewowi deleted the next-iteration branch June 8, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant