Skip to content

Add 10 stateless and stateful effects#3

Merged
ewowi merged 1 commit into
mainfrom
feature/more-effects
May 22, 2026
Merged

Add 10 stateless and stateful effects#3
ewowi merged 1 commit into
mainfrom
feature/more-effects

Conversation

@ewowi

@ewowi ewowi commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ten new effects for the light pipeline — the effects-only half of the more-effects work.

  • Stateless (zero-heap): Plasma, PlasmaPalette, Metaballs, Checkerboard, Spiral, Ripples, GlowParticles, LavaLamp
  • Stateful (own dynamic buffer, freed when disabled): Fire (heat grid), Particles (trail buffer)

Each effect ships a module spec in docs/moonmodules/light/effects/ and a doctest case. color.h gains palette helpers shared by the palette-driven effects. main.cpp registers all ten via ModuleFactory; the startup pipeline keeps the single default Noise effect.

Effects use the existing MoonModule::enabled() system control — no parallel enabled field. Fire/Particles free their buffer when disabled.

Not included — dynamic effect management

The /api/types + /api/modules/<n>/move endpoints and the add-effect picker / reorder UI are intentionally excluded from this PR. They land separately as their own change.

Test plan

  • Desktop build clean (-Werror)
  • 104 unit-test cases pass (23 new effect cases)
  • 8 in-process scenarios pass
  • Platform boundary + spec check (20/20) PASS
  • CodeRabbit review

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR extends the LED effect pipeline by adding 10 new visual effects (Plasma, Fire, Metaballs, Particles, Spiral, Checkerboard, Lava Lamp, Glow Particles, Ripples, PlasmaPalette), introducing integer-only math utilities for fast rendering, enabling dynamic effect reordering/addition/deletion via HTTP APIs and UI controls, and updating project documentation.

Changes

Effect Pipeline Expansion

Layer / File(s) Summary
Integer Math Utilities & Base Effect Controls
src/core/color.h, src/light/EffectBase.h, src/light/Layer.h, src/light/NoiseEffect.h, src/light/RainbowEffect.h
New sin8_lut, sin8, cos8, atan2_8, dist8 constexpr functions in color.h for fast integer math. EffectBase adds public enabled boolean with control registration. Layer::loop() reverses child iteration for correct top-to-bottom draw order. NoiseEffect and RainbowEffect call EffectBase::onBuildControls() to inherit base controls.
Stateless Effects: Plasma, Spiral, Checkerboard, Lava Lamp, PlasmaPalette
src/light/PlasmaEffect.h, src/light/PlasmaPaletteEffect.h, src/light/SpiralEffect.h, src/light/CheckerboardEffect.h, src/light/LavaLampEffect.h
Five effects with no persistent buffers: PlasmaEffect (four-sine waves with HSV output), PlasmaPaletteEffect (plasma with fire/ocean palette lookup), SpiralEffect (rotating spiral using atan2_8 and dist8), CheckerboardEffect (animated checkerboard with two HSV hues), and LavaLampEffect (three slow blobs with fixed palette). Each maintains internal phase state and renders via elapsed-time-based animation.
Fire & Metaballs Effects
src/light/FireEffect.h, src/light/MetaballsEffect.h
FireEffect allocates a heat buffer (sized to LED count), simulates per-frame cooling/upward diffusion, injects random sparks at the bottom, and renders heat-to-RGB via an internal palette with optional hue shift. MetaballsEffect computes four moving ball centers using sin8 with per-ball phases, renders per-pixel via inverse-distance field summation, and outputs HSV-to-RGB.
Particle & Glow Particle Effects
src/light/ParticlesEffect.h, src/light/GlowParticlesEffect.h
ParticlesEffect maintains a persistent per-pixel trail buffer, lazily initializes 64 fixed-point particles with RNG, advances particles with boundary-bounce physics, fades trail each frame, and copies final trail to layer buffer. GlowParticlesEffect manages 8 particles with an inverse-squared metaball field per pixel for a soft-glow effect.
Ripples Effect
src/light/RipplesEffect.h
Maintains 8 ripple instances with centers, radii, and hues; advances radii over time and respawns ripples at random positions when they exceed visible bounds; accumulates per-ripple contributions per pixel with thickness falloff and age-based fade.
Module Reordering API
src/core/MoonModule.h, src/core/HttpServerModule.h
MoonModule adds moveChild(child, delta) to swap child pointers in children_ array. HttpServerModule adds GET /api/types endpoint (returns JSON array of registered module types) and POST /api/modules/{Name}/move handler (validates and applies reordering via parent->moveChild()).
UI Effect Type Management
src/ui/app.js, src/ui/style.css
app.js fetches /api/types alongside /api/state on load, stores available effect types. renderCards() detects layer containers, inserts "Add effect" picker card, and renders effect children with move/delete buttons. New helpers (createAddEffectCard, addModule, deleteModule, moveModule) wire UI to HTTP endpoints. style.css adds flex layout to card headers, plus .card-actions, .card-btn, .add-card styling.
Effect Registration & Defaults
src/main.cpp, src/light/ArtNetSendDriver.h, src/light/PreviewDriver.h
main.cpp includes all 10 new effect headers and registers them with ModuleFactory; changes default initial effect from NoiseEffect to PlasmaEffect. ArtNetSendDriver and PreviewDriver increase fps control maximum from 60/120 to 255.
Effect Documentation & Tests
docs/moonmodules/light/*.md, docs/testing.md, test/CMakeLists.txt, test/test_*.cpp
Markdown documentation for all 10 effects, plus EffectBase.md and testing.md subsections. Five test files validate non-zero RGB output, spatial variation, and buffer allocation/freeing for stateful effects (Fire, Particles, Plasma, Metaballs) and stateless effects (Checkerboard, Spiral, PlasmaPalette, GlowParticles, RipplesEffect, LavaLamp).
Project Status Assessment
docs/history/assessment-2026-05.md
Objective assessment covering pipeline completeness, missing items, completion estimates, technical strengths/debt, dimension scoring, historical comparison, and prioritization.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Possibly related PRs

  • ewowi/projectMM-v3#1: Foundational pipeline PR that introduced the HTTP server and MoonModule architecture; this PR builds directly on those APIs by extending HttpServerModule with effect-type discovery and move endpoints, and leveraging MoonModule for child reordering.

🐇 Ten effects bloom bright,
With swirling sine and blazing heat,
Math packed tight, pixels light,
A dancing show—now move and delete!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: adding 10 new effects to the system.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feature/more-effects

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ewowi

ewowi commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, regarding

Rate limit exceeded

it says wait 56 min. but how much is it now? As I need a review from you

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check recent comments on the PR for rate limit info
gh pr view 3 --repo ewowi/projectMM-v3 --comments --json comments | jq '.comments[-5:] | .[] | {author: .author.login, createdAt: .createdAt, body: .body[0:300]}'

Length of output: 595


@ewowi The rate limit was triggered at 13:44 UTC with a 56-minute cooldown, meaning it expires at approximately 14:40 UTC. When you posted your question at 14:22 UTC, there were roughly ~17 minutes remaining.

The window should be clear (or very close to it) by now — I'll kick off the review for you! 🐇

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ewowi

ewowi commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, please review this pr and provide a prompt for all review comments with AI agents

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

@ewowi Sure! I'll kick off a full review of this PR right away. Once the review is complete, each comment will include an AI agent prompt that you can use to implement the suggested fix directly. 🐇

✅ Actions performed

Full review triggered.

@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: 15

Caution

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

⚠️ Outside diff range comments (1)
src/light/NoiseEffect.h (1)

10-15: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the scale range comment to match the control bounds.

Line 10 says 1-32, but Line 15 now exposes 1-255.

Suggested fix
-    uint8_t scale = 4;  // spatial frequency (1-32)
+    uint8_t scale = 4;  // spatial frequency (1-255)
🤖 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/NoiseEffect.h` around lines 10 - 15, The comment for the uint8_t
field "scale" is out of sync with the control bounds: update the comment on the
"scale" member (declared as uint8_t scale = 4) to reflect the actual range used
by controls_.addUint8 in onBuildControls (1-255) so the inline documentation
matches the control limits; locate the scale declaration and the onBuildControls
method to change the comment from "spatial frequency (1-32)" to "spatial
frequency (1-255)" (or a wording that indicates the 1–255 range).
🤖 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/history/assessment-2026-05.md`:
- Line 26: Update the assessment to match this PR’s post-merge state (or clearly
label it as a pre-PR baseline): replace the Effects entry that currently lists
only "Rainbow, Noise" with the full set added by layers 2–5 (Plasma, Metaballs,
Fire, Particles, Checkerboard, Spiral, PlasmaPalette, Ripples, GlowParticles,
LavaLamp), mark that RipplesEffect is implemented (layer 5), change the "UI type
picker + dynamic effect switching" missing note to present since layer 7
implements the UI and wiring for /api/types, and remove/adjust the claim that
GET /api/types does not exist because HttpServerModule (layer 6) adds that
endpoint; ensure references to RipplesEffect, HttpServerModule, GET /api/types,
and the layer numbers are updated or add a “pre-PR baseline” header so readers
know the snapshot’s intent.

In `@src/core/color.h`:
- Around line 65-78: atan2_8 produces wrong quadrant offsets: fix by computing
the 4 quadrant base (0,64,128,192) from the signs of x and y (use (x<0) and
(y<0)), keep the octant swap logic but make offset represent 0 or 32 (45°
octant), compute the intra-octant step b in 0..31 (use y*32/x, not *64/x) and
return quadrant_base + offset + b; update variables r, offset, and b in atan2_8
accordingly so final value maps 0..255 with +X≈0, +Y≈64, -X≈128, -Y≈192.

In `@src/core/HttpServerModule.h`:
- Around line 458-463: The loop emitting the /api/types JSON array uses the loop
index to decide comma placement but skips null names, which can produce a
leading comma; change the emission logic in the loop that iterates
ModuleFactory::typeCount() / calls ModuleFactory::typeName(i) to track a bool
first = true (outside the loop), and when emitting each non-null name write a
comma only if first is false, then set first = false after emitting; keep using
jsonBuf_, pos and JSON_BUF_SIZE and the existing snprintf/bounds checks when
appending.
- Around line 146-155: The current check using std::strstr(path + 13, "/move")
matches any occurrence of "/move" and can accept malformed paths; change the
condition to require "/move" to be an exact suffix (or followed only by
query/fragment) before calling handleMoveModule. Specifically, find the existing
strstr usage and replace it with a check that locates the "/move" token (e.g.,
via std::strstr or std::strcmp on the found pointer) and then assert that the
characters after "/move" are only end-of-string, '?' or '#' (reject
"/move/extra" etc.), then proceed to extract nameBuf and call
handleMoveModule(nameBuf, body).

In `@src/light/FireEffect.h`:
- Line 5: Remove the direct platform dependency from FireEffect.h by deleting
`#include` "platform/platform.h" and any direct calls to platform allocation APIs
inside the FireEffect class; instead inject or call an abstract allocator
interface (e.g., IMemoryAllocator) via FireEffect’s constructor or an initialize
method and replace platformAlloc/platformFree usages with
IMemoryAllocator::allocate/ free (or equivalent) calls; keep only the allocator
interface declaration visible to src/light (reference symbols: class FireEffect,
FireEffect::FireEffect(...), FireEffect::initialize(...), and any
platformAlloc/platformFree calls) and move the concrete platform include and
implementation of the allocator into the platform-specific module so FireEffect
only depends on the allocator abstraction.

In `@src/light/GlowParticlesEffect.h`:
- Around line 37-54: The first-frame dt can be huge because lastElapsed_ is
initialized to 0; change the delta calculation so that if lastElapsed_ is 0 (or
otherwise indicates “no prior sample”) you treat dt as 0 instead of
now-lastElapsed_. In practice, call elapsed() into now, then compute dt =
(lastElapsed_ == 0 ? 0 : now - lastElapsed_), then set lastElapsed_ = now; this
prevents the huge initial sx/sy updates in the particle update loop that touches
particles_ and uses p.vx/p.vy, speed, and dt.

In `@src/light/LavaLampEffect.h`:
- Around line 49-63: The radius is currently used as an absolute pixel value
when computing r2, causing saturation on small panels; adjust radius before
computing r2 by scaling it to the panel size (e.g., multiply radius by a factor
derived from w and h such as min(w,h)/reference_size or normalize to [0,1] then
multiply by min(w,h)) so that r2 = (scaledRadius * scaledRadius) uses the
panel-aware value; update the calculation that sets r2 (and any use of radius in
the inner loop that influences d2/field/scaled) so that NUM_BLOBS, bx/by,
intensity and palette_ logic remain unchanged but operate on the normalized
radius to restore spatial variation.

In `@src/light/ParticlesEffect.h`:
- Around line 70-95: The particle motion is frame-rate dependent because sx/sy
are computed as (p.vx * speed) >> 6 with no time factor; update ParticlesEffect
to use elapsed milliseconds (dt) like GlowParticlesEffect::loop(): add/maintain
a lastElapsed_ timestamp, compute dt = elapsed - lastElapsed_, multiply the
velocity term by dt (so displacement = p.vx * speed * dt * scaling_factor rather
than just shifting), use that to compute sx/sy and update p.x/p.y, and update
lastElapsed_ after the loop so the slider and motion are resolution-independent;
reference symbols: sx, sy, p.vx, speed, p.x/p.y, particles_, lastElapsed_, and
loop().

In `@src/ui/app.js`:
- Around line 230-252: addModule, deleteModule and moveModule currently ignore
fetch responses and errors so UI refreshes even when the server operation fails;
update each function to await the fetch response, check response.ok (and parse
error body when not ok), throw or handle errors (e.g., log/display) and only
call refreshAndRender if the request succeeded, and wrap the fetch in try/catch
to handle network errors and prevent silent failures.

In `@test/test_fire.cpp`:
- Around line 72-74: Replace the direct effect-level manipulation (setting
fire.enabled = false and calling fire.onAllocateMemory()) with the layer-level
flow: locate the Layer that owns the fire effect and call its
disable/setEnabled(false) API so the Layer handles reallocation, then trigger
the Layer's allocation/reallocation entry point (e.g., Layer::onAllocateMemory
or Layer::reallocateMemory) and assert fire.dynamicBytes() == 0; do not call
fire.onAllocateMemory() directly. Ensure you reference the fire.enabled and
fire.onAllocateMemory() occurrences to remove them and use the Layer methods
instead.

In `@test/test_metaballs.cpp`:
- Around line 51-55: The test dereferences the raw pointer returned by
layer.buffer().data() (variable data) without checking for null; add a null
precondition by inserting a REQUIRE(data != nullptr) immediately after obtaining
data (before the uint8_t r0 = ... line) so a failed allocation is reported by
doctest instead of crashing the test; ensure the check references the same
expression (data) used later in the comparisons.

In `@test/test_particles.cpp`:
- Around line 68-70: The test currently disables particles and calls
particles.onAllocateMemory() directly, which bypasses the Layer-level
disable/reallocation contract; instead, after setting particles.enabled = false
you should invoke the owning Layer's reallocation API (i.e., trigger the Layer
reallocation flow that the effect contract uses rather than calling
particles.onAllocateMemory() directly) so the Layer performs the proper
disable/reallocate behavior, then assert particles.dynamicBytes() == 0; replace
the direct onAllocateMemory() call with the Layer reallocation call that owns
this particle set.

In `@test/test_plasma.cpp`:
- Around line 52-56: The test directly indexes layer.buffer().data() and can
crash if the buffer is null or too small; before accessing data[0] and
data[lastIdx+2] add REQUIRE checks that the buffer pointer is non-null and that
layer.buffer().size() (or equivalent size accessor on layer.buffer()) is greater
than lastIdx+2 so the accesses are safe, then proceed with the existing uint8_t
r0/g0/b0, r1/g1/b1 and CHECK; reference the local variables data, lastIdx and
the CHECK assertion when adding these precondition REQUIREs.

In `@test/test_stateless_effects.cpp`:
- Around line 58-74: The tests generated by STATELESS_EFFECT_TEST must assert
the stateless contract by checking effect.dynamicBytes() == 0; add an assertion
after allocation/loop in both generated TEST_CASEs so that for each mm::EFFECT
created (inside Ctx ctx(...); mm::EFFECT effect; ctx.layer.addChild(&effect);
ctx.layer.onAllocateMemory(); ctx.layer.loop();) you call
CHECK(effect.dynamicBytes() == 0) to ensure heap-free behavior; update the macro
STATELESS_EFFECT_TEST (and the same pattern in the other tests covering lines
76-118) to include this assertion immediately after onAllocateMemory()/loop().
- Around line 92-94: The test currently only compares a single channel (data[0]
!= data[4 * 3]) which is brittle; change it to assert the full RGB triplet
differs by comparing the three color bytes for the two pixels retrieved from
ctx.layer.buffer().data() (the local variable data). Replace the single-channel
check with a full-pixel comparison (e.g., use std::memcmp(&data[0], &data[4 *
3], 3) != 0 or explicitly check data[0..2] vs data[4*3..4*3+2]) so the test
verifies RGB-level spatial variation as specified.

---

Outside diff comments:
In `@src/light/NoiseEffect.h`:
- Around line 10-15: The comment for the uint8_t field "scale" is out of sync
with the control bounds: update the comment on the "scale" member (declared as
uint8_t scale = 4) to reflect the actual range used by controls_.addUint8 in
onBuildControls (1-255) so the inline documentation matches the control limits;
locate the scale declaration and the onBuildControls method to change the
comment from "spatial frequency (1-32)" to "spatial frequency (1-255)" (or a
wording that indicates the 1–255 range).
🪄 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: 8327ded8-cca9-4eed-ab2f-eed2ca9b1261

📥 Commits

Reviewing files that changed from the base of the PR and between 2a116af and 9e28ef8.

📒 Files selected for processing (41)
  • docs/history/assessment-2026-05.md
  • docs/moonmodules/light/EffectBase.md
  • docs/moonmodules/light/effects/CheckerboardEffect.md
  • docs/moonmodules/light/effects/FireEffect.md
  • docs/moonmodules/light/effects/GlowParticlesEffect.md
  • docs/moonmodules/light/effects/LavaLampEffect.md
  • docs/moonmodules/light/effects/MetaballsEffect.md
  • docs/moonmodules/light/effects/ParticlesEffect.md
  • docs/moonmodules/light/effects/PlasmaEffect.md
  • docs/moonmodules/light/effects/PlasmaPaletteEffect.md
  • docs/moonmodules/light/effects/RipplesEffect.md
  • docs/moonmodules/light/effects/SpiralEffect.md
  • docs/testing.md
  • src/core/HttpServerModule.h
  • src/core/MoonModule.h
  • src/core/color.h
  • src/light/ArtNetSendDriver.h
  • src/light/CheckerboardEffect.h
  • src/light/EffectBase.h
  • src/light/FireEffect.h
  • src/light/GlowParticlesEffect.h
  • src/light/LavaLampEffect.h
  • src/light/Layer.h
  • src/light/MetaballsEffect.h
  • src/light/NoiseEffect.h
  • src/light/ParticlesEffect.h
  • src/light/PlasmaEffect.h
  • src/light/PlasmaPaletteEffect.h
  • src/light/PreviewDriver.h
  • src/light/RainbowEffect.h
  • src/light/RipplesEffect.h
  • src/light/SpiralEffect.h
  • src/main.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/test_fire.cpp
  • test/test_metaballs.cpp
  • test/test_particles.cpp
  • test/test_plasma.cpp
  • test/test_stateless_effects.cpp

| **MoonModule + controls + scheduler** | Complete, consistently used |
| **Pipeline** | LayoutGroup → GridLayout → Layer → effects → modifier → DriverGroup → ArtNet + Preview |
| **Mapping** | MappingLUT, MirrorModifier, BlendMap, 1:1 optimization without extra buffer |
| **Effects** | Rainbow, Noise |

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 | 🏗️ Heavy lift

Assessment is inconsistent with this PR's changes.

This assessment document describes features as missing that are actually being delivered in this PR:

  • Line 26 lists only "Rainbow, Noise" effects, but this PR adds 10 new effects (Plasma, Metaballs, Fire, Particles, Checkerboard, Spiral, PlasmaPalette, Ripples, GlowParticles, LavaLamp) per layers 2–5.
  • Line 44 claims "UI type picker + dynamic effect switching" is missing, but layer 7 implements exactly this (UI fetches /api/types, adds picker card, wire add/delete/move actions).
  • Line 47 lists "Ripples" as missing, but RipplesEffect is added in layer 5.
  • Line 102 states "/api/types does not exist yet," but layer 6 adds GET /api/types in HttpServerModule.

Since this document is part of the PR and dated the same month, readers will assume it reflects the post-merge state, making these claims misleading.

Recommendation: Either update the assessment to reflect the state after this PR, or clearly label it as a "pre-PR baseline" snapshot for historical reference.

Also applies to: 44-44, 47-47, 102-102

🤖 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 `@docs/history/assessment-2026-05.md` at line 26, Update the assessment to
match this PR’s post-merge state (or clearly label it as a pre-PR baseline):
replace the Effects entry that currently lists only "Rainbow, Noise" with the
full set added by layers 2–5 (Plasma, Metaballs, Fire, Particles, Checkerboard,
Spiral, PlasmaPalette, Ripples, GlowParticles, LavaLamp), mark that
RipplesEffect is implemented (layer 5), change the "UI type picker + dynamic
effect switching" missing note to present since layer 7 implements the UI and
wiring for /api/types, and remove/adjust the claim that GET /api/types does not
exist because HttpServerModule (layer 6) adds that endpoint; ensure references
to RipplesEffect, HttpServerModule, GET /api/types, and the layer numbers are
updated or add a “pre-PR baseline” header so readers know the snapshot’s intent.

Comment thread src/core/color.h
Comment on lines +65 to +78
// Fast octant atan2: returns 0-255 for full circle (y, x) in -32768..32767
constexpr uint8_t atan2_8(int16_t y, int16_t x) {
uint8_t r = 0;
if (y < 0) { y = static_cast<int16_t>(-y); r = 0x80; }
if (x < 0) { x = static_cast<int16_t>(-x); r = static_cast<uint8_t>(r | 0x40); }
uint8_t offset = (x > y) ? 0 : 32;
if (x < y) {
int16_t t = y;
y = x;
x = t;
}
uint8_t b = (x == 0) ? 0 : static_cast<uint8_t>((static_cast<uint16_t>(y) * 64) / static_cast<uint16_t>(x));
return static_cast<uint8_t>(r + offset + b);
}

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

atan2_8 currently maps key directions to wrong angles.

Line 66-78 does not produce a correct 0..255 full-circle mapping (e.g., (y=1,x=0) should be ~64, (y=0,x=-1) should be ~128). This will skew angle-based effects.

Suggested fix
-constexpr uint8_t atan2_8(int16_t y, int16_t x) {
-    uint8_t r = 0;
-    if (y < 0) { y = static_cast<int16_t>(-y); r = 0x80; }
-    if (x < 0) { x = static_cast<int16_t>(-x); r = static_cast<uint8_t>(r | 0x40); }
-    uint8_t offset = (x > y) ? 0 : 32;
-    if (x < y) {
-        int16_t t = y;
-        y = x;
-        x = t;
-    }
-    uint8_t b = (x == 0) ? 0 : static_cast<uint8_t>((static_cast<uint16_t>(y) * 64) / static_cast<uint16_t>(x));
-    return static_cast<uint8_t>(r + offset + b);
-}
+constexpr uint8_t atan2_8(int16_t y, int16_t x) {
+    if (x == 0 && y == 0) return 0;
+
+    const uint16_t ax = static_cast<uint16_t>(
+        x < 0 ? -static_cast<int32_t>(x) : static_cast<int32_t>(x));
+    const uint16_t ay = static_cast<uint16_t>(
+        y < 0 ? -static_cast<int32_t>(y) : static_cast<int32_t>(y));
+
+    uint8_t base;
+    if (ax >= ay) {
+        base = static_cast<uint8_t>((static_cast<uint32_t>(ay) * 32u) / (ax == 0 ? 1u : ax)); // 0..32
+    } else {
+        base = static_cast<uint8_t>(64u - (static_cast<uint32_t>(ax) * 32u) / ay); // 32..64
+    }
+
+    if (x >= 0) {
+        return (y >= 0) ? base : static_cast<uint8_t>(256u - base);
+    }
+    return (y >= 0) ? static_cast<uint8_t>(128u - base) : static_cast<uint8_t>(128u + base);
+}
🤖 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/color.h` around lines 65 - 78, atan2_8 produces wrong quadrant
offsets: fix by computing the 4 quadrant base (0,64,128,192) from the signs of x
and y (use (x<0) and (y<0)), keep the octant swap logic but make offset
represent 0 or 32 (45° octant), compute the intra-octant step b in 0..31 (use
y*32/x, not *64/x) and return quadrant_base + offset + b; update variables r,
offset, and b in atan2_8 accordingly so final value maps 0..255 with +X≈0,
+Y≈64, -X≈128, -Y≈192.

Comment thread src/core/HttpServerModule.h Outdated
Comment on lines +146 to +155
} else if (std::strncmp(path, "/api/modules/", 13) == 0 &&
std::strstr(path + 13, "/move") != nullptr && body) {
// POST /api/modules/Name/move with body {"delta": -1|1}
char nameBuf[32] = {};
const char* slash = std::strstr(path + 13, "/move");
size_t nameLen = static_cast<size_t>(slash - (path + 13));
if (nameLen >= sizeof(nameBuf)) nameLen = sizeof(nameBuf) - 1;
std::memcpy(nameBuf, path + 13, nameLen);
nameBuf[nameLen] = 0;
handleMoveModule(conn, nameBuf, body);

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

Tighten /move route matching to an exact suffix.

Current matching accepts any path containing "/move" after /api/modules/, so malformed paths can still trigger moves.

Suggested fix
-            } else if (std::strncmp(path, "/api/modules/", 13) == 0 &&
-                       std::strstr(path + 13, "/move") != nullptr && body) {
+            } else if (std::strncmp(path, "/api/modules/", 13) == 0 && body) {
+                const char* suffix = "/move";
+                const char* tail = path + std::strlen(path) - std::strlen(suffix);
+                if (tail >= path + 13 && std::strcmp(tail, suffix) == 0) {
                 // POST /api/modules/Name/move with body {"delta": -1|1}
                 char nameBuf[32] = {};
-                const char* slash = std::strstr(path + 13, "/move");
+                const char* slash = tail;
                 size_t nameLen = static_cast<size_t>(slash - (path + 13));
                 if (nameLen >= sizeof(nameBuf)) nameLen = sizeof(nameBuf) - 1;
                 std::memcpy(nameBuf, path + 13, nameLen);
                 nameBuf[nameLen] = 0;
                 handleMoveModule(conn, nameBuf, body);
+                } else {
+                    sendResponse(conn, 404, "text/plain", "Not found");
+                }
🤖 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.h` around lines 146 - 155, The current check using
std::strstr(path + 13, "/move") matches any occurrence of "/move" and can accept
malformed paths; change the condition to require "/move" to be an exact suffix
(or followed only by query/fragment) before calling handleMoveModule.
Specifically, find the existing strstr usage and replace it with a check that
locates the "/move" token (e.g., via std::strstr or std::strcmp on the found
pointer) and then assert that the characters after "/move" are only
end-of-string, '?' or '#' (reject "/move/extra" etc.), then proceed to extract
nameBuf and call handleMoveModule(nameBuf, body).

Comment thread src/core/HttpServerModule.h Outdated
Comment on lines +458 to +463
for (uint8_t i = 0; i < ModuleFactory::typeCount(); i++) {
const char* name = ModuleFactory::typeName(i);
if (!name) continue;
int n = std::snprintf(jsonBuf_ + pos, JSON_BUF_SIZE - pos,
"%s\"%s\"", (i == 0 ? "" : ","), name);
if (n > 0 && static_cast<size_t>(pos + n) < JSON_BUF_SIZE) pos += n;

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

Use a first flag when emitting /api/types array entries.

Comma placement is tied to index, but null names are skipped. That can produce invalid JSON with a leading comma.

Suggested fix
     void serveTypes(platform::TcpConnection& conn) {
         int pos = std::snprintf(jsonBuf_, JSON_BUF_SIZE, "{\"types\":[");
+        bool first = true;
         for (uint8_t i = 0; i < ModuleFactory::typeCount(); i++) {
             const char* name = ModuleFactory::typeName(i);
             if (!name) continue;
             int n = std::snprintf(jsonBuf_ + pos, JSON_BUF_SIZE - pos,
-                "%s\"%s\"", (i == 0 ? "" : ","), name);
+                "%s\"%s\"", first ? "" : ",", name);
             if (n > 0 && static_cast<size_t>(pos + n) < JSON_BUF_SIZE) pos += n;
+            first = 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/core/HttpServerModule.h` around lines 458 - 463, The loop emitting the
/api/types JSON array uses the loop index to decide comma placement but skips
null names, which can produce a leading comma; change the emission logic in the
loop that iterates ModuleFactory::typeCount() / calls ModuleFactory::typeName(i)
to track a bool first = true (outside the loop), and when emitting each non-null
name write a comma only if first is false, then set first = false after
emitting; keep using jsonBuf_, pos and JSON_BUF_SIZE and the existing
snprintf/bounds checks when appending.

Comment thread src/light/FireEffect.h

#include "light/Layer.h"
#include "core/color.h"
#include "platform/platform.h"

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 | 🏗️ Heavy lift

Keep platform-specific includes and API calls out of src/light.

This effect directly includes platform headers and calls platform allocation APIs from the light layer. Please route memory allocation through a non-platform abstraction owned outside src/light so platform coupling remains confined to src/platform/.

As per coding guidelines, src/**/*.{h,hpp,cpp,cc}: All #ifdef, platform-specific #include``s, and hardware API calls must live exclusively in src/platform/`.

Also applies to: 29-30, 114-114

🤖 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/FireEffect.h` at line 5, Remove the direct platform dependency from
FireEffect.h by deleting `#include` "platform/platform.h" and any direct calls to
platform allocation APIs inside the FireEffect class; instead inject or call an
abstract allocator interface (e.g., IMemoryAllocator) via FireEffect’s
constructor or an initialize method and replace platformAlloc/platformFree
usages with IMemoryAllocator::allocate/ free (or equivalent) calls; keep only
the allocator interface declaration visible to src/light (reference symbols:
class FireEffect, FireEffect::FireEffect(...), FireEffect::initialize(...), and
any platformAlloc/platformFree calls) and move the concrete platform include and
implementation of the allocator into the platform-specific module so FireEffect
only depends on the allocator abstraction.

Comment thread test/test_metaballs.cpp
Comment on lines +51 to +55
auto* data = layer.buffer().data();
uint8_t r0 = data[0], g0 = data[1], b0 = data[2];
size_t lastIdx = (32 * 32 - 1) * 3;
uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2];
CHECK((r0 != r1 || g0 != g1 || b0 != b1));

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

Add a null precondition before dereferencing the buffer pointer.

Line 52 dereferences data without guarding for allocation failure. Add a REQUIRE(data != nullptr) so failures report cleanly instead of crashing.

Proposed fix
     auto* data = layer.buffer().data();
+    REQUIRE(data != nullptr);
     uint8_t r0 = data[0], g0 = data[1], b0 = data[2];
As per coding guidelines, "Unit and integration tests using doctest. Verify tests cover edge cases and match the specifications in docs/moonmodules/."
🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 54-54: The function 'addText' is never used.

(unusedFunction)

🤖 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/test_metaballs.cpp` around lines 51 - 55, The test dereferences the raw
pointer returned by layer.buffer().data() (variable data) without checking for
null; add a null precondition by inserting a REQUIRE(data != nullptr)
immediately after obtaining data (before the uint8_t r0 = ... line) so a failed
allocation is reported by doctest instead of crashing the test; ensure the check
references the same expression (data) used later in the comparisons.

Comment thread test/test_particles.cpp Outdated
Comment on lines +68 to +70
particles.enabled = false;
particles.onAllocateMemory();
CHECK(particles.dynamicBytes() == 0);

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

Validate disable memory release via Layer reallocation flow.

Line 69 calls particles.onAllocateMemory() directly, which skips the layer-level disable/reallocation behavior required by the effect contract.

Proposed fix
     particles.enabled = false;
-    particles.onAllocateMemory();
+    layer.onAllocateMemory();
     CHECK(particles.dynamicBytes() == 0);
As per coding guidelines, "Unit and integration tests using doctest. Verify tests cover edge cases and match the specifications in docs/moonmodules/."
🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 69-69: The function 'removeChild' is never used.

(unusedFunction)


[style] 70-70: The function 'physicalLightCount' is never used.

(unusedFunction)

🤖 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/test_particles.cpp` around lines 68 - 70, The test currently disables
particles and calls particles.onAllocateMemory() directly, which bypasses the
Layer-level disable/reallocation contract; instead, after setting
particles.enabled = false you should invoke the owning Layer's reallocation API
(i.e., trigger the Layer reallocation flow that the effect contract uses rather
than calling particles.onAllocateMemory() directly) so the Layer performs the
proper disable/reallocate behavior, then assert particles.dynamicBytes() == 0;
replace the direct onAllocateMemory() call with the Layer reallocation call that
owns this particle set.

Comment thread test/test_plasma.cpp
Comment on lines +52 to +56
auto* data = layer.buffer().data();
uint8_t r0 = data[0], g0 = data[1], b0 = data[2];
size_t lastIdx = (16 * 16 - 1) * 3;
uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2];
CHECK((r0 != r1 || g0 != g1 || b0 != b1));

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

Guard buffer pointer/size before direct indexing.

This test can crash instead of failing cleanly if allocation/config regresses; add REQUIRE checks before data[0]/data[lastIdx] access.

Proposed fix
     layer.onAllocateMemory();
     layer.loop();

-    auto* data = layer.buffer().data();
+    auto& buf = layer.buffer();
+    REQUIRE(buf.data() != nullptr);
+    REQUIRE(buf.bytes() >= (16u * 16u * 3u));
+    auto* data = buf.data();
     uint8_t r0 = data[0], g0 = data[1], b0 = data[2];
     size_t lastIdx = (16 * 16 - 1) * 3;
     uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2];

As per coding guidelines, test/** requires edge-case-aware tests; crashing on null/short buffers bypasses that.

🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 54-54: The function 'addText' is never used.

(unusedFunction)

🤖 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/test_plasma.cpp` around lines 52 - 56, The test directly indexes
layer.buffer().data() and can crash if the buffer is null or too small; before
accessing data[0] and data[lastIdx+2] add REQUIRE checks that the buffer pointer
is non-null and that layer.buffer().size() (or equivalent size accessor on
layer.buffer()) is greater than lastIdx+2 so the accesses are safe, then proceed
with the existing uint8_t r0/g0/b0, r1/g1/b1 and CHECK; reference the local
variables data, lastIdx and the CHECK assertion when adding these precondition
REQUIREs.

Comment on lines +58 to +74
#define STATELESS_EFFECT_TEST(EFFECT) \
TEST_CASE(#EFFECT " writes non-zero RGB") { \
Ctx ctx(16, 16); \
mm::EFFECT effect; \
ctx.layer.addChild(&effect); \
ctx.layer.onAllocateMemory(); \
ctx.layer.loop(); \
CHECK(ctx.hasNonZero()); \
} \
TEST_CASE(#EFFECT " spatial variation") { \
Ctx ctx(32, 32); \
mm::EFFECT effect; \
ctx.layer.addChild(&effect); \
ctx.layer.onAllocateMemory(); \
ctx.layer.loop(); \
CHECK(ctx.cornersDiffer()); \
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add dynamicBytes()==0 assertions for stateless effects.

These tests currently miss a key documented contract (heap-free/stateless memory behavior). Please assert effect.dynamicBytes() == 0 after allocation/loop for each stateless effect case.

Proposed pattern
 `#define` STATELESS_EFFECT_TEST(EFFECT) \
     TEST_CASE(`#EFFECT` " writes non-zero RGB") { \
         Ctx ctx(16, 16); \
         mm::EFFECT effect; \
         ctx.layer.addChild(&effect); \
         ctx.layer.onAllocateMemory(); \
         ctx.layer.loop(); \
+        CHECK(effect.dynamicBytes() == 0); \
         CHECK(ctx.hasNonZero()); \
     } \

As per coding guidelines, tests in test/** must match docs/moonmodules/ specifications, and those effect docs explicitly require heap-free / dynamicBytes() = 0 behavior.

Also applies to: 76-118

🧰 Tools
🪛 Clang (14.0.6)

[warning] 58-58: function-like macro 'STATELESS_EFFECT_TEST' used; consider a 'constexpr' template function

(cppcoreguidelines-macro-usage)

🪛 Cppcheck (2.20.0)

[style] 69-69: The function 'removeChild' is never used.

(unusedFunction)


[style] 63-63: The function 'cos8' is never used.

(unusedFunction)


[style] 61-61: The function 'lut' is never used.

(unusedFunction)


[style] 70-70: The function 'physicalLightCount' is never used.

(unusedFunction)


[style] 74-74: The function 'lutSkipped' is never used.

(unusedFunction)

🤖 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/test_stateless_effects.cpp` around lines 58 - 74, The tests generated by
STATELESS_EFFECT_TEST must assert the stateless contract by checking
effect.dynamicBytes() == 0; add an assertion after allocation/loop in both
generated TEST_CASEs so that for each mm::EFFECT created (inside Ctx ctx(...);
mm::EFFECT effect; ctx.layer.addChild(&effect); ctx.layer.onAllocateMemory();
ctx.layer.loop();) you call CHECK(effect.dynamicBytes() == 0) to ensure
heap-free behavior; update the macro STATELESS_EFFECT_TEST (and the same pattern
in the other tests covering lines 76-118) to include this assertion immediately
after onAllocateMemory()/loop().

Comment on lines +92 to +94
auto* data = ctx.layer.buffer().data();
CHECK(data[0] != data[4 * 3]);
}

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

Use full RGB-triplet comparison for checkerboard variation.

Comparing only one channel is brittle; assert full-pixel difference to match the spec intent.

Proposed fix
     ctx.layer.onAllocateMemory();
     ctx.layer.loop();
     auto* data = ctx.layer.buffer().data();
-    CHECK(data[0] != data[4 * 3]);
+    const size_t a = 0;
+    const size_t b = 4 * 3;
+    CHECK(data[a] != data[b] || data[a + 1] != data[b + 1] || data[a + 2] != data[b + 2]);

As per coding guidelines, tests under test/** should match specs in docs/moonmodules/, which define spatial variation at color/pixel level.

🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 94-94: The function 'forEachDestination' is never used.

(unusedFunction)

🤖 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/test_stateless_effects.cpp` around lines 92 - 94, The test currently
only compares a single channel (data[0] != data[4 * 3]) which is brittle; change
it to assert the full RGB triplet differs by comparing the three color bytes for
the two pixels retrieved from ctx.layer.buffer().data() (the local variable
data). Replace the single-channel check with a full-pixel comparison (e.g., use
std::memcmp(&data[0], &data[4 * 3], 3) != 0 or explicitly check data[0..2] vs
data[4*3..4*3+2]) so the test verifies RGB-level spatial variation as specified.

Ten new effects, registered with ModuleFactory and available in the pipeline:

- Stateless (zero-heap): Plasma, PlasmaPalette, Metaballs, Checkerboard,
  Spiral, Ripples, GlowParticles, LavaLamp
- Stateful (own dynamic buffer, freed when disabled): Fire (heat grid),
  Particles (trail buffer)

Each effect ships a module spec in docs/moonmodules/light/effects/ and a
doctest case (test_fire, test_metaballs, test_particles, test_plasma,
test_stateless_effects — 23 cases). color.h gains palette helpers shared by
the palette-driven effects. main.cpp registers all ten; the startup pipeline
keeps the single default Noise effect.

This is the effects-only half of the more-effects work. Dynamic effect
management (the /api/types + /api/modules/move endpoints and the add-effect
picker UI) is intentionally left out — it lands separately. Effects use the
existing MoonModule `enabled()` system control (Fire/Particles free their
buffer when disabled); no parallel `enabled` field is introduced.

Reduced pre-commit (effects + docs, no render-loop or platform change):
desktop build clean, 104 unit-test cases pass (23 new), 8 scenarios pass,
platform boundary + spec check (20/20) PASS. ESP32 build/flash skipped — no
ESP32-specific change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ewowi
ewowi force-pushed the feature/more-effects branch from 9e28ef8 to 43bba8c Compare May 22, 2026 15:16
@ewowi ewowi changed the title Multiple effects + dynamic effect management Add 10 stateless and stateful effects May 22, 2026
@ewowi
ewowi merged commit 989d538 into main May 22, 2026
1 check passed
@ewowi
ewowi deleted the feature/more-effects branch May 22, 2026 15:17
ewowi pushed a commit that referenced this pull request May 22, 2026
David Jupijn authored docs/history/assessment-2026-05.md in commit 9e28ef8
on the feature/more-effects branch. That branch was later rewritten (reset
+ re-apply, to split the effects from the dynamic-effect-management work)
and force-pushed; the rewritten single commit 43bba8c carried the file
content but was authored by ewowi, so David's authorship of 9e28ef8 was
lost when PR #3 merged.

This empty commit re-establishes the attribution: the project assessment
in docs/history/assessment-2026-05.md is David Jupijn's work. The file
content already lives on main (via 43bba8c); only the credit is restored
here.
ewowi added a commit that referenced this pull request May 23, 2026
KPI: 16384lights | PC:283KB | tick:37/38/111/108us(FPS:27027/26315/9009/9259) | ESP32:913KB | tick:43892us(FPS:22) | heap:132KB | src:45(7036) | test:23(2922) | lizard:21w

Multiple cohesive threads landed together:

# Auto-extrusion & Dim system

* `mm::Dim` enum (D1/D2/D3) added to core/types.h — domain-neutral so
  both EffectBase and ModifierBase can refer to it without including
  each other.
* `EffectBase::dimensions()` defaults to D3 ("I iterate every axis the
  layer gives me"). D2/D1 are opt-in promises: declaring D2 tells the
  framework `Layer::extrude` may duplicate the z=0 slice across z;
  declaring D1 lets it fill y and z. Effects that don't honestly
  iterate every axis must declare a lower D.
* `ModifierBase::dimensions()` defaults to D3 (modifiers touching the
  LUT are 3D-capable by default; advisory chip in the UI).
* `Layer::extrude(effectDim)` runs after each effect's loop(). Hot-path
  cost is one comparison + branch for the D3 case; inner loops are
  guarded by `depth_ > 1` / `height_ > 1` so a D2 effect on a 2D layer
  pays nothing. Real memcpy work only when the effect declared fewer
  axes than the layer has.
* Effect declarations: NoiseEffect + PlasmaEffect = D3 (true z-aware
  math). RainbowEffect + the 9 effects added in the more-effects PR
  = D2 (loops iterate y,x only; extrude fills z). FireEffect and
  ParticlesEffect resized their dynamic buffers from full-3D to z=0
  plane (w*h*cpl) — saves depth× heap on 3D installations.
* New contract pinned in EffectBase.h: loop() must honour the layer's
  runtime dimensions; never hardcode bounds. A D3 effect may run on a
  D1 or D2 layer.

# /api/types: dim + displayName

* `ModuleFactory::registerType<T>` captures dim via if-constexpr in the
  single existing probe — no separate registerEffectType /
  registerModifierType wrappers (reviewer-driven: that was a no-op
  abstraction).
* HttpServerModule emits `"dim":N` and `"displayName":"X"` per type.
  displayName uses the factory's existing displayNameFor() (strips
  Effect/Modifier/Layout/Driver/Module suffix) — fixes the picker rows
  that still read "RainbowEffect" instead of "Rainbow".
* UI app.js adds DIM_EMOJI (1:📏, 2:🟦, 3:🧊) and merges into
  emojiTagsFor(). The picker row label now uses t.displayName.

# Creator emoji 🦅

* Ten effects authored by David Jupijn / Rising Step in PR #3 carry the
  🦅 chip in their `tags()` (appended to origin emoji). UI shows it on
  the card and in the picker chip filter.
* core/ui.md describes the chip mechanism domain-neutrally;
  architecture-light.md owns the LED-domain emoji-key assignments
  (role / dim / origin / creator / audio / moving-head).

# src/light/ restructure

* New subfolders: effects/, modifiers/, drivers/, layouts/. Layer.h,
  Buffer.h, MappingLUT.h, BlendMap.h stay at src/light/ root.
* 20 module headers moved (16 renames preserved by git's similarity
  heuristic; 3 effects came through as A+D because session edits put
  similarity below 50% — history follows via `git log --follow`).
* 37 #include paths rewritten across src/ + test/. No CMake list to
  update (headers are glob-included via the include path).
* EffectBase and ModifierBase live in their category folders for
  discoverability.

# UI ↔ light-domain spec split

* docs/moonmodules/core/ui.md is now domain-neutral. Describes the
  UI engine: tags string is opaque graphemes for the chip filter; the
  preview channel is a binary WS frame whose leading byte dispatches
  to a domain renderer; the module tree shape is whatever main.cpp
  pins.
* docs/architecture-light.md gains a "UI integration (light domain)"
  section: the fixed LayoutGroup→Layer→DriverGroup tree, the 3D
  preview frame format ([0x02][13-byte header][RGB…]), the emoji-key
  table for the light domain.

# Tests

* New test/test_extrude.cpp pins the dim contract: D2 effect on 3D grid
  (Rainbow), D1 stub on 3D grid, D3 effect on 2D and 1D layer (Noise
  and Plasma — protects against hardcoded depth bounds), D2 effect on
  3D layer for the three styles (Checkerboard stateless, Fire stateful
  with heat grid, Particles stateful with trail buffer).
* test_mirror.cpp gains a `dimensions() == D3` case to pin the
  ModifierBase default.
* 121 unit tests + 8 scenarios all pass.

# Pre-commit gates

1. Desktop build clean, zero warnings (-Wall -Wextra -Werror)
2. ctest: 121/121 unit tests pass (1286+ assertions)
3. Scenarios: 8/8 pass
4. Platform boundary: PASS
5. Spec check: 21/21 modules ok
6. ESP32 build clean: 1,040,785 B image, IRAM 67.74%, DRAM 20.27%
7. Reviewer agent (Opus) re-run: PASS, no findings (after addressing
   first review's two SHOULD-FIX items: wrappers collapsed; D2
   declarations made honest by labeling the 9 non-z-aware effects as
   D2 rather than reframing docs to match wrong code)
8. KPI: see one-liner above + details below
9. perf snapshot row added to docs/performance.md (Plasma on Olimex
   ~44ms / 22 FPS, well within run-to-run jitter band)
10. testing.md adds test_extrude entry; EffectBase.md and Layer.md
    gain Dimensions / extrude sections; cross-links to architecture-
    light.md and the test file

# KPI Details

Desktop (macOS Apple Silicon):
  Lights: 16,384
  Binary: 283 KB
  Test cases: 121 passed, 0 failed, 0 skipped
  tick: 37us, 38us, 111us, 108us (FPS: 27027, 26315, 9009, 9259) per scenario
  Scenarios: 8/8 passed
  Platform boundary: PASS
  Specs: 21 modules, 21 ok

ESP32 (Olimex Gateway Rev G, no PSRAM, 320KB internal):
  Image: 1,040,785 bytes (43% partition free)
  Flash (code+data): 913 KB
  DRAM: 36,676 / 180,736 (144,060 free)
  tick: 43892 us (FPS: 22), heap free: 135,200

Code:
  45 source files (7036 lines)
  23 test files (2922 lines)
  32 specs, 8 scenarios
  Lizard: 21 warnings (no new ones; existing complex functions unchanged)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 2, 2026
…ling, generated docs

Introduce a performance-contracts framework: every scenario step carries a
per-target `contract` block (tick ceiling + heap floor with set_by/reason)
and an auto-captured `observed` block (latest reading, updated on every
successful run). Renames `expected`/`measured` → `contract`/`observed` for
clarity. README gains a headline FPS/heap table across 4 grid sizes × 3
targets, sourced from scenario contracts.

KPI one-liner: n/a (no src/ changes)

Core
- (none — no src/ changes in this commit)

Tests
- test/scenario_runner.cpp: read `contract[<target>]` blocks per step; tick
  is a ceiling, heap is a floor; default tolerances 20%/200µs floor on PC,
  10%/5µs floor on ESP32; print "vs contract" / "margin" output.
- test/scenarios/{core,light}/*.json: 10 scenarios converted to the
  contract+observed shape, with clean round contract values (set_by
  2026-06-02, reason "initial contract"); 2 new scenarios added
  (scenario_GridLayout_grid_sizes for the 4-grid sweep, scenario_Layer_buildup
  for incremental pipeline build measurement); construct/mutate suffix
  redundancy removed from descriptions.

Scripts / MoonDeck
- scripts/scenario/run_scenario.py: parse MEASURE lines from the C++ runner,
  write observed.<host-target> back to each scenario JSON on every successful
  run; `--update-contract --reason "..."` flag (replaces --update-expected)
  renegotiates the contract with stamp.
- scripts/scenario/run_live_scenario.py: same on-device, target detected from
  SystemModule.board; print `model=N` (sum of dynamicBytes across the live
  module tree) alongside heap; 500ms post-set_control settle to avoid
  transient "module not found" during buildState rebuild; pc_only dead code
  removed.
- scripts/docs/_test_metadata.py: per-step fields now returned as dicts
  carrying mode/live_only/bounds/contract/observed; pc_only field removed.
- scripts/docs/generate_test_docs.py: surface mode/also/live_only meta,
  bounds, contract, observed per target with FPS conversion (FPS = 1e6 /
  tick_us); non-measured prep steps collapse to a "Setup" bullet list under
  the next measured step to control page length.

Docs / CI
- README.md: new "## Performance" section with FPS + free-heap tables across
  16×16/32×32/64×64/128×128 grid sizes for Apple Silicon, esp32-eth-wifi,
  esp32-eth. Sub-µs PC cells flagged "below host clock resolution".
- docs/testing.md: new "Performance contracts" + "Persistent observations"
  subsections covering semantics, tolerance defaults (PC floor dominates
  below ~1 ms), bounds-vs-contract coexistence, renegotiation workflow,
  bespoke-convention note for the construct/mutate/fixture/reset trinity.
- docs/performance.md: slimmed (~150→100 lines), per-step tick tables moved
  to scenario JSONs; structural sizeof / heap breakdown / firmware sizes
  kept.
- docs/plan.md: logged NoiseEffect ~47ms/tick cost at 128×128 mirror as a
  future investigation (P1 — reaching 18 FPS at 128×128 with NoiseEffect
  needs ~40% effect cost reduction).
- docs/tests/scenario-tests.md: regenerated.
- CLAUDE.md: pre-commit gate #3 now runs scenarios via
  \`uv run scripts/scenario/run_scenario.py\` (the wrapper persists observed
  blocks); direct ./build/test/mm_scenarios still works for ad-hoc checks.

Reviews
- 👾 Reviewer (Opus, on-demand pre-commit): naming \`expected\`/\`measured\` →
  \`contract\`/\`observed\` (fixed); tolerance floor swallows percentage on PC
  for sub-ms ticks (documented honestly in testing.md); observed write-every-
  run policy validated as deliberate (B3); pc_only dead code removed; doc
  generator verbosity trimmed; PC 16×16 README cell changed from
  "clock-noise" hand-wave to em-dash + footnote; bespoke-convention note
  added to scenario_runner.cpp mode dispatcher + testing.md; bounds-vs-
  contract coexistence paragraph added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 3, 2026
Moondeck.json restructures from a flat device list + single port into named
networks, each holding its own devices, last-used serial port, and WiFi
credentials. Header network bar auto-selects from host's current subnet on
load; manual override pins. Improv WiFi reads the active network's creds —
wifi_credentials.json retired. Existing moondeck.json migrates in place.

KPI: n/a (no functional src/ changes — only comments in scenario_runner.cpp)

Tests
- scenario_runner.cpp: KEEP IN SYNC comment pointing at run_live_scenario.py tolerance defaults.
- unit_ArtNetSendDriver_no_alloc_in_loop.cpp: present-tense ("would need" → "needs", "grow happens" → "grow runs"). [CodeRabbit]
- unit_FilesystemModule_persistence.cpp: present-tense ("would have caught" → "catches"). [CodeRabbit]
- scenario_PreviewDriver_detail.json: present-tense descriptions ("must still hit" → "still hits", "must not affect" → "does not affect"). [CodeRabbit]
- All scenario JSONs: routine observed-range widenings from gate sweeps.

Scripts / MoonDeck
- moondeck.py: load_state() detects legacy flat shape and runs _migrate_to_networks (buckets by /24 subnet, largest bucket "Home"); save_state() strips volatile per-device fields including conditionally board when it equals the deduced value; new _active_network / _auto_select_network / _deduce_board helpers; /api/state GET auto-selects on host subnet, /api/discover attributes found devices to matching network, /api/refresh refreshes single network's devices; flash-event breadcrumb still links last_port. NAMING COLLISION breadcrumb added to _deduce_board docstring pointing at docs/plan.md. KEEP IN SYNC note between _deduce_board and the JS board picker list. contextlib.suppress for three inline try/except sites. [CodeRabbit #5]
- moondeck_ui/app.js: getActiveNetwork() helper; renderNetworkBar() + setupNetworkBar() build the dropdown, Rename/Add buttons, WiFi panel; applyNetworkBarVisibility() hides the bar on PC tab; every state.devices / state.port site re-routed through the active network; cross-ref comment at the hardware-board picker pointing at moondeck.py::_deduce_board.
- moondeck_ui/index.html: network-bar div above the per-tab content in the sidebar.
- moondeck_ui/style.css: .network-bar + .network-wifi styling; .device-board picker layout fix.
- build/host_wifi.py: primary credentials source is the active network's wifi block in moondeck.json; OS auto-detect fallback stays.
- check/collect_kpi.py: 18 FPS banner → derived from MIN_ESP32_FPS_LED_PRODUCT (currently 10). [CodeRabbit #3]
- scenario/run_live_scenario.py: max_alloc_block 0 fails when contract demands >0 (was silently passing). [CodeRabbit #6]; KEEP IN SYNC comment about tolerance defaults mirroring scenario_runner.cpp.
- scenario/_observed.py: shared widen-only range update for observed.<target> blocks (factored from both runners).
- docs/screenshot_modules.py: --extras-only flag + _ExtrasOnlyDone sentinel; lets MoonDeck UI re-shoots run without projectMM.
- build/flash_esp32.py: writes scripts/.last_flash.json breadcrumb on success; MoonDeck consumes it to attribute last_port.

Deleted
- scripts/build/wifi_credentials.example.json — the live source moved into moondeck.json's network records.
- .gitignore entry for scripts/build/wifi_credentials.json.

Docs
- testing.md: new Live tab screenshot; observed-range model wording.
- building.md: MoonDeck PC tab + ESP32 tab screenshots placed in their sections.
- MoonDeck.md: Network bar UI Features bullet; improv_provision section updated for the new active-network credentials flow.
- docs/plan.md: "Board vs firmware separation, runtime board presets (multi-commit, started)" roadmap.
- docs/tests/unit-tests.md + scenario-tests.md: regenerated (esp32 row now included in PreviewDriver tables from stale-doc fix). [CodeRabbit #1]
- assets/screenshots/moondeck_{pc,esp32,live}.png: re-captured with the network bar.

Reviews
- 🐰 CodeRabbit: 6 fixed (#1 esp32 row from stale doc → regen; #2 unit-test tense; #3 18 FPS → 10 FPS; #5 contextlib.suppress; #6 max_alloc_block 0 fails; #7 scenario descriptions present-tense). 1 skipped: #4 (show tolerated contract values in perf table) — conflates contract (promise) with tolerance (measurement noise); reviewer agent agreed.
- 👾 Reviewer (Opus, branch diff): ship with changes; 3 architectural calls accepted (sync comments for tolerance defaults, board naming-collision breadcrumb, hardware-board catalog cross-ref) + 1 minor refactor (FPS helper docstring extended to name shared core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 12, 2026
…bit review

Renames the audio module from MicModule to AudioModule (it does audio acquisition plus level/FFT analysis, and will gain line-in / USB sources beyond the I²S mic, so the name now reflects the module, not one source). Makes the primary-source references in the docs clickable (datasheets, Espressif API pages, vendor sites) per the spec-and-test principle. Processes the CodeRabbit review on the prior commit: six findings fixed, six skipped with a stated reason.

KPI: 16384lights | PC:340KB | tick:121/89/119/122/56/2/37/21/117/342us(FPS:8264/11235/8403/8196/17857/500000/27027/47619/8547/2923) | tick:21177us(FPS:47) | heap:33066KB | src:87(16753) | test:53(8518) | lizard:68w

Core:
- MicModule.h → AudioModule.h (git mv, history preserved): class MicModule → AudioModule, registration string "MicModule" → "AudioModule", the latestFrame() static, the main.cpp wiring. The mic-source platform seam (hasI2sMic, audioMicRead, AudioMicHandle) is deliberately NOT renamed: it correctly names today's actual source; it broadens when line-in lands (concrete-first). Verified live on the S3: AudioModule registers and runs (AudioModule:523us in the tick breakdown), no boot-loop.

Light domain:
- AudioLevel.h: clamp the DcBlocker IIR output to the int32 range before narrowing (a settling transient could push the float past int32 bounds, which is UB on the cast). 🐇 #1.
- AudioSpectrumEffect.h: fix a height-gradient off-by-one — the gradient divided by (h-1) but the spectrum spans specH rows; when the bottom row is the level meter (specH == h-1) the top spectrum row never reached full red. Now divides by (specH-1). 🐇 #4.
- AudioVolumeEffect.h: defensively zero any per-light channels beyond RGB (buffers are RGB/cpl=3 today; this keeps the write correct for any cpl and leaves no stale bytes). 🐇 #3.

Scripts / build:
- idf_component.yml: pin esp_wifi_remote ~1.6.1 and esp_hosted ~2.12.9 (the P4-NANO-validated versions) instead of "*", so the P4 build can't silently drift to a new minor; also corrected the stale "esp_hosted bring-up prelude" comment (that prelude was removed). 🐇 #2. Verified the pins resolve and the P4 builds + boots.

Docs / CI:
- AudioModule.md (was MicModule.md): retitled; opening reframed as "acquires an audio source ... named for what it does, not one source" (present-tense, no claim line-in exists yet). Added primary-source links: INMP441 datasheet, esp-dsp, ESP-IDF I2S API, Hann window. First fully em-dash-free module spec.
- RmtLedDriver.md / LcdLedDriver.md / ParlioLedDriver.md: linked WS2812B datasheet, ESP-IDF RMT v2 / esp_lcd / Parlio API pages, the v6.0 migration guide (legacy-RMT removal), ESP32-P4 product page. (Caught + fixed an agent-guessed Parlio URL that 404'd; all links verified live.)
- SystemModule.md / building.md: linked ESP32-C6, esp_hosted, esp_wifi_remote, Waveshare ESP32-P4-NANO.
- decisions.md: reworded the "designed fresh" audio lesson so it no longer reads as "don't look at prior art at all" (which fought the study-with-respect principle); now "reference proven behaviour, don't trace structure," with the flat-mic / no-correction-table call as the example. 🐇 #6.
- AudioVolumeEffect.md: em-dashes removed (touched alongside the effect). 🐇 #7 (partial).
- AudioSpectrumEffect.md / AudioFrame.h / test files: AudioModule rename propagated (@module annotations, references).

Reviews — 🐇 CodeRabbit, prior commit (b79d54f):
- Fixed #1 (DcBlocker int32 clamp), #2 (component version pins), #3 (AudioVolumeEffect extra-channel clear), #4 (AudioSpectrumEffect specH off-by-one), #6 (decisions.md prior-art wording), #7 (AudioVolumeEffect.md em-dashes).
- Skipped #5 (architecture.md Buffer*/Correction* "pin at wiring time"): describes the identity-mapping fast path where the pointer is genuinely stable; the suggestion proposes a different architecture, not a doc fix; paragraph untouched this branch.
- Skipped #7 (README.md / backlog.md bulk em-dash sweep): the rule is "as files are touched, not a single sweep"; a ~176-dash reformat is its own task.
- Skipped #8 (AudioModule stale frame): already handled — latestFrame() returns the static silent frame when active_ is null (teardown nulls it); reinit keeps the same active mic and refreshes within one loop.
- Skipped #9 (AudioModule platform boundary): not a violation — the boundary check passes and the neutral platform.h facade is included by ~10 core modules; the rule forbids platform-specific code outside src/platform/, not the abstract interface. An IAudioSource indirection is exactly the bespoke abstraction the architecture avoids.
- Skipped #10 (desktop improv signature): decl and definition match exactly; the desktop build passes, which proves it.
- Skipped #11/#12 (platform_esp32.cpp fcntl error-handling / include): pre-existing code not changed this branch; expanding into untouched code violates minimal-change scope.

Hardware verified this commit: S3 boots clean at 225 FPS with AudioModule live; P4 boots clean at 60 FPS on Ethernet with the pinned components resolved and wifiCoproc reporting "not detected" (the known C6-slave-firmware blocker, unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 12, 2026
… fixes

Processes the Event-2 (pre-merge) Reviewer pass over the branch. The headline is a structural deduplication of the three LED drivers: the parallel WS2812 drivers (S3 LCD_CAM i80, P4 Parlio) were ~250 of ~370 lines byte-for-byte identical, now one shared CRTP base. Also fixes four stale doc/comment items, makes the AudioModule enabled-toggle actually stop its FFT cost, and removes two dead platform flags. No control names or output behaviour change; the LED hot path is unchanged (measured zero overhead, see below).

KPI: 16384lights | PC:340KB | tick:116/95/118/115/56/1/37/20/113/332us(FPS:8620/10526/8474/8695/17857/1000000/27027/50000/8849/3012) | ESP32:1178KB | tick:17341us(FPS:57) | heap:33068KB | src:88(16543) | test:53(8518) | lizard:65w

Light domain (the dedup, per the No-duplication rule):
- ParallelLedDriver.h (new): a CRTP base ParallelLedDriver<Derived> holding the shared body of the parallel drivers — the pins/ledsPerPin/loopback controls, parseConfig, the per-ROW fused correct+encode loop(), reinit/deinit, the loopback self-test. Binding is CRTP (static polymorphism), NOT a virtual second hierarchy: the base calls derived()->busX() resolved at compile time, no vtable, no per-light indirection — so it stays inside the data-over-objects / hot-path rules and "the one deliberate class hierarchy is the module tree" rule.
- LcdLedDriver.h: 378 → 92 lines, now a derived shell supplying only the i80-specific pieces (clockPin/dcPin, the exactly-8-pins rule via kExactLaneCount, the platform::lcdWs2812* calls).
- ParlioLedDriver.h: 362 → 82 lines, the simpler shell (no clock/dc, kExactLaneCount=false, kClockHz).
- Drivers.h (DriverBase): absorbs the status-string lifecycle (configErr_/failBuf_, setConfigErr/clearConfigErr/failBufEnsure/clearFailBuf) that was triplicated verbatim across all three drivers (RMT too).
- RmtLedDriver.h: loses its status-lifecycle copy (now inherited); symbol-per-light model stays standalone (genuinely different from the parallel drivers — concrete-first boundary).

Core:
- AudioModule.h: removed respectsEnabled()=false so the Scheduler honours `enabled` — disabling the module now skips loop() entirely, stopping the FFT (the real per-tick cost). Verified on hardware: 524us enabled -> 0us disabled. The FFT is never gated while enabled (it is the capability audio effects consume, not an optional cost).
- platform_config.h (esp32 + desktop): removed the dead isEsp32/isEsp32S3 family flags (no users anywhere); kept isEsp32P4 (drives ethPins + hasWifiCoprocessor). Desktop now mirrors with isEsp32P4=false.

Docs / CI:
- architecture.md: § Firmware vs board now lists the two P4 firmwares (esp32p4-eth, esp32p4-eth-wifi); § Drivers updated for the LCD_CAM/Parlio drivers and the four-driver shared Correction pointer. (👾 MUST-FIX 1, 2)
- sdkconfig.defaults.esp32p4-eth-wifi: corrected the comment that described an esp_hosted bring-up prelude the code deliberately does NOT do (it was removed; esp_hosted self-inits at boot). Following the stale comment would reintroduce the bench-fixed SDIO teardown. (👾 MUST-FIX 3)
- platform_esp32_lcd.cpp: the header + #else/#endif comments now name the correct SOC_LCDCAM_I80_LCD_SUPPORTED macro (they said SOC_LCD_I80_SUPPORTED — the exact macro confusion that caused the classic-ESP32 boot loop). (👾 MUST-FIX 4)
- check_specs.py: skip CRTP template bases (template<...> class X : public DriverBase) — shared infrastructure, not a registered module; the concrete derived classes still carry the docs.
- backlog.md: the LED-driver duplication item updated (driver-logic dedup landed via the CRTP base; the platform loopback capture+verify extraction remains as a follow-up).

Reviews — 👾 Reviewer (Fable 5, over git diff main...HEAD; verdict "merge with noted fixes"):
- Fixed all 4 MUST-FIX (stale docs/comments, above).
- SHOULD-CONSIDER #1/#2 (Lcd/Parlio driver-logic + status duplication): FIXED via the CRTP base + DriverBase status lifecycle (chose to do it now per the No-duplication rule rather than backlog). The remaining platform loopback capture+verify duplication is the tracked follow-up.
- SHOULD-CONSIDER #4 (AudioModule enabled toggle couldn't stop the FFT): FIXED (above); the FFT itself is must-have, only the disabled-still-runs gap was the bug.
- SHOULD-CONSIDER #5 (unused isEsp32/isEsp32S3 flags): FIXED (removed).
- SHOULD-CONSIDER #3 (audio headers in src/light/ giving a core->light arrow): accepted as-is — documented producer/consumer placement, boundary check passes.

Verification: 56 driver/encoder unit tests pass, desktop build zero-warning, spec + platform-boundary clean. All three ESP32 targets (esp32, esp32s3-n16r8, esp32p4-eth-wifi) build clean under -Werror, including both CRTP instantiations. On hardware: S3 LcdLedDriver loopback bit-perfect over a 13->12 jumper (1536/1536 symbols captured, 0-bits 15..15 / 1-bits 30..30 ticks, zero jitter); P4 ParlioLedDriver renders the S3's audio spectrum live over the DDP path. Hot-path A/B (same S3, same config): pre-dedup ~3421-3442us/frame vs CRTP ~3425-3436us/frame — statistically identical, zero overhead (CRTP is static dispatch; the per-light loop is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 21, 2026
Follow-up to the "Improv = REST over serial" work on this branch: processes the CodeRabbit review of the prior commit and adds the host-side + device-side test coverage that makes config-push over serial provable without hardware. The Improv frame format (built three times — device C++, Python, installer JS) and the device's APPLY_OP chunk reassembly + sequence guard now have automated tests; the installer's frame builders were extracted into a shared, dependency-free module so the JS is testable in node at all. Also restores 18 historical plans under the corrected Plan-YYYYMMDD (ISO-8601) naming.

KPI: 16384lights | PC:365KB | tick:111/87/110/9/1/312/37/15/19/111/11us(FPS:9009/11494/9090/111111/1000000/3205/27027/66666/52631/9009/90909) | ESP32:1249KB | tick:5155us(FPS:193) | heap:8335KB | src:97(19754) | test:68(10118) | lizard:77w

(ESP32 tick 5155us is a fresh live capture from the S3 running the full testbench config — Grid + AudioSpectrum + RandomMap + RmtLed + NetworkSend broadcasting Art-Net. Higher than the old idle-ish 3.6ms because NetworkSend is active; this change touches no render-path code, so the tick is unchanged for a like-for-like config.)

Core:
- ImprovOpReassembler (new): extracted the APPLY_OP chunk-reassembly + out-of-order/duplicate sequence guard out of the ESP32 handler into a pure, header-only core state machine (the ImprovFrame.h precedent — algorithm in core, UART in platform). Returns a typed Continue/Ready/Error. This makes the heart of "REST over serial" desktop-unit-testable and shrinks the platform handler (removed two g_improv fields).
- HttpServerModule: split OpResult::NotFound into ModuleNotFound + ControlNotFound so the HTTP /api/control handler reports the two distinct 404 bodies again (CodeRabbit #1); added OpResult::AlreadyExists so an idempotent add reports {"ok":true,"note":"already exists"} again (#5); documented the serial "parent" vs HTTP "parent_id" key difference at the applyOp site (#6).
- ImprovProvisioningModule: a non-Ok APPLY_OP result is now surfaced — logged over serial and parked in provision_status — so a silently-misconfigured device is visible on a monitor and via /api/state instead of looking like a clean install (#2). Ok/AlreadyExists are both treated as success.

Platform:
- platform_esp32_improv.cpp: improvHandleApplyOp now delegates chunk merge + seq guard to mm::ImprovOpReassembler, keeping only the serial I/O (busy guard, ack/error sends). No behaviour change; the logic is now the unit-tested core path.

UI:
- improv-frame.js (new): the pure Improv frame builders (buildImprovFrame, encodeApplyOpFrames, the command IDs) extracted from install-orchestrator.js so node:test can import them without the orchestrator's top-level unpkg imports. The orchestrator imports them back — no behaviour change.
- install-orchestrator.js: APPLY_OP inter-op pacing 30ms -> 120ms to cover the worst-case single-buffer consume window with headroom (CodeRabbit #3; the closed-loop read-back-ack upgrade is backlogged, ops are idempotent).

Tests:
- unit_ImprovOpReassembler (new, 12 cases): in-order multi-chunk reassembly + NUL-termination, duplicate-chunk rejection, out-of-order/skipped-seq rejection, overflow at the buffer-minus-NUL boundary, exact-fit boundary, mid-stream seq-0 reset, empty final chunk, recovery after every error.
- test/js/improv-frame.test.mjs + test/python/test_improv_frame.py (new): assert the SAME golden frame (buildImprovFrame(0x03,[0x01]) == 49 4d 50 52 4f 56 01 03 01 01 e3) so the device C++, Python, and JS frame builders provably agree; the JS suite also pins APPLY_OP chunking (seq/last, the 125-byte boundary). Encode (JS) + reassemble (C++) prove APPLY_OP end to end without hardware.
- unit_HttpServerModule_apply: updated for the ModuleNotFound/ControlNotFound/AlreadyExists split.

Scripts / CI:
- .github/workflows/test.yml (new): PR-triggered pytest (test/python) + node --test (test/js) — the first PR-level test gate (none existed). Python tests carry deps in a PEP-723 inline block (repo convention, no central pyproject).

Docs / CI:
- testing.md: new "Host-side tests (Python + JS)" tier with a per-test inventory of exactly what the Python/JS frame suites pin, plus the C++ reassembler coverage. test/python + test/js added to the file layout.
- CLAUDE.md: added commit gate 10 (host-side Python/JS unit tests); corrected the plan naming convention to Plan-YYYYMMDD (ISO-8601, sorts chronologically) and clarified plans are product-owner reference (agents write, don't auto-read).
- ImprovProvisioningModule.md: documented the serial-vs-HTTP parent key difference.
- docs/history/plans: restored 18 historical plans (plan-01..18, deleted in an old merge) under Plan-YYYYMMDD names; renamed the Improv-as-REST plan to the corrected ISO date.
- backlog: removed the shipped "board injection / general data injector" item (it IS APPLY_OP now); added the closed-loop-pacing follow-up; renamed stale boards.json -> deviceModels.json references.

Reviews:
- 🐇 #1 module-vs-control 404 specificity — fixed (OpResult split).
- 🐇 #2 dropped APPLY_OP result — fixed (logged + provision_status).
- 🐇 #3 open-loop 30ms pacing — fixed (bumped to 120ms; ack-loop backlogged).
- 🐇 #4 perf-bound "regression" — accepted: the cited tick_us are observed.* telemetry (0 bounds, 21 observed), which only widens on a slow run; no render-path change, not a regression.
- 🐇 #5 dropped "already exists" note — fixed (AlreadyExists OpResult).
- 🐇 #6 parent vs parent_id — fixed (documented at code site + spec).
- 🐇 #7 NUL-guard off-by-one — no action (CodeRabbit confirmed correct).
- 🐇 #8 plan date format — fixed (YYYYDDMM -> ISO YYYYMMDD, all plans renamed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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