Skip to content

Unit Tests

Auto-generated from test/unit/{core,light}/unit_*.cpp by moondeck/docs/generate_test_docs.py. Do not edit by hand — update the source file's @module / @also and per-TEST_CASE // descriptions instead, then regenerate.

Unit tests are the fastest tier in the test strategy: they run the production code in-process with doctest, no platform, no network. Each section below covers one module.

ActiveInstance

test/unit/core/unit_ActiveInstance.cpp

  • A fresh seat is empty until someone claims it.
  • First claim wins; a second claimant does NOT displace the holder (claim-if-empty).
  • vacate() only releases if this instance holds the seat — it never yanks another's.
  • After the holder vacates, a surviving instance reclaims the empty seat with the SAME claim() call (the idempotent-claim = survivor-reclaim contract that AudioService's tick relies on).
  • The destructor vacates a held seat — the dangling-static guard. Without it, active() would point at freed memory after the holder is destroyed.
  • Destroying a NON-holder leaves the holder's seat intact (vacate is guarded on "if mine").
  • The seat is PER-TYPE: two different participant types have independent seats.

AnalogService

test/unit/core/unit_AnalogService.cpp Also touches: InputMapping, Scheduler.

  • an analog input drives a control across its travel
  • a pedal's usable travel is what maps, not the full sweep
  • an inverted input reads the other way round
  • a reversed min/max pair means inverted, rather than being an error
  • a resting input stops writing, so jitter does not flood the control
  • the first reading is taken whole, so a pedal does not sweep up from zero on boot
  • an analog row scales into whatever range its target actually holds
  • an analog row refuses a field it would silently ignore
  • an out-of-range pin is refused rather than narrowed into a different pin
  • an analog row pointed at a pad refuses, rather than firing it every tick
  • an unconfigured or unassigned row does nothing, quietly

AudioService

test/unit/light/unit_AudioBands.cpp Also touches: AudioSpectrumEffect.

  • AudioBands: silence yields all-zero bands and no peak
  • AudioBands: a low tone lands in a low band, a high tone in a high band
  • AudioBands: the reported peak frequency tracks the played tone
  • AudioBands: a single tone concentrates energy, not smears it everywhere
  • AudioBands: noiseFloor gates a low idle spectrum to zero, gain scales it back
  • AudioBands: zero / degenerate input never crashes
  • every band owns at least one FFT bin, so no band is dark whatever the music
  • band edges rise with frequency, so a band is a range rather than a reshuffle
  • a small FFT still yields sixteen usable bands, because a fixture may run one
  • the low bands keep the resolution the FFT can actually deliver
  • a band rises to a transient at once and falls back slowly, the PPM ballistic
  • the ballistic reaches its target exactly, so a held level does not sit one short
  • equal rise and fall reduce to a symmetric follower, so the ballistic is a superset
  • every band gets its own ballistic, so a hit in the bass does not smooth the treble
  • spectral flux reads a rise, ignores a fall, and is zero on a steady spectrum
  • an onset fires once per hit, not once per block the hit lasts, and not on a swell
  • at ratio 1:1 the conditioner changes nothing, so the music's own balance is untouched
  • a spectrally tilted rig reads flat at a high ratio, once the learner has settled
  • each band learns its own floor, so a hum in one band does not raise the others
  • maxGain caps the lift, so a silent band is never amplified into its own noise
  • learning off freezes the tables, the deterministic mode a show wants
  • the peak releases over seconds, not blocks, so one loud bar does not re-level the display
  • A quiet passage is not silence, and must keep its dynamics. The range clamp used to be the anti-noise mechanism and was set high enough (12 dB) to squash real music: a band swinging 6 dB filled only half the display, which reads as vivid bands with no dynamic range. The silence gate took that job over, so a band with real swing now uses the whole window.
  • The level path levels itself in automatic mode, the other half of the one levels decision: the learner measures the VU's window the way it measures each band's, so the manual floor/gain sliders are genuinely manual-only rather than still shaping the picture from behind a hidden row.
  • The silence gate, the fix for a learner that levelled an empty room up to full scale. Measured on a Dig-Next-2: the raw path read flux 0-3 in a quiet room while the conditioner made 33-68 of it, because the lift is dominated by relocating a quiet band up into the display window and silence was relocated as eagerly as music.
  • Flux is a difference against the PREVIOUS block and the onset detector carries a running mean, so a source that stops and starts must not measure its first new block against the last block of the old one: that reports a hit nobody played. AudioService::deinit clears both with the frame.
  • The gate that ships, exercised through magnitudesToBands rather than process() directly: every conditioner test above hands process a hand-picked gateDb, so none covers the value the caller actually passes. It sits AT the display window's floor, deliberately: a band reports its bins' PEAK while the level path reports an RMS, so the level's 20 dB silence margin is a far larger concession here. Measured on a Dig-Next-2, a 20 dB margin took a quiet room from flux 1-2 to 49-102 with onsets firing.

test/unit/core/unit_AudioCapture.cpp

  • Device enumeration always offers at least "default", at entry 0, from stable platform storage.
  • The capture device someone picks survives the device list changing under it. A loopback like BlackHole is deliberate routing, set up once, and the OS list it lives in is LIVE: unplug a webcam or let a Continuity Camera drop off and everything below it shifts up a slot, so a config that stored the INDEX silently starts naming a different device. Reported from the desktop bench 2026-08-29 ("I entered blackhole 2ch a few times but later I saw it was changed").
  • The full lifecycle neither crashes nor wedges, whatever the host's audio situation: a successful init delivers a readable (possibly silent) stream; a failed init degrades to false with reads returning nothing.
  • An index past the device list fails loudly rather than opening some other device (the stale-persisted-index case: OS device order changed since the pick).
  • A capture device delivering silence must not raise the I2S wire diagnosis: on desktop there are no pins to check, and silence is a quiet room or an idle loopback (the flagship BlackHole case between songs). The verdict is compile-time-gated to hasI2sMic.

test/unit/light/unit_AudioLevel.cpp Also touches: AudioVolumeEffect.

  • DcBlocker: a constant DC offset is filtered out
  • DcBlocker: an audio tone passes through (DC removed, AC kept)
  • DcBlocker: reset clears state, null-safe
  • AudioLevel: silence reads zero
  • AudioLevel: pure DC reads zero (DC offset stripped)
  • AudioLevel: a loud sine reads a higher level than a quiet one
  • AudioLevel: DC bias does not change the level of a sine
  • AudioLevel: a high noiseFloor (dB floor) gates a modest signal to zero
  • gain reads the same way on both paths (higher = narrower window = hotter), but scales the level's OWN base span rather than being used raw: a block RMS covers far more dB than a single bin's peak, and sharing the raw number left the VU in the bottom third of the meter at the settings that made the spectrum look right (measured on a Dig-Next-2: RMS 39-83 of 255).
  • AudioLevel: empty / null input is silence, never a crash
  • AudioLevel: isqrt64 matches floor(sqrt) on a spread of values
  • Regression: the boot wiring in main.cpp does create("AudioService")->markWiredByCode() and create() returns nullptr for an UNREGISTERED type — so a missing registerType made the deref crash and the device boot-looped (found on the S3 bench). These pin that AudioService and the two audio effects are all registered + createable through the factory, and that latestFrame() is never null even with no mic (so a consumer added before the mic can't deref null).
  • AudioService::latestFrame is never null (silent frame with no active mic)

test/unit/light/unit_AudioService.cpp

  • AudioService: a fresh, unconfigured module is idle (pins default unset)
  • AudioService: setup/release is repeatable with no residual state
  • AudioService: release clears the active mic (latestFrame falls back to silence)
  • AudioService: two mics — first wins, survivor re-elects, any order stays coherent
  • AudioService: a DISABLED module does not win the mic election at boot

test/unit/core/unit_AudioService_sync.cpp Also touches: WledAudioSyncPacket.

  • Regression: the mic/capture status is a LOCAL-mode read-out. Switching to Receive network / Simulate must clear it so a stale message doesn't linger on the status row, those modes report through the separate "sync status" row and have no input to diagnose. Before the fix, prepare()'s non-Local branch deinit()'d the peripheral but left the status string set. What Local mode leaves depends on the host: a capture-capable desktop usually inits cleanly (no status at all, capture IS live), a locked-down one reports "capture init failed", an I2S target with unset pins reports "mic: set sckPin / wsPin / sdPin". The rule under test is the same in every case: whatever Local left, leaving Local clears it.
  • AudioService Local+send: lazy-opens once and reports sending
  • AudioService Local+send: broadcasts are throttled to ~kSyncSendIntervalMs
  • The fleet-source contract: a desktop in Local mode with "send audio" on captures its own audio AND broadcasts, send fires from the same tick() that runs the capture path, so the capture gate no longer starves the sender (the pre-capture desktop returned from tick() before ever sending in Local mode was impossible: sends ran first, this pins that the two paths now COEXIST on a capture host: capture may be live, and sends still fire throttled).
  • AudioService Receive: a localhost WLED packet drives frame, then holds it and reports listening_
  • AudioService Receive: a failed bind backs off instead of retrying every tick
  • AudioService Local (not sending): no socket, reports off
  • Regression: a persisted send must NOT broadcast once the module switches to Simulate mode, Simulate has no captured frame worth sending, so sync() (and thus the socket) must go quiet. Pins the mode==0 guard on the send leg of sync().

test/unit/core/unit_platform_audiofft.cpp

  • Random vectors: every bin within a tight relative tolerance of the DFT reference.
  • A pure tone lands its energy in the right bin, at the DFT's magnitude.
  • Silence in, zeros out.

AuroraEffect

test/unit/light/unit_AuroraEffect.cpp Also touches: polar, oscillators, noise.

  • the contrast control decides how much of the field lights up
  • curtains appear rather than an even wash of light
  • the composition keeps moving, and no two moments look alike
  • a still speed holds the picture, so a fixture can be frozen
  • each layer adds structure, so the cost knob buys something
  • the kaleidoscope fold makes the composition symmetric
  • Aurora renders on a grid too small to have a center
  • Aurora renders the same picture whether or not the polar table is available
  • on a volumetric fixture the curtains have depth instead of one repeated slice
  • each projection composes the volume differently
  • Aurora renders on a strip, a panel and a cube alike

BeatRipplesEffect

test/unit/light/unit_BeatRipples.cpp Also touches: golden_frame.

  • still water shows its ripples, and they spread

BlendMap

test/unit/light/unit_BlendMap.cpp Also touches: MappingLUT.

  • Identity mapping (logical N → physical N) leaves every byte unchanged.
  • One logical light routed to multiple physical positions copies the color to each (mirror-style mappings work).
  • A paged LUT (forced via the maxAllocBlock test cap) must produce a byte-identical dst to a single-alloc LUT with the same mapping. Paging is an allocation detail; blendMap output must not depend on it. This is the end-to-end pin for the no-PSRAM-fragmentation fix.
  • An additive (overwrites=false) LUT folding two sources onto one physical light adds and clamps at 255 (no overflow). overwrites=false is the opt-in for the within-layer overlap case; the default copy path would instead overwrite, and a full-opacity Overwrite op still routes through this additive accumulate, so this pins the contract explicitly (the regression after the multi-layer rewrite).
  • The default (overwrites=true) path plain-copies: two sources mapped to the same physical means the LAST writer wins, no addition. Pins the fast path.
  • Sparse overwrite mapping clears untouched physical cells. A sphere-style layout maps only a subset of the physical box to a source; the rest must end up black, not retain stale data from a previous frame. Pre-fills dst dirty and asserts unmapped cells are zeroed — fails if BlendMap's dst.clear() is removed (the regression target).
  • Alpha-over at half opacity: dst = srcα + dst(255-α). With dst=200, src=100, α=128 → 100128 + 200127 = 12800 + 25400 = 38200; /255 ≈ 150.
  • Alpha at full opacity collapses to overwrite (src replaces dst exactly).
  • Alpha at opacity 0 is a no-op (dst unchanged) — the invisible-layer case.
  • Additive with opacity scales the source before adding, then clamps. dst=100, src=200, opacity=128 → add 200*128/255 ≈ 100 → 200.
  • clearFirst=false preserves dst cells the source doesn't touch — the mechanic that lets a top layer blend onto the bottom layer's already-composited frame.
  • No-LUT alpha-over at half opacity: dst = div255(srcα + dst(255-α)). dst=200, src=100, α=128 → div255(100128 + 200127) = div255(38200) = 149.
  • No-LUT alpha at full opacity collapses to a plain copy (overwrite).
  • No-LUT alpha at opacity 0 is a no-op (the invisible top layer).
  • No-LUT additive with opacity scales the source then clamps at 255. dst=100, src=200, opacity=128 → 100 + div255(200*128)=100 → 200.
  • No-LUT additive at full opacity saturates: 200 + 100 = 300 → clamp 255.
  • A mapping outliving the buffer it was built for must not write past that buffer. This is the resize window: prepareTree rebuilds the layer's mapping and the driver's output buffer in separate steps, and a render tick landing between them sees the NEW mapping's physical indices with the OLD, smaller buffer. Unbounded, the write ran off the end and corrupted the heap — the crash then surfaced later inside an unrelated allocation, which is why resizing a scripted layout looked intermittently fatal rather than pointing at the real writer. A mapped light the destination cannot hold is skipped. The frame is briefly wrong (the window is an ordering problem, not fixed here); it cannot corrupt memory, which is the property that counts.

BlockModifier

test/unit/light/unit_BlockModifier.cpp

  • The centre light of the box folds to distance 0 (the innermost ring, y=0); a corner folds to the largest distance, and z is always cleared to 0.
  • The distance is the MAXIMUM of |dx| and |dy| (a square ring), not the sum or the Euclidean length: an off-diagonal light sits on the ring of its larger axis delta.
  • modifyLogicalSize collapses the box to one column, its height = the max block distance + 1 (rings from centre to the far corner, inclusive), depth 1.
  • Degenerate grids never crash and stay well-formed: 0x0x0 and 1x1x1 both fold and size without dividing by zero or producing a zero-height box (the Effects hard rule).

BlurzEffect

test/unit/light/unit_BlurzEffect.cpp Also touches: AudioService.

  • With no live audio source the buffer stays black: the dot is audio-gated, so silence renders nothing.
  • With a synthesized audio frame the effect lights the buffer: the colored dot appears and the blur smears it into a soft blob, so at least some lights become non-zero.
  • geqScanner sweeps the dot steadily across the strip: one pixel per frame, so consecutive frames land the lit dot at different linear positions rather than the same spot.
  • The hard rule: the effect runs at any grid size without crashing, including a 0×0×0 and a 1×1 grid.

BouncingBallsEffect

test/unit/light/unit_BouncingBallsEffect.cpp

  • On the first frame every ball is at rest (zero-init state) and bounces off the floor, so the effect paints the bottom row of every column and leaves the rows above it black.
  • numBalls=0 draws nothing: the effect clamps below its minimum and the buffer stays black.
  • The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule).
  • A ball reaches its apex mid-flight: once time advances, at least one ball has risen off the bottom row, so the lit column occupies a row above the floor.

Buffer

test/unit/core/unit_Buffer.cpp

  • allocate(N,3) reserves count×channels bytes; count/channelsPerLight/bytes/data/span all reflect that.
  • clear() zeroes every byte in the allocated range.
  • Move-constructing transfers the data pointer and resets the source (no double free, no copy).
  • Move-assigning transfers ownership the same way the move constructor does.
  • Calling free() twice is harmless; pointer and count remain zeroed.
  • allocate() refuses zero-count or zero-channels (returns false, no allocation, buffer left empty so a caller that ignores the bool doesn't get a partial state).

ButtonService

test/unit/core/unit_ButtonService.cpp Also touches: Scheduler.

  • a press toggles the control its row targets, once per press
  • a bounce shorter than the debounce window is not a press
  • a momentary row writes while held and clears on release, which is what a pedal needs
  • a delta row nudges its target, clamped by the control
  • two buttons on two pins act independently
  • a row with no pin, or no target, is a valid state and does nothing
  • rows are added and deleted at runtime
  • an active-high row reads the opposite level
  • a target round-trips through the type and number the editor shows
  • editing the type or the number re-composes the target, so they cannot disagree
  • a button fires the pad in that grid position, so a preset has a physical key
  • a button bound to an empty pad reports it rather than firing something else
  • a delta on a wide control counts from its real value, not a clamped byte
  • a delta moves a negative control down, which a clamped read could not

CheckerboardModifier

test/unit/light/unit_CheckerboardModifier.cpp

  • A mask leaves the logical box unchanged.
  • size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. Passing cells keep their coord.
  • invert flips which parity passes.
  • size>1 groups cells into squares: with size=2, the 2×2 block at the origin is all one square (parity 0), so all four pass; the next block over drops.

CircleModifier

test/unit/light/unit_CircleModifier.cpp

  • The box centre folds to the origin (distance 0), and every coord collapses onto the single x=0/z=0 column — the box becomes a 1D radial run.
  • A light's ring is its integer-truncated Euclidean distance from the centre, so two lights equidistant from the centre map to the SAME radius — the defining circle property. Centre (4,4): (7,4) and (4,7) are both 3 away; (7,7) is sqrt(18)→4.
  • modifyLogicalSize folds the far corner to its distance, then grows every axis by one: the logical box is a (radius+1)-tall column with x=1 and z=1. Corner (8,8,1) off centre (4,4,0) is sqrt(16+16+1)=5.74→5, so the size is (0+1, 5+1, 0+1).
  • Effects-run-at-every-grid-size hard rule: a degenerate box never crashes and still yields a valid single-column size. 0×0×0 folds to (0,0,0)→+1 = (1,1,1); 1×1×1's corner is sqrt(3)→1, so size (1, 2, 1).

Color

test/unit/core/unit_Color.cpp

  • Hue 0 is pure red.
  • Hue 85 (one third round the wheel) is pure green; a sliver of red is tolerated since 85 is approximate, not exact.
  • Hue 170 (two thirds round) is pure blue.
  • Zero saturation produces a grey of the given value, regardless of hue.
  • Zero value is black, regardless of hue or saturation.
  • A hue between the cardinal points blends two channels (here: orange = red + green).
  • hsvToRgb is constexpr — evaluable at compile time.
  • scale8(v, f) multiplies two 8-bit values and returns 8 bits. Factor 255 is identity, factor 0 zeroes, factor 128 halves (within integer rounding).
  • scale8 is also constexpr.

Control

test/unit/core/unit_Control_apply_absent_key.cpp Also touches: FilesystemModule.

  • hasKey distinguishes an absent key from one whose value is 0 — the capability the fix relies on. parseInt alone can't (returns 0 for both).
  • The core regression: a control bound with a non-zero value, overlaid with a JSON that does NOT contain its key, must keep its value — not snap to 0.
  • A present key still applies (the fix must not break the normal load path).
  • A present key whose value IS 0 must apply the 0 (don't confuse "present 0" with "absent"). Guards against an over-eager fix that skipped on value rather than key.
  • a per-control validator accepts a valid value and rejects bad input
  • Length boundary of the deviceModel validator (accepts 1..31). Uses a buffer wider than the validator's limit so the 32-char value reaches the validator intact (parseString truncates to bufSize-1, so the buffer must exceed 32 for the validator's own length check — not parse truncation — to be what rejects it). The scratch buffer in applyControlValue is sized to bufSize, so a long value isn't truncated before validation.
  • a Text control with no validator accepts anything that fits
  • applyControlValue: a string palette value does not crash and applies numerically
  • The complement: a Select's aux IS the options array, so a string LABEL value matches an option by name (the board-portable catalog path — a peripheral label is stable while its filtered index is not). This keeps the label-match feature working where it is safe.
  • An empty option list (max == 0) has no valid index — applying a value must not manufacture index 0. Strict rejects; Lenient (Clamp) leaves the bound value untouched. Guards a board-filtered Select that filtered down to zero options (e.g. a peripheral list on a chip that supports none).
  • The Palette twin of the empty-Select guard: an empty palette (addPalette(..., 0)) has no valid index, so a value must not write index 0 — and critically must not let hi = c.max - 1 underflow to -1 and clamp the stored value up to 255. Lenient leaves the sentinel untouched; Strict returns OutOfRange.
  • A label longer than any real option (here, longer than the parse buffer) must NOT match a real option by prefix — it is "no such option", so Lenient keeps the default and Strict rejects. Guards against a truncated value spuriously equalling a shorter option that shares its leading characters.
  • The exact boundary of the overlong guard. The Select label parses into a 64-byte buffer and a value that FILLS it (length >= 63, i.e. buffer_size - 1) is treated as overlong — it may have been truncated to the cap, so it cannot legitimately equal any option and the match is skipped. A value one shorter (62) is NOT overlong and matches normally. This pins the threshold so a future buffer-size change can't silently shift where a legitimate long label starts being rejected. Real option labels sit far below this (the longest peripheral/mode label is ~35 chars), so the boundary only ever fences off junk — but the test makes that contract explicit rather than incidental.
  • A Select over ENUMERATED options (a NIC list, an audio device list) persists by LABEL when flagged: the index shifts when the machine's device list reorders, but the name is what the user chose. Both directions of robustness: a label round-trips, and an old index-persisted value still applies (the apply path always accepted both).

test/unit/core/unit_Control_filepath.cpp

  • a file-path control carries the directory and extension the module declared
  • a file-path control with no directory offers no picker rather than a broken one
  • a file-path control listing every file omits the extension filter
  • The value is a NAME, never a body. A control write that tried to carry a file's contents would arrive here, and it must fill the buffer and stop rather than run off the end of it: any input, any size, degrade visibly (the robustness rule).
  • It persists like the text control it is: a device that reboots comes back pointing at the same file, which is what makes "the script survives a power cycle" true.

test/unit/core/unit_Control_int32.cpp

  • an int32 control carries a value no 16-bit control could hold
  • an int32 control round-trips a negative value
  • an int32 control clamps a write past its range and refuses it under Strict
  • an int32 control publishes its range to the UI
  • A compiled module and a MoonLive script now spell a control the same way. The widget follows the VARIABLE'S TYPE, which the compiler already knows — so a call cannot disagree with the declaration, and there is one vocabulary to learn rather than five names carrying a width.
  • A call that omits min/max means "no UI constraint", and what that means DIFFERS per type: each overload defaults to its own type's full range. Unifying them would silently move the bounds of every control that relies on the default, which is why the overloads keep separate signatures.
  • An explicit range is carried through unchanged, per type.

test/unit/core/unit_Control_list.cpp

  • EditableList: a plain ListSource is not editable
  • EditableList: add returns a fresh stable id each time
  • EditableList: edit a row field by id
  • EditableList: delete by id; a locked row is protected
  • The load-bearing invariant for reference-by-id: an id assigned to a row NEVER changes across add / delete / reorder of OTHER rows. A driver that stored "preset id 2" still resolves it.
  • ControlType::List value serializes as an array of row summaries
  • ControlType::List metadata carries a parallel detail array
  • ControlType::List with an empty source emits []
  • ControlType::List type identity + persistable + restore round-trip

ControlModule

test/unit/core/unit_ControlModule.cpp Also touches: FilesystemModule, Scheduler.

  • The feature in one test: keep a look, change it, bring it back.
  • A preset is a file, so the folder is the list: what is on disk is what the user sees.
  • Deleting a preset deletes its file. Nothing else holds preset state, so there is no second copy that could disagree with the folder.
  • Saving without a name would write ".json" and produce a nameless row, so it is refused with a message rather than silently creating something the user cannot identify.
  • A preset carries exactly ONE role, so a file written by an older build that names several is listed but not applied: applying it would do something other than what its name suggests. It stays on the grid (and on disk) so it can be seen and deleted, rather than silently disappearing.
  • A preset carrying a module this build does not have applies what it can and says what it skipped: a preset from another board must degrade rather than refuse or crash.
  • A truncated file (an interrupted upload) must leave the device with the look it already had.
  • The row says what the preset carries, so a user can tell a portable look from a device snapshot before applying it.
  • The pad grid answers "what is on right now" without a click, so the applied preset marks itself active and any other preset does not.
  • A pad click and a row button are the same action from two presentations, so both reach apply.
  • Pads can be dragged into the order the user wants, so a grid can be arranged to match a physical control surface, and that arrangement survives a rescan — the filesystem's own file order is not the order anyone chose.
  • A preset says which roles it covers, so a pad can show the same emoji the module cards use and a user can tell a portable look from one that carries the hardware.
  • Fader 1 rides the global brightness every driver scales by, through the same setControl primitive IR and the network bridges use — so a hardware surface bound to it later drives the device the same way the on-screen fader does.
  • The unassigned faders have no target yet, so moving one must be inert rather than driving something by accident.
  • A save lands on the pad the user right-clicked, not in the first free cell: on a surface, WHERE something goes is the user's choice, and a save that ignored it would scatter presets.
  • only the bound encoder drives anything
  • Presets hold their roles independently, which is what lets a surface show a layout choice and a look choice lit at the same time. Applying a layer preset replaces the layer holder and leaves the layout one alone, so both pads stay lit and the grid says what is on across all four roles.
  • Each role is held independently, so applying a look replaces the look and leaves the geometry alone. With one role per preset this is the whole supersede rule.
  • Renaming a preset renames its file, so the pad keeps its contents under the new name. The name IS the identity here (there is no id inside the file), which is what makes a rename a file move rather than an edit.
  • A preset that captures nothing the device has still leaves a usable surface: the row exists, and applying it reports failure rather than silently claiming success. Pins the distinction the status line depends on — "applied" and "nothing applied" must not look the same to the caller.
  • A preset name becomes a file name, so it must not be able to steer the path out of the preset folder. ESP32's filesystem layer does no path normalization (the desktop one does), so a name carrying .. would escape on device while looking clean on a developer's machine — and delete and rename write through the same path. The name control's validator is what closes that.
  • Renaming onto a name that already exists must refuse rather than overwrite: the write would clobber the other preset and the follow-up remove would delete the source, losing a preset the user never named. Same refuse-on-collision stance the pad move takes.
  • The saved file must be valid JSON, not merely readable by our own first-match key helpers: a preset is downloaded, edited and re-uploaded by users and tools. The separator between the header and each namespaced subtree is easy to get wrong in a way our lenient reader would not notice.
  • An applied preset must SURVIVE a reboot. Applying rebuilds the live tree, but the boot loader restores from the config file -- so without marking the tree dirty the device renders the preset now and comes back to the previous look after a restart, which reads as "the preset did not save".
  • Only a pure look may be reachable from outside. A preset that also carries Drivers or Layouts rewires pins or geometry, and an external surface (a voice assistant, an automation) must not be able to do that while it thinks it is picking a colour scheme.
  • The Home Assistant effect list is sized from what the device actually has, not from a fixed cap: a cap would either reserve RAM a small setup never uses, or silently publish nothing once the list outgrew it (a truncated config is refused, never sent, so the entity would just vanish).
  • Home Assistant caches the preset list and only re-fetches when the device's reported revision changes. A value that can stand still across a mutation means a preset saved, renamed or deleted after HA set the device up never appears in its dropdown — the endpoint stays correct while HA shows a stale copy forever. The revision is a COUNTER, not a timestamp, precisely so two mutations inside the same second still read as two changes — which also makes this test deterministic, with no clock involved.
  • A pad holds one preset. Saving a DIFFERENT name onto an occupied pad would leave two files claiming the same cell, of which the grid can render only one — so the save refuses and says who holds the pad. Saving the SAME name on its own pad is the normal save-over flow and still works.
  • A pad must go dark when its preset is deleted. The active-role slots refer to a preset BY NAME, so without clearing them the grid keeps lighting a pad for a file that no longer exists — and a new preset saved under the reused name would inherit the lit state.
  • A renamed preset keeps its lit pad: the active-role slot follows the new name rather than pointing at a name that no longer exists.
  • The switch row: a desk's channel buttons, and the control type an on/off target needs. A fader can only say on as 0 or 255, which is a switch pretending to be a slider, so these are their own bank. They sit FIRST because control order is render order and a channel's buttons are above its knob and fader on the surfaces this mirrors.
  • A surface that attaches mid-show is correct immediately. Without the seed it would show whatever its own defaults were until something happened to change, which on a quiet rig is never. Seeded from the TARGET, not from the mirror's own last value: fader 1 rides Drivers.brightness, so what a connecting surface must be told is what the rig is running at. Setting the mirror byte directly (what this test used to do) asserted the stale reading instead: a surface connecting between ticks was sent the boot default while the rig was at another level.
  • THE two-way half. A surface that only writes drifts from what it drives, and starts out of step: switch1 read off at boot on a device whose Drivers.on was on, because the surface's own default had never met the target's value.
  • The same for a fader, driven from the OTHER side: turning brightness down in the web UI must move the fader that drives it, or the surface shows a value the rig is not running on.
  • Only CHANGES go out. This is the first half of the echo guard: a value a surface just sent us already matches what we would send back, so it never bounces.
  • THE echo guard, and the reason feedback is sampled rather than sent from the write path: a value that came FROM a surface must not be sent back to it. Without this, a fader dragged over two seconds gets last second's position pushed back under the user's finger mid-drag.
  • A hand on a control suppresses feedback to it. Drive a motorised fader while someone is moving it and the device fights the user, which is why a desk reports touch at all. On release it resyncs, rather than the missed value being lost.
  • An endless encoder reports MOVEMENT, not position: a detent goes straight to whatever the encoder targets, and the TARGET's own type and bounds decide the result. The surface holds no copy, which is what removed the mirroring the absolute form needed (pull every second, push changes back, remember what each surface was told).
  • Switch 1 is the master on/off every driver honours, the natural partner to fader 1's brightness: the two controls a lighting desk expects to find first. It sends a BOOL body rather than a number, because the target is a bool control and parseBool reads true/1 but not the 255 a byte path would produce, which is exactly how the OSC switches failed before.
  • The display strip: a knob that selects a palette has to read as the palette, not as a number. The name lives in the light domain and ControlModule is core, so this also pins that the seam carrying it (JsonSink::requestName into the PaletteOptionsFn) actually works end to end.
  • The surface's bindings were three hardcoded names (fader1 to brightness, switch1 to on, encoder1 to palette). They are assignments now: a string per control, settable like any other control, persisted with the module, and reaching anything the REST API can set.
  • a surface control follows the control it drives, so the two never disagree

Correction

test/unit/light/unit_Correction.cpp

  • At brightness=255, the LUT maps every input value to itself (no scaling).
  • At brightness=128, every entry is roughly halved using scale8 (255→128, 128→64, 2→1).
  • RGB preset at full brightness passes the source RGB through unchanged (3 output channels, no white).
  • GRB preset swaps R and G in the output (G first, then R, then B) — for WS2812-like drivers.
  • BGR preset reverses the channel order entirely (B, G, R).
  • RGBW preset adds a fourth white channel derived as min(R, G, B) per pixel.
  • GRBW preset combines the GRB reorder with the W derivation (G, R, B, W=min).
  • Brightness scaling runs before white derivation so W = min of the scaled RGB values.
  • rebuild() can switch the output channel count between RGB (3) and RGBW (4) on the fly.
  • whiteMode = Min is the default and derives W = min(scaled R,G,B) leaving RGB intact — this is the byte-identical behavior the earlier RGBW tests already pin. whiteMode = None forces the white channel to 0 each frame (for effects that drive W themselves) — written, not skipped, so a reused buffer can't keep a stale value (see the assertion below).
  • whiteMode = Accurate pulls the common white component OUT of RGB (so the white LED carries it) rather than adding it on top — R,G,B each drop by min(R,G,B).
  • A Custom wiring is described by a channel-role array; rebuild() derives the color offsets from it. Here: white first, then B, G, R — a 4-channel arbitrary order that no curated preset names, proving the role array reaches any wiring.
  • A color role absent from the array stays kAbsent and apply() doesn't write it — a wiring can carry any SUBSET of color roles (e.g. a 2-channel R,B light with no green channel).
  • A non-color role (Pan) occupies a channel but apply()'s RGB path ignores it — the channel is left for the fixture role writer, and outChannels still counts it.
  • WarmWhite / Yellow / UV are synthesized from RGB off the SAME whiteMode as White, so a fixture carrying them lights up (best-effort approximations, not a color model yet): WW ≈ min(RGB), Yellow ≈ min(R,G), UV ≈ the blue-excess max(0, B-max(R,G)). This is the "all channels burn so you can eyeball a fixture" behavior the finding asked for; a real per-emitter model comes later.
  • Under Accurate, White pulls its component OUT of RGB — but the additive stand-ins (WW/Yellow/UV) must approximate from the RGB the effect produced, BEFORE that subtraction, or they collapse. This pins the compute-stand-ins-before-White ordering (a regression would compute them post-subtraction).
  • UV stays dark on a warm color (no blue excess), and every synthesized emitter is forced to 0 under whiteMode=None so none holds a stale value — the same reuse-safety the White channel has.
  • A fixture with a master dimmer channel must actually be LIT. The dimmer is a real output, not a motion role: a moving head whose preset maps Pan/Tilt/Dimmer/RGBW stayed completely dark on the bench with a perfectly correct color map, because nothing ever wrote its dimmer and a linear dimmer at 0 emits nothing. The pre-existing "IRGB" preset had the same defect.
  • Motion roles are somebody else's to write: apply() must not touch them, or a future pan/tilt writer would fight the color path every frame.
  • Motion channels ride the same buffer as color: a wide light carries pan/tilt at the offsets the preset gives them, and apply() hands those bytes to the fixture untouched.
  • A fixture with no motion channels must not pay for motion support: the flag is what keeps the hot path a single branch instead of a five-slot scan for every light of every frame.
  • The layer must be wide enough to hold the motion slots BEFORE it allocates, or an effect's setPan() falls outside the light and the fixture never moves. Modules prepare in registration order with Effects ahead of Drivers, so this only works because Drivers publishes the fixture layout in setup(), which runs for every module before any module's prepare(). Bench-observed before the fix: 12 bytes for 4 lights on a cold boot, and a motionless head until a rebuild.
  • The formations are the point of the moving-head effect: the same sweep, different relationships between the heads. A formation that produced identical aim for every head would be unison wearing another name, so each one is pinned by what makes it distinguishable on a real rig.
  • CIE 1931 lightness (CIE 15 / ISO 11664-4) is the default because it models the thing actually being corrected: the eye's response to luminance. The endpoints are what a user notices first, and the shape between them is what makes a fade look even.
  • The guard that stops a fade-out snapping to black. Every curve here crushes the low end at 8 bits, so without it the dimmest usable values are simply missing.
  • Linear is not a fallback: a downstream device that corrects its own output needs the value on the wire to mean duty cycle, or the picture is corrected twice.
  • Brightness is a LINEAR pre-scale and the curve is applied last. Turning the slider down must not change the shape of the curve, only how far up it reaches.
  • The white emitters are derived in LINEAR light and curved on the way out, so min() and the Accurate subtraction mean what they say. A curve applied before the subtraction would remove an amount that does not correspond to the light the white LED adds back.

DemoReelEffect

test/unit/light/unit_DemoReelEffect.cpp

  • The reel enumerates the effect registry, hosts one effect at a time, renders it, and advances through the whole list without crashing — the create/release/delete churn every tick is the robustness path this pins. Registering two real effects + the reel gives it something to cycle.

DevicePlugin

test/unit/core/unit_DeviceIdentify.cpp Also touches: DevicesModule.

  • MmPlugin claims a presence packet carrying the projectMM marker
  • MmPlugin declines a plain WLED packet (no projectMM marker)
  • WledPlugin claims a plain WLED packet as WLED
  • WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)
  • Plugins decline a short / garbage datagram, never read out of bounds
  • WledPlugin tolerates an empty name (the module supplies the IP fallback)

DevicesModule

test/unit/core/unit_DevicesModule_ageout.cpp

  • A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h — else a long-gone persisted device would survive forever across reboots (its clock resets to "boot" each restore). It drops once past kCachedGraceMs.
  • DevicesModule: a cached device drops once past the probation window
  • A live-confirmed device (a presence packet cleared its cached flag) gets the full 24 h.
  • DevicesModule: a live-confirmed device drops once past kStaleMs (24h)
  • A projectMM peer also answers as a plain WLED (its presence packet without our marker), so a later WLED-classified sighting must NOT relabel a restored projectMM row. This drives the downgrade-prevention in upsertDevice through the public path: restore the row as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM.
  • DevicesModule: restore tolerates an empty / malformed cache

test/unit/core/unit_DevicesModule_discovery.cpp Also touches: DevicePlugin.

  • DevicesModule: a plain WLED packet lists a WLED device with its name
  • DevicesModule: a projectMM-marked packet lists a projectMM device
  • DevicesModule: a short / garbage datagram is ignored, never listed
  • The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep their OWN name + type — no cross-contamination between packets.
  • A peer RENAME must propagate: a later packet from the same IP with a new name updates the row in place — the live-update requirement (the name rides the presence packet).
  • A projectMM device stays projectMM even when a later plain-WLED packet arrives from the same address — the type only RAISES toward projectMM, never downgrades. (A projectMM peer could be seen via an unmarked packet too; that must not relabel it WLED.)
  • DevicesModule: a DISABLED module does not claim the active seat at boot_

test/unit/core/unit_DevicesModule_hue.cpp

  • DevicesModule: a Hue bridge is listed with its color count
  • DevicesModule: upsertHueBridge is idempotent, updates count in place
  • DevicesModule: a persisted Hue bridge restores as a Hue row with its count
  • DevicesModule: a corrupt persisted color clamps to the valid range, row still restores

DistortionWavesEffect

test/unit/light/unit_DistortionWavesEffect.cpp

  • DistortionWavesEffect writes non-zero RGB data
  • DistortionWavesEffect produces spatial variation
  • DistortionWavesEffect speed 0 is frozen (stable across ticks)
  • DistortionWavesEffect survives a 0x0x0 grid

Drivers

test/unit/light/unit_Drivers_container.cpp

  • Regression (the preset-edit LED-blank bug): editing a live light preset blanked the strip for ~½s, even on drivers NOT using that preset. Cause: the list-mutation handler re-ran a whole-tree prepareTree(), and a physical driver's prepare() reinits its output peripheral (an RMT channel teardown → dark for a tick). A preset edit changes correction DATA, not pipeline STRUCTURE, so the fix routes it through rebuildAllCorrections() — the tier-1 correction refresh — which must re-resolve each driver's correction WITHOUT calling its prepare(). This pins that split so the blank can't return: rebuildAllCorrections() bumps the correction path, never prepare().
  • The on control is master power: on=false scales the correction LUT to zero (output black) while PRESERVING the brightness value, so on=true restores the exact level. It rides the same cheap LUT rebuild as brightness (no pipeline realloc). This pins the shared power control IR/MQTT/WLED drive.
  • Regression (the localBrightness bug): a per-driver localBrightness change must RE-SCALE that driver's correction LUT — global × local — just like a global brightness change does. The bug was that localBrightness edits didn't reach the LUT (only global did). Both sliders must reach output.
  • Disabled child drivers don't tick: toggling enabled flips whether that driver's tick() runs.
  • The "+ add" picker under Drivers must offer ONLY drivers, not every generic system module — else the 6 drivers are buried under ~18 generics (Devices, Filesystem, …). acceptsChildRoles drives that picker, so it returns "driver" alone. The one non-driver child (the boot-wired LightPresets library) is added directly at boot, bypassing this check, and is non-deletable — so it needs no "generic" here. Pins the filter the product owner asked for.
  • The boot-wired light-preset library is a permanent singleton: not user-deletable (Drivers accepts only driver, so a deleted library could never be re-added, and every driver resolves its preset through it). Mirrors the boot-wired PreviewDriver's userEditable(false).
  • Regression, the Drivers half of the dangling LivePalettes seam (the seam-contract half is pinned in unit_Palette.cpp): the /api/modules probe constructs a Drivers, reads its controls, and destroys it. That throwaway used to publish the seam from defineControls() and so owned it when it died, first dangling it (the /api/state SIGSEGV) and, once clear() ran in the destructor, emptying the running device's scripted-palette list instead. Publication belongs to prepare(), which only a scheduler-mounted module runs, so a probe must leave the seam exactly as it found it.
  • The power relay is the physical expression of "the lights are off", and brightness 0 is off as much as on = false is: a WLED-style client says off by sending bri 0 without touching on, and a strip at zero still draws its idle current through a closed relay. So the relay opens at brightness 0 and closes again the moment brightness returns, with on unchanged either way.
  • A typo in the relay list must not leave the previous relays closed. Reporting the parse error and returning looked right, but the pins from the last VALID list stayed asserted on GPIOs no control named any more: the strip kept its power through a brightness of zero, and nothing in the UI said why. An unparseable list means no relays, which is the same state as an empty one.

test/unit/light/unit_Drivers_firstOutputRgb.cpp

  • Drivers::firstOutputRgb reads pixel 0 of the driven buffer
  • Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)
  • Drivers::firstOutputRgb returns false when there is no driven buffer
  • MoonModule::firstOutputRgb defaults to false (no output module)

test/unit/light/unit_Drivers_rendersplit.cpp Also touches: platform.

  • DIAGNOSTIC (bench flap): a SINGLE enabled layer + one driver + multicore. On the bench renderWait alternated on/off second-to-second. needOutput is false here (one layer, no LUT), so the split is held only by splitWanted forcing outputBuffer_. Tick many frames and assert the split stays STABLY engaged — never flaps off — with no config change between ticks.
  • render-split: multicore on → every driver ticks on the worker, never on a torn frame
  • render-split: multicore off → drivers tick inline on the render core (the proven path)
  • REGRESSION (v3.0.0 "UI refresh freezes the LEDs"): GET /api/types (which the web UI fetches on every page load) builds a throwaway probe of each registered type to read its default control values. A ParallelLedDriver probe runs selectDefaultPeripheral → swapPeripheral in its constructor/defineControls, and swapPeripheral fired MoonModule::notifyQuiesceRender() unconditionally. That global hook resolves to the LIVE Drivers via the static ActiveInstance seat (Drivers::active()) — so a DETACHED probe (never in the tree) tore down the running split, and nothing re-engaged it. On a fast board it silently dropped to single-core; on a slower one the LEDs visibly froze until multicore was toggled. The fix: swapPeripheral notifies only when it is about to free a real backend (peripheral_ non-null). A probe's first swap selects the default peripheral from a null backend, so it never notifies — the live worker is untouched. This pins that a detached ParallelLedDriver swapping its peripheral leaves a live split untouched.
  • render-split: no driver → the split does not engage (nothing to move)
  • render-split: live disengage stops the worker when the last driver leaves
  • THE INVARIANT: core quiesces the worker before any structural mutation of a container's children. MoonModule::removeChild() calls quiesce() — a no-op for a module with no worker, overridden by Drivers to wait out the in-flight encode — so a mutation cannot begin while core 1 is inside a child's tick(). Violate it and the sequence removeChild → release → deleteTree frees the driver (and its DMA buffers) out from under the worker mid-encode: a use-after-free, LoadProhibited on ESP32. The test deletes the driver at the one instant that is unsafe: while the worker is provably inside its tick(). Under ASan a regression is a heap-use-after-free; without ASan, the ordering assert still catches it (removeChild must not return until the worker is out).
  • The SIBLING-SUBTREE case: mutating a node OUTSIDE the Drivers subtree — here a LAYOUT — while the encode worker runs. The worker ticks the drivers, and a driver walks the whole tree (PreviewDriver::sendFrame → Layouts::placeLights), so freeing a layout mid-walk is a use-after-free EVEN THOUGH the mutated node's parent (Layouts) owns no worker. this->quiesce() alone misses it — Layouts::quiesce() is the no-op default. The fix routes MoonModule::quiesceForMutation() through the quiesce-render HOOK, which reaches the render worker wherever it lives. This is the exact crash seen replacing a layout on a running split device (LoadProhibited); the test pins it via the hook.
  • The FOURTH mutator: reordering children (the drag-reorder UI → moveChildTo) permutes children_ under the worker's index-based tick loop. No free, so not a use-after-free — but a slot shift mid-loop can tick a child twice or skip one (the data-race class the batch closes). moveChildTo must quiesce like the other three. Same harness: park the worker inside a driver tick, reorder Drivers' children, and prove moveChildTo waited for the worker (fails without the quiesce).
  • render-split: toggling multicore live engages and disengages the worker
  • ROBUSTNESS FLOOR: a wedged core-1 worker must not hang the RENDER loop. The frame boundary waits for the encode, normally bounded by one encode (the renderWait KPI measures it). But a worker that never signals done — starved, wedged, a lost notify — would otherwise spin core 0 forever, and a permanent wedge ranks BELOW "degraded": the device must keep running, even poorly. So the boundary times out, DISENGAGES the split, and every driver falls back to ticking inline on core 0 — the same single-core path a memory-tight board already takes. Slower, still lit. This times tick()'s boundary specifically. It does NOT go through quiesce() (the structural-mutation hook), which deliberately JOINS the worker on timeout — a blocking join is right there (the caller is about to free the driver) but would be wrong here, on the render path.
  • A degraded state must be able to END. The stall warning above was set once and never lifted, so a card kept reporting "encode worker stalled" long after the split was healthy again, and even beside a multicore toggle the user had since switched OFF. A status that cannot clear stops describing the device and starts describing its history. Reported from a Linux bench, whose screenshot showed exactly that pair.

EffectBase

test/unit/light/unit_Effects_golden.cpp Also touches: RadialSpectrumEffect, VuMetersEffect, TrailsEffect, TruchetEffect, TunnelEffect, WaterRippleEffect, WaveEffect.

  • A 2D grid wide enough that a phase error shows as a visible column shift, small enough to stay a fast unit test. Eight frames at the real 20 ms cadence exercise the accumulator's carry. Four goldens moved when the trail fade became the Layer's, and each for a stated reason. Fireworks and Lissajous carried their own elapsed-to-amount conversion (both flooring to 1, which over-faded at high rates) and now pass a rate. StarField's fade left the step gate that was throttling it twice. BouncingBalls moved because the Layer now resets its fade clock in prepare(), which discards the idle gap before the first frame: the guarantee LissajousEffect used to give for its own trail, given once for every effect. Reviewed and re-blessed together. Four goldens moved on 2026-09-03 when the noise core became Perlin improved gradient noise (PolarNoise, Tunnel, Noise, Noise2D). The names, coordinates and output ranges are unchanged; the field itself is a different, smoother one, which is the point of the swap. No other golden moved, which is the evidence that the change is confined to the noise callers. The three fbm effects (PolarNoise, Tunnel, Aurora) moved again on 2026-09-04 when fbm stopped narrowing its own range: octaves are near-independent, so their spread grows like the root of the sum of squares while the normalizer divides by the sum of amplitudes, and 4 octaves had shrunk to 54..199 of 0..255. Every fbm field is now higher contrast, which is a visible improvement rather than a neutral change; unit_noise pins the range at every octave count. Trails moved on 2026-09-04, for two deliberate fixes. Its first tick now measures a ZERO delta rather than the whole uptime, which had teleported the flow and decayed the trail away on the frame it started. And its emitters are paced by TIME rather than firing every frame: writing a head per frame injects light at the framerate (measured 1.37 at 1200 fps against 60, which the framerate audit caught). Scaling the head's brightness by dt was tried first and is wrong here, because writeWide SETS the pixel rather than accumulating, so twenty dim writes do not add up to one bright one and the fast device came out twice as dark instead. Tunnel moved on 2026-09-04 for the same reason as PolarNoise below, and Spiral moved with them (it has no golden). All three are pinned instead by unit_PolarLut_equivalence, which renders each through the table and through the computed address and requires the 16-bit table to be identical. PolarNoise moved again on 2026-09-04: it reads its per-pixel angle and radius from PolarLut instead of calling atan16 and dist16 every frame, and takes its drift from an oscillator. The address is the same one, and unit_PolarLut_equivalence pins that the 16-bit table renders BIT-IDENTICALLY to the computed path; the default 8-bit table quantizes the angle to 256 steps, which is what moved the hash.

test/unit/light/unit_Effects_gridsweep.cpp

  • The sweep. One TEST_CASE over every effect keeps the failure output readable: a broken effect names itself and the grid it died on, and the rest still run. 1 and 2 channels are the interesting cases: an effect that assumes RGB either writes past its light or (with a guard) declines to render at all, and a user sees a black fixture either way.
  • every effect survives degenerate grid sizes
  • The Layer does NOT clear the buffer between frames (ADR-0003: an effect can fade its own last frame for trails, or read prior pixels for a scroll). The corollary is a contract every effect owes: it owns its background. An effect that only writes the pixels it lights, and skips the rest, inherits whatever was on screen — its own path from earlier frames as permanent ghosts, and the entire picture of whatever effect ran before it. A golden-frame test cannot see this: it renders into a buffer that starts zeroed, so the pixels an effect never writes are black by luck and hash correctly. This starts from a DIRTY buffer, which is what a real device hands an effect on every frame after the first.
  • A TUBE RIG: 1 wide, 60 down y, 10 deep. Ten tubes of sixty lights, which is a real installation shape and the one geometry where the depth axis carries the fixtures rather than being 1. "Survives" is a lower bar than "renders": the sweep above proves an effect holds together on this grid, and this proves its output REACHES the rig. An effect that indexes by x + y * width alone writes only the first tube and leaves the other nine dark, which reads on the bench as nine dead fixtures rather than as a bug in the effect. The mechanism that makes it work is extrude (Layer::tick): a D1 effect paints the x=0 column down y, a D2 effect paints the z=0 slice, and the framework duplicates that across the remaining depth. So every effect fills the rig whatever its own dimensionality, and this pins that.

Effects

test/unit/light/unit_Effects_container.cpp Also touches: Layer.

  • A Effects container with one child Layer must produce the same output as that Layer used directly (no-op container).
  • With two child Layers, each one's tick() runs and writes its own buffer (the container iterates all enabled children).
  • Multi-layer composition: Drivers blends ≥2 enabled Layers into its own output buffer and hands THAT to drivers (not a single Layer's buffer). Bottom layer overwrites; top layer blends per its blendMode/opacity. This is the end-to-end pin for the composite loop in Drivers::tick.
  • Disabling the top layer drops cleanly to the single (bottom) layer — no crash, the driver now sees the bottom layer's content. Pins the robustness path.
  • Drivers' composition/output-buffer allocation contract (architecture.md § Adaptive allocation). The driver output buffer exists ONLY when the pipeline must blend into physical space; otherwise the lone layer's buffer is handed to drivers directly (zero-copy). dynamicBytes() reflects outputBuffer_.bytes(), so it's 0 ⇔ no buffer. Pins all three cases in one place: 1. one identity (no-LUT) layer → NO output buffer (zero-copy) — WITH multicore off 2. two enabled Layers → output buffer (must composite) 3. one layer WITH a LUT → output buffer (must map logical→physical) The multicore render↔encode split adds a fourth reason to own a buffer: it is the frame core 1 reads while core 0 renders the next one, so with the split ON the identity case DOES allocate one (case 1b). That is the documented cost of multicore; turning it off (or failing to allocate) restores the zero-copy profile exactly.
  • activeLayer() returns the first enabled child, or the only child if all are disabled (so dimensions stay queryable during boot/toggle-off).
  • firstEnabledLayer() is the output-selection counterpart to activeLayer(): it never falls back to a disabled layer, so it returns nullptr exactly when nothing renders.
  • If the container holds only non-Layer children, activeLayer() returns nullptr (the role-guard skips, never miscasts).
  • The disable cascade: disabling a PARENT releases every descendant's resources, because applyState() routes each node by its own effectivelyEnabled() — which is false for a child whose ancestor is disabled. This is the core guarantee of the unified lifecycle: a disabled subtree holds nothing (memory or hardware). FireEffect is the probe — its heat buffer's dynamicBytes() is host-observable, standing in for any per-module resource.

FileManagerModule

test/unit/core/unit_FileManagerModule.cpp

  • FileManager: mkdir creates a dir at the target path; delete removes it
  • FileManager: mkdir nested under an existing dir
  • FileManager: delete of a non-empty folder is rejected, not a crash
  • FileManager: delete removes a file too
  • FileManager: a '..' traversal never escapes root (the seam's confinement)
  • parseFilePath is the single path guard every filesystem HTTP entry (read/write/dir/mkdir/delete) runs on — pure string→string, so it's tested directly here without a socket. It decodes the path= query value (%XX + '+'), roots a relative path at the mount, and rejects a missing/empty path, a .. traversal (raw OR percent-encoded), and an overlong (buffer-filling) value.
  • HttpServer::parseFilePath rejects traversal, empty, missing, and overlong
  • FileManager: fsWriteStream writes a multi-chunk NUL-containing payload in full
  • A source that reports a short read (mid-stream failure) still commits atomically — here a clean end just yields the bytes delivered; there's no partial/torn file (temp → rename).
  • A source that aborts mid-stream (an incomplete/timed-out upload) must NOT commit — fsWriteStream discards the temp and returns false, so a truncated body never lands as a real file.
  • HTTP header names match case-insensitively, so any client's Content-Length counts
  • removeRecursive: the DELETE /api/dir path, exercised directly rather than through a socket. It is public for exactly this, and until now nothing called it: the header claimed the tests exercised the real recursion while none referenced it. These are the behaviors a user reaches by deleting a folder from the File Manager.
  • The depth bound is what keeps a user-shaped tree from running the stack out. A tree deeper than the bound is REFUSED rather than half-deleted: reporting failure lets the caller delete again and take the next batch, which is the same contract the width cap (DirLevel::kMax) has.
  • A single file, which is the case that returns on the first fsRemove without ever listing.

FilesystemModule

test/unit/core/unit_FilesystemModule_persistence.cpp Also touches: Scheduler, Layer.

  • The settings directory is created when the filesystem mounts, not left for the first save to discover. A shipped binary starts in a directory that has never held one, and before this the first WRITE was what failed, then every write after it, once per save, forever.
  • A root that exists and is a directory can still reject writes, which is the case the probe exists for and the one a read-only extraction produces. A DIRECTORY where the probe file belongs blocks its creation while leaving the root itself perfectly valid, so this reaches the probe instead of failing earlier at the is_directory check the case above covers.
  • MM_DATA_DIR wins over every other rule. This is the contract the test suite itself relies on: ctest sets it so a test can never write into the developer's real settings directory.
  • An unusable location is refused at mount, which is what lets the caller say "persistence disabled" once instead of emitting a failed save per module per change. A plain file where the directory belongs stands in for the real cases (a read-only extraction, a protected folder, a directory owned by someone else): every one of them accepts the path and rejects the writes.
  • Persistence round-trip: set deviceName → save → recreate Scheduler+modules → load → assert. Uses fsSetRoot to isolate the test from any real /.config/ on disk. A control change (deviceName) saved with flush() reappears on the next boot once a fresh Scheduler loads the same path.
  • Structural persistence: hand-write a Layer.json describing a different tree shape than the one main.cpp builds, then load and verify the live tree reconciles to match the JSON — type swap at position 0, trim of position 1. On load, a Layer's children are reconciled against the saved JSON: position 0 swaps to the saved type, extras at later positions are trimmed.
  • Pins the wiredByCode-preserves-child contract that lets a new firmware revision add a code-created child (e.g. ImprovProvisioning under NetworkModule) without the child getting trimmed on every boot for users whose saved Network.json predates the addition. Setup: an on-disk file describes Layer with zero children. Live tree has Layer with a RainbowEffect child that main.cpp would have wired and marked. After scheduler.setup() runs the persistence load, the wired child must survive. A code-wired child (markWiredByCode) survives a load from older JSON that doesn't mention it — new firmware additions aren't trimmed for existing users.
  • Companion to the wiredByCode case above: when the JSON describes a different type at the position where a code-wired child lives, the position-replacement must NOT kill the code-wired child. Stop reconciliation at that index instead and let the next save re-write the file with the actual tree shape. When the saved JSON wants a different type at the position where a code-wired child lives, reconciliation stops at that index instead of destroying the wired child.
  • Round-trip persistence with children: write a Layer subtree that contains both controls and child modules with controls of their own, then read the file back as text and verify it parses as valid JSON. Regresses the missing-comma bug between each child's "N.type" field and that child's first control (e.g. "0.type":"X""0.foo":1 instead of "0.type":"X","0.foo":1). Saving a Layer with multiple children produces valid JSON — comma separators between child N.type and the child's first control field are present.
  • No size cap: a config LARGER than the old fixed 2 KB save buffer round-trips in full. The save serializes into a growable JsonSink and the load reads a file-sized heap buffer, so neither side truncates. Built from a LightPresetsModule with many custom presets — its persisted array of role wirings comfortably exceeds 2048 bytes, which the old fixed buffer would have silently dropped (returning false → nothing written → config lost on reboot).
  • Singleton survives probe lifecycle: /api/types factory-creates a probe of every registered type (including FilesystemModule) to capture defaults, then deletes it. The probe's destructor must NOT clear the singleton — otherwise every save path (noteDirty, debounced tick1s, flushPending on reboot) silently no-ops for the rest of the device's life. The fix is to register the singleton in setScheduler(), not in the constructor. This test catches that singleton-clear regression. /api/types factory-creates a temporary FilesystemModule probe; its destruction must NOT clear the static singleton (otherwise every later save silently no-ops).
  • Regression: Int16 controls (GridLayout's width/height/depth, Layer's start/end) round-tripped through the filesystem load path were clamped to c.min/c.max, which default to 0,0 because ControlDescriptor.min/max are uint8_t and can't represent an int16 range. Every Int16 control loaded as 0 — so a 128×128 grid became 0×0×0 after restart and the whole pipeline allocated no buffers. Int16 controls (GridLayout width/height, RegionModifier start/end) preserve their saved value across load — no zero-clamping from uint8 min/max bounds.
  • FilesystemModule restores a value-dependent control across reload (the peripheral/clockPin bug)
  • Regression: a user-added module recorded AFTER two code-wired siblings must survive a load even when the code-wired siblings' boot order differs from the saved order. This is shiffy's "spontaneously lost ParallelLedDriver" bug: the Drivers container boot-wires LightPresets then Preview, but the file was saved as Preview(0), LightPresets(1), ParallelLed(2). The old positional reconciler hit index 0 (JSON: Preview, live: LightPresets, code-wired) and BROKE, dropping the ParallelLedDriver at index 2 on every reboot. The align pass reorders the code-wired children to the saved indices first, so the user module is reached and restored. Modeled with two code-wired effects (singletons per container, like Preview/LightPresets) swapped vs. the file, plus a user effect after them.
  • Regression: an UNKNOWN entry BEFORE a boot-wired child must not spawn a DUPLICATE of the wired child. The stale-slot branch restores a mismatched wired child from a later matching JSON entry — but that later entry is ALSO walked by the main loop, and if pos has moved past the wired child, the loop would factory-create a second instance of the wired type (two Rainbows). The reconciler must consume the wired child's saved entry once, not twice.
  • User-module reorder must round-trip: the drag-reorder UI (moveChildTo) permutes children, saves the new order, and on reboot the reconciler must restore THAT order (user-module order is meaningful — render/composite order). This is the invariant the reconciliation fix must not break: user modules are created fresh in file order, so a saved [B, A] loads as [B, A]. Two user effects, no code-wired children, saved in a non-boot order.
  • The EXACT shiffy scenario: an UNKNOWN/renamed type mid-list (a pre-consolidation MoonLedDriver that no longer registers) followed by a real USER module. The renamed entry must drop WITHOUT taking the user module after it — the old break dropped the tail, so the user's real driver vanished on every reboot. This is the dominant cause on shiffy (distinct from the code-wired-reorder case above), pinned here.
  • FilesystemModule restores a control that only exists after prepare()
  • A live control is left out of the saved file, and its neighbours still save
  • Writing a live control does not mark its module dirty, so a slow save still lands
  • A continuous writer cannot defer a pending save forever

test/unit/core/unit_FilesystemModule_subtree.cpp Also touches: Scheduler, Layer, ControlModule.

  • A subtree serializes into a caller's buffer, so a preset file can hold the same bytes the persistence engine writes rather than needing a second serializer that could drift from it.
  • The round trip a preset IS: capture a tree, change it live, put the capture back. This is the whole feature in one assertion.
  • A preset that carries MORE than the device has must add what is missing: applying it on a tree whose children were deleted rebuilds them, which is what makes a preset a restore rather than a value overlay.
  • And the reverse: a preset captured from a smaller tree must REMOVE what it does not describe, or applying it would leave the previous look layered underneath.
  • A module type this build does not have is skipped and the rest of the preset still applies: a preset from a newer firmware, or from a board with a driver this one lacks, must degrade rather than refuse to load. Same tolerance the boot loader already has.
  • Malformed JSON leaves the tree alone rather than half-applying or crashing. A truncated or corrupted preset file is the realistic case (an interrupted upload), and the device must survive it with the look it already had.
  • Several subtrees share one flat object, each under its own "." prefix, and each reads back independently. This is the shape a preset file uses: it is what lets one file carry a selectable set of captures without a nested-object parser.
  • The live-reconfiguration rule extended to the file-upload path: writing /.config/.json (the File Manager upload, a config restore) applies onto the RUNNING tree, no reboot. Found as a real gap when the config-restore flow ended in a "reboot device" button.
  • The runtime twin of boot's phase 5: a restored config can carry a value for a control that only exists once prepare() has run (a MoonLive script's declared controls). The write requests a values-reapply that fires right after the next prepared tick, so the saved value lands.
  • The upload path queues, the render tick applies: nothing mutates the tree on the caller's task (re-running a system module's setup() on the web-server task crashed the ESP32), and a multi-file upload coalesces to one apply per module.

FireEffect

test/unit/light/unit_FireEffect.cpp

  • On a 16×16 grid the heat buffer sizes to width × height bytes (one byte of heat per cell).
  • With sparking at max, the buffer contains non-zero pixels within 50 frames (sparks emerge and propagate).
  • Disabling the effect releases its heat buffer back (dynamicBytes drops to 0).

FirmwareUpdateModule

test/unit/core/unit_FirmwareUpdateModule.cpp

  • The firmware control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). The firmware card owns firmware identity (version/build/firmware) + the partition usage.
  • OTA phase is surfaced through the shared status slot (MoonModule::setStatus()), not a control. publishStatus() runs in setup()/tick1s() and maps the platform OTA status string to a severity: "idle" clears the banner, an "error: " prefix is Severity::Error, anything else is neutral Severity::Status.

FixedRectangleEffect

test/unit/light/unit_FixedRectangleEffect.cpp

  • A small box (2×2 at the origin) lights exactly its cells and leaves every cell outside it black.
  • With defaults (origin 0,0,0 + 15×15×15 extent) the box fills the whole grid — the origin corner lights up.
  • The box is offset away from the origin: only the offset cell lights, the origin stays black.
  • A degenerate 0×0×0 grid must not crash (Effects run at every grid size).

Fluid

test/unit/light/unit_fluid.cpp Also touches: draw, FluidEffect.

  • each step drives the flow further toward divergence-free
  • more solver iterations leave less divergence, so the cost knob buys correctness
  • a jet carries the medium downstream, so a push is felt where it points
  • a fluid at rest stays at rest, so an idle fixture does not drift
  • a long stall leaves a plausible field rather than infinities
  • a grid too small to have an interior is refused rather than half-built
  • a fluid whose grids were released reports itself not ready, and resizing to the same shape rebuilds them
  • Fluid ticks dark rather than crashing on the frame between a release and its prepare, then renders again
  • Fluid reshaped to the same light count starts from black rather than the old layout's dye

FreqMatrixEffect

test/unit/light/unit_FreqMatrixEffect.cpp Also touches: AudioService.

  • A real tone above the 80 Hz gate paints a lit color at the source pixel (0,0).
  • The column is a shift register: a lit source pixel scrolls to y=1 on the next tick.
  • Silence (no active mic → the static silent frame) paints black: no tone, no light.
  • The "runs at every grid size" hard rule: degenerate grids never crash.

FreqSawsEffect

test/unit/light/unit_FreqSawsEffect.cpp Also touches: AudioService.

  • With no audio (silence) and keepOn off, every band decays to rest so the effect draws nothing — the whole buffer stays black. (No mic is active here, so latestFrame() is the static silence.)
  • keepOn keeps every band drawing even when its speed has fully decayed, so on a rested (silent) panel the columns are still lit rather than fully dark between hits.
  • Fed a live (simulated) audio frame, the effect reacts: loud bands rise and paint their columns, leaving the buffer non-black even with keepOn off (so the light comes from the audio, not keepOn).
  • The "runs at every grid size" hard rule: a degenerate 0×0×0 grid and a 1×1 grid both render without crashing (the imap zero-span guard and the sizeX/sizeY<=0 early-out cover them).

GEQ3DEffect

test/unit/light/unit_GEQ3DEffect.cpp Also touches: AudioService.

  • Silence (no active mic) leaves the buffer all-black — every band magnitude is 0, so no bar rises.
  • A synthesized sweep frame with only the lowest band lit paints the LEFT of the grid and leaves the far RIGHT dark.
  • The effect runs at a degenerate 0×0×0 grid without crashing (the "every grid size" hard rule).
  • A narrow grid with fewer columns than bands still spreads bars (numBands is clamped to the column count, so no divide-by-zero, no bar pile-up at x=0) and never crashes.

GEQEffect

test/unit/light/unit_GEQEffect.cpp Also touches: AudioService.

  • With no live audio source every band is silent, so no bar rises and the buffer stays black.
  • A bar grows from the floor up: when a column's band is loud, its bottom (floor) pixel is lit while a pixel above the bar's top stays dark — bars fill upward from the bottom row, not top-down or floating.
  • colorBars colors each bar by its column index, so two well-separated lit columns take different hues rather than sharing the row-height gradient — the toggle changes what color a bar is.
  • The hard rule: the effect runs at any grid size without crashing, including 0×0×0 and 1×1, with a live audio frame feeding it every tick.

GameOfLifeEffect

test/unit/light/unit_GameOfLifeEffect.cpp

  • The B#/S# parser turns a rule string into birth/survive neighbour sets. Conway = B3/S23.
  • A 2×2 block is a Conway still life: every live cell has 3 neighbours (survives), and the surrounding dead cells never have exactly 3 (no births). It must be identical after a step.
  • Regression: a 3D grid gives a cell up to 26 neighbours (3×3×3 minus self), but the B/S rule tables are sized 9 (single-digit Conway notation, 0..8). A dense 3D neighbourhood must not read those tables out of bounds — a count ≥9 is in no single-digit ruleset, so the cell dies / stays dead. This fills a 3×3×3 cube (the centre has all 26 neighbours alive) and just steps: the test passing under ASan/bounds-checking is the OOB-read pin; behaviourally the over-crowded centre dies (26 ∉ S) and the dense interior doesn't survive.
  • A horizontal 3-cell blinker oscillates to vertical after one step (period-2 oscillator). This is the canonical "the rules actually run" check: birth on 3, death of the ends (1 neighbour each).
  • A lone cell (0 neighbours) dies — the dead-by-isolation rule, and a sanity check that an empty grid stays empty (no spontaneous births at count 0 under Conway).

GridBlacksLayout

test/unit/light/unit_GridBlacksLayout.cpp Also touches: Layouts, GridLayout.

  • A dark column run: [blackStart, blackStart+blackCount) is black in every row. The physical index still advances across gaps (they are wire slots), the coordinate is the true (x,y), and lit/black is decided on x — so lit columns beyond the gap keep their true positions (the picture is HOLED, not collapsed).
  • The gap test is on the TRUE x, not the wire order, so a serpentine strip keeps the same physical columns dark whichever way it snakes into a row.
  • No black run → no gaps, and the walk is byte-identical to a plain grid (a GridBlacks with blackCount 0 renders exactly like a Grid). blackCb never fires; hasBlackPixels is false.
  • Robustness: a black run wider than the grid darkens every column (whole grid dark, no crash), and a run starting past the right edge darkens nothing.

GridLayout

test/unit/light/unit_GridLayout.cpp Also touches: Layouts.

  • A 4×4×1 grid yields 16 lights iterated row-major: x sweeps fastest, then y, then z.
  • Serpentine reverses x on odd rows (boustrophedon), so the strip snakes back and forth: driver index advances linearly while the emitted x zigzags. Even rows L→R, odd rows R→L. The COORDINATE is always the true (x,y) — only the index→position order changes, which is what makes the mapping non-identity.
  • A 3D 2×2×2 grid yields 8 lights with z-plane separation (indices 0-3 at z=0, 4-7 at z=1).
  • A single-light grid (1×1×1) is a valid layout: one coordinate at (0,0,0).
  • Layouts with a single child delegates totalLightCount and placeLights to that child directly.
  • Two child layouts produce contiguous physical indices: the second layout's coords are offset by the first's lightCount.

HlsDriver

test/unit/light/unit_HlsDriver.cpp Also touches: Drivers, Correction.

  • Without ffmpeg the driver is inert but SAFE, and says why: the state every machine without it is in, including CI, so it is the default path rather than an edge case.
  • The ffmpeg invocation IS the desktop encode contract: raw RGB in at the grid size and chosen rate, zerolatency x264 at the chosen bitrate, 1 s segments on a short rolling playlist (the live tuning behind the documented 2-5 s latency), segments deleted as they age out. The driver states only the numbers (EncoderConfig); this pins what the desktop platform makes of them.
  • A grid change re-states the geometry: the encoder is fixed at start, so the numbers the driver hands the platform must follow the layout rather than any control of its own.
  • One frame piped per tick within the rate: the grid's pixels, tight RGB, corrected: what the wall shows is what the stream shows, pixel for pixel.
  • targetFps is a ceiling the driver enforces itself: the render loop ticks faster and the frames beyond the rate are simply not encoded.
  • A full pipe is a dropped frame, counted and shown, never a blocked render tick.
  • A dead encoder restarts from the housekeeping tick, and the stream resumes.
  • release() stops the encoder and leaves nothing behind; a re-prepare comes back streaming.
  • The restart budget is finite: an encoder that dies on every attempt (an encoder name this ffmpeg's build lacks exits immediately after every spawn) ends at the visible give-up status, not an endless respawn loop. encoderStart() can only verify ffmpeg launches, so this status IS how an unavailable encoder surfaces.
  • The frame rate must hold EXACTLY over time, not just per-frame. 1000/fps truncates (30 fps asks for a 33 ms period, so 30 frames span 990 ms), and pacing from each frame's arrival time lets every late tick shift the schedule for good. Either way the stream drifts against the player's clock, and the player stalls to re-buffer: the periodic hiccup seen on the bench.
  • A late tick must not shift the schedule: the frames after it stay on the original grid, so a one-off stall costs one frame rather than permanently offsetting the stream.
  • A wall smaller than the encoder's minimum frame is blown up rather than refused: the P4's hardware encoder will not accept anything under 80x80, and a player showing a 4x2 stream renders a postage stamp. Auto picks the smallest whole factor that clears the floor on BOTH axes, so the aspect ratio is untouched.
  • A wall already past the minimum is left alone: auto never upscales what does not need it.
  • Upscaling replicates, never interpolates: each light becomes a solid square block, so the stream introduces no color the wall does not have and every light stays individually visible.
  • The SHORT axis decides the factor, and the ceiling must not get in the way. A 4x2 wall needs x40 to lift its height to 80; a ceiling below that would hand the encoder a 64x32 frame it refuses, so the feature would fail precisely on the smallest walls it exists for.
  • Both operands can be sane while their PRODUCT is not: lengthType is int16_t, so an 821x4 wall at scale 80 wraps to 144x320. The frame buffer would then be sized from the wrapped number while the pixel loop still walks the real 821x4 source, writing ~196 KB past the end of the heap buffer. The scaled geometry is therefore computed wide and rejected before narrowing.
  • The same guard must not refuse a frame that genuinely fits: 640x480 at scale 3 is 1920x1440, exactly the encoder's width limit and inside its height limit.
  • A long stall resyncs the schedule rather than firing a burst to catch up. The frame sent AT the resync is the new schedule's frame 0, so the next is due a full period later. Counting it as frame 0 instead made the following tick recompute its due time back to that same instant and fire again one millisecond later, which is a duplicate frame in the stream.

HttpServerModule

test/unit/core/unit_HttpServerModule_apply.cpp

  • A parent's declared child roles are a RULE the device enforces, not advice to the UI. The picker filters by the same declaration, so a user never sees a bad pairing, but the API is reachable without it: an effect nested inside a layout ticks in the wrong pass, and because the UI resolves a card by module name it renders its controls onto the parent's card.
  • apply-core: applyAddModule adds a child, idempotent on the id
  • applyAddModule reports the created module's FINAL name (post-disambiguation) via outName, so the HTTP handler can return it and the UI can select + focus the new module (the "+" focus fix).
  • apply-core: applySetControl writes a value, rejects out-of-range / unknown
  • apply-core: applyClearChildren empties a container (replaceChildren)
  • apply-core: applyOp dispatches each op type and tolerates bad input
  • A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is enforced THROUGH the apply-core — so the APPLY_OP set the installer pushes over serial is guarded exactly like an HTTP write, with no per-transport special-casing. This is the point of moving validation onto the control: one backend check, every path.
  • The WLED shim's {on,bri} apply drives the real on + brightness controls independently: turning off must NOT clobber the brightness value (the whole point of the shared on control, replacing the old bri=0 fudge). Home Assistant + the WLED app both post through this path.
  • Diff-on-the-wire (the 1 Hz-stutter fix): the periodic WS push sends only CHANGED control values, not the whole ~34 KB tree every second. buildStatePatch value-hashes each leaf against a baseline and emits only the ones that differ. These pin the core guarantees: an unchanged tree → EMPTY patch (the whole point — no per-second re-serialise of static config), and a single value change → a one-entry patch addressed by "/".
  • A schema change (rebuildControls — hidden flags / option sets) can't be seen by the value-hash patch, so any module's rebuildControls() flips the WS full-resync flag through the static schema-changed hook. This is what carries a metadata-only change (WiFi addressing hides fields, a preset Select gains an option) to connected clients. Pins the hook wiring + the subtraction of the old per-call-site resyncs.
  • buildStatePatch: a changed control value yields a one-entry patch
  • A module's STATUS must ride the 1 Hz value-diff, not the full state alone. A driver can fault at any moment (a bus that won't init, a loopback verdict, a Hue pairing result) with no schema change and no structural change — so nothing triggers a resync, and a status carried only by the full state would sit stale indefinitely. It is worse with the tabbed UI: a module whose card is behind a collapsed tab would surface no fault at all. This pins @status/@severity as patch leaves.
  • REGRESSION GUARD for the front/back-end sync class of bug that recurred several times: a control change the UI can only learn from the FULL state (never the per-second value patch) MUST request a full resync, or the client's cached state keeps the stale value and reverts the change ~1 s later. The canonical case is the module enabled toggle (it rides the full state, not the patch). The counterpart is equally load-bearing: an ORDINARY value change must NOT request a resync, or every slider drag nukes+rebuilds the whole UI (the "expander collapses / picker closes" symptom). This test pins BOTH directions at the one seam where they broke — Scheduler::setControl, via applySetControl — using the schema-changed hook as the resync signal.
  • a written file asks the tree to re-derive
  • a burst of file writes costs one re-derive, not one per file
  • a replaced module is named by the caller, then by its old custom name, then by its type
  • a file write with no scheduler is a no-op, not a crash
  • The preview channel's inbound framing: masked client data frames whose payloads are handed on OPAQUELY (the producer owns their meaning). The parser's job is refusal and unmasking: wrong opcode, unmasked, oversized, truncated, all -1, never a read past the buffer.
  • TCP coalesces: two requests sent in quick succession can land in ONE read. The parser reports how many bytes a frame occupied so the caller can walk the whole buffer and deliver each payload in arrival order.
  • The request channel takes only SMALL payloads: anything using the WebSocket extended-length forms (126/127) or a plain length over 8 is refused whole, consuming nothing, so a hostile or confused client cannot make the walker misstep into its bytes.

HueDriver

test/unit/light/unit_HueDriver.cpp

  • HueDriver: a colored pixel becomes an on/bri/hue/sat state body
  • HueDriver: a black pixel becomes on:false
  • HueDriver: RGB→HSV maps the primaries to the right Hue wheel positions
  • HueDriver: unchanged color is not resent, a changed one is
  • HueDriver: parseLights keeps only color-capable, reachable lights
  • Room + light selection filters which color lights the driver actually drives. Both dropdowns default to "All" (index 0): then every color light is driven (unchanged behaviour). Selecting a room narrows the driven set to that room's color lights; selecting a light drives just that one.
  • The single status line (folding what were the separate hueStatus / colorLights controls) shows the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not.
  • fetchLights sizes its read buffer by growing while the body looks truncated. The signal is "does the body end in '}'": a too-small buffer cuts the JSON mid-content. (Regression: an earlier check tested strlen==cap-1, which never fires because httpRequest strips headers first, so a >2 KB bridge response was parsed truncated and lights silently disappeared.)

ImprovFrame

test/unit/core/unit_ImprovFrame.cpp

  • improvChecksum returns the sum of all input bytes modulo 256 (zero-length input is 0).
  • buildImprovFrame writes the full wire shape: "IMPROV" magic + version + type + length + payload + 1-byte checksum.
  • A payload larger than kImprovMaxPayload (128) is refused: builder returns 0 bytes written.
  • If the caller's output buffer can't hold the framed bytes, the builder refuses (returns 0).
  • A zero-length payload is valid: length byte is 0, checksum covers magic+version+type+length only.
  • Feeding a well-formed frame byte by byte ends in FrameReady; the parser exposes the type, length, and payload.
  • A zero-length payload frame parses to FrameReady with lastPayloadLen() == 0.
  • A corrupted checksum byte yields BadChecksum at the end of the frame.
  • A length byte greater than kImprovMaxPayload trips OversizePayload at that byte (before any payload data arrives).
  • Garbage bytes before the magic 'I' are silently skipped; a fresh well-formed frame after them parses normally.
  • "I" followed by another "I" treats the second byte as a fresh magic-start (not discarded) — the parser doesn't lose a real frame that begins mid-aborted-magic.
  • When the byte after MagicV isn't the version but happens to be 'I', the parser re-enters magic search at Magic1 — recovers a new frame that arrives right after a corrupted header.
  • Every defined ImprovFrameType (CurrentState, ErrorState, Rpc, RpcResponse) round-trips through builder + parser cleanly.
  • After FrameReady the parser returns to Magic0 and parses the next frame on the same instance without reset.

ImprovOpReassembler

test/unit/core/unit_ImprovOpReassembler.cpp

  • a single-frame op (seq 0, last 1) is Ready with the exact bytes
  • a multi-chunk op reassembles in order and NUL-terminates
  • a duplicate chunk is rejected and resets the buffer
  • an out-of-order chunk (skipped seq) is rejected
  • a non-zero opening seq (no fresh start) is rejected
  • overflow past the buffer (minus the NUL) is rejected, not truncated
  • exactly buffer-minus-one bytes fits (boundary)
  • seq 0 mid-stream abandons a partial op and starts fresh
  • an empty final chunk still completes (last with zero bytes)
  • reset() drops a partial op

InfraredService

test/unit/core/unit_InfraredService.cpp Also touches: Scheduler.

  • a learned code toggles the control its row targets
  • a delta row nudges its target and stops at the control's own bounds
  • a delta row steps a select and clamps at both ends
  • learning binds the next code to the armed row, and only that row
  • arming a row disarms any other, so one code cannot bind twice
  • an unlearned code is reported and changes nothing
  • a row whose target module is gone is a no-op, not a crash
  • a row with no target does nothing at all
  • rows are added and deleted at runtime, which is what a fixed action table could not do
  • the pin state decides what the service reports about itself
  • a code that is not a number is refused rather than binding something else
  • a set row is refused on a remote, which has no release to clear it
  • one remote key binds to one row, so a re-learned key moves rather than duplicates
  • an explicit false disarms a row, so a learn can be canceled

IpList

test/unit/core/unit_IpList.cpp

  • parseIpList: a range expands over the last octet
  • parseIpList: a list of bare host numbers continues the same subnet
  • parseIpList: full quads may switch subnet, and ranges/lists mix
  • parseIpList: blank is not an error — it means no destinations (idle)
  • parseIpList: malformed input is rejected, never guessed at
  • parseIpList: the destination cap is enforced, not silently truncated

JsonSink

test/unit/core/unit_JsonSink_detach.cpp

  • JsonSink::detach hands over the heap buffer; sink frees nothing after
  • JsonSink::detach is a no-op in fixed-buffer mode
  • JsonSink::detach on an empty buffer-mode sink returns null, not a dangling block

JsonUtil

test/unit/core/unit_JsonUtil_parse.cpp

  • parse a flat object reads each typed field
  • parse an array of objects (the persisted device list use case)
  • parse a nested object
  • escaped quotes and backslashes round-trip inside a string value
  • negative and fractional numbers
  • malformed inputs fail cleanly without crashing
  • no node cap: a large array parses (heap-grown node pool)
  • overflow safety: nesting deeper than kMaxDepth fails cleanly
  • no length cap: a long input parses (heap-sized text arena)
  • parseString must DECODE the JSON string escapes our own writer emits (JsonSink/writeJsonString) — \" \ \n \r \t \b \f — so reader and writer are symmetric. A multi-line value (a script with \n) must arrive as a real newline, not a literal backslash-n.

test/unit/core/unit_JsonUtil_parseint.cpp

  • a JSON integer value is read up to the character that ends it
  • text that does not start with a number reads as the fallback, not as zero-by-accident
  • a value too large to represent reads as the fallback instead of wrapping
  • parseInt reads a key's integer value, and absent keys read as zero

Layer

test/unit/light/unit_Effects_framerate.cpp Also touches: draw, particles.

  • every effect behaves the same at any framerate
  • a feedback trail compounds at the same rate however fast the device renders
  • fireworks launch the same number of shells per second at any framerate

test/unit/light/unit_Layer_extrude.cpp Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect.

  • A D2 effect (Rainbow) on a 3D layer writes z=0 once; Layer::extrude copies that slice across every z>0 — slices are byte-identical.
  • A D1 effect writes the x=0 column; extrude duplicates it across every x and every z-slice.
  • NoiseEffect declared D3 still produces a valid image on a depth=1 layer (it honours the runtime depth instead of hardcoding z).
  • PlasmaEffect (D3) on a 2D layer same contract: valid 2D image, no buffer overrun.
  • NoiseEffect (D3) on a 1D layer (height=depth=1) writes a valid strip and never overflows.
  • PlasmaEffect (D3) on a 1D layer same contract: valid 1D strip, no overflow.
  • SpiralEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract).
  • FireEffect (D2, stateful — heat buffer sized to w×h) extrudes cleanly across z on a 3D layer.
  • ParticlesEffect (D2, stateful — trail sized to w×h×cpl) extrudes cleanly across z on a 3D layer.

test/unit/light/unit_Layer_live_modifier.cpp Also touches: RotateModifier, ModifierBase.

  • With a Rotate present, the live pass rotates the gradient each frame as the angle advances — so two frames at different times differ. A static GradientEffect alone would produce identical frames, so any difference is the live remap.
  • PAY-FOR-WHAT-YOU-USE: a Layer with no live modifier must NOT run the live pass — the static gradient is byte-identical across frames regardless of the clock.
  • A DISABLED Rotate must not run the live pass either (the gate keys off ENABLED live modifiers). Same static gradient → identical frames.
  • COALESCED REBUILD: two beat-driven modifiers (RandomMap) on one Layer both ask for a rebuild on a beat; Layer::tick() must rebuild ONCE (not re-enter prepare per modifier) and the Layer must stay valid — the composed mapping changes, no crash.

test/unit/light/unit_Layer_modifier_chain.cpp Also touches: ModifierBase.

  • Region (left half) THEN Multiply (2× mirror): the logical box folds twice. On a 16-wide axis: Region 0..50 → 8, then Multiply 2 → 4. Both modifiers apply — the second is no longer dead weight.
  • Order matters: Region-then-Multiply differs from Multiply-then-Region. Region's percentage applies to whatever box it sees, so the composed logical size differs.
  • A DISABLED middle modifier is skipped — the chain folds only the enabled ones.

test/unit/light/unit_Layer_phase_animation.cpp Also touches: MetaballsEffect, SpiralEffect, LavaLampEffect, SpiralEffect.

  • Metaballs visibly changes over 100ms even when per-tick dt is sub-millisecond (no phase-accumulator truncation).
  • SpiralEffect advances at desktop speed (the spiral rotates across 100ms).
  • LavaLamp animates across 100ms (blobs move).
  • Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen.

test/unit/light/unit_Layer_sparse_mapping.cpp

  • Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast path is preserved exactly (the grid short-circuit).
  • Serpentine grid: dense (every box cell is a light, so the count check alone would pick the identity fast path) but SHUFFLED (driver index i != box cell i). isNaturalOrder() measures that from the coords and routes it through the box->driver LUT instead. This is the lever for exercising the non-identity mapping path without a sparse layout or a modifier.
  • Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box.
  • Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount).
  • REGRESSION: a high fan-out Multiply (8×8×4 = 256) on a 128×128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount × maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 × 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range — the symptom that black-screened the device.
  • Region carving: a RegionModifier shrinks the Layer's LOGICAL box to the region (so the effect renders only there), and the LUT maps each region cell to its box cell at the start offset — every destination in range, none outside the region. The driver buffer still holds all physical lights; cells outside the region simply get no logical source (dark). Default 0/100 = full box (the no-carve fast path) is covered by unit_RegionModifier; here we carve a quarter.
  • Black pixels (mid-strand dark gaps): a GridLayout with a dark column run leaves the identity fast path (an identity map would light the gap) and builds a LUT that maps only the LIT cells. The gap's physical index is a real wire slot (counted in the physical/driver total) but is NO logical cell's destination, so the scatter never writes it and it stays black — a "physical pixel that stays black". This is the GridLayout-native form of the sparse mapping the sphere/region tests above pin.
  • Robustness: an ALL-black grid (every column dark) is a valid degenerate config — every physical slot is a real wire position the driver still clocks, but NO cell maps to a light, so the LUT has zero destinations. Must build and run (buffer stays black), never crash.

test/unit/light/unit_Layer_zero_grid.cpp Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, MetaballsEffect, RingsEffect, RipplesEffect, LavaLampEffect, FireEffect, ParticlesEffect, GameOfLifeEffect, GEQ3DEffect, PaintBrushEffect.

  • Rainbow on 0,0,0 grid: no crash.
  • Noise on 0,0,0 grid: no crash.
  • Plasma on 0,0,0 grid: no crash.
  • Spiral on 0,0,0 grid: no crash.
  • Metaballs on 0,0,0 grid: no crash.
  • Rings on 0,0,0 grid: no crash.
  • Ripples on 0,0,0 grid: no crash.
  • LavaLamp on 0,0,0 grid: no crash.
  • Fire on 0,0,0 grid: no heat buffer allocated, no crash.
  • Particles on 0,0,0 grid: no trail buffer allocated, no crash.
  • GameOfLife on 0,0,0 grid: no heap alloc for 0 cells, no crash.
  • GEQ3D / PaintBrush on 0,0,0 grid: audio effects, no crash with no buffer.
  • PaintBrushEffect on 0,0,0 grid
  • A modifier keeps its per-frame state moving while the grid is empty. A beat-driven modifier that stalled here would come back in the wrong phase once the layout returns, so the empty interval has to pass THROUGH the modifier chain rather than around it. This is the half of the rule the per-effect cases above cannot see: they assert nothing runs, this asserts something still does.
  • A zero channel count is rejected at the setter rather than defended against downstream: it would allocate a zero-byte buffer and make every effect's per-light stride 0. Enforcing it at the one entry point is what lets effects and draw primitives assume cpl >= 1.
  • The live pass walks the modifier mapping into the buffer, so an empty layout has nothing for it to remap. It is gated on hasGrid alongside the effect pass; the modifier ticks still run.

Layouts

test/unit/light/unit_Layouts_container.cpp

  • Disabled layouts contribute nothing; enabled siblings shift down to close the gap (no index holes).
  • Disabling the Layouts container itself zeroes totalLightCount and yields no coordinates.

test/unit/light/unit_Layouts_mutation.cpp

  • Add a single layout: the container reports its light count and iterates it.
  • Add more than one layout (mixed types): counts sum, indices stitch end-to-end.
  • Replace a layout with a different type at the same slot: the other layouts and their order are preserved; only the replaced slot's contribution changes.
  • Remove a layout: it leaves the tree, the remaining layouts shift to close the gap, and the total drops by exactly the removed layout's light count.

test/unit/light/unit_Layouts_toggle_cycle.cpp Also touches: Layer, Drivers.

  • Disabling the only layout child and re-enabling it must not crash Drivers, and rendering resumes cleanly.

LightPresetsModule

test/unit/light/unit_LightPresetsModule.cpp

  • LightPresets seeds the curated built-ins as locked rows
  • Option-array hoist (the 1 Hz-push efficiency fix): the 14 channel-role option strings are emitted ONCE per list in optionSets["channelRole"], and each ch select references it via optionsRef — NOT re-inlined per channel per row. A 32-channel fixture × 13 rows would otherwise repeat that array 400+ times in every state push. Pins that the row detail carries optionsRef, never inline options.
  • LightPresets add / edit / resolve a custom preset
  • setListRowField parses the "ch" channel index with strtol, not atoi — a malformed suffix must be REJECTED, not silently coerced to channel 0 (atoi("ch3x")→0 would misroute the write). Pins that a trailing-garbage or out-of-range channel name returns false and leaves the roles unchanged.
  • Regression (live bug): growing a preset's channel count must PRESERVE the roles already set on the existing channels — only the new channels get defaults. The earlier rebuildPool copied the NEW (larger) count of bytes from the old (smaller) slice, reading past it / skipping the copy, so the existing picks were lost on every channel increase.
  • LightPresets: a built-in is protected; a missing id does not resolve
  • LightPresets: delete + reorder keep ids stable
  • LightPresets: a custom preset round-trips through persistence with its roles
  • Regression: a preset name containing a JSON metacharacter (a double-quote or a backslash) must round-trip through persistence. writeListRow escapes the name via writeJsonString; a raw %s would emit malformed JSON that fails to parse on the next boot, SILENTLY WIPING every custom preset — a legal keystroke ("MH \"BeeEyes\"") wiping the whole library. The persisted form must be valid JSON for any name the editor accepts.
  • Regression: a persisted role byte out of the valid ChannelRole range (a hand-edited / corrupt file) must clamp to a safe default on restore, matching the validation setListRowField applies on the live edit path. An unclamped cast would store e.g. 250, which the UI's role Select mis-renders and the derived Correction silently drops — the "corrupted-but-looks-fine" outcome the robustness contract forbids. (The load path is ApplyPolicy::Clamp: a stale/bad value snaps to a valid one, never survives.)
  • A driver's preset Select is populated from the library, and picking one resolves
  • CONSISTENCY (the product owner's requirement): editing a preset's wiring must immediately reach EVERY driver that references it — no reboot. The device wires this via prepareTree() (fired by the list mutation), which re-runs each driver's rebuildCorrection() → re-resolves its preset from the library. This test edits a referenced preset and re-resolves, asserting the driver's Correction now reflects the edit. Two drivers on the SAME preset both update — one shared definition, consistent.
  • A newly-ADDED preset must become selectable on a driver — the driver's preset Select option set is built from the library, so adding a preset has to refresh it. On the device, afterListMutation rebuilds every module's controls after a list mutation for exactly this reason; here we simulate that by rebuilding the driver's controls after the add and checking the Select grew.
  • whiteMode visibility: a driver's whiteMode control is hidden unless the REFERENCED preset carries a white channel — an RGB/GRB strip has nothing to synthesise. Regression: after Inc 2 moved preset selection to a library reference, whiteMode was shown for every preset (the old inline-preset hasWhite check was gone).
  • The migrated color-order built-ins resolve to the right channel offsets. WRGB (ws2814) puts white at channel 0, so R/G/B shift up one — a distinctive layout that catches a bad migration.
  • RGBCCT carries a cold white (W) AND a warm white (WW). The new WarmWhite role must count as "has white" so a driver referencing it still shows whiteMode — the second white is white too.
  • A moving-head built-in migrates as a wide fixture: the RGB block sits at its real offset within the DMX map (BeeEyes: R@10,G@11,B@12 of 15), the fixture is the right width, and it resolves without crashing at that odd width (Robust-to-any-input). The Pan/Tilt/Zoom/Gobo channels carry their roles in the preset but aren't color offsets, so they're inert until effect writers land.
  • APPEND-ONLY regression: inserting WarmWhite/Yellow/UV after White must NOT renumber the existing color roles, or every persisted RGBW preset's bytes would resolve to the wrong colors. A straight RGBW built-in still deriving R@0,G@1,B@2,W@3 proves the low indices are unchanged.
  • MIGRATION SECURITY (the product owner's ask): a driver whose referenced preset no longer exists — a custom preset deleted, or a persisted reference to a preset a firmware no longer ships — must fall back to the default (first) built-in, resolving to a valid RGB output rather than blanking or crashing. rebuildCorrection re-points a dangling id to defaultId(); this pins that path so the fallback can't silently regress (Robust-to-any-input). Two routes are checked: a deleted custom, and a straight-up bogus id.

LissajousEffect

test/unit/light/unit_LissajousEffect.cpp

  • A single frame paints part of the grid: the swept curve lights some pixels (not a black frame).
  • The curve is sparse: on a large grid it lights only some pixels, leaving others black.
  • On a 1×1 grid the whole curve collapses onto the single origin light without indexing out of bounds.
  • Effects must run at every grid size (hard rule): a 0×0×0 grid renders without crashing.

MappingLUT

test/unit/core/unit_MappingLUT.cpp

  • A fresh LUT carries no mapping (hasLUT==false, logicalCount==0); BlendMap takes the fast identity copy path.
  • setIdentity(N) declares a 1:1 mapping for N lights without allocating a LUT; forEachDestination still iterates correctly.
  • Each logical light can map to a different count of physical lights; forEachDestination yields every mapped index in order.
  • When no single contiguous block fits (forced via the test cap) but total heap allows it, build() pages the destinations array. The mapping must read back identically to a single-alloc build — paging is an allocation detail, not a behaviour change. isPaged() confirms the fallback actually engaged.
  • build() returns false on genuine exhaustion — total free heap (minus the reserve) can't hold the destinations — so the caller degrades to 1:1. Forced here via a non-zero freeHeap is desktop-only-unavailable, so this case pins the paged path's success and the boundary; the tier-3 false path is covered by the Layer sparse-mapping degrade test on real heap limits.
  • free() releases memory and resets counts; build() can be called again to install a fresh mapping.

MetaballsEffect

test/unit/light/unit_MetaballsEffect.cpp

  • One tick on a 16×16 grid leaves at least one non-zero byte in the layer buffer (proves the effect rendered).
  • Pixels at opposite corners of a 32×32 grid differ in color (the effect is not flat-filling the buffer).

MirrorModifier

test/unit/light/unit_MirrorModifier.cpp

  • modifyLogicalSize halves each mirrored axis, rounding up: an even 128 → 64, an odd 65 → 33 (the centre column stays unpaired).
  • A coord and its mirror across the box centre map to the same logical position: on an even 8-wide axis, physical 7 folds to 0, 6 to 1, 5 to 2, 4 to 3, while the near half 0..3 passes through unchanged.
  • An odd extent keeps its centre column unpaired: on a 5-wide axis the half-extent is 3, so 0,1,2 pass through and the far edge 4→1, 3→2, leaving logical column 2 unpaired.
  • A disabled axis is left untouched: with only Y mirrored, X and Z sizes are unchanged and X coordinates pass through while Y folds.
  • Degenerate axes don't crash: a 1-wide axis stays 1 ((1+1)/2 == 1) and no coordinate ever reaches the half-extent to fold; a 0-extent axis stays 0.

ModuleFactory

test/unit/core/unit_ModuleFactory.cpp

  • registerType(name) instantiates a probe of T to read its role(), then stores name+role+constructor for later create() calls.
  • create(name) returns a heap-allocated instance whose role and typeName match what was registered.
  • create() returns nullptr for an unknown name or a nullptr name (no crash on bogus input).
  • typeName/typeRole with an out-of-range index returns nullptr / Generic safely (never UB).
  • The factory grows its registry capacity dynamically — registering 10+ extra types past the initial size still works and every name stays discoverable.

MoonLedDriver

test/unit/light/unit_MoonLedDriver.cpp Also touches: MultiPinLedDriver, ParallelLedDriver.

  • The load-bearing difference from its sibling. MultiPinLedDriver runs on the i80 bus — LCD_CAM on the S3/P4 AND the I2S peripheral on the classic ESP32 (IDF's esp_lcd picks the backend). MoonI80 programs LCD_CAM directly, so it must NOT claim the classic chip: lanesAvailable() reads lcdLanes alone, without the + i2sLanes its sibling adds. Getting this wrong would offer the driver on a chip whose peripheral it cannot drive. lanesAvailable()/kSupportsPinExpander/kPowerOfTwoBus/kLoopbackFullWidth moved from static constexpr on the driver to virtuals on the MoonI80Peripheral backend — a bare instance reaches them without a live driver's peripheral_ (protected on ParallelLedDriver).
  • The i80 BUS is 8 or 16 bits wide whatever the pin count, so the base rounds it up (powerOfTwoBus()) and parks the lanes the board does not use. And the loopback cannot build a 1-lane private bus, so its test frame is encoded at the full operational width.
  • The bus control pins are a '595 cost, not an i80 cost — and owning the DMA is what proves it. DC exists so an LCD panel can separate command bytes from data bytes; a WS2812 strand has no such concept, and the peripheral holds DC at a constant level. WR is the pixel clock, which only a shift register consumes (as SRCLK) — WS2812 is self-clocked, so a strand ignores it. esp_lcd mandates a valid GPIO for BOTH regardless (wr_gpio_num >= 0 && dc_gpio_num >= 0), which is why the sibling still spends two pins on them. This backend routes its own GPIO matrix, so it routes neither in direct mode: there is no dcPin at all, and clockPin reaches a pad only under the expander. The observable consequence, and what this case pins: in DIRECT mode clockPin may freely name a GPIO that a strand also uses, because the signal never leaves the peripheral. Rejecting that would forbid a working config to protect a signal nobody reads.
  • Under the expander WR IS routed (it clocks the '595s), so now it can collide — and a data lane sharing it is silent corruption: the matrix drives both signals onto the one pad and that strand emits the shift clock instead of pixel data.
  • The '595 latch rides a DATA lane (the peripheral gives only one clock output, and WR is already the shift clock), so it must not land on WR — the latch would ride the shift clock itself and nothing would ever latch, which looks like a dead strip rather than a config error. clockPin now lives on the MoonI80Peripheral backend (not a MoonLedDriver member), so it is read/set through the control API defineDriverControls() binds — the same mechanism the UI and persistence use.
  • The '595's shift clock IS WR, so shift mode needs clockPin on a real GPIO. Unset (-1) would route the peripheral's WR signal to GPIO 65535 — catch it as a config error, not a bad pad write. Direct mode does not care (WR is unrouted there), so the same unset pin is fine without the expander.
  • Sanity: with a valid config the driver is a working CRTP sibling — it slices lanes and reports the lights it drives, exactly like its sibling. (The lane/frame ARITHMETIC itself is the base's, and is covered once, in unit_I80LedDriver and the Mock suites.)

test/unit/light/unit_ParallelLedDriver_ring.cpp Also touches: ParallelLedDriver.

    1. TILING — the ring's slices, concatenated in row order, reproduce a whole-frame encode byte for byte. This is the invariant the prior attempt violated (the 16-stride "domino" repeat). A slice writes to dst+0 for any firstRow, so if the tiling is right the reassembled buffer == the frame.
    1. ROWS-ONLY — no slice appends a latch pad; every buffer is zero past its rows. The buffers are allocated rows-only (the WS2812 reset comes from stopping the peripheral, not a pad in a circulating buffer), so a slice that wrote a latch word past its rows would overrun the allocation on hardware.
    1. RECYCLED == FRESH — a second frame through the SAME (recycled, not zeroed) ring buffers produces byte-identical output to the first. This catches a stale-constant / stale-pad bug that a single-frame test cannot see — the failure mode unique to a recycled ring.
    1. NON-MULTIPLE-OF-16 STRAND LENGTH — the last slice is SHORT and lands in a REUSED buffer, so its pad window still holds that buffer's earlier full slice. The pad-zero must scrub it, or ghost rows clock in place of the ≥300 µs LOW reset and a strand can miss its latch. 200 lights → 13 slices > 8 buffers (reuse) AND lastRows=8 (short) — exactly the case 128/192/256 (all ×16) never hit.
    1. SOURCE SNAPSHOT — the ring encodes off the render thread across the ~6 ms wire, so it must read a frozen per-frame copy, not the live Layer buffer. tickRing calls snapshotSourceForRing() before kicking the frame; after that the render loop is free to overwrite (or free) the source. This pins the invariant: once snapshotted, the encode's bytes track the SNAPSHOT, so mutating the live source mid-frame changes nothing on the wire. (On device this is what stops a grid resize / RGBW switch mid-wire from tearing or reading freed memory.)
    1. WINDOWED SNAPSHOT — the snapshot copies only THIS DRIVER'S WINDOW (winLen_ × srcCh from winStart_), not the whole source, then biases encodeSrc_ by -winStart_ so the encode's index math is unchanged. With a NON-ZERO window start the bias is load-bearing (a plain full-copy would read the wrong pixels), so this drives the same content two ways — once through a windowed snapshot at start=W, once by hand- offsetting a whole-buffer source so the live read lands on the same pixels — and asserts they match.
  • 7a. CLEAN LOW TAIL — with enough buffers that the frame does NOT reuse (nSlices < bufs), every buffer clocked after the last real slice is all-LOW: the ≥300 µs WS2812 reset that latches the '595. This is the no-reuse stopgap's guarantee; it is what renders clean at ≤240 lights/strand (kRingBufs=16).
  • 7b. THE NO-REUSE STOPGAP SIZING — with bufs > nSlices the tail buffer is a distinct physical buffer the priming loop zeroed and the wrap re-clocks as a clean LOW; with bufs == nSlices there is NO spare buffer, so the tail clock wraps onto buffer 0. The BYTE model shows the wrap re-clocks buffer 0 after the ISR has zeroed it on the very first drain's refill, so bytes alone stay LOW — but on HARDWARE the equal case STALLS ("output stalled") because the stop counter (nSlices + kTailBufs drains) needs a buffer the priming never left free, a DMA-timing property the byte model cannot see (256 lights at kRingBufs=16 is exactly this; the fix is kRingBufs >= 17). What this test CAN pin is the correct-sizing guarantee and the drain count, so a future change that breaks the tail or the stop timing at the correct sizing is caught here; the equal-case stall is covered in the backlog.
    1. RAGGED TILING — the same byte-for-byte tiling invariant as test 1, but with strands of DIFFERENT lengths. This is the case the uniform tests structurally cannot see, and it is a real end-user config: ledsPerPin takes one entry per STRAND (pins × 8 with the expander), precisely so two strands on the SAME '595 can differ — a strip on one output, a panel on the next. Why ragged is a distinct risk from uniform: the shift constants (the pulse-start word) depend on the ACTIVE MASK, and a strand that runs out drops from that mask at the row where it ends. So the constants stop being frame-uniform and must be laid per RUN of rows sharing a mask (ParallelLedDriver::prefillShiftRows). Get it wrong — lay row 0's mask over every row — and an exhausted strand keeps its pulse-start asserted and FLASHES WHITE at full brightness. The lengths are chosen so a strand ends INSIDE a slice, not on a buffer boundary: 16 rows/buffer and a strand ending at 100 puts the mask change in the middle of slice 6 (rows 96..111). That is the composition — run-splitting × slice tiling — that neither the encoder-level ragged tests (which never slice) nor the driver-level ring tests (which are never ragged) reach on their own.
  • 9a2. COALESCED EOFs — THE v2 REGRESSION TEST. The GDMA EOF interrupt is a latch, not a queue: under load two drains arrive as ONE firing, and the clock-oracle ISR then batch-refills both slices in that single invocation. The old one-refill-per-firing design shifted every later slice by one position when this happened (the shifted-region / wrong-color wall artifact); the batch contract requires the wire bytes to be IDENTICAL whatever the grouping. Deep-lapping geometry so batches cross the recycle boundary repeatedly.
  • 9b. EMPTY LANES ARE NOT RAGGED. The prefill-skip gate (uniformLaneCounts) requires a frame-constant active mask, and a count-0 lane is in NO row's mask — so a source that fills only 15 of 16 expander strands must count as uniform (skip allowed), not ragged (prefill every refill, ~1/3 of the refill cost). This pins BOTH halves: the gate says uniform, and the ring's recycled-buffer frames — which now skip the prefill — stay byte-identical to the whole-frame encode.
    1. RAGGED — AN EXHAUSTED STRAND GOES DARK. The tiling test above compares the ring against the whole-frame encode, so it catches any DISAGREEMENT between the two paths — but it would not notice both being wrong the same way. This one asserts the actual hardware requirement directly, with no reference frame: once a strand runs out, every byte it clocks is 0. This is the bug's real-world face. The '595 is fed serially and every bus word is an SRCLK edge, so a strand cannot be "left alone" — keeping it dark means clocking ZEROS into its shift position. The pulse-start word carries the active mask, so a stale mask re-asserts an exhausted strand and it FLASHES WHITE at full brightness. Driving the source all-0xFF makes any leak maximally visible. Both frames are checked: the constants must be re-laid correctly on a RECYCLED buffer too (ring buffers are reused, not zeroed), which is where a "lay it once at init" shortcut breaks on frame 2.
    1. THE RING MUST NOT SURVIVE A FAILED BUILD. reinit() builds the ring in two steps — the bus, then the source snapshot the ring's encode reads — and BOTH must hold or the driver falls back to whole-frame. The trap is the middle case: the bus builds (a GDMA channel, its EOF interrupt, and ~150 KB of internal DMA buffers are now live) and the snapshot then fails to allocate. That is not a hypothetical — it is the likeliest OOM in the whole driver, because the snapshot is asked for right after the ring took the scarcest memory on the chip. If the fall-through path tears down conditionally (if (inited_) busDeinit()), it walks straight past that live ring: inited_ is false by construction on this path, and the whole-frame build below then OVERWRITES the bus handle — leaking the channel, the ISR registration and the RAM, with no way back but a reboot. Worse, it repeats on every prepare rebuild, so a board tight enough to hit it once bleeds on every geometry change. This pins the rule that makes it safe: after a failed ring build the bus is torn down UNCONDITIONALLY. The mock's busDeinit clears ringActive_, so a surviving ring is visible here.
  • MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical to the whole-range serial
  • The snapshot window clamp must key on the SOURCE stride (srcCh), not outCh — the buffer is a raw srcCh-strided memcpy. With outCh > srcCh (an RGB source through an RGBW correction: srcCh=3, outCh=4) an outCh-based clamp would compute winLen4 > winLen3 and silently drop ~1/4 of the window, leaving its tail reading stale bytes. This pins the full window survives.
  • ringSnapshot is meaningful ONLY when a ring runs (wantsRing()); the schema must hide it otherwise so the user never sees a control that does nothing on their config. wantsRing() reads plain flags (not the source buffer), so the gate resolves correctly even at defineControls() time before the buffer is wired.

MoonLive

test/unit/light/unit_MoonLiveMotion.cpp Also touches: MoonLiveEffect.

  • The point of the feature: a script can aim each head independently, and the value lands in the channel the fixture map names rather than at some offset the engine guessed.
  • A strip has no pan channel, so the same script must run and write nothing rather than corrupting a color byte. This is what lets one script be used on both kinds of rig.
  • Motion is not color: dimming a rig must not swing its heads toward 0/0, so the brightness scaling that applies to color must not touch these channels.
  • An index past the end is a script bug, not a crash: the same bounds guard setRGB has. Writing through it would corrupt whatever follows the buffer.
  • The two SHIPPED motion scripts, run on a real head rig. They are the reference a user reads to learn setPan/setTilt, so "it compiles" is not enough: aim.mle must put every head where its sliders say, and sweep.mle must actually move them and differ between formations.
  • sweep.mle moves the rig and its formations differ
  • The audio vocabulary. Its most important property is what happens with NO audio: a script written for a rig with a microphone must still run on one without, rendering nothing rather than failing. That falls out of AudioService::latestFrame returning a silent frame rather than null, and this pins it, because the alternative (a crash or a compile error on an audio-less device) would only ever be found on someone's hardware.
  • A band index outside the 16 the spectrum has reads 0 rather than wrapping: a script asking for band 20 has a bug, and wrapping would answer it with a plausible number from the wrong end.
  • The names are audio* on purpose: a builtin RESERVES its name, so a bare level would stop every script that declares one from compiling. This is the regression test for that.
  • A script says WHAT IT IS, and the module answers with it. The two questions a compiled module answers with Dim dimensions() and const char* tags(), asked of a script the same way: by running the function the script wrote. This is what makes a scripted effect indistinguishable from a compiled one in the picker, and it is load-time only: both are read once per compile, never per frame.
  • A script that says nothing keeps the defaults, so every script written before scripts could declare anything behaves exactly as it did: D2, and the notepad that marks it as scripted.
  • A void dimensions() is not an answer: reading a value from it would hand back whatever sat in the return register. The declared type is what makes that checkable rather than conventional.
  • An out-of-range dimension cannot make the layer extrude along an axis that does not exist.
  • The point of a script declaring D1: the LAYER extrudes it. A D1 script paints the x=0 column down y and the framework fans it across the width, so one script fills a wall it never indexed. This is the behavior the declaration buys, and it is what makes int dimensions() more than a picker label: get it wrong and a script paints one column of a panel and leaves the rest dark.
  • The type REGISTRY stores the pointer tags() returns, once, from a probe instance (ModuleFactory registerType). A scripted tags() points into the engine's string pool, which is freed on the next compile: if that pointer ever reached the registry it would dangle for the life of the device. It cannot, because the probe has no script loaded and so answers with the static "📝". This pins that, because the failure it prevents is a use-after-free in a static table read on every UI refresh, and the thing keeping it safe is easy to break by giving MoonLiveEffect a default script.

test/unit/light/unit_MoonLiveScriptResolve.cpp Also touches: MoonLiveLayout, MoonLiveEffect, MoonLiveModifier.

  • The ordinary case for a script nobody has edited: it lives only in the factory directory, and naming it is enough. Without the fallback every downloaded script would report "script not found".
  • THE fork rule. The editor only ever saves to the user directory, so a copy there is the user's edit of a factory script, and it has to win or an edit would appear to do nothing.
  • Un-editing, and the reason the two directories exist at all: deleting the fork restores the factory script with no network, where a single directory would need it downloaded again.
  • A name in neither directory is not found, and the path it reports is the USER one: a message naming a place a user would not write to sends them looking in the wrong folder.
  • The two readers must agree. compileScriptFile reads the text and scriptFileHash answers "has it changed since I compiled it": resolve them differently and a fork compiles from one file while its hash comes from the other, so it looks changed on every prepare sweep and recompiles forever.

test/unit/light/unit_MoonLiveScripts.cpp Also touches: MoonLiveLayout, MoonLiveEffect, MoonLiveModifier.

  • every script in moonlive/ compiles
  • The catalog is what a DEVICE knows about: it carries these names and fetches a script's text the first time someone picks it. A script in the repo but not in the catalog is invisible on every device, and nothing else would notice, since the build succeeds and the file is right there.
  • Comments are what makes a script in moonlive/ readable, so the lexer has to treat a plain // line as whitespace — anywhere, including between the statements of a loop body. The one exception was // @control min..max, a comment that declared a UI slider. defineControls() replaced it, so every comment is now genuinely a comment. Each binding supplies the system variables it actually WRITES, and supplying a name is also what reserves it. That split is what keeps x usable as a loop counter in a layout while still making it mean "the light being folded" in a modifier — and what turns a layout reading width into an error instead of a silent 0 that places no lights and reports success. ONE vocabulary for all three roles. A name means the same thing in every script, and the only thing a binding decides is which slots it WRITES each frame. The per-role tables this replaced did not prevent a mistake: a layout reading width got a compile error, which is the same outcome as reading a value that is always zero. What they did create was a trap, because they were different vocabularies rather than nested ones, so a name was legal in one role and RESERVED in another. disasm.py compiled against the widest table and therefore refused grid.mll, the shipped default layout, as "name is a system variable".
  • The three role accessors are aliases of the one table now. Pinned so a future change that re-splits them has to say so here rather than silently reintroducing the trap above.
  • EVERY comment is whitespace, with no exception. There used to be one: // @control 1..240 declared a control's range, so a comment changed behavior and a malformed one was a compile error. defineControls() replaced it, which means a comment can no longer be wrong.
  • a comment changes nothing about what a script does
  • a script reads elapsed time, so it can animate
  • noise is smooth across neighboring points, and varies across the field
  • mod wraps a sweep, so an animation repeats instead of running off the end
  • A for releases its counter's REGISTER when the loop ends, not just its name. Dropping only the name left the vreg allocated for the rest of the compile, so every loop a script wrote cost one permanently: two sequential loops held two counters even though the first was long dead. That put an ordinary two-loop effect one register over the smallest register file (Xtensa has twelve) while each loop compiled fine on its own — the confusing part, since neither half looked too big.
  • The DOCUMENTATION's script examples compile. A doc example is what a user copies first, so one that no longer parses is worse than no example: it teaches a syntax the engine rejects, and it fails on their device rather than in CI. The language gained declared return types and every example in four files went stale at once, which is exactly the drift this catches. Read from the .md files rather than pasted here: a pasted copy stops being the documented one the first time someone edits the real page.
  • every script example in the docs compiles
  • The service vocabulary is what a .mls compiles against, and it is easy to get wrong in a way no other test would catch: a missing entry shows up only as "unknown function" on a device, at the column of whichever call happened to come first.

test/unit/core/unit_moonlive_codegen_arm64.cpp

  • b.ge (cond 0xA) against b.hs (cond 0x2): the condition nibble is what decides whether a negative compares below zero or above everything. retValue parks a script's return value where the ABI hands it back. Byte-checked because the register differs per ISA and a wrong one is SILENT: the host reads a plausible number, so dimensions() would answer with whatever that register happened to hold rather than crashing.
  • arm64: branchGeS branches on GE where branchGeU branches on HS
  • The Q16.16 primitives. smull+lsr is the arm64 spelling of "the signed high 32 bits": a 32-bit vreg pair widened to 64, then the top word taken. Checked against clang's own encodings.
  • asr fills from the sign bit and lsl does not: the pair is what int <-> fixed conversion is, and using the logical shift for the down-conversion would turn every negative coordinate into a large positive one.
  • The 4-byte slot access every scalar now uses. The immediate is scaled by 4, so the encoded field is offset/4 — reading it as a raw byte offset would address four times too far and walk off the 64-byte arena into the system variables.
  • The indexed forms, which is how an int[] or fixed[] element is reached once the lowering has scaled the index.
  • arm64 codegen: skipped (not an arm64 host)

test/unit/core/unit_moonlive_codegen_riscv.cpp

  • The Q16.16 primitives. mulh is mul with funct3 = 1 — a single bit apart from the multiply the engine already emits, which is exactly why it is worth pinning: the wrong funct3 silently returns the LOW word, so a fixed multiply would be off by a factor of 65536 rather than fail.
  • srai sets bit 30 of the immediate field; without it the shift is srli and a negative fixed value converts to a huge positive int instead of the number the script wrote.

test/unit/core/unit_moonlive_codegen_x86_64.cpp

  • retValue parks a script's return value where the ABI hands it back. STRUCTURAL, not exact bytes: which vreg holds rax differs between Win64 and SysV, and what must hold on both is that the destination IS rax. A wrong register here is silent: the host reads a plausible number.
  • x86_64: movImm(R0, 42) is mov r64, 42 (sign-extended imm32)
  • x86_64: movImm(R2, 42) uses REX.B for the r8-r15 destination on Win64
  • x86_64: movImm(R0, -1) sign-extends via the imm32 form (no negative-imm branch)
  • x86_64: movReg(R0, R0) elides the move (a no-op copy emits nothing)
  • x86_64: movReg(R0, R1) is a 3-byte REX.W + 89 /r register-to-register mov
  • x86_64: movPtr(R0, addr) is the 10-byte movabs r64, imm64 form
  • x86_64: addReg(R0, R0, R1) is REX.W + 01 /r (in-place accumulate)
  • x86_64: addReg(R0, R1, R2) inserts a mov first (d != a and d != b)
  • x86_64: mulReg(R0, R0, R1) is REX.W + 0F AF /r (two-operand)
  • x86_64: store8(R0, R1, R2) is mov [R0+R1], R2_low8 with a SIB byte
  • x86_64: store8 with a byte-half register (val is R4/R12/etc.) always emits REX
  • x86_64: load8(R0, R4, 3) is a movzx r32, byte ptr [ctrls + 3] with disp32
  • x86_64: prologue(0) is push rbp; mov rbp,rsp; push nonvols; sub rsp,N; (Win64: load kArg4)
  • x86_64: mulImm(R0, R1, 42) is imul r64, r/m64, imm32
  • x86_64: an unsigned comparison branches on the unsigned condition
  • x86_64: a signed comparison branches on the signed condition, so a negative compares below zero
  • x86_64: the compare is 32 bits wide, the width a MoonLive value actually has
  • x86_64: branchNe emits cmp + jne (0F 85 rel32)
  • x86_64: load8Idx(R0, R1, R2) is movzx r32, byte ptr [base+index] with SIB
  • x86_64: call() emits at least 14 vreg saves + a movabs + call rax + 14 restores
  • x86_64: the epilogue pops exactly what the prologue pushed, in reverse
  • x86_64: ret is a single 0xC3 byte
  • x86_64: spillStore(R0, 0) writes to slot 0 through rbp with disp32
  • x86_64: bind + branchIfZero(R0, L) patches to the correct rel32 after finalize
  • x86_64: callLabel(L) patches to rel32 target - (site + 5)
  • x86_64: two sequential call-bearing loops stay under the density bound
  • x86_64: a class with a script-to-script call compiles
  • The Q16.16 multiply. The sequence must survive d aliasing a or b, AND must not borrow any register the allocator can hand out. Two earlier versions failed that second rule: the first borrowed rax (vreg R13), the second r10/r11 — which are R5/R6, the FIRST temps the allocator assigns, so it was strictly worse. Both produced a silently wrong number: pop restoring a stale value over the result when d aliased the scratch, or a source destroyed before it was read. The intermediate now lives on the STACK and only rax is touched, saved and restored around the whole sequence. Bytes verified against clang's assembly of the same instruction sequence.
  • The destination aliasing each source, and rax itself. None may lose an operand or its result: with d == rax the saved value must NOT be popped back over the answer.
  • x86_64 codegen test skipped: not an x86_64 host

test/unit/core/unit_moonlive_codegen_xtensa.cpp

  • A register outside the backend's map is a value written somewhere the program does not own. The map is a2..a11: a12/a13 are call scratch and the store8 address register, and a14/a15 carry this routine's own retw.n return linkage — using those two as general registers corrupted the return path and produced Guru Meditation (IllegalInstruction) the moment a scripted layout ran. Asserted against the map itself rather than a copy, so the check cannot drift from its subject. a8..a11 ARE in the map and that is deliberate: call8 rotates them out, so XtensaAssembler::call saves and restores exactly a8/a9/a10/a11 around every call. The map is legal because of that save-set, so the two have to agree — the count below is what fails if a register is added to the map without being added to the save-set.
  • addi.n aD, aA, #imm encodes its immediate in a 4-bit field whose value 0 means MINUS ONE: the narrow form covers 1..15 and cannot express "add zero" at all. Every caller passed a literal 1 until an array based at arena offset 0 asked for +0, which emitted addi.n aX, aX, -1 and shifted every element access down a byte. It compiled, emitted a plausible length, and passed every host test, because only this backend has the narrow form. The fixture stayed dark. Asserted on the ENCODER rather than on a script's bytes: a difference-based test cannot see it (the wrong bytes still differ from other wrong bytes), which a control run confirmed.
  • The relaxed branch emits the INVERTED condition over a jump, so signed bge appears as blt (0x2) where unsigned bgeu appears as bltu (0x3). This is the nibble the old inversion table's fallthrough would have gotten wrong, emitting the OPPOSITE condition.
  • The Q16.16 primitives, pinned against the ESP-IDF assembler's own output (xtensa-esp32-elf-as emitted every byte below). The shift immediates are the reason these are pinned: slli encodes 32-n and srai encodes n, in fields that are easy to place plausibly and wrongly, and a wrong placement is an illegal instruction on the board while every host test stays green.
  • Xtensa load32/store32 reach the WHOLE arena, not just the first 60 bytes
  • Xtensa shlImm encodes 32-n where sarImm encodes n
  • The 4-byte slot access, in the NARROW forms: l32i.n/s32i.n are 2 bytes where the halfword forms are 3, so every scalar access got SMALLER as well as wider. The offset field holds offset/4, which is the encoding that would silently address four times too far if read as a byte offset — pinned against the ESP-IDF assembler's own bytes.
  • The indexed forms compute into a12, the scratch outside the vreg map, so an array element access can never clobber a live virtual register.
  • A fixed multiply must reach the DEVICE backend, not just the host one. The three-instruction sequence (mulsh for the high half, mull for the low, then the shifts that join them) is what makes Q16.16 arithmetic work on an ESP32, and a host-only test would never notice its absence: every render test on this machine executes the arm64 backend.

test/unit/core/unit_moonlive_compiler.cpp

  • compileSource: fill(r,g,b) fills every light
  • compileSource: setRGB(index, r,g,b) writes one pixel
  • A function the script calls is handed the same lights and the same controls as the one calling it. Both halves matter and they fail differently: without the buffer the helper writes nowhere visible, and without the controls arena its first control read dereferences a null pointer. Found on hardware, not here: an S3 running a three-function effect died with LoadProhibited and EXCVADDR 0x9: offset 9 into a null ctrls. Every backend had it, including this one, because a local call emitted the call instruction alone while each function's prologue parks the host arguments out of the argument registers into its own frame. The callee therefore parked whatever the caller had last left in them. The whole-block tests could not see it: they check that a script COMPILES, and this is a script that compiles perfectly and then reads the wrong memory.
  • A helper that calls a helper: the arguments have to survive being passed on twice, not just once. One level of nesting would pass even if a call clobbered what it forwards.
  • UNBOUNDED recursion must degrade visibly and keep the device rendering. A fixed render-task stack means the alternative is a reset, which the robustness rule forbids. A reset is what this script produced before the depth guard: ~176 bytes of frame per activation against a 12 KB main task is a device that reboots at roughly 64 deep, mid-frame, with no diagnostic. The guard lives in the callee's prologue and refuses by returning, so the recursion stops at kMaxCallDepth and everything above it still runs. What the user sees is a picture that is wrong where the recursion bottomed out, on a device that is still running.
  • A function with an EMPTY body still balances the recursion counter. The depth guard is emitted at a function's first real op, because it has to follow the host arguments being parked into the frame. A function whose whole body is that parking has no first real op, so it got no increment while its epilogue still decremented: every call to it drove the counter DOWN, two calls wrapped the byte past zero, and the next legal call was refused as too deep. What a user saw was a call that silently did nothing, in a script with no recursion in it.
  • An over-long function name is REFUSED, not truncated. The engine copies entry names into a fixed buffer, so a longer one would be clipped there: and two functions sharing a 23-character prefix would then land under the same name, with entry() returning whichever came first. A call would dispatch to the wrong function and nothing would say so.
  • REMARK #1: every argument is an expression — random16 in ANY slot.
  • A literal spans the whole signed 32-bit range, because a member does: the old 0..65535 cap was the widest member of the day, which left a literal and the member it was assigned to disagreeing about what a number could be.
  • The lexer accumulates in int64 and checks BEFORE each multiply. long is 32 bits on both ESP32 targets and a script COMPILES ON THE DEVICE, so the old guard could never fire there: the multiply wrapped first (signed overflow, UB) and the script got a number nobody wrote, while every host test stayed green. These are the two shapes that broke.
  • compileSource: out-of-range index is bounds-rejected at runtime
  • compileSource rejects malformed programs with a diagnostic, never crashes
  • MoonLive.compile(source) on a bad script leaves the engine !ok with an error
  • a multi-call statement reuses dead vregs and stays within the register budget
  • DOMAIN-NEUTRAL: the core compiler owns no function names. With an EMPTY table it knows nothing — setRGB/fill/random16 are all "unknown function". The LED vocabulary lives only in the host's table; a different host registers different names. (Remark #3.)
  • MoonLive recompiling swaps the program live (fill <-> setRGB)
  • CONTROLS: a declaration is a member, and addControl("name", name, lo, hi) in defineControls A control is declared by CALLING addControl inside defineControls, the same call a compiled module makes. The declaration alone is a member: state the script owns, which the UI never sees unless the script asks for it. That split is the whole point, so both halves are checked here. Engine-level rather than compileSource-level, because a control now exists because a function RAN: compileSource emits the code, and runDefineControls executes it.
  • A control's range is an ORDINARY EXPRESSION, like every other argument in the language. Making addControl the one call whose arguments must be literals would be a special case wearing a disguise, so this pins that it is not one.
  • A system variable is a value the HOST hands the script — the layer's size, the light being transformed, the clock. Letting a script declare the same name would shadow the value it is being given, silently: an effect declaring width = 16 on an 8x8 panel draws off the edge, and every statement in it still runs perfectly. So the name is refused wherever a name can be introduced, which is exactly two places: a control declaration and a for loop variable.
  • Found by review: this compiled cleanly and emitted a program that NEVER RETURNED. The inner loop bound a second register to the same name, so its step wrote the register the outer back edge tested and the counter never advanced — a hang on the render task, from a script a user can type into the editor. Robustness says any input degrades visibly rather than wedging the device.
  • The emitted loop tests and advances its OWN counter whatever name the condition and step clauses write, so a mistyped name used to compile clean and run as though it said the right thing — a wrong fixture with no diagnostic anywhere. Found by review.
  • a for loop declares its counter, as every other variable in the language does
  • a for loop's condition and step must name the loop variable
  • The op array is sized to the script, so count is a uint16_t — and every loop over it has to be one too. A uint8_t counter wrapped at 256 ops and spun forever, which on a device is a watchdog reset from a script that merely got long. Found by bisecting: 60 statements fine, 80 hung.
  • compileSource: malformed control declarations fail with a diagnostic, never crash
  • The SYMBOL TABLE: which functions a class defined, and where each one's code starts. A binding asks for an entry by name and gets an address inside the single emitted block, which is how a compiler and a linker have always worked: one code section, a name-to-offset map over it. With one function the map is trivially right (its body is the whole program), so the case that proves anything is TWO: the second must start after the first, not at zero.
  • A script may define functions the host does not know about. They are still recorded, because which names exist is what decides a script's ROLE once the per-role entry points arrive.
  • The '/' and '%' operators. Both lower to a host call (no ISA here has a divide), so what needs pinning is not the arithmetic but the GRAMMAR: a hand-written precedence-climbing parser gets binding wrong silently, and a wrong answer here is indistinguishable from a working effect. The rule the parser must not get backwards: / and % bind tighter than +, and equally with *, so a chain runs left to right. 12 / 2 * 3 is 18; grouping it as 12 / (2 * 3) gives 2.
  • Parentheses override the precedence, which is what makes the operators usable at all.
  • A script must degrade, never fault. Dividing by zero is the one input the hardware would trap on, and it reaches the host helper as an ordinary value. The masked result SATURATES with the numerator's sign — IEEE's ±infinity mapped onto an int, and the visually right value: k / dist at dist == 0 is the center of a ripple, where max is the peak the eye expects and 0 punched a dark hole exactly there. So no zero-check is ever needed before a divide. The remainder stays 0: there is no "infinite remainder".
  • A subtraction that goes below zero is the ordinary way to ask "which of these is bigger", and before this it silently answered the opposite: the difference wrapped to a huge unsigned value and every < 0 test was false. Four separate rendering bugs in one session came from this.
  • Every relational operator routes through the same two branch ops, so each spelling needs its own check: > swaps the operands and >= / <= go through the two-branch strict form.
  • The one remaining trap in signed division: INT32_MIN / -1 overflows, which is UB and a SIGFPE on x86-64 where the other three ISAs quietly wrap. A script can write it (65536 * 32768 wraps the multiply to INT32_MIN), so the host guards it the same way it guards divide-by-zero.
  • An int ARRAY holds full 32-bit elements, negatives included: element access lowers through the 4-byte indexed load, which has no sign to lose. This is what the old int16_t-array refusal existed to stand in for — the language now has the load it was missing.
  • A STRING array is refused: a string is a reference into the compiled program's pool, so an array of them would be an array of references with no way to fill it — there is no runtime string. A refusal names the gap; a wrong number would not.
  • A coordinate far outside the plane must escape immediately, not overflow: the value below is past the |8.0| input clamp, and without that clamp its square alone reaches 2^62.
  • The escape-time fractal, pinned at the points every textbook names. escape() is the one loop a script cannot write itself (it squares signed fixed-point in 64 bits), so its contract is pinned here rather than by the script that uses it.
  • escape near the set boundary counts more steps than far outside
  • a nonzero seed selects the Julia set rather than the Mandelbrot set
  • ASSIGNED in tick(), not just seeded by the initializer: the store and the load are different ops, and the bug this pins wrote only part of the member, so the load read a stale byte and every stored coordinate collapsed to 0..255. A whole shader rendered one flat color, and the initializer-only test above stayed green throughout. The 4-byte slot removes the class.
  • 65436 is simply a positive number to an int. It is here because it USED to be the bit pattern a uint16_t member held for -100, so the two were indistinguishable in storage; with a 4-byte signed slot they are different values and the comparison says so.
  • A brightness is a byte channel, and a script computing n * 255 for "full" meant full. The truncating cast turned that into an arbitrary pattern (n=50 gave 206, n=128 gave 128) while every part of the expression still looked right.
  • a brightness that went below zero renders black rather than full
  • The loop guard deliberately stayed UNSIGNED when comparisons went signed: a loop counter is a count, and for (i = 0; i < width; ...) must run whatever a signed reading would make of it.
  • An int holds what its name says: the full signed 32-bit range, negatives included. The old language had no such member — uint16_t wrapped at 65536 and int16_t at 32768 — so a script needing a big number had to know which width to reach for and got a silently wrong value when it guessed wrong.
  • an int member written negative reads back negative
  • Assigning past a byte's range TRUNCATES rather than wrapping the slot: the store writes one byte, so the member keeps 0..255 and the three bytes above it stay zero. That zero is what lets a byte control's descriptor point at the slot's low byte and still read the member's value.
  • A bool is a flag, and its initializer is 0 or 1 — anything else is a declaration error, so a script cannot quietly seed a flag with a number it will later compare against.
  • A bool is written the way it reads. bool on = 0; was a C-ism the old language forced, and the literals cost nothing: they are 1 and 0, so every comparison and arithmetic path takes them unchanged.
  • A script writes the number it means. fixed half = 0.5; is the Q16.16 word 32768, and toInt brings it back to a whole number — the pair is what makes fractional arithmetic expressible without a float anywhere in the engine.
  • The multiply RESCALES: two Q16.16 values have 32 fraction bits between them, so the product has to come back down by 16. Without that, 0.5 * 0.5 would be 0.25 scaled wrong by 65536 — either 0 or an enormous number, depending which way the shift went missing.
  • A NEGATIVE fixed value survives the multiply. The low word of the 64-bit product is unsigned while the high word is signed, so joining them with the wrong shift turns -0.5 into a large positive: the logical/arithmetic distinction is the whole reason both shifts exist.
  • Division rescales the other way: the numerator is pre-shifted so the quotient lands back in Q16.16 rather than collapsing to a whole number.
  • A whole number seeding a fixed member is converted at COMPILE time: fixed z = 2; means 2.0, and no runtime shift is spent on a constant.
  • An integer LITERAL meeting a fixed value converts at compile time — its Const is patched to the same number in Q16.16, free at run time — so v * 2 and if (v < 0) read naturally. A VARIABLE never adopts: its scaling is not visible at the site, so it keeps the explicit rule.
  • The fixed divide is exact over the WHOLE range, because the widening happens in int64 in the host (fdiv), not by shifting a 32-bit register. A first implementation split the shift around an integer divide and silently wrapped for any |value| past 128.0 — which froze two shipped shaders whose animation flowed through exactly such a divide, while every small-value test stayed green.
  • Dividing a fixed value by fixed zero saturates exactly as the integer divide does: the same policy, stated once per representation because they are different host calls.
  • The new ops under REGISTER PRESSURE. sourcesOf/writesDst tell the allocator which vregs an op reads and whether it defines one; get either wrong for a new op and the allocator spills the wrong value or keeps a dead one, which shows up as an arithmetic answer that is wrong only in the programs big enough to spill. A long chain of live fixed values forces that state.
  • The same for the whole-number ops the slot access shares: a value stored to a member, read back after other work has claimed every register, and compared.
  • The two BOUNDARY literals, in an expression rather than an initializer. Both have a magnitude one past their type's positive limit, so a lexer that judged the number before the sign made them unwritable: -2147483648 is the most negative int and -32768.0 the most negative fixed.
  • A fixed multiply where the destination is also a source, and where a long chain forces the allocator to reuse registers. On x86-64 the emitted sequence borrows a scratch register and writes its destination last; an ordering mistake there returns a*a, or a stale value, rather than the product. Run rather than decoded, because the byte shape is what hid the bug twice.
  • A byte is exactly a hardware channel: 0..255, and an initializer outside that is a COMPILE ERROR naming the member rather than an arbitrary in-range number. byte n = 300; used to become 44 with nothing reporting it.
  • Every scalar occupies the SAME 4-byte slot, so a byte and an int cost the same arena and the member after either one sits at the same offset. The old rule — a byte packed beside its neighbour, a wide member skipped to an even byte — is what this replaces.
  • An array still PACKS at its element width — that is where the width question survives, because a byte[] heat map costs a quarter of an int[] one and the classic ESP32 has no PSRAM to absorb the difference. Two arrays of the same length, different element types, different extents.
  • A control binds a member whose type the UI has a widget for. A fixed member has no widget yet, and a slider writing a Q16.16 word is worse than a diagnostic saying so.
  • true and false say bool, so seeding another type with one is a diagnostic rather than a silent 1.
  • THE WALL: mixing the two representations is a COMPILE ERROR naming the conversion, because at run time they are the same 32 bits and a silent mix is a number 65,536 times off with nothing reporting it. This is the diagnostic the whole type-tracking exists to produce.
  • The conversions are explicit in BOTH directions, and each refuses a value already of its target type: toFixed on a fixed value is a mistake worth naming, not a no-op to absorb.
  • A fixed member outside the representable range is refused at the declaration rather than wrapping: 40000.0 does not fit Q16.16's ±32767.99998.
  • A literal too big for Q16.16 cannot adopt: patching 40000 to 40000.0 would wrap the word, so the meet refuses it rather than producing a number nobody wrote.
  • The boundary to a built-in stays whole-numbered: a fixed value crossing unconverted would be read 65,536 times off, so the conversion is written where the call is.
  • An ARRAY INDEX counts elements, so it is a whole number wherever it appears. A fixed index would address by the raw Q16.16 word — 1.5 reading element 98304, clamped to the last one.
  • An array ELEMENT reports the ARRAY's type, not whatever the index expression left behind. A literal index in a fixed context used to adopt the INDEX — patching heat[3]'s 3 into 196608, clamping to the last element, and reading a byte as though it were Q16.16.
  • An element STORE takes what the element type holds, the same wall a scalar store enforces.
  • A LOOP counts. A fixed limit would run the body ~65,536 times — a multi-second stall on the render thread rather than a diagnostic, which is the robustness rule's whole point.
  • A fixed remainder keeps the fixed scale — (a2^16) mod (b2^16) is (a mod b)*2^16 — which is what makes the fractional-part idiom work. Typed int, toInt on it would be refused.
  • A member may not take a name the expression parser resolves first, or it could be declared and then never read. Same stance the language already takes for a builtin's name.
  • A fixed literal cannot seed a whole-number member: byte b = 0.0; says two different things about what b is.
  • A fixed ARRAY is refused rather than half-working: an element's type has to reach both the expression that reads it and the value that writes it, which scalars get from their declaration.
  • toFixed of a literal past the representable range is a compile error, matching what adoption already refuses at a meet point. It used to shift and wrap into a number nobody wrote.
  • Every script this project SHIPS compiles on the host. The device codegen tests already sweep the same folder for Xtensa and RISC-V, but nothing did it for the host backend — the one every desktop runs and every other test in this file uses. A language change that a shipped script no longer parses would otherwise reach a board before it reached a test. Read from disk deliberately, so the check cannot drift from what ships.
  • A sign has no meaning on a boolean. -true consumed the minus and then ignored it, seeding the member to 1 as though nothing had been written.
  • A system variable's arena offset is validated at REGISTRATION, because the failure it prevents is silent: LoadCtrl32 reads four bytes, so an offset at the depth slot reads the recursion counter and one byte past the arena, and an unaligned one straddles two cells. Neither shows up as a compile error or a wrong pixel: the script just reads a number nobody wrote.
  • return: the language's first statement that ANSWERS rather than acts. Two jobs, tested separately because they fail differently. As an early exit it is what a script writes when a guard fails and the rest of the frame is pointless; the failure there is the statements after it running anyway. As the way a function reports a value it is what dimensions() and tags() are built on; the failure there is a plausible wrong number, which is why the value is read back rather than merely compiled.
  • A return inside a loop leaves the FUNCTION, not just the iteration: the classic early-out.
  • A conditional return: the guard shape a real script writes. Both directions in one test, because a return that ALWAYS fires and one that never does are both wrong and only the pair rules them out. The condition is a literal comparison rather than a grid variable: this fixture runs with no layout, so width/height are 0 and a guard reading them is not the branch under test.
  • The other half of return: the host reads the answer. This is what dimensions() and tags() are built on, so it is pinned at the engine level before any binding depends on it. Failure here is a plausible wrong NUMBER rather than a crash, which is why the value is read back rather than the script merely compiled: a return-register move emitted for the wrong register (they differ per ISA) produces a number that looks like an answer.
  • A returned value survives arithmetic and a control read, so it is a real expression rather than only a literal the parser happened to fold.
  • A STRING literal returns as the pointer it compiles to, which is what tags() needs: the host reads it as a const char*. The source text outlives the call, so the pointer stays valid.
  • A function DECLARES what it hands back, so a script reads like the compiled module it stands in for (void tick() beside void tick() override) and the host can tell a function that answers from one that acts. The declaration is required rather than optional: accepting a bare name as implicit void would leave two spellings meaning the same thing forever.
  • The three types are all the language has values for. byte tick() is refused rather than silently treated as int: it would suggest the engine narrows the value, which it does not.
  • A return must match what its function declared. Without this the declaration would be a label rather than a contract: return "x"; in a void function compiles, and the host that calls it for its effect never looks at the register, so a script silently disagrees with its own signature.
  • A member and a typed function open with the SAME token, and only the token after the name says which. Both orders compile: a class whose members come first, and one that starts with a function, which is what the lookahead exists for.
  • an array is indexed correctly at both element widths, by a computed index
  • an out-of-range index still clamps to the last element after the scaling change
  • a local variable holds a value for the rest of the tick
  • a local variable is assignable after it is declared
  • a local is initialized from a call, which is what a sensor read looks like
  • a local declared inside an if body does not leak past it
  • blocks reuse frame slots, so the frame is not spent by a script's total locals
  • two functions each get the whole frame rather than sharing one
  • a local variable must be initialized where it is declared
  • a byte local WRAPS at 255, exactly as a byte member does
  • a bool local truncates the same way, so any non-zero reads true
  • a byte local keeps its width across a block boundary
  • a local of every value type is accepted; only string is refused
  • a local literal is range-checked against its declared type
  • a local may not shadow a built-in function
  • a local may not shadow a member or a system variable
  • an int local takes a whole number, so a fractional initializer is refused
  • a local may be fixed, which is what a per-pixel coordinate needs
  • a fixed local starts at a whole number, which the literal adopts
  • the two scalings do not mix in a local, in either direction
  • a fixed local reports its scaling where it is read
  • A script's own function can hand a value back, so a helper computes rather than only acts. The return machinery already existed at the HOST boundary (a function declares int or void, and the exit parks the value in the ABI register); what was missing was the call site taking it. What makes it harder than moving a register: the result has to survive the calls that FOLLOW it in the same expression. So a script call now preserves the whole vreg pool exactly as a builtin call does, and the callee's value is delivered into the destination vreg once that pool is back. Parking it in the frame's argument region instead was tried and rotated the enclosing call's arguments, since that region is still being filled as the expression parses.
  • the builtin table has room for every name the light domain registers
  • a script function's return value can be used in an expression
  • a returned value survives the calls that follow it in the same expression
  • a helper's value can drive a loop and a member
  • a void function cannot be used as a value
  • a returning function still works as a statement, with its value dropped

test/unit/core/unit_moonlive_fill.cpp

  • MoonLive emitFill produces a non-empty routine
  • MoonLive emitFill rejects a too-small buffer (degrades, no overrun)
  • MoonLive emitFill/emitAnimatedFill reject a null output buffer (no crash)
  • MoonLive compiles and fills a buffer with the chosen color
  • MoonLive run on zero lights writes nothing (robust to empty)
  • The native routines write channels +0/+1/+2 per light, so a layer with fewer than 3 channels per light can't hold RGB — run() must leave it untouched, not overrun it.
  • MoonLive recompile swaps the color; free returns to !ok
  • MoonLive animated fill derives color from the per-frame t
  • platform allocExec returns usable executable memory, freeExec releases it
  • a loop counter survives a call in the body
  • elapsed time survives a call that happens before it is read
  • The card says how big the compiled program is, and warns only when a budget is nearly gone. Size, because nothing could answer "how big is my script": the card's memory figure is the word-rounded ALLOCATION, which says nothing about the program. One budget, not five, and only past half full: a script can hit ten ceilings, but five are derived from code size or nesting and a number the author cannot act on is noise.
  • A FAILED recompile drops the declared controls rather than leaving them named "". The editor loop pushes broken text constantly: that is what editing is. A control's name is a pointer the UI dereferences on every /api/state, and it points into the engine's string pool, so a pool cleared while the records survived left every card named "" and unmatched by both name-keyed persistence and POST /api/control. The user's own sliders came unbound from a typo.
  • MoonLive controls: declaredControls + controlSlot seeded from the default
  • A declared range is an arbitrary expression, so a script can write min > max. The write path tests v < min || v > max, which is true of EVERY value when the range is inverted: the slider would appear and then silently refuse everything the user does to it. Refusing the declaration instead leaves the control absent, which is visible — the same stance a range past the declared width already takes.
  • MoonLive controls: arena address is STABLE across a recompile and the slot value survives
  • A member keeps its live value across a recompile because its name and offset still match. But EDITING ITS TYPE OR LENGTH makes it a different member at the same address, and its new bytes have never held anything: a widened uint8->uint16 would keep the old program's byte as the new value's high half, and a grown array would keep stale elements past the old end. Both read back as numbers the script never wrote.
  • MoonLive controls: free() releases the arena (no stale slot after release)
  • line(x1, y1, x2, y2, r, g, b), the SEVEN-argument builtin and the widest call a script makes. This is the behavioral pin for the args-array call ABI at an arity past the register file: all seven values must arrive intact, in order, through the same staging every backend uses.
  • No canvas installed (a layout or modifier host) → every draw call must no-op, not write through a stale or foreign pointer. The detach after each run is what this pins.
  • A binding that installs nothing (a modifier) must not consume one of the two per-thread slots just by RUNNING a script that draws. Slots are claimed on install and released on detach, so a claim taken on the READ path is never given back. Two such threads exhaust the table, and from then on every install silently fails: the render thread's line() goes dark with nothing to see but a stopped drawing. Two threads is the smallest case that shows it, because a single thread re-uses the slot it already leaked.
  • Script arithmetic is unsigned, so a coordinate that went "negative" arrives as a huge value. The builtin clamps endpoints to the canvas, which keeps the draw instant and on-grid instead of sending the line walker on a billions-of-steps march (robustness: any input, degrade visibly, never stall the render thread).
  • ENTRY POINTS DISPATCH: a script with two functions runs the one that was ASKED for. This is the case a symbol table exists for, and the one that fails silently when it is wrong: both functions compile, both are callable, and running the wrong one just draws the wrong thing. The two write different colors to different pixels, so which ran is visible in the buffer.
  • Asking for a name the script did not define runs NOTHING. The alternative: falling back to the block start: would run some arbitrary function and look like it worked, which is the failure a binding cannot diagnose.
  • A NAME IS A MOMENT, not a role. One class may define several entry points, and the host calls whichever the moment calls for: tick when a frame renders, modifyLogical when a coordinate is folded. That is what lets an effect also fold coordinates without any feature being added for it, and it is why nothing validates which names a class defines.
  • A class that defines NEITHER of a binding's moments is not an error: it compiles, and the binding simply has nothing to call. The script author decides what their script is for.
  • Calling a name the class did not declare is refused, rather than resolving to something else.
  • A member could be declared and read but never WRITTEN: x = expr; was reachable only inside a for header. That made every member a constant, so the whole class of effects that carry state forward (fire, trails, decay) was inexpressible. This is the statement that makes a member state.
  • The other half of "a member is the script's own state": one function writes it, another reads it. A frame slot could not do this, because each function has its own frame.
  • A loop counter is a frame slot, and the step clause already writes one, so a body assignment is the same store: refusing it would have made the header a special case for no reason.
  • The engine rewrites a system variable before every call, so a store to one would be silently undone. Refused with the reason, rather than compiling into something that does not work.
  • An assignment to a name nothing declared is a typo, and the message says where a name comes from.
  • Every comparison, at, above and below the boundary. Six operators lower onto TWO branch ops by swapping operands and negating the sense, so an off-by-one in that mapping is invisible except at the boundary itself: a < b and a <= b differ on exactly one input. The table is the proof.
  • An else-block must run when, and only when, the then-block did not: the then-block falls through to the end label rather than into the else, which is the jump an if without an else never needs.
  • A conditional inside a loop is where a mis-scoped label shows: the if's skip must land inside the body, not past the back edge, or the loop runs once and exits.
  • The condition is an ordinary expression on both sides, not a name-against-literal special case: the same orthogonality that lets addControl take a computed range.
  • A conditional makes a member's value decide control flow, which is the combination step 3a and step 6 exist for: state that steers, rather than state that is only read out.
  • = and == differ by one character and mean opposite things. Maximal munch is what keeps them apart, and lexing == as two assignments would make a comparison silently parse as something else.
  • A member's arena offset is a BYTE CURSOR, not its declaration index, and every SCALAR advances it by a whole 4-byte slot whatever the member's type. Pinned because everything downstream keys on the offset: the bindings cache arena slot pointers, persistence uses it, and addControl passes it by reference.
  • The arena's byte budget and the record count are now two different limits, and a script can exhaust either one first. Asking for more member data than the arena holds is a compile error with a message about the arena, rather than a member silently landing on top of another one.
  • A uint16_t member holds a value a byte cannot. This is the correctness wall on a 256-wide wall: every arena slot was 8-bit, so a coordinate clamped at 255 and a modifier could not walk a light off a large grid. The round trip is what matters: seeded wide, read wide, written wide.
  • The high byte must survive being stored and reloaded. A store that wrote only the low half would pass the test above on the first tick and lose the value on the second, so the boundary at 256 is checked directly: 255 -> 256 is exactly where a byte member wraps to 0 and a halfword does not.
  • EVERY scalar takes a whole 4-byte slot, whatever its type: a byte does not pack in beside its neighbour, so a mixed declaration order costs the same as a uniform one and no member ever straddles a boundary. That uniformity is what removed the per-width alignment rule this replaces, where a wide member had to skip to an even byte because two backends scale a halfword load's immediate and cannot encode an odd offset at all.
  • A control no longer declares a width to keep in step with its member: ONE call surfaces any member and reads the widget from the member's own type, so the pair that could disagree is gone. What survives is the range check — a range past what the member's type holds is refused rather than truncated, because a slider whose top silently wraps is worse than one that never appears.
  • The point of an int member: a script exposes a value a byte cannot hold — a dwell time, a 0..1000 scale — as ONE control, instead of packing it into two byte sliders. The declaration reaches the binding with its full range intact, and the live value spans the member's whole slot.
  • A range the MEMBER'S TYPE cannot hold is refused rather than truncated, so a slider's top can never silently wrap to a small number. An int member takes any 32-bit range; a byte member does not, and the declaration is dropped rather than published with a top it cannot reach.
  • The initializer is checked against the DECLARED type, so a value a uint8_t cannot hold is a compile error rather than a member that silently starts at a different number than it says.
  • The same value is legal once the member is declared wide enough to hold it.
  • An array is the difference between an effect that draws a formula and one that SIMULATES something: a particle list, a heat buffer, a per-light decay. Write each element, read it back.
  • Array contents survive across ticks, like any other member: the arena outlives every call. This is what a decay or trail effect is built on, where each frame reads what the last frame left.
  • THE SAFETY CASE. A script computes an index from live control values, so out of range is an ordinary run-time state, not a defect. It must not write outside the member: the system variables and the recursion depth counter share the arena, so a stray write would corrupt the engine rather than the picture. The index is clamped to the last element, which degrades visibly (the last light repeats) and never crashes.
  • The same clamp on the READ side, and the system variables must be intact afterwards: reading past the end must not reach into the arena region the host owns.
  • The index is an arbitrary EXPRESSION, not a bare loop counter: the same orthogonality that lets addControl take a computed range and an if condition take one on both sides.
  • An array of a wide type: the element scaling and the halfword access have to agree, which is the case where an index multiplied by the wrong width silently reads a neighbour's byte.
  • An array has no single arena byte, so assigning one as a whole is refused with the shape that does work, rather than silently writing its first element.
  • And the reverse: a scalar indexed as though it were an array is a typo worth catching.
  • An array asking for more bytes than the arena holds is a COMPILE error, not a failed allocation while a fixture is running: a script must not be able to ask a classic ESP32 for memory it has not got and find out at run time.
  • a modifier writes its coordinate without naming a destination slot
  • The old four-argument form is REFUSED rather than quietly reinterpreted: taking it would read the coordinate's x as the slot index and silently write the wrong thing.
  • setRGB is untouched: its index is meaningful, so it still takes four.
  • A wide member's INITIALIZER must survive to the arena. It was cast to a byte on the way in, so uint16_t phase = 1000; started at 232 (1000 & 0xff). Every existing test observed through setRGB, which truncates to a byte, and the error is always a multiple of 256: invisible. Observed here through a COMPARISON instead, which the byte channel cannot hide.
  • The palette is what makes a scripted effect follow the device's palette control instead of hard-coding colour, which is the split the compiled effects settled long ago.
  • polarA/polarR turn a pixel's offset from a center into an angle and a distance, which is what lets a radial effect run without the fixture-sized lookup table the original form needs.
  • smoothstep is what turns a DISTANCE into LIGHT: a shape's edge stops being jaggy and becomes a falloff whose width the script chooses. The failure that matters is not the curve, which shader.h already pins, but the unsigned boundary: a script writes smoothstep(0, w, w - d) and w - d WRAPS the moment d passes w, so a missing re-center reads a huge positive where a small negative was meant, and the shape renders inverted-and-solid.
  • A ramp, not a switch. An implementation that truncated the normalize to an integer before the cubic would still pass the monotone check above while drawing a hard edge.
  • uv is the mapping a shader starts from: centered on the grid and normalized on the SHORT side, biased at 32768 the way sin/cos already are. Its guarantee is that one unit of uv is the same number of PIXELS on both axes, so a shape written as a distance comes out round. Skipping it is why a design stretches on a non-square panel: with a raw x - width / 2, one x-unit and one y-unit differ, and a circle drawn on a 32x8 grid arrives 4:1 wide.
  • A coordinate has an origin: the center of the grid is 0, the left half is NEGATIVE, and a script uses the number it is given. No bias to subtract, which is what made uvX(...) - 32768 wrap on the left half and tear a shader's plane into blocks.
  • smin is what makes two shapes read as ONE surface rather than as two stamps that overlap. The visible difference the blend control sells, stated as a test.
  • draw::smin widens to 64 bits precisely so a large blend radius cannot WRAP. A wrap makes smin return a value larger than both inputs, which inverts the blend rather than lengthening it. Note smin legitimately goes BELOW both inputs as k grows: that is the merge, not an error, so the property to pin is the ordering against a plain union, not a floor.
  • fade(amt) is the trail primitive: an effect that fades rather than clears leaves a decaying tail behind what it draws. It goes to the LAYER, which collects every request and applies the gentlest once per frame, so what a script can observe here is that the request ARRIVES and carries the amount, not that pixels changed (Layer::tick does that, and Layer owns that test).
  • An amount past a byte is clamped rather than wrapped: fade(300) is "fade hard", and wrapping it to 44 would be a gentle fade where the script asked for the opposite.
  • A layout and a modifier install no fade sink, so the call reaches nothing. Without this a script moved between roles would fade a layer it is not ticking in.
  • A script's values are UNSIGNED 32-bit, and 65535 * 65535 is an expression it can write. Read back as a signed int that is a large NEGATIVE number, so a coordinate far off the right of the grid used to clamp to the LEFT edge, having overflowed a signed multiply on the way. A coordinate past the edge must saturate at the edge it passed.
  • The table was FULL at 16 entries and add() failed silently, so the next builtin registered would have vanished and surfaced as "unknown function" in a script.

test/unit/core/unit_moonlive_ir.cpp

  • MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)
  • MoonLive compiled fill is robust: zero lights writes nothing
  • MoonLive compileSource degrades on a too-small code buffer
  • MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected
  • MoonLive control: a declared control reads the arena live (no recompile on value change)
  • MoonLive control survives a host call (kArg4 live across random16)

test/unit/core/unit_moonlive_spill.cpp

  • THE key test: spilling changes where a value lives, never what the program computes.
  • A returned value is a DEFINITION, and the allocator has to see it as one. The rewriter remaps every operand of every op it renumbers, but only remaps a dst for an op that declares it writes one. A value-returning call did not, so after a compaction the call still wrote its pre-compaction register while the consumer read the new one: a() + b() came out as whatever that register happened to hold. Invisible at the host's own budget, which takes the already-fits path and renumbers nothing, so this drives the SQUEEZED budget through the script's tick entry rather than the block start a plain renderAt would call.
  • The back-edge case. Naive first-def-to-last-use intervals look dead early in a loop body, so the allocator would hand a counter's register away and the next iteration would read someone else's value — placing lights twice, or not at all. Nested, so the extension has to apply innermost-first.
  • Slots live in the routine's OWN frame, and call() moves sp underneath it. Addressing a slot from the frame pointer rather than sp is what makes this hold; from sp it would read into the callee's saved registers instead.
  • The control arena pointer (kArg4) is a fixed ABI vreg the allocator must never reassign or spill. If it did, a control read after a spill would load from a register holding something else.
  • FAIL, NEVER MISCOMPILE. A budget with no room for the fixed ABI vregs plus the reload temps, and one with no frame slots to spill INTO, must both refuse — silently emitting code that names a register the target does not have is the failure this whole pass exists to make impossible.
  • A program that fits is left byte-identical: the allocator must not exist as far as a non-spilling script is concerned, or every shipped script pays for a feature it never uses.
  • Loop extension, pinned on its own — the one part of the allocator no script in this grammar can currently exercise, and the part whose failure is a silent miscompile rather than a refusal. A value defined before a loop and read only EARLY in the body has a naive live range that ends at that read. Every later value then looks free to take its register, and the next iteration reads whatever took it. The IR is built by hand because the wall a script hits first is its own variable count: by the time a source program names enough live values to force allocation, the values are short-lived ones the naive analysis already gets right. Extension is therefore untestable through the front end today, and this is what stands in for it until script-local functions make longer live ranges expressible.
  • The shape that resets both Xtensa boards: a SYSTEM VARIABLE read as a loop bound, with a HOST CALL in the body. Bench-bisected, each ingredient alone is fine, and only the three together fail: loop, constant bound, call in body -> runs loop, member-bound limit, call in body -> runs width read, no loop -> runs width loop, no call in body -> runs width loop WITH a call in body -> LoadProhibited inside the emitted code A system variable lives in the controls arena, reached through kArg4, a host argument that now lives in a frame slot and is reloaded at each use. Run here on the one backend a test can execute, so the failure is debuggable in a process rather than from a crash dump.

MoonLiveLayout

test/unit/light/unit_MoonLiveLayout.cpp Also touches: MoonLive, LayoutBase, Layouts.

  • the default script lays out a grid, one light per cell
  • the light count is known before any coordinate is asked for
  • the count and the coordinates always agree, because one script produces both
  • a scripted layout allocates nothing, like every other layout
  • a script places lights wherever it likes, which is the point of scripting one
  • a script can place a shape no rectangular layout can express
  • a broken script leaves an empty fixture rather than taking the pipeline down
  • editing the script changes the fixture
  • Every example in MoonLiveLayout.md must actually compile. A doc that shows a call the language does not have sends the reader to a parse error on their first attempt — and it happened here: an early draft advertised cos8/sin8, which are not registered built-ins.
  • The container asks a layout for its count and its coordinates SEPARATELY, and both must work at any time: not only immediately after prepare(). Layouts::prepare walks placeLights for the bounding box, and the Layer asks lightCount() when it sizes its buffer; a layout that answers only once reports an empty fixture to whichever asks second.
  • Subtraction has to produce the WHOLE value, not just a byte that happens to look right. a - b compiles to a + (b * -1), and if -1 is materialised as 65535 (which it was, on two of three targets) the result is correct only modulo 256. A coordinate comparison cannot see that — both the right answer and the widened one truncate to the same byte. A layout's light INDEX can see it: the count comes from how many times addLight ran, so a loop bound computed by subtraction that came out ~65k places a wildly different number of lights.
  • A slider you moved has to survive editing the script — that is the live-authoring loop, and the control arena delivers it by keeping a slot's value when the control persists across a recompile (MoonLive.h, ensureArena). The consequence, which is easy to be surprised by: the arena matches controls by OFFSET, so a DIFFERENT script whose first control happens to sit at the same offset inherits the value rather than its own initialiser. Two scripts that both open with a cols are the same slot as far as the engine is concerned. This pins the behaviour so a change to it is a decision rather than an accident. Setting the control after the edit is what makes a script's own extent authoritative.
  • A layout script never fills, so it should not pay for the scratch registers a fill needs. The backends reserved them unconditionally, and on the smallest register file (Xtensa, 12) that one register was the difference between a nested loop compiling and being refused outright — the shipped default script is a nested loop, so the module's own default did not compile there. This is a register-budget property, and the budget differs per target, so what it pins portably is the behaviour: a nested loop places every light of the grid it describes.
  • A for counter must survive whatever the body does to it. The device backends built a light's byte address by multiplying the index register IN PLACE — fine when the index is a throwaway temp, wrong when it is the loop counter, which the step and the loop test read again afterwards. A gradient (for (i…) { setRGB(i, …) }) therefore ran the wrong number of times on Xtensa and RISC-V while being correct on the desktop host, which used a scratch register instead. Pinned through a LAYOUT because the count is the observable: the host executes this test, and the arithmetic the backends share is the same. addLight's index is likewise the counter.
  • A malformed script must produce a diagnostic, never a hang. The for header's step expression is scanned by a small loop that skips to the closing paren; a lexer ERROR is not the END token, and the lexer does not move past the offending character, so the scan spun forever on a stray symbol. A device compiling that script would wedge with no message at all.
  • A scripted layout is asked for its lights from more than one thread: the HTTP task runs the script when a control is edited, while the render task walks the same layout for the frame. The sink the script calls out through used to be one process-wide pair, so one thread cleared it while the other was mid-run — the built-in then called a live function pointer with a null context and the render core took a null dereference. It presented as an intermittent crash while resizing on an S3. Two threads walking their own layouts concurrently must each see their own sink.
  • The card's memory figure has to be the memory the module actually holds, or it is not worth reading. A scripted module owns two heap blocks — the emitted code and the control-values arena — and dynamicBytes counted only the first, so every scripted card under-reported. It also read 0 whenever the script failed to compile, while the arena was still allocated. ...and hands it all back when disabled. MoonLive::free() drops the exec block but does not touch the owner's counter, so a binding that forgets to report the release leaves a disabled module's card claiming memory nobody holds. All three scripted bindings share one helper for this.
  • a scripted layout reports every heap byte it holds, compiled or not
  • The layer builds its mapping in two passes over the layouts: pass A counts destinations, pass B scatters driver indices into an array sized by that count. Both passes call placeLights: and a scripted layout COMPILES lazily inside placeLights, so a control edited between the two passes makes pass B emit more lights than pass A counted. The scatter then ran past its array and corrupted the heap; the crash surfaced later inside an unrelated allocation, which is why resizing a scripted layout failed at random rather than pointing anywhere near the layer. A layout that grows mid-build must cost a dropped destination, never memory.
  • naming a different script through the control actually swaps the program
  • A layout that cannot compile must stay quiet, not keep trying. The pipeline asks a layout for its size and then walks it, and BOTH ask it to compile first — so a failure that leaves "nothing is compiled" looks exactly like "not compiled yet" and every ask re-reads the file. On an ESP32 one attempt is two LittleFS operations (~5 ms), and the repeated asks during a single rebuild starved the task until the 12-second watchdog reset the board: a missing script took the whole device down rather than showing an error. The behaviour to pin is that a failed layout still places no lights however many times it is asked, and says so.
  • A layout that starts with NO script must still compile the first real one it is given. Every device boots a fresh layout card with an empty script control, so the very first compile always fails with "no script — set the script name". When the give-up flag was a bare bool that failure latched, and the card then reported "no script" forever however many valid names were set afterwards: the render loop asks for the light count long before a control write can clear a flag, so the guard re-armed itself on every tick. Bench-caught on an S3 — the host never saw it because a test constructs a fresh layout per case and never boots one empty.
  • The fixed script directory is a boundary: a module names a file inside it, and cannot address the filesystem. Without this, a control value of "../.config/NetworkModule.json" reads the device's saved WiFi credentials as if they were a script.
  • A script that STOPS being valid must take its lights with it. Every check in the loader returns before the compile, and the compile is what releases the previous program, so a rename, a delete or an emptied file used to leave the old code executing while the card reported the error: the fixture kept rendering a script the user had removed. The one state a user can never debug is a device that disagrees with its own status line.
  • The name the LOADER accepts and the name the CONTROL can hold must be the same length. They were not: the control held 31 characters while the loader accepted 40, so a longer valid name was silently truncated on its way in, and truncation can cut the extension off, turning a real script into a name the loader then rejects. The user sees an extension complaint for a file that has one.
  • A SERPENTINE over an arbitrary number of rows: every other row reversed. This was the standing example of what the language could not express, because it needs a per-row decision and there was no if. It is also the most common real panel wiring, so it is worth pinning as a layout rather than only as a compiler test.
  • --- editing a script's CONTENTS recompiles it ------------------------------------------------- The gap this closes: a binding keyed its recompile on the script's NAME, so saving new text into the same file changed nothing. The module kept running the program built from the PREVIOUS text, and the only way to make it notice was to rename the file. That is why editing a script on its own card could not work, and it is what a file write now triggers tree-wide.
  • The other half of the same rule, and the one a modifier depends on: an unchanged file must be RECOGNISED as unchanged. A modifier turns "a new program was installed" into "ask the Layer to rebuild", and the Layer's rebuild calls prepare() again, so answering "changed" every time makes the two call each other forever and the fixture renders nothing at all.
  • A broken script that is FIXED IN PLACE compiles, without being renamed. This is the failure the editor makes routine: type a typo, see the parse error, correct it, save. Keyed on the name alone (which is what the bindings did before) the corrected script stays refused until it is renamed. NOT pinned here: that a broken script is tried ONCE rather than on every ask. The latch exists because each retry is two LittleFS reads (~5 ms on an S3) and the pipeline asks repeatedly while sizing a fixture, so the retries starve the render task until the watchdog resets the device. On the host a re-read costs microseconds and nothing observable differs, which four attempts at a test confirmed: removing the latch entirely leaves every assertion passing. Backlogged rather than papered over with a test that cannot fail.
  • A script's ROLE is its file extension: .mle an effect, .mll a layout, .mlm a modifier. It is stated by the author rather than derived from what the class defines, so that adding (say) a per-frame tick() to modifiers later cannot silently start listing them in effect pickers. The LOADER is role-blind and accepts all three, exactly as the engine is: which picker offered a file is the binding's business, and a class may serve several moments. What the extension decides is which card offers the file, not what the engine will do with it.
  • A disabled scripted module must not publish controls bound into a freed control arena. release() frees the engine's arena; the control descriptors registered by defineControls() hold raw pointers into it, so re-publishing them would read (and a UI write would WRITE) freed heap. Symptom on hardware: reading a disabled MoonLive card returned a different value every read.
  • naming a different script through the control actually swaps the program
  • A layout that cannot compile must stay quiet, not keep trying. The pipeline asks a layout for its size and then walks it, and BOTH ask it to compile first — so a failure that leaves "nothing is compiled" looks exactly like "not compiled yet" and every ask re-reads the file. On an ESP32 one attempt is two LittleFS operations (~5 ms), and the repeated asks during a single rebuild starved the task until the 12-second watchdog reset the board: a missing script took the whole device down rather than showing an error. The behaviour to pin is that a failed layout still places no lights however many times it is asked, and says so.
  • A layout that starts with NO script must still compile the first real one it is given. Every device boots a fresh layout card with an empty script control, so the very first compile always fails with "no script — set the script name". When the give-up flag was a bare bool that failure latched, and the card then reported "no script" forever however many valid names were set afterwards: the render loop asks for the light count long before a control write can clear a flag, so the guard re-armed itself on every tick. Bench-caught on an S3 — the host never saw it because a test constructs a fresh layout per case and never boots one empty.
  • The fixed script directory is a boundary: a module names a file inside it, and cannot address the filesystem. Without this, a control value of "../.config/NetworkModule.json" reads the device's saved WiFi credentials as if they were a script.
  • A script that STOPS being valid must take its lights with it. Every check in the loader returns before the compile, and the compile is what releases the previous program, so a rename, a delete or an emptied file used to leave the old code executing while the card reported the error: the fixture kept rendering a script the user had removed. The one state a user can never debug is a device that disagrees with its own status line.
  • The name the LOADER accepts and the name the CONTROL can hold must be the same length. They were not: the control held 31 characters while the loader accepted 40, so a longer valid name was silently truncated on its way in, and truncation can cut the extension off, turning a real script into a name the loader then rejects. The user sees an extension complaint for a file that has one.
  • A SERPENTINE over an arbitrary number of rows: every other row reversed. This was the standing example of what the language could not express, because it needs a per-row decision and there was no if. It is also the most common real panel wiring, so it is worth pinning as a layout rather than only as a compiler test.
  • --- editing a script's CONTENTS recompiles it ------------------------------------------------- The gap this closes: a binding keyed its recompile on the script's NAME, so saving new text into the same file changed nothing. The module kept running the program built from the PREVIOUS text, and the only way to make it notice was to rename the file. That is why editing a script on its own card could not work, and it is what a file write now triggers tree-wide.
  • The other half of the same rule, and the one a modifier depends on: an unchanged file must be RECOGNISED as unchanged. A modifier turns "a new program was installed" into "ask the Layer to rebuild", and the Layer's rebuild calls prepare() again, so answering "changed" every time makes the two call each other forever and the fixture renders nothing at all.
  • A broken script that is FIXED IN PLACE compiles, without being renamed. This is the failure the editor makes routine: type a typo, see the parse error, correct it, save. Keyed on the name alone (which is what the bindings did before) the corrected script stays refused until it is renamed. NOT pinned here: that a broken script is tried ONCE rather than on every ask. The latch exists because each retry is two LittleFS reads (~5 ms on an S3) and the pipeline asks repeatedly while sizing a fixture, so the retries starve the render task until the watchdog resets the device. On the host a re-read costs microseconds and nothing observable differs, which four attempts at a test confirmed: removing the latch entirely leaves every assertion passing. Backlogged rather than papered over with a test that cannot fail.
  • A script's ROLE is its file extension: .mle an effect, .mll a layout, .mlm a modifier. It is stated by the author rather than derived from what the class defines, so that adding (say) a per-frame tick() to modifiers later cannot silently start listing them in effect pickers. The LOADER is role-blind and accepts all three, exactly as the engine is: which picker offered a file is the binding's business, and a class may serve several moments. What the extension decides is which card offers the file, not what the engine will do with it.

MoonLiveModifier

test/unit/light/unit_MoonLiveModifier.cpp Also touches: MoonLive, ModifierBase, Layer.

  • a scripted modifier mirrors the pattern, the way a hand-written one would
  • the script reads the light's own position, not a fixed value
  • a script can swap axes, which is a transform no control could express
  • a script can offset a coordinate, the scroll a modifier usually hard-codes
  • a broken script leaves the pattern alone rather than taking the layer down
  • a coordinate beyond 255 is scripted like any other, in and out
  • editing the script changes the transform without a rebuild of the firmware
  • Arithmetic is what makes a scripted modifier worth having: without it a script can only pass a coordinate through or swap two axes. These pin the operator set and, more importantly, that precedence is real — 2 + 3 * 4 silently giving 20 would corrupt every non-trivial transform.
  • a scaled mirror, the transform this binding exists to make possible
  • The cost question this binding raises: modifyLogical is a native CALL per light, so a large fixture pays it once per light on every mapping rebuild. It is the cold path (a rebuild, not a frame), but "cold" is not a licence to be slow — a 16k-light wall rebuilding must not stall.
  • editing a script asks the layer to rebuild its mapping
  • The black-screen bug, pinned. A mirror written against a hard-coded 255 sends every light of a 16-wide grid to x≈240, the Layer drops each one as out of bounds, and the fixture goes dark — with no error anywhere, because the script compiled and ran perfectly. A script therefore has to be able to read the EXTENT it is folding within, and the default has to use it.
  • a script can read the grid extent it is folding within
  • The black-screen failure end to end, through a real Layer. Byte arithmetic wraps: a script that computes a negative x (a mirror against a box that has not been established, or simply a bad expression) lands at 255, which is outside every real grid — so the Layer discards every light and the fixture goes dark with no error reported anywhere. The fold has to REJECT a coordinate it cannot place, which is what modifyLogical's bool return is for.
  • re-preparing with an unchanged script does not ask for another rebuild
  • print(v) is the only way to see inside a running script: one that compiles cleanly and renders wrong gives no other clue. It returns its argument so it can wrap any sub-expression without changing the result — print(x) where x stood still computes x.
  • Subtraction is emitted as a + (b * -1), and -1 has to survive into the register. The assemblers materialise a constant with a 16-bit immediate, so a naive -1 becomes 65535 and every subtraction is right only MODULO 256 — invisible in a stored byte, and wrong everywhere the full value is used: a bounds-guarded index silently drops the light, and a value handed to a host call is nonsense. Byte-comparison tests cannot see this, so it is checked through print(), which returns the full 32-bit value.
  • a for loop runs its body once per step
  • a loop over an empty range runs its body no times
  • loops nest, which is what placing a grid of lights needs
  • The loop's real purpose, end to end: a script that PAINTS with it. A modifier's script transforms one coordinate, so it can never show a loop writing many lights — an effect can, and this is the shape a scripted Layout will use to place its lights.

MoonLivePalette

test/unit/light/unit_MoonLivePalette.cpp Also touches: Palette, Effects.

  • a scripted palette fills all sixteen entries
  • setPalEntryHSV reaches the same table, in the space a palette is reasoned in
  • an out-of-range entry index writes nothing at all
  • entries the script does not write keep their previous color
  • a broken palette script leaves the last good palette, rather than going dark
  • setPalEntry does nothing outside a palette script

MoonLiveParticles

test/unit/light/unit_MoonLiveParticles.cpp Also touches: MoonLiveEffect, MoonLive, particles.

  • a script sizes its own particle pool and is told what it got
  • The pay-for-what-you-use rule, on the memory that matters most: a shader script must not carry particle buffers it never asked for, so there is no default pool.
  • THE guarantee that keeps a malloc off the render path: sizing is reachable only from defineControls(), where the sink is installed. A script asking from tick() is told the live count and nothing is allocated, every frame, forever.
  • The live-edit rule applied to memory: editing the script's text recompiles, which re-runs defineControls, which resizes.
  • Disabling a scripted effect must hand the memory back AND leave the pool invalid rather than pointing at freed buffers, which is the trap ParticlesEffect documents at its own prepare().
  • A script reaching the particle vocabulary from a layout or a modifier finds no pool installed, so the calls do nothing rather than writing through another module's buffers.
  • The test that says why the feature exists: a script writes physics, not positions. Nothing here tells a spark where to go; it leaves at an angle, gravity pulls on it, and where it turns over is wherever the physics puts it.
  • A pool is a fixed set of slots. Emitting into a full one stops rather than overwriting a living particle, so a script that over-emits degrades to "no new sparks" instead of corrupting motion.
  • Without aging, a long-running fountain silently stops emitting once every slot is taken. That is a bug which only shows up after a minute on the bench, so it is pinned here instead.
  • Two scripted effects each own their own buffers, so one script's particles can never appear in another's layer. This is the "what does a second script asking for a pool get" question.
  • The shipped example, driven through the real binding: a fountain reaches a steady state where sparks are emitted, fly, and die at the same rate, rather than filling the pool once and stopping.
  • A spray has to look like a spray. angleEmit hashes (index, seed) into an angle and a speed, so a seed that does not move makes every frame throw the IDENTICAL set of sparks: they stack into a few fixed streams and the plume pulses instead of flowing. Emitting the same arguments twice must therefore produce different trajectories.
  • collide() makes particles notice each other. Dropped down the SAME column, balls without it fall straight through one another and stay in that one column; with it they shove sideways and spread. That difference is the whole feature, and it is what turns a shower into a pit.

MoonLiveService

test/unit/core/unit_MoonLiveService.cpp Also touches: Scheduler.

  • a scripted service reads a pin and drives the control surface
  • a scripted service declares its own controls, which a user can set
  • a service with no script, or a broken one, is a valid state and ticks harmlessly
  • a scripted service reads an analog pin and scales it itself
  • a script cannot reach past the control surface

MoonModule

test/unit/core/unit_MoonModule.cpp

  • setup() and release() each fire exactly when called and update their respective state flags.
  • name() starts empty; setName() copies the string into the internal 16-byte buffer.
  • typeName (set by the factory) is independent of name; setName doesn't touch typeName so a human-renamed module still serializes under its real type.
  • dirty()/markDirty()/clearDirty() round-trip cleanly (the bit FilesystemModule polls for save scheduling).
  • parent() starts null; setParent() records the upstream container for tree walks.
  • Adding Uint8/Bool controls stores live pointers to the module's fields, so changes propagate either direction (field ↔ control->ptr).
  • controls().clear() empties the list; calling defineControls() again repopulates it (the standard rebuild path).
  • schemaSignature: value change is invisible, schema change is not
  • schemaSignature: recurses into children (a child schema change is caught)
  • addReadOnly binds a char buffer the UI can render; updating the buffer is visible through control.ptr.
  • addSelect binds a uint8 + an options array (stored in aux) — control.max carries the option count.
  • addProgress binds a uint32 plus a "total" value (in aux) — the UI renders value/total as a progress bar.
  • enabled defaults to true; setEnabled flips the universal enable gate (Scheduler and parent containers respect it).
  • a bool addControl binds a bool field — toggling the field updates control.ptr's view.
  • appearsInUi() defaults to true (every ordinary module shows in the UI) and is overridable to false so infrastructure modules (FilesystemModule, HttpServerModule) can hide — the flag the state serializer reads to skip a module. A base MoonModule and a control-bearing one both appear; only a module that overrides to false hides.
  • readBool/readUint8 — the shared generic control reader (reviewer #8): one implementation so the absent-control default can't disagree between callers (HttpServerModule + MqttModule both read Drivers.on through this). Returns the bound value; returns the caller's default when absent/wrong-type.

test/unit/core/unit_MoonModule_control_change_gate.cpp Also touches: GridLayout, MultiplyModifier, NoiseEffect, Drivers.

  • Layout and Modifier modules opt in to rebuild on a control change (their controls reshape the pipeline).
  • Effects and Drivers opt out — their controls are values read directly in the hot path, no rebuild needed (prevents slider stutter).

test/unit/core/unit_MoonModule_lifecycle.cpp

  • A parent's default tick() fans out to every enabled child — no per-container boilerplate needed.
  • Disabled children are skipped during propagation (the universal enable-gate).
  • Modules that override respectsEnabled() to false (NetworkModule, SystemModule, …) tick regardless of their enable bit.
  • tick20ms / tick1s use the same gate-and-propagate rule as tick().
  • A leaf module (no children) ticks safely as a no-op with no accumulated timing.
  • Each child's tickTimeUs() reflects its own accumulated cost (Scheduler reads per-child timing, not the parent's sum).

test/unit/core/unit_MoonModule_movechild.cpp

  • Moving a child to its current index returns false and changes nothing.
  • Moving a child forward shifts intervening children leftward to close the gap.
  • Moving a child backward shifts intervening children rightward to make room.
  • Single-position moves work in either direction (UI's up/down arrow buttons).
  • A target index beyond childCount() is refused (returns false, tree untouched).
  • A module that isn't actually a child of the parent is refused.
  • Middle-to-middle moves preserve the integrity of every sibling's index.

test/unit/core/unit_MoonModule_replacechild.cpp

  • Replacing position 1 swaps that child while leaving siblings and child count untouched.
  • The returned old child has its parent cleared; the fresh child has its parent set to the container.
  • An out-of-range index returns nullptr and the tree (plus the rejected replacement's parent pointer) stays untouched.
  • A nullptr replacement returns nullptr and leaves the tree intact.
  • After replace, the caller follows the lifecycle order: defineControls → setup → prepare on the fresh module, then release on the old.

MovingHeadEffect

test/unit/light/unit_MovingHeadEffect.cpp

  • The bug this pins: a 2D truss where every fixture in a row moved identically. The effect used to declare Dim::D1, so the Layer EXTRUDED its single column across x and every row was a copy of head 0. A rig is not a gradient: each head has its own place in the formation, on a 1 x N chain, a 2D truss and a 3D array alike. Found on the bench 2026-08-29.
  • The same promise one dimension up: a 3D array must not repeat slice by slice.
  • A 1 x N chain is the shape a fixture chain actually takes (heads at consecutive DMX addresses), and the position-driven formations must still behave there: mirror splits the chain in half.

MpegTs

test/unit/light/unit_MpegTs.cpp Also touches: HlsDriver.

  • The invariant a demuxer relies on above all others: the stream is a whole number of 188-byte packets, each starting with the sync byte. Everything else is read relative to that.
  • A player joining mid-stream can only decode a segment that tells it what the segment contains, so each one opens with the program tables before any video.
  • A player finds the video by following the PAT to the PMT and the PMT to the elementary stream. Testing only that those packets EXIST is not enough: the PAT once pointed at PID 0 instead of the PMT, so a player that trusts it (VLC) saw no video track at all, while ffmpeg still played the file because it probes for streams regardless. This walks the chain the way a player does.
  • The continuity counter is how a player detects a dropped packet: it must advance by one per packet on each PID independently, wrapping at 4 bits.
  • Only the first packet of a frame may claim a payload start; a decoder uses the flag to find frame boundaries, so a spurious one mid-frame corrupts the split.
  • A player needs a clock reference where it can start decoding, and that is the keyframe. A non-key frame carries none.
  • Muxing a long run of frames must not disturb anything around it. The muxer assembles each PES header in a fixed local buffer, and it once overran that buffer by a byte -- invisible in the output, caught only by a sanitizer. This drives the same path with a guard around the output so the CI sanitizer build has a case that would fail loudly.
  • A frame larger than the buffer must be reported, never written past the end: on the P4 the destination is a fixed PSRAM slot, so a silent overrun would corrupt whatever follows it.
  • The whole frame reaches the stream: what a decoder reassembles from the payloads must be the bytes we handed in, in order, with nothing dropped at a packet seam.
  • A Writer is constructed per FRAME in production, so the continuity counters cannot live inside it: MPEG-TS requires one unbroken sequence per PID across every frame and every segment. When they did live in the Writer, each frame restarted at zero and ffmpeg reported "Packet corrupt" on every one, which players show as a stream that stalls and re-buffers.
  • A frame the segment cannot hold is DISCARDED, and the counters must be discarded with it: the writer advances them per packet as it emits, so keeping the advance for packets that were never sent leaves a gap a player reads as lost packets. The caller restores them; this pins that the counters are a plain value the caller can snapshot for exactly that purpose.

MqttModule

test/unit/core/unit_MqttModule.cpp Also touches: Scheduler.

  • MqttModule: on/set drives Drivers.on
  • MqttModule: brightness/set rescales 0-100 to 0-255
  • MqttModule: hsv/set maps a hue to the nearest palette + value to brightness
  • MqttModule: ha/set {state} drives Drivers.on
  • MqttModule: ha/set {brightness} maps 0-255 with no rescale
  • MqttModule: ha/set is key-order-independent
  • MqttModule: ha/set with only state leaves brightness untouched
  • The discovery announce: on CONNACK the module publishes a RETAINED config to homeassistant/light/projectMM_/config. Assert via the outbound-capture seam (no live socket).
  • Regression (found live on P4/S31 hardware): turning haDiscovery OFF must free the discovery buffers EVEN when the socket is not currently Connected (mid-reconnect). The original guard bailed on state_ != Connected before reaching the free, so a discovery-off toggle during a reconnect stranded the 768 B until release — breaking "no memory when discovery is off". Freeing local memory needs no socket, so the retract path frees unconditionally; only the empty-retained PUBLISH needs a live link.
  • MqttModule: a PUBLISH on an unrelated topic is ignored, not a crash
  • MqttModule: a PUBLISH split across feeds still routes (fragment reassembly)
  • Regression (reviewer): the topic identity is the STABLE MAC (projectMM/), NOT the device name — so renaming the device must NOT change which topics the module listens on. A command on the MAC-based topic keeps working after a rename; a command on a name-based topic never matched.
  • A preset saved, renamed or deleted while the broker is CONNECTED must re-announce the retained discovery config: Home Assistant only re-reads the effect list when that message changes, so without this a mid-session preset never appears in the dropdown until the next reconnect. All three mutations funnel through one revision (pinned in unit_ControlModule), so one path proves the announce mechanism for all of them.
  • Applying a look changes neither on, brightness nor palette, so the ha/state change gate must include the look itself — without that signature Home Assistant keeps showing the previous effect after a look-only change (including one made on the device's own pad grid).

MqttPacket

test/unit/core/unit_MqttPacket.cpp

  • --- Remaining-length varint (§2.2.3) — the fiddly bit, boundary-tested ---
  • mqttWriteFixedHeader must validate space for the WHOLE header (type byte + varint) before it writes any of it — a bodyLen needing a 2-byte varint into a 2-byte buffer must return 0 without touching out[2] (the byte past the buffer). Regression guard for a fixed-header overrun.
  • --- CONNECT golden vector (§3.1) ---
  • MqttPacket: CONNECT with username + password sets the flags + payload
  • Regression (reviewer #10 / MQTT-3.1.2-22): a password without a username must NOT set the password flag — a compliant broker rejects that CONNECT. The builder drops the password when username empty.
  • A Last Will (§3.1.2.5-.7 + §3.1.3) makes the broker publish the will payload if the client drops — the availability seam HA greys the entity out on. Will Topic + Will Message go in the payload AFTER the clientId and BEFORE username/password; the flags byte carries the Will + Will-Retain bits.
  • --- PUBLISH golden vector (§3.3), QoS0 ---
  • The retain flag (§3.3.1.3) sets bit 0 of the fixed-header type nibble — used for the friendly name topic so a late-subscribing hub still receives it.
  • --- SUBSCRIBE golden vector (§3.8) ---
  • --- PINGREQ / DISCONNECT ---
  • --- Inbound parser: CONNACK return code read off the completed body (how the module reads it) ---
  • --- Inbound parser: round-trip a PUBLISH built by our own builder ---
  • --- Inbound parser: fragmentation — a packet split across feeds still reassembles ---
  • --- Inbound parser: a multi-byte remaining-length (128+ body) is handled ---

MultiPinLedDriver

test/unit/light/unit_MultiPinLedDriver.cpp Also touches: Drivers, Correction.

  • Explicit counts slice the buffer consecutively; the frame is sized by the LONGEST lane. The bus always has all 8 lanes — unused strands take the 0-light remainder and idle LOW.
  • Empty ledsPerPin splits evenly — same PinList semantics the RMT driver uses.
  • An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light).
  • A whole-frame driver with a bounded DMA (the classic ESP32 i80 = internal-RAM-only I2S, no ring) must REFUSE cleanly when the frame won't fit — never choke the bus init (which can busy-wait to a watchdog reset on hardware). On desktop / PSRAM chips the budget is 0 (no bound), so the frame-fit gate NEVER triggers regardless of grid size: a large frame is never rejected for its size here (the classic-i80 branch is compiled out). Pins the "budget 0 = no bound" contract — the gate is inert off the classic chip, so this refactor changes nothing on every non-classic target. The hardware behavior (a too-big frame on the real classic i80 idles with the clear "over DMA" status) is proven on the Olimex.
  • frameFitsDmaBudget() is the pure predicate the classic-i80 gate leans on; its logic is compiled out on desktop (budget always 0), so exercise it directly with synthetic budgets. A FINITE budget rejects an oversized frame and accepts one that fits; a ZERO budget ("no bound", the LCD_CAM/PSRAM/desktop case) never rejects, whatever the frame size.
  • A bad pin list idles the driver with the parse literal in the status; fixing it recovers.
  • Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing the 8 data GPIOs on its own. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.)
  • The BUS width is 8 or 16; the PIN COUNT is whatever the board drives. They are different numbers and the driver reconciles them: the peripheral always clocks a full 8- or 16-bit word, but nothing requires every bit to reach a GPIO — busPinList() parks the lanes the board doesn't use on WR, where the peripheral already drives and no strand reads them. So a 5-pin board is 5 data lanes on an 8-bit bus, not a config error, and needs no fake "ghost" pins to pad the list out.
  • WR and DC are lines the LEDs never read, so what an UNSET one costs is the chip's business: the classic ESP32 sinks it onto an input-only pad (no GPIO spent), the LCD_CAM chips need a real pad because an invalid number reaches the ROM's matrix routine. The desktop emulates the LCD_CAM backend, so here an unset WR idles the driver with a status that names the chip's rule.
  • MultiPinLedDriver on an LCD_CAM chip idles with a named status when WR is unset
  • A pin the PACKAGE lacks fails the same silent way a flash pin does: the ESP32-PICO-V3-02 has no GPIO 18/23, and routing the i80 clock there wedged its flash cache with no panic. The platform knows the package; the driver must refuse by name before the peripheral touches the pad.
  • A data lane on the same GPIO as the WR (clockPin) or DC pin is a WARNING, not a blocker: that lane carries the clock/DC waveform instead of pixel data, but on a board that wires all 8/16 lanes yet drives fewer strands, parking WR/DC on an unused data pin is a valid choice — so the driver still runs and flags a warning. clockPin/dcPin default to 10/11.
  • A 0×0×0 grid is a clean idle: zero counts, zero frame (no pad for an empty frame), no crash.
  • setup/release cycles leave no residue (status clean, ASAN-checked heap).
  • loopbackRxPin is bound always, visible only while loopbackTest is on.
  • loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (lcdLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility).
  • The pinExpander switch is HIDDEN where the silicon can't host the '595 (supportsPinExpander() false: the classic ESP32 i80 = the I2S peripheral, whose DMA can't read PSRAM, so the ×8 expander frame has nowhere to live). Desktop has lcdLanes==0 too, so the control is hidden here — the same compile-time gate the classic build takes. On the LCD_CAM chips (S3/P4) the flag is true and the control shows; that path is exercised on-device. Turning it on where unsupported only ever yields a config error, so not offering the switch is the honest UI. (Before this, the toggle was shown on every chip.) kSupportsPinExpander moved from a static constexpr on the driver to a virtual on the I80Peripheral backend (supportsPinExpander()) — a bare I80Peripheral instance reaches it without needing a live driver's peripheral_ (which is protected).
  • The host bus is REAL MEMORY, not a refusal: busInit used to return false on desktop, so every bus assertion was unreachable off-device and the driver's encode path only ever ran on hardware. The contract is identical for all three peripherals, so it lives in one place.
  • MultiPinLedDriver gives the host bus two distinct buffers when asked

test/unit/light/unit_ParallelLedDriver_pinexpander.cpp Also touches: ParallelLedDriver.

  • Each data pin fans out to 8 strands through its '595, so the driver drives pins x 8 lanes — the whole point of the expander (pins are the scarce resource, not strands).
  • ROBUSTNESS INVARIANT: a driver consumes only what IT drives (pins x ledsPerPin) and ignores any EXCESS the layout declares. A layout can legitimately publish more lights than one driver covers (a 2D panel grid the driver only partly maps, or several drivers splitting one big source), so the driver must clamp to its own lane capacity and keep every source read in-bounds — never encode past its share into unmapped source (which shows as frozen/garbage LEDs on the uncovered region). This pins the clamp: source = 1920, but 2 pins x 8 x 60 = 960, so the driver drives EXACTLY 960 and its furthest read is < 1920.
  • Direct mode is unchanged: one strand per pin, no latch. Pins the no-regression half — the expander must not have altered the existing behaviour.
  • THE COUNTER-INTUITIVE RESULT, and the one the memory budget rests on: the x8 fan-out multiplies the frame (a '595 is serial-in — each slot costs 8 shift cycles), but extra STRANDS are free (they ride the bus width). So the PO's two panels — 15x256 = 3,840 lights and 48x256 = 12,288 lights — cost the SAME DMA frame. If this ever regresses, the memory model is wrong and the bigger panel will silently fail to allocate.
  • The x8 IS paid for, in the frame: the same strands at the same length cost 8x the DMA bytes with the expander fitted. (This is what walls the classic ESP32 — its DMA can't reach PSRAM.)
  • The bus width follows the PHYSICAL pins (+ the latch lane), not the strand count. 48 strands on 6 pins is still an 8-bit bus — if this regressed to keying on the strand count, the driver would encode 16-bit slots into an 8-bit bus and emit a frame of pure garbage.
  • The latch is a real bus lane (a bit in every bus word), so it cannot double as a data pin — that lane would carry the latch waveform instead of pixel data. A config error, not a crash.
  • Without a latch the '595s never present a byte, so the strands would stay dark. Refuse the config rather than run a bus that silently outputs nothing.
  • The latch must not land on the peripheral's own WR/DC pins either — bench-found, because WR defaults to GPIO 10 and that is the first free-looking pin a user reaches for. The i80 bus builds fine, so the failure is silent garbage on the strands rather than an init error; that is what makes it worth an explicit guard. (The check itself lives in I80Peripheral::validateBusFatal, which the mock does not have — this pins the base's half: a data-pin collision is caught, so the mechanism is live. The WR/DC half is a compile-time-visible guard in the i80 backend.)
  • The loopback test frame must carry the expander's ×8 factor. The rig transmits the REAL frame through the real DMA path, so a frame sized without the ×8 would clock out a truncated waveform and fail every bit — a false failure that looks like broken hardware. (The mock's busLoopback is a stub, so this pins the SIZE arithmetic, which is the part that was wrong; the captured-bits half is hardware and is proven on the bench.)
  • Robust to any input (CLAUDE.md): flipping the expander on and off on a live driver must reconfigure cleanly each time, never wedge or leave stale lane state behind.
  • A transfer that NEVER completes (the done-callback never fires) must not wedge the driver. On the bench this manifested as a 42 ms driver tick for a 4.6 ms transfer — the driver spent every frame inside a timing-out wait. It must stay responsive, and it must not corrupt the in-flight buffer.
  • The buffer-safety invariant the timeout exists to protect: while a transfer may still be reading a buffer, the driver must NOT encode into it again. A re-encode mid-transfer is what puts half of one frame and half of the next on the wire — the "scattered random pixels" seen on the bench.
  • The robustness rule: a broken bus must not cost the user the DEVICE. Every dead transfer is a wait on the render thread, so a bus that never delivers doesn't just fail to light LEDs — it eats the CPU that WiFi/HTTP need and the device drops off the network entirely, with no way back in except a cable. That is exactly what a misconfigured shift-register frame did to a bench board (2026-07-14): unreachable and unrecoverable remotely, from nothing but an LED setting. So a persistently-dead bus must be GIVEN UP ON: the driver reports the failure and stops paying for it. Output idle, device alive — never the other way round.
  • Giving up must not be permanent: the user fixes the setting that broke the bus, the driver rebuilds, and the LEDs come back — no reboot (the live-reconfiguration rule).
  • THE INVARIANT THE STREAMING RING RESTS ON. The ring never materialises the big encoded frame: the DMA loops a few small INTERNAL buffers, and the CPU encodes each slice straight into the buffer the DMA is about to read. That is only sound if encoding in slices produces EXACTLY the bytes the whole-frame encode would have produced — otherwise the wire sees a different frame depending on how it happened to be chunked, which is the class of bug that is invisible on a sparse effect and catastrophic on a dense one. Note the latch pad: only the LAST slice closes the frame (closeFrame), because the pad is what makes every strand idle LOW into the WS2812 reset. A pad emitted mid-frame would reset the strand halfway.
  • The pin list handed to the peripheral must be exactly as long as the count that goes with it. The BUS is 8 or 16 bits wide whatever the board drives, so a 3-pin board still hands the peripheral an 8-entry list: 3 data lanes, then the spares parked on the clock pin (a real GPIO the peripheral already drives, so the lane is inert). This is what lets a board name only the pins it uses. The bug this pins: busPinList() used to shortcut if (!pinExpanderMode()) return laneList_ — the RAW, unpadded list — while busPinCount() reported the rounded width. With fewer pins than the bus is wide, the platform would then read past the end of laneList_. Direct mode never hit it only because the validation rejected any count but 8 or 16; allowing any count is what exposes it. A backend that routes its own GPIOs does not need a pad for a spare lane, and must not get one: the pad is a REAL claim on a REAL pin. On an ESP32-S31 the default clock pin (10) is an RGMII transmit line, so a one-strand board silently drove an Ethernet data pad and corrupted every frame the MAC sent, while the driver reported a healthy link and zero drops. The peripheral says whether it needs the pad; only the lanes a strand actually reads are handed over.
  • bus pin list is padded to the full bus width, in both modes
  • The shift-mode loopback frame must reserve the trailing latch word it unconditionally writes. encodeLoopbackFrameShift emits lights rows and then ALWAYS calls encodeWs2812ShiftLatchPad to close the frame — one extra Slot past the last row. The buffer was sized for the rows only, so that final word landed one-to-two bytes past the end of the heap block: a real overrun on every shift-mode loopback, silent on a forgiving allocator and a crash on an unlucky one. (Direct mode writes no pad, so it is unaffected — and that asymmetry is why sizing them the same was wrong.) The bug is a heap write, so the assertion that really catches it is the sanitizer, not a CHECK: run this suite under ASan and the pre-fix code trips heap-buffer-overflow here. What the CHECKs below can pin on their own is that the loopback ran to completion and left the driver healthy rather than tripping the out-of-memory path.
  • Switching to (or loading a saved config on) a peripheral that can't host the expander must degrade to direct mode — because the pinExpander toggle is hidden there, an error status would be unfixable from the UI. The degrade is in the EFFECTIVE mode (pinExpanderMode()), NOT a mutation of the stored value: the saved pinExpander=true is preserved so A/B'ing back to a supporting peripheral restores shift mode. Pins: the driver KEEPS pinExpander, drives DIRECT (effective mode off), and reports no error.
  • Regression: the i80 clock/DC defaults were 10/11, free GPIOs on the ESP32-S3 they were chosen on and the FLASH bus (6-11) on a classic ESP32. On an ESP32-PICO-V3-02 (QuinLED Dig-Next-2) every pin list wedged the board the moment it was applied: a TG1WDT_SYS_RESET with both CPUs stuck at the same PC, no panic and no coredump, so nothing named the pin. The defaults are now chosen per chip off the same i2sLanes > 0 discriminator dmaBudgetBytes() uses, and reinit() refuses any reserved bus pin outright rather than routing I/O onto flash.
  • The guard itself, on the host: routing a bus pin onto a chip's flash/PSRAM lines corrupts the device, and on the classic ESP32 the failing path busy-waits to a watchdog reset rather than returning an error, so the driver must refuse BEFORE it touches the bus. setTestGpioCapability is what makes that testable here, where every real pin is otherwise "safe".
  • The WR/DC half of the reserved-pin refusal, and the case the lane sweep alone cannot see: at exactly the bus width there are no spare lanes for WR to be parked on, and DC never rides the lane list at all (it goes straight to busInit). Both must still be refused. Through wire()/applyState() with a REAL I80Peripheral, not a bare instance: validateBusFatal() reaches owner_->pinExpanderMode() on its no-hit path, and owner_ is only ever set by attach() (setPeripheralForTest calls it), so a peripheral built and queried standalone is a null-owner crash that no production caller can hit, since ParallelLedDriver always attaches before use.

test/unit/light/unit_ParallelSlots.cpp Also touches: Correction.

  • One lane, one byte 0xA5: slot0 always the mask, slot1 follows the bits MSB-first, slot2 always zero.
  • Two lanes 0xFF/0x00 in one row: the data slot carries lane 0's bit only — the transpose itself.
  • A lane excluded from the mask contributes to NEITHER slot 0 nor slot 1, even with garbage wire bytes — short strands idle LOW (no white flashes).
  • Mask 0 (a row past every lane's strand) is a fully idle row.
  • Channel order comes from Correction (logical red → GRB wire {0,255,0}); the encoder is order-agnostic.
  • RGBW rows emit 4 channels × 8 bits × 3 slots = 96 bytes.
  • The branch-free SWAR transpose must equal the naive per-bit-per-lane gather it replaced, for EVERY lane pattern and mask — the whole point is a behavior-identical speedup. Pin it directly (not just via a few golden rows) so a future edit to the delta-swap constants can't silently corrupt a plane.
  • The 16-lane transpose (transposeLanes16x8, for the 16-bit bus) must equal the naive 16-lane gather for EVERY pattern and mask — the low byte of each uint16 plane is lanes 0..7, the high byte lanes 8..15. Cycles mask shapes including high-lane-only (lanes 8..15 set, 0..7 clear), the case the byte-split hinges on.
  • 16-lane golden encoder cases: the uint16 slot carries 16 data lines. Prove a high lane (15) lands in the plane's high byte, and a boundary pair (7 + 8). The wire lane stride is channels (here 1), so lane L's byte 0 is wire[L * channels].
  • RGBW 16-lane row: 4 channels × 8 bits × 3 slots = 96 uint16 slots, all written.
  • The latch is pulsed on the LAST shift cycle of every slot and nowhere else — that is what presents the shifted byte on the '595 outputs. A latch that fired early would present a half-shifted byte; one that never fired would leave the strands dark. The latch fires on the FIRST bus word of each slot, never the last — and this is THE bug that broke the first bench run, so it is pinned hard. The i80 WR (pixel clock) is the '595's shift clock, so every bus word clocks one bit in, INCLUDING the word carrying the latch. RCLK is rising-edge triggered, so it must fire when the slot's 8 bits are already all in — which is one word AFTER the last shift word, i.e. word 0 of the NEXT slot. Latching on the LAST word (the intuitive choice, and what this encoder shipped first) asserts RCLK while the 8th bit is still being clocked, so the '595 presents a byte shifted one short: on real hardware only the first LED or two of every strand lit. The old version of this test asserted latch iff LAST cycle — it passed while the panel was broken, which is exactly why the assertion is now written the other way round.
  • A '595 shifts MSB-of-the-register-first: the bit clocked in FIRST ends up on the LAST output (QH). So strand V (output V) must be carried on shift cycle (kPinExpanderOutputs-1-V). Get this backwards and every strand lights its neighbour's data — the failure mode that is nearly impossible to debug on a wired panel, so it is pinned here.
  • The whole point of the fan-out: strands on DIFFERENT physical pins ride the same shift cycle in parallel (different bus bits), while strands on the SAME pin are serialised across cycles. This is why extra strands are free but the x8 is not.
  • A strand whose strip is shorter than the longest must idle LOW for the rest of the frame (the activeMask rule) — it must not flash white. Same contract as the direct encoder, but it has to survive the fan-out: an inactive strand contributes no set bit on ANY cycle.
  • The real-world shape of the rule above, and the one that bites on hardware: two strands on the SAME '595, one longer than the other. Once the short strand is exhausted its activeMask bit clears while its neighbour keeps rendering — so the pin stays busy, and only a PER-CYCLE activity test can keep the exhausted strand dark. (A per-pin "has any live lane" mask drives the pulse-start HIGH on every cycle of that pin, and the short strand flashes white at full brightness for the rest of the frame.)
  • The pulse-start slot clocks in a 1 for every ACTIVE STRAND (so the '595 presents the start of the WS2812 pulse on that output), and the tail slot clocks in zeros. Note this is a per-CYCLE property, not a per-pin one: cycle c carries shift position kSh-1-c, so the bit belongs to exactly one strand of that pin. With all 8 strands of a pin active, the pin is HIGH on all 8 start-slot cycles — clocking in 0xFF, which the '595 presents as all-outputs-HIGH.
  • =========================================================================== THE TEST THAT MATTERS: replay the emitted words through a '595 simulator and check the strand receives a real WS2812 waveform. Every earlier shift test asserted bus-word indices — the encoder's internal layout — and they all passed while the panel showed garbage. This one asserts what the LED actually sees, so it fails when the hardware would. The WS2812 wire contract (ParallelSlots.h): each data bit is 3 slots — all-HIGH pulse start, the data bit, all-LOW tail. So a "1" is HIGH for 2 slots and a "0" for 1 slot.
  • THE RESET. After the last data bit the frame ends with a zeroed latch pad — >=300 us of idle that tells every WS2812 "frame over, latch what you have". The strand MUST be LOW for that whole pad. With a '595 that is not automatic, and this is the bug the first waveform test walked straight past (it breaks before the final bit, with a comment noting the tail "runs off the end of the frame"). The pipeline is one slot deep: the byte clocked during slot N is presented during slot N+1. The LAST clocked slot therefore needs a latch edge AFTER it — and a pad of pure zeros contains no latch bit at all. So the '595 keeps presenting the final DATA byte for the entire pad: final wire byte even (last bit 0) -> strand idles LOW -> resets correctly final wire byte ODD (last bit 1) -> strand idles HIGH -> NEVER resets A strand that never sees the reset appends the next frame's bits to an unlatched stream and garbles — content-dependently, which is the worst kind of bug to chase. Hence: the pad must open with one latch-only word.
  • The prefill/data split must be byte-identical to the whole-slot encoder. Two thirds of the shift encoder's stores write frame-CONSTANTS: the pulse-start word (which strands are active) and the pulse-tail word (all-LOW) are the same for every light in the frame. Only the middle word carries pixel data. So the constants are pre-filled once (cold path) and the per-light encoder writes only the data word — which is what takes the encode from ~9.7 µs/light to ~3 µs on an S3, and it is the difference between 8 fps and 25 fps on a 48-strand panel. That is only sound if prefill + data == the whole-slot encode, byte for byte. A single wrong word here is a corrupted waveform on every strand, so it is pinned exactly.
  • The same, with a SHORT strand — the case the per-cycle active mask exists for. An exhausted strand sharing a '595 with a longer one must stay dark, and the prefill is what encodes that (its pulse-start word omits the dead strand's pin bit on that cycle). If the prefill and the encoder disagreed about which strands are live, the short one would flash white at full brightness.
  • The hot-path sweep — the safety net for the packed-transpose rewrite. encodeWs2812ShiftData keeps the whole transpose in registers: it packs the lane bytes straight into the SWAR word and shifts each bit-plane byte back out, with no staging arrays. The packing depends on the PIN COUNT, and the 16-bit bus adds a second packed word (pins 8-15) — so there are several distinct paths, and a wrong shift or mask in any of them silently corrupts a strand. So sweep every pin count against the whole-slot encoder, on BOTH bus widths, with a dense pattern and exhausted strands mixed in. encodeWs2812ShiftSlots is the reference: the simple, unoptimized form that the '595 simulator already validates end-to-end. (This sweep earned its keep immediately: it caught a real bug in a batched-transpose variant that the rest of the suite passed clean.)

MultiplyModifier

test/unit/light/unit_MultiplyModifier.cpp

  • MultiplyModifier advertises D3 dimensions
  • Defaults (multiply 2/2/1) → a 128×128 physical grid folds to a 64×64 logical box.
  • MultiplyModifier logical size on Z
  • FAN-OUT (fold direction): with the defaults (mult 2, mirror XY), all four physical CORNERS fold onto the single logical pixel (0,0) — the inverse of the old "logical (0,0) → 4 physical corners". This is the kaleidoscope fold made concrete.
  • mirrorX only: two physical columns fold to the same logical column (original + its horizontal reflection). The logical box is 64 wide.
  • All multipliers 1 → identity: the box is unchanged and every coord folds to itself.
  • Tiling WITHOUT mirror repeats (does not reflect): physical x=64 (tile 1) folds to logical x=0, same as physical x=0 — both tiles map identically, no reflection.
  • multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so depth stays 1 and the layer isn't blanked.
  • A multiplier larger than the axis extent clamps to the extent.
  • REGRESSION (🐇): a non-divisible extent leaves a leftover edge strip that the tiles don't cover — those pixels must be DROPPED, not wrapped back into a tile (which would duplicate the edge). 5-wide, multiply 2 → tile width 2, covers pixels 0..3; pixel 4 is the leftover and has no tile.

NdiDriver

test/unit/light/unit_NdiDriver.cpp Also touches: Drivers, Correction.

  • Without the runtime the driver is inert but SAFE, and says why. This is the state every machine without NDI installed is in, including CI, so it is the default path rather than an edge case.
  • The frame a receiver gets is the grid: one pixel per light, at the layer's physical size.
  • The per-driver output correction is what makes a receiver see what the WALL sees: halving brightness must reach the NDI frame, not just the LEDs.
  • fps is a CEILING: a second tick inside the interval must not produce a second frame, or a fast render loop would flood the receiver with frames it never asked for.
  • A blank sourceName means the device's own name — what a user scanning a receiver's source list expects to find, rather than an empty entry.
  • A layer smaller than the frame must not leak the previous frame's pixels into the tail — a shrunk layout should go dark there, not show stale image.

NetworkModule

test/unit/core/unit_MoonBaseContract.cpp Also touches: FilesystemModule.

  • The keys MoonBase scrapes sit inside its 2048-byte prefix read of NetworkModule.json, with room for the ESP32-only eth block.
  • The OTA routes are the OTHER cross-image contract, and the one with two speakers: the browser drives an update by talking to the application, which hands over to MoonBase mid-flight, so the page keeps calling the same paths against a different image. The two therefore have to agree on the names, and nothing else pins that: MoonBase is a standalone project sharing no sources, so a route renamed on one side compiles cleanly on both and fails only on a device, halfway through an update, with the app already gone. They diverged once (MoonBase served /install and /install-url while the app served /api/firmware/upload and /api/firmware/url), which cost a debugging round: the app answers an unknown large POST with 413, so pushing to the wrong name reads as "the image is too big" rather than "no such route". This test reads both sources and requires the shared vocabulary.

test/unit/core/unit_NetworkModule.cpp

  • setWifiCredentials copies SSID + password into internal buffers and raises the dirty flag so the next tick1s() applies them.
  • A nullptr SSID is silently ignored (no copy, no dirty flag) — guards against a bogus caller.
  • A nullptr password is treated as empty (open networks), still copies SSID and marks dirty.
  • An over-length SSID (100 chars) is truncated cleanly into the 33-byte buffer; ASAN catches any overflow.
  • After setup(), NetworkModule exposes a mode read-only control whose value reflects the current state-machine state. On the desktop platform every network init stub returns false, so the cascade lands on Idle.
  • parseDottedQuad (in Control.h) is the validator on every IPv4 write, over both the HTTP API and persistence. Pin the contract.
  • The static-IP fields (ip / gateway / subnet / dns) are bound as IPv4 controls — 4 bytes of storage each, not 16-char dotted-quad strings. They start hidden because addressing defaults to DHCP.
  • In WiFi-capable builds (anything other than --firmware esp32-eth), the rssi and txPower controls are present and start hidden — Idle/Ethernet don't expose live WiFi metrics. The Ethernet-only build compiles them out entirely so the iteration finds nothing, which is still a valid pass shape.
  • Conditional controls: the static-IP fields (ip/gateway/subnet/dns) are visible only when addressing == Static (1), hidden under DHCP (0) — but ALWAYS bound so persistence can load a saved static config regardless of the live mode. This is the documented add-then-setHidden pattern (architecture.md § Conditional controls); the test pins it both ways so a regression (e.g. dropping setHidden, or conditionally NOT adding the field) fails here, not on hardware.

test/unit/core/unit_NetworkModule_ethernet.cpp

  • The enum values are a wire contract: the Select index, the ethInit() switch, and every deviceModels.json ethType all agree on these. Pin them so a reorder fails here.
  • Desktop has no Ethernet: the default PHY type is ethNone, so a board that never pushes an eth config still reports "no Ethernet" and the cascade falls through.
  • Ethernet is OPT-IN: NetworkModule's ethType control defaults to ethNone(0), so a board whose deviceModels.json entry has no ethType brings up NO PHY (a WiFi-only board like Shelly, or the QuinLED Dig-Uno/Quad with optional-only eth, must not waste an RMII/SPI init on a PHY it lacks). A board with real Ethernet sets ethType explicitly in its catalog eth block. On ESP32 the control default seeds ethType_ = 0; the platform's ethConfigDefault (whose phyType is the chip's historical PHY) still seeds the PINS so an opt-in board gets them without re-listing — but the PHY selection is off until the catalog turns it on. ethNone==0 is what makes "unset → off" work.
  • The platform seam must accept any runtime config and never bring Ethernet up on desktop — ethInit() returns false so NetworkModule cascades to WiFi/AP. Pushing a fully-populated W5500 config and an RMII config both leave ethInit() false and ethConnected() false; ethStop() is safe to call when nothing is running.
  • Regression: the ethPhyAddr control MUST be a SIGNED int16 whose range starts at -1 and which renders as a number field (not a slider). -1 is ESP_ETH_PHY_ADDR_AUTO (scan the MDIO bus — the RGMII default). It was once a uint8 control: the uint8 mangled the platform's -1 default to 255 and the 0..31 control clamped it to 31, a fixed address no PHY answered, so the S31's RGMII never linked. This pins the control-metadata contract that fixed it — signed storage so -1 round-trips, min == -1 so the sentinel is in-range, and numberField because an MDIO address is an identity, not a magnitude. Tests the int16 control + setNumberField seam directly (the NetworkModule control is if constexpr (hasEthernet)-gated, absent on desktop), so a future edit that reverts to a slider or an unsigned type fails here, off-hardware.
  • Static-IP addressing contract. The addressing Select (DHCP=0 / Static=1) and the four IPv4 controls (ip/gateway/subnet/dns) are what platform::netSetStaticIPv4 applies to the active interface. Pin the control shape a future edit could break: the Select stores the mode index and defaults to DHCP, the static fields exist with their documented defaults, and they are HIDDEN in DHCP mode (visible only when addressing==Static). These are always bound (not hasEthernet-gated), so the contract is testable on the desktop host.
  • The desktop platform's static/DHCP setters are inert no-ops: addressing is OS-managed on the host, so netSetStaticIPv4 / netSetDhcp must accept any input and change nothing (no crash, no interface brought up). Mirrors the "desktop net seam is a safe no-op" guarantee for ethInit etc.
  • Static addressing on WiFi STA is applied during BRING-UP (WaitingSta), not only after a lease event: a DHCP-less network never fires one, so waiting for "connected" before pinning the static IP would strand a static STA into the AP fallback (the WaitingEth static poll's mirror). The test seam fakes an STA radio so the host can drive the cascade into WaitingSta; the platform apply counter pins that tick1s invoked netSetStaticIPv4(Sta).
  • The pin map must report what the HARDWARE holds, not what the control says. On an RMII/RGMII board a type change is saved and applied on the NEXT BOOT (syncEthLive hot-reinits only W5500), so the EMAC keeps driving its pads after the user selects None. Reading the pending control there frees those pins in the map while the MAC still drives them, and an LED lane could then take one with nothing flagging the collision, which is the failure this whole mechanism exists to prevent. Only the CAPACITY half is checkable here: hasEthernet is false on the desktop, so fixedPins returns 0 on both sides of the applied-vs-pending distinction and a host test cannot tell them apart. The distinction is exercised on hardware (an S31 keeps its twelve pads listed while the interface runs) and by the esp32s31/esp32p4rev1-eth firmware builds.

NetworkReceiveEffect

test/unit/light/unit_NetworkReceiveEffect.cpp Also touches: NetworkSendDriver.

  • A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift.
  • Bad magic, non-OpDmx opcodes, truncated headers, and lying length fields are all rejected — the receiver drops them.
  • Universe universe_start lands at byte 0; the next universe lands at byte 510 — the same split the sender uses.
  • The layer clears its buffer every tick; staging holds the last frame, so the lights don't strobe black between packets.
  • A tick with no new packet must not re-copy staging over the layer buffer: the Layer does not clear between frames, so the copy would be identical bytes at real cost (3.5 ms per tick at 12288 lights on an S3). Another effect writing the shared buffer after us proves the copy was skipped.
  • Hold-last-frame must survive a sibling effect fading the shared layer buffer. The Layer runs the collected fade BEFORE the effect pass, so a fading sibling darkens the held frame every tick; if the receiver only re-copies when a packet arrived, an idle stream fades to black instead of holding. This is the contract the module documents, and it is what a real installation looks like: a receiver on the same layer as any of the 30 effects that call fadeToBlackBy.
  • Universes below universe_start are ignored; universes relative to a non-zero start land at offset 0.
  • A payload overrunning the buffer end is clamped; a universe entirely beyond the buffer is ignored.
  • A 0×0×0 grid accepts packets as a clean no-op — degraded, not crashed.
  • Staging is sized in prepare (off the hot path), tick() never reallocates it, release frees it.
  • A real packet sent over localhost UDP lands in the layer buffer — the end-to-end proof of the platform receive path.

test/unit/light/unit_NetworkReceiveEffect_protocols.cpp Also touches: NetworkSendDriver.

  • A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift.
  • Truncated headers, a bad ACN identifier, wrong layer vectors, a non-zero start code, and a lying property count are all rejected.
  • A packet built by the sender's builder parses back to the same byte offset and payload.
  • Truncated headers, wrong version bits, and a lying length field are rejected.
  • Each universe-protocol parser refuses the other protocols' datagrams — port mix-ups degrade to silence, not garbage.
  • An ArtPoll datagram is recognised (the discovery hook Resolume/Madrix use); OpDmx and non-ArtNet packets are not polls.
  • The ArtPollReply carries the fields controllers read: opcode, IP, port, names, universe switches, MAC.
  • DDP's byte addressing lands payloads at the exact offset; out-of-range and overflowing offsets are clamped or dropped.
  • channels_per_universe = 512 maps universes at 512-byte strides and clamps a 512-channel payload to its slot.
  • Three senders — one per protocol — hit the same effect on its three ports; each payload lands. The autodetect proof.

NetworkSendDriver

test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp Also touches: Drivers, Correction.

  • prepare sizes the correction-applied buffer to source-count × out-channels. The size matches what tick() needs on its first send. Calling tick() after prepare must not reallocate — pin the data pointer + shape.
  • A preset toggle from RGB to RGBW grows outChannels from 3 to 4. The grow runs in onCorrectionChanged, off the hot path.
  • A brightness-only change keeps outChannels at 3 — onCorrectionChanged is still called, but the resize short-circuits (existing buffer already fits).

test/unit/light/unit_NetworkSendDriver_packet.cpp

  • The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data.
  • Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format.
  • 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap.
  • The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec.
  • The built E1.31 packet carries the exact ACN layout strict sACN receivers (and tools like xLights) validate: identifier, the three flags+length fields, CID, source name, priority, universe, property count, start code.
  • The built DDP packet carries version+push bits, RGB data type, default destination, and big-endian offset/length.
  • The destination list is EMPTY by default, so an unconfigured driver idles instead of falling back to the 255.255.255.255 broadcast. Broadcast does not scale and its failure lands on the NETWORK, not the device: an ArtNet universe id sits in the payload, so every host on the segment must receive and parse every packet before it can discard it (~4,850 pkt/s on a 128x128 grid — measured starving an ESP32 until its HTTP stopped answering). Art-Net 4 forbids broadcast ArtDmx outright.
  • The multi-destination fan-out: ONE driver feeds N tubes, each with its own IP and its own contiguous run of the window. This is the Art-Net-conformant shape (ArtDmx must be unicast to the node owning each universe), and it costs the same total packets as a single broadcast stream while keeping every packet off the other nodes' NICs.
  • NetworkSendDriver: lightsPerIp follows the ledsPerPin idiom
  • A malformed entry must leave the driver IDLE, not half-configured. parseIpList fills its output as it goes, so a bad address AFTER good ones (a typo in tube 3 of 5) yields a partial list; publishing that would send real packets to a real subset of hosts while the card shows an error. Wrong output is worse than no output — so prepare() parses into locals and publishes only when everything validates.
  • A hand-typed lightsPerIp list will sometimes not match the destination count — the likeliest real typo. It must not silently mis-slice: a SHORT list even-splits the remainder across the rest (the documented ledsPerPin broadcasting rule), and a LONG list simply ignores the extras.
  • sACN's native addressing: the universe is IN the destination group, 239.255.{hi}.{lo} (E1.31 section 9.3.1). That is what lets a switch with IGMP snooping filter per universe in hardware, so a node's NIC sees only the universes it joined. Unicast E1.31 stays the default because multicast floods exactly like broadcast on a switch that does NOT snoop.
  • A fixture must not straddle two DMX universes. At 512 channels per universe an 11-channel moving head divides 46 times with 6 bytes left over, so an unrounded chunk would put fixture 47 half in one packet and half in the next, and that fixture would read a neighbour's channels as its own pan and tilt. Harmless on a 3-channel strip (a partial pixel is just a pixel), which is why it only surfaced once fixtures had more than color in them.

NoiseEffect

test/unit/light/unit_NoiseEffect.cpp Also touches: noise, Palette.

  • both motions paint a field rather than a flat wash
  • drift moves the field across the fixture; morph changes it in place
  • the two motions are genuinely different fields
  • on a volumetric fixture drifting slices differ from each other
  • Noise renders on a strip, a panel and a cube alike
  • Noise survives a degenerate grid rather than faulting
  • morph shows the same field in every slice, because time is its third axis

NoiseMeterEffect

test/unit/light/unit_NoiseMeterEffect.cpp Also touches: AudioService.

  • With no live audio source the level is 0, so no row lights and the fading buffer settles to black.
  • Fed a loud audio frame the meter fills from the floor upward: a lit row above the floor implies every row below it (down to the floor) is also lit — the column never floats. Extrude also means a lit row is complete across x, so column 0 and the last column of a lit row agree.
  • The width gain control scales level→length: width=0 zeroes the length (tmpSound2 = level20/255), so even a loud audio frame lights no row and the buffer stays dark.
  • The "runs at every grid size" hard rule: 0×0×0 and 1×1 both render with a live audio frame every tick without crashing (the sizeX/sizeY<=0 early-out and the maxLen constrain cover them).

OscModule

test/unit/core/unit_OscPacket.cpp

  • The exact bytes a controller puts on the wire. If this changes, every TouchOSC layout and Resolume patch built against projectMM breaks, so the golden vector is the alarm.
  • The pad rule is where a hand-rolled parser goes wrong: an address whose length is already a multiple of 4 gets a WHOLE extra word of padding, not none.
  • An int argument parses and reads back as both forms
  • Controllers disagree on the value range: TouchOSC and Resolume send 0..1 floats, hardware bridges send ints in the target's own range. Both must land on the same control value, and out-of-range must CLAMP: a controller sending 0..127 would otherwise look broken.
  • A SWITCH is a flag, not a level, so it reads the raw value rather than the scaled byte. Scaling first rounds a small float to zero, which would turn a switch off while its controller says on.
  • The parse reads an unauthenticated datagram off the LAN, so a malformed one must be refused rather than read past. Every case here would be an out-of-bounds read in a naive parser.
  • A message with no arguments is legal, and is how a pad press arrives.
  • A controller that labels its messages must not make them unusable: the string is stepped over and the number after it is still found.
  • The address contract, tested as a whole: these strings are what a TouchOSC layout or a TouchDesigner patch is built against, so a change here breaks someone's file. Routing is checked through the parse + toByte pair the module uses, without needing a live socket.
  • A string argument whose NUL lands 1-3 bytes short of a 4-byte boundary AT THE END of the datagram. stringLen returns the PADDED length, which then exceeds the bytes actually available: subtracting it from a size_t wrapped argAvail to a huge number, every later argAvail < 4 guard passed, and the next numeric argument was read off the end of the buffer. One unauthenticated UDP packet, reproduced under AddressSanitizer as a heap-buffer-overflow in beFloat32. Every other fixture in this file pads its arguments, which is exactly why none of them could catch it: the bug lives in the UNpadded tail a real attacker controls.
  • A switch is a BOOL control, and controllers send FLOATS: Open Stage Control's button emits 1.0 for on. toByte scales that to 255, which parseBool (accepting true or 1) reads as false, so "on" arrived as off while "off" appeared to do nothing. The switch route therefore converts to a boolean rather than passing a byte. Found on the bench 2026-08-30 with a real surface.
  • Encoding is the mirror of parsing, so the strongest test is a round trip: what we emit, our own parser must read back. The padding boundary is where a hand-rolled encoder goes wrong, and it is the same boundary the inbound overread lived on, so every length is exercised.
  • Refuses rather than truncates: a half-written packet on the wire is worse than none.

PacmanEffect

test/unit/light/unit_SpritePopulation.cpp

  • Changing how many of each KIND are on the wall must change what is drawn. Slots are positional (the first pacmen slots are Pacman, the rest ghosts) and a slot's role is decided when it launches. Trading one kind for another keeps the TOTAL the same, so no slot respawns, and without a re-role pass the cast keeps the shapes it already had: the controls say three Pacmen while the wall still shows one. Found in review.

PaintBrushEffect

test/unit/light/unit_PaintBrushEffect.cpp Also touches: AudioService.

  • A live broadband signal draws strokes: at least some lights are lit after a frame.
  • Silence draws nothing: with no active mic the frame is all-zero bands, so every line's length maps to 0 (below the minLength gate) and the buffer stays fully black.
  • The minLength gate suppresses strokes: raised to its maximum, no line is ever long enough to draw, so even a loud broadband frame leaves the buffer black — the gate, not the audio, decides.
  • The "runs at every grid size" hard rule: degenerate grids never crash, with a live frame each tick.

Palette

test/unit/light/unit_Palette.cpp

  • Palette: gradient endpoints land on the first/last stop colors
  • Palette: a mid-gradient sample interpolates between stops
  • Palette: colorFromPalette index 0 reads entry 0; brightness scales
  • Palette: the index wraps at 255→0 (no out-of-range read)
  • Palette: a degenerate (empty) gradient is all black, never out-of-bounds
  • Palettes::active swaps the global palette on setActive
  • The HomeKit-color-wheel → palette mapping (MQTT/Homebridge). Each palette's representative (hue, sat) is computed from its expanded entries; nearestForHue picks the closest. A vivid hue snaps to that hue's palette family; a low-saturation target snaps to the desaturated Rainbow.
  • Regression (reviewer #4): a hue >= 360 (a broker client can send "400,…" on hsv/set) must not overflow the int32 squared-distance math — nearestForHue folds any input into 0..359 up front. 400 % 360 == 40 (orange), 720 % 360 == 0 (red), so both resolve to a valid, sensible index.
  • LivePalettes: a departing publisher cannot unpublish its successor
  • LivePalettes: an unconditional clear detaches whoever owns the seam
  • The WLED shim serves palettes as a POSITIONAL array and palcount as its length: Home Assistant renders one dropdown entry per name and sends the index back as seg[0].pal. Both stopped at the built-ins while the device's own palette control already accepted the scripted indices above them, so a scripted palette was unnameable and unselectable from HA. paletteCount() is the one home for that length; this pins it to what paletteNames() actually writes.

PanelCardDriver

test/unit/light/unit_PanelCardDriver.cpp Also touches: Drivers, Correction.

  • A panel is sent as one frame per row followed by a single sync — the sync is what latches the image, so it must arrive after every row it applies to.
  • A card on v12-or-older firmware acts on the FIRST copy and reads a second sync as another latch, aborting the refresh already running: the wall then updates once every few seconds. That generation gets exactly one brightness and one sync, which is also what FPP sends such a card.
  • A buffer smaller than the wall sends only the rows it covers, then latches — rather than reading past the buffer for the rows it does not have.
  • A row wider than one packet splits into several, each carrying its own pixel offset — the wide- panel case, where 497 pixels is the per-packet ceiling.
  • Pixel bytes reach the wire unchanged, so what an effect rendered is what the panel receives.
  • The fps control caps the send rate, so a render loop faster than the panel needs doesn't saturate the link.
  • Nothing is latched when the window is empty: an all-zero geometry must not emit a bare sync, which would blank a panel that another driver is feeding.
  • A dropped frame doesn't stall the driver: the cards give no acknowledgement, so a failed send is tolerated exactly as a dropped UDP packet is, and the next tick proceeds.
  • The correction-applied buffer is sized off the hot path, so tick() never allocates — the same contract the other sinks hold.
  • The driver CLAIMS the ethernet interface while it is enabled, which is how NetworkModule tells "the cable is broken" apart from "a driver is using the wire below IP". Stated at prepare rather than inferred from traffic, so the claim is in place before the first frame and cannot race the network cascade's DHCP timeout.
  • Re-preparing (a geometry or window edit) must not stack claims, or the link would stay claimed after the driver is gone.
  • The wall a PanelsLayout describes is what reaches the card: two 128x64 panels stacked give 128 rows of 128 pixels, and the driver reads that from the Layout rather than from controls of its own. This is the setup a ColorLight card is normally configured for.
  • Panels chained side by side widen each card row instead of adding rows — the same Layout control the user already sets, with no second place to state it.
  • A transmit path that refuses every frame must SAY so. It reads as a healthy link otherwise — the IDF driver keeps reporting the negotiated speed while rejecting every frame — so "0 packets/s at 1 Gbit" would look identical to an idle effect.
  • A link that fails every send must not latch: the sync frame tells the cards to show what they have, so emitting one after a frame where nothing arrived would blank a wall that was previously showing a good image.
  • A window covering no whole row sends nothing, rather than putting the brightness pair on the wire every tick for a wall it cannot fill.
  • A wedge earns ONE recovery attempt. The second and third wedges in the same episode must NOT fire again: a restart cannot fix an unplugged cable, and retrying would tear the interface down every tick under a user reading the card to find out what is wrong.
  • A wedge that survives its restart is REPORTED, with the refusal count, rather than retried.
  • A recovery that FAILS leaves the interface stopped, which no user action short of a power cycle resolves. That must not be reported as "no ethernet link", the message for an unplugged cable.
  • Failures are counted by CAUSE, because a down link and a full TX ring are different faults with different fixes; one total cannot tell them apart, which is what made a real bug unreadable.
  • The interface Select lists the DETECTED host NICs (none-first) and persists by label, the fix for the Windows index-mismatch trap: whatever the OS or Npcap renumbers, the name the user picked keeps meaning that adapter. Enumeration is faked through the test seam.
  • A persisted adapter NAME whose NIC is gone must degrade to capture-only without crashing: the label no longer matches any option, the Select stays at row 0 (none), and prepare()'s nullptr bind is today's blank-interface capture path.
  • The NIC list is re-enumerated on every rebuild and the OS does not promise a stable order. The selection must follow the ADAPTER, not the row it happened to occupy: a NIC appearing ahead of the chosen one would otherwise silently move panel output to a different adapter.
  • Interface labels come from the OS, and on Windows they are free-form descriptions. One containing a quote or a backslash must not be able to break the JSON the whole UI loads from.
  • The documented reset for the test seam is (nullptr, 0); it must clear the override rather than form a range from a null pointer.
  • An interface label carries the adapter's live link speed ("Realtek PCIe GbE, 1 Gb") so a user can tell a 1 Gb NIC from a Wi-Fi radio or a virtual switch. That detail CHANGES: a renegotiated link, or the same cable at 100 Mb, rewrites the label. The selection must survive it, or the driver silently falls back to capture-only the first time a link renegotiates.
  • The same rule on the APPLY path: a persisted label whose speed has since changed still selects its adapter, so a config restored onto a machine whose link renegotiated keeps working.
  • An adapter that DISAPPEARS between rebuilds (unplugged USB NIC, a driver uninstall) must not leave the selection pointing at whatever now occupies that row: the driver would send panel data out of a NIC the user never chose. No match means capture-only, explicitly.

test/unit/light/unit_PanelCardDriver_packet.cpp

  • The cards filter on a fixed destination MAC, so a frame sent anywhere else (broadcast, or from this device's own address) is discarded without any error the sender can see.
  • The format overloads the EtherType field: byte 12 is the packet type and byte 13 is ALREADY the first payload byte. Pinning this is what stops the whole layout drifting one byte, which the cards answer with silence rather than an error.
  • A row number above 255 spans both payload bytes, so a tall panel addresses its lower rows.
  • A row wider than one packet splits, each carrying its own pixel offset so the card can place the chunk without depending on arrival order.
  • A full-width packet is the largest frame the format builds, and it sizes the driver's one reused buffer — if this grew past COLORLIGHT_MAX_FRAME the driver would overrun it.
  • The sync frame latches everything sent since the last one. Fixed size, mostly zeros, with brightness repeated in the four places the cards read it.
  • The brightness frame sets the card's own gain, ahead of the row data.

ParallelLedDriver

test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp Also touches: MultiPinLedDriver, ParlioLedDriver.

  • Double-buffer mode: the encode target alternates 0,1,0,1,… and a buffer's wait fires only right BEFORE that buffer is reused — never after every transmit. So the first two ticks transmit without a preceding wait (both buffers start idle), and from tick 3 on each tick waits on the buffer it's about to reuse.
  • Single-buffer mode (no second buffer): the driver stays on buffer 0 and waits on it EVERY frame before re-encoding — the old synchronous wait-after-transmit path, so a memory-tight board keeps its old fps rather than failing to init.
  • doubleBuffer is the on/off knob AND drives allocation: OFF (default) allocates ONE buffer and runs the synchronous path; ON requests a second buffer and alternates. Flipping it rebuilds the bus (affectsPrepare) so the second buffer is freed (→off) or allocated (→on) — a board that leaves it off never holds the second buffer. This mirrors the live toggle (the A/B knob), which routes through applyState()/prepare() the same way.
  • Regression (MoonI80 double-buffer freeze): a peripheral that reports supportsDoubleBuffer()==false must run SINGLE-buffer even when the doubleBuffer control is ON — its own-GDMA two-buffer completion handshake races and wedges the bus (the ~200 ms-per-frame freeze). The orchestrator gates the second-buffer request on supportsDoubleBuffer(), so the peripheral never gets a second buffer and the tick stays on the proven synchronous path. The saved doubleBuffer value is preserved (it just doesn't engage here), so switching to a peripheral that DOES support it restores the async behavior.
  • A board that WANTS async but can't fit the second buffer (memory-tight) degrades to single-buffer synchronous — never fails to init. doubleBuffer is on, but the mock refuses the second buffer.
  • The frameTime KPI: tick1s() pulls the platform's measured wire time via busLastTransmitUs(). The string formatting + the actual DMA timing are verified on hardware (the metric's whole point is a real wire measurement); here we just pin that tick1s reads the seam without crashing pre-first-frame.
  • Robustness + no-caps (regression for a live bootloop): a correction can carry ANY channel count (RGB=3, RGBW=4, RGBCCT=5, an N-channel fixture). The per-row encode scratch (wire_) is heap-sized to kMaxLanes × outChannels off the hot path, so a >4-channel correction lays out without overrun — the old fixed-4-byte-stride array overflowed and corrupted memory (the SE16 bootloop, 2026-07-13). The driver must DRIVE it (size the frame + encode), not idle and not crash.
  • A reinit (grid resize / pin edit) must drain BOTH buffers' in-flight transfers before freeing them — a live DMA reading a buffer about to be freed is a use-after-free. After two ticks both buffers are in flight; the resize's reinit waits on both before rebuilding. (async on → two buffers.)
  • A wait that TIMES OUT means the DMA may still be reading that buffer. Encoding into it anyway would hand a live transfer a half-rewritten frame — the exact corruption the timeout exists to prevent. (The seam used to return void, so the driver couldn't tell a completion from a timeout and cleared inFlight_ either way; 🐇 CodeRabbit caught it.) The contract now: on timeout the buffer STAYS in-flight, the frame is skipped, and the driver re-waits next tick — self-healing, never corrupting.
  • After ENOUGH consecutive dead frames the driver GIVES UP (stops spending the render thread on a bus that won't deliver — a misconfigured bus must not starve the network). But give-up is not permanent: a TRANSIENT stall (the streaming ring's refill missing one deadline under a burst of HTTP load) must self-recover WITHOUT a reinit, or a momentary hiccup leaves the LEDs dark until the user touches a control. So once given up, the driver periodically lets one frame through; if the bus is alive again, output resumes on its own. This pins that retry-recovery.

test/unit/light/unit_ParallelLedDriver_swap.cpp Also touches: MultiPinLedDriver, ParlioLedDriver.

  • ParallelLedDriver: a peripheral swap does not double-free or dangle the control list
  • Regression (pre-merge Reviewer BLOCKER): a LIVE peripheral swap frees the old backend, and the core-1 encode worker dereferences that backend (busBuffer/busTransmit) — so swapPeripheral MUST stop the worker (fire the quiesce-render hook) BEFORE deleting the backend, or it is a use-after-free, the same class the structural-mutation quiesce fixes. This pins the ORDERING, not merely that the hook fired: SwapMock's destructor snapshots (into g_hookFiredBeforeLastDtor) whether the quiesce hook had already fired at the moment the outgoing attached backend is freed. A swap that deleted the backend BEFORE quiescing would leave that flag false even though g_hookFires ends up > 0.

ParlioLedDriver

test/unit/light/unit_ParlioLedDriver.cpp Also touches: Drivers, Correction.

  • Three lanes (Parlio accepts any 1..8 count) slice the buffer consecutively; the frame is sized by the LONGEST lane.
  • Empty ledsPerPin (the default) splits evenly over the 8 lanes — shared PinList semantics, same as the RMT/LCD drivers.
  • The Parlio-vs-LCD difference: 1..8 pins are ALL valid (no exactly-8 rule).
  • 9..16 pins are accepted (Parlio drives 1..16, the 16-bit bus); more than 16 is rejected.
  • A 16-lane (>8) config uses the 16-bit bus, so each slot is 2 bytes: the frame is DOUBLE the byte size of the same per-lane lights at ≤8 lanes. Pins the slotBytes threading through frameBytesFor (including the doubled latch pad).
  • An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light).
  • Parlio single-transfer hardware ceiling (PARLIO_LL_TX_MAX_BITS_PER_FRAME = 0x7FFFF bits = 65535 bytes on P4/S3/most targets): the peripheral clocks the WHOLE frame out in one transaction, so a per-lane strand whose frameBytes exceeds this is rejected by parlioWs2812Init (fixed — was a silent tx failure). The ceiling is a byte limit (65535 bytes/lane), so the equivalent LIGHT count depends on channels-per-light: WS2812 encodes 24 slot-bytes per channel, plus a ~864-byte per-lane latch pad. So the max lights/lane is ~ (65535 − 864) / (channels × 24): 897 for RGB (3ch), ~673 for RGBW (4ch), ~538 for RGBCCT (5ch) — wider fixtures fit fewer lights per one-shot transfer. This pins the boundary in host-visible frameBytes terms for the RGB and RGBW cases. The reject itself is hardware-only (the host bus allocates but enforces no Parlio transfer ceiling), verified on the P4 (LEDs burn at 8×896 RGB/lane; the driver reports a status error above the ceiling). Catches the ceiling shifting if the encoding changes. Mirrors the platform constant.
  • A bad pin list idles the driver with the parse literal in the status; fixing it recovers.
  • Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing a GPIO. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.)
  • A 0×0×0 grid is a clean idle: zero counts, zero frame, no crash.
  • tick() is crash-safe across single-pin / multi-pin / pre-init configs (the transmit path is gated out on the host; this pins the reachable contract).
  • setup/release cycles leave no residue (status clean, ASAN-checked heap).
  • loopbackRxPin is bound always, visible only while loopbackTest is on.
  • loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (parlioLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility).
  • The host bus is REAL MEMORY, not a refusal: busInit used to return false on desktop, so every bus assertion was unreachable off-device and the driver's encode path only ever ran on hardware. The contract is identical for all three peripherals, so it lives in one place.
  • ParlioLedDriver gives the host bus two distinct buffers when asked

ParticlesEffect

test/unit/light/unit_ParticlesEffect.cpp

  • The trail buffer sizes to width × height × 3 bytes (one RGB per cell, used to fade existing pixels).
  • A single tick is enough to paint particles into the buffer.
  • Disabling the effect releases the trail buffer (dynamicBytes returns to 0).

PinsModule

test/unit/core/unit_PinsModule.cpp

  • PinsModule: exposes a single read-only pins list, fixed System module (Generic role)
  • PinsModule: collects set Pin controls with name-derived roles, skips -1
  • PinsModule: parses the LED-driver pins CSV into per-lane claims
  • PinsModule: rows are GPIO-ordered and a double-claim stays visible in the detail
  • PinsModule: a conflict promotes a strap warn to error (severity is the max)
  • PinsModule: a disabled module's pins are released from the map, re-claimed on enable
  • PinsModule: a child module's pins are walked (depth-first), not just the roots
  • PinsModule: disabling a PARENT frees its children's pins (effectivelyEnabled cascade)
  • PinsModule: a claim survives its owning module being destroyed (no use-after-free)
  • PinsModule: a CSV with an out-of-range pin claims nothing, and never a false GPIO
  • PinsModule: a claim on a reserved pin is flagged severity error
  • PinsModule: a driven role on a strap pin is flagged severity warn
  • PinsModule: an input role on an input-only pin is NOT flagged
  • PinsModule: a driven role on an input-only pin IS flagged warn
  • PinsModule: a safe pin carries no severity field
  • PinsModule: a claimed pin with live state emits level + drive columns
  • PinsModule: a pin with no live state (valid=false) omits the live columns
  • PinsModule: dir column reflects the live pad direction (out/in/both/off)
  • PinsModule: dir is shown as info, NOT a warning — a driven role with output off is unflagged
  • PinsModule lists the pins a module holds without a control naming them

PinwheelModifier

test/unit/light/unit_PinwheelModifier.cpp

  • PinwheelModifier 2D reshape puts petals on X, radius on Y
  • PinwheelModifier 1D reshape puts petals on Y (1 x petals x 1)
  • The regression: on a 1D layer, sweeping the source x must map to MORE THAN ONE petal cell, and each mapped coordinate must land within the reshaped {1, petals, 1} box (x == 0, 0 <= y < petals). Before the fix the petal index went to pos.x, so every cell mapped to x == value >= 1 (out of the 1-wide logical box) except petal 0 — the pinwheel collapsed to a single petal.

PlasmaEffect

test/unit/light/unit_PlasmaEffect.cpp Also touches: NoiseEffect.

  • One tick on an 8×8 grid produces at least one non-zero byte.
  • Opposite corners of a 16×16 grid differ in color (the plasma is not flat-filling).
  • Plasma and Noise produce visibly different frames on the same grid (sanity check that they're distinct algorithms).

PraxisEffect

test/unit/light/unit_PraxisEffect.cpp

  • Praxis overwrites EVERY pixel each frame (a full-grid palette field, no black background) — with a non-black palette active, no light is left at (0,0,0).
  • The hue is a function of (x, y): pixels far apart in the grid carry different colors, so the field is spatial, not a uniform fill.
  • Hard rule: the effect runs at a degenerate grid without crashing. width/height <= 0 is guarded, and a 1×1 grid exercises the render loop at its smallest.

PreviewDriver

test/unit/light/unit_PreviewDriver.cpp

  • A sphere sends its SHELL lights (210), not the dense 9x9x9 box (729).
  • The device serves the resolution the CLIENT requests, it no longer measures the link itself (a device-side controller can only see its own socket, and cycled; the receiver measures the true end-to-end rate). The stride changes exactly when a request arrives, and never otherwise.
  • Garbage from the network must not steer the lattice: hints outside [1, 64] are ignored.
  • PreviewDriver per-frame RGB count matches the coordinate table
  • A small grid sends every light at its grid position (stride 1, exact).
  • A large layout is SPATIALLY downsampled (a regular per-axis lattice, not every-Nth-flat- index) so the payload fits the send-buffer cap without the diagonal moiré that linear stride produced on a grid whose width didn't divide the stride. The wire "stride" field carries the per-axis lattice/downscale factor (color k still maps 1:1 to coord k).
  • A SPARSE layout under the cap must NOT be downsampled for its big BOUNDING BOX alone: the lattice bound is the layout's LIGHT count, not its box cell count, so a sphere whose shell fits the cap sends every light at stride 1 (a radius-8 sphere → ~812 shell lights, well under the 4096 display cap, in a 17³≈4913-cell box). (A genuinely huge sparse layout above the cap downsamples like any other — the cap is about points streamed, not box size.)
  • Default fps is the rate-limited preview stream rate.
  • Regression: a coordinate table dropped under backpressure must be RETRIED, and color frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips (count mismatch) and the preview freezes for the whole session. Drives tick() (where the coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through.
  • Regression: deleting the active Layer must not leave a driver holding a dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned when the active Layer was null, leaving PreviewDriver's layer_ pointing at the freed Layer; the next prepare read layer_->layouts() on freed memory and crashed the device (LoadProhibited → boot loop, since the broken tree persists). Now passBufferToDrivers clears the drivers' layer_/sourceBuffer_ to null, a safe idle state. This drives the real path: Drivers bound to a Effects CONTAINER (self-healing), the Layer removed, then prepareTree re-resolves activeLayer()=null.
  • The pull model: a coordinate table is sent ONLY when a client asks ([0x52]), never on a timer, never per-frame, never volunteered on a connect or a geometry change (the device is a dumb producer; a client whose cache misses asks). Driven through tick() with a frozen clock.
  • A full-res RGB frame is sent through the RESUMABLE buffered path (sendBufferedFrame), whose body is the DRIVER (consumer) buffer itself — no copy. For a dense identity grid that's the Layer's dense box buffer; for a sparse/mapped layout it's the LUT-mapped output buffer (the real lights), the same buffer the LED drivers consume — NOT the dense box.
  • Sparse layout: the buffered send streams the LUT-mapped DRIVER buffer (only the real lights, in driver order), exactly like the LED drivers — NOT the dense bounding box. So coordCount == the shell count and the frame is sent whole at full res through the resumable path.
  • Dense-grid CLOSED-FORM downsample, exact color placement: a wide strip pinned over the cap strides in x only, so the kept lights are columns 0,s,2s,… The color pass must read each from its dense buffer index (closed-form x for a 1-row grid) and pack them in the SAME order as the coord table: no placeLights. Painting a known color at a kept column and finding it at the matching frame position pins the index math + the lattice order.
  • ADAPTIVE FRAME RATE: while a buffered send is still draining (a slow link), tick() must NOT start a new frame — it waits for bufferedSendIdle(). So the effective rate self-limits to the link.
  • PreviewDriver cancels an in-flight buffered send on rebuild (resize safety)
  • a wedged link never blocks a tick, never closes a client, and resumes when it drains
  • A moving head's AIM reaches the preview, so the browser can draw where the beam points. The wire carries pan/tilt, never a rendered look, so a richer visual later is a client change alone.
  • The requirement this feature had to meet: a rig WITHOUT motion pays nothing for it. Most rigs are strips and panels, and they must not gain a per-frame message, a gather, or an allocation because moving heads exist.

RainbowEffect

test/unit/light/unit_RainbowEffect.cpp

  • A single frame on a 4×4 grid leaves the buffer non-zero (rainbow always paints somewhere).
  • Pixel (0,0) carries a lit palette color — confirms the effect writes a real RGB there.
  • Distant pixels carry different hues (the rainbow gradient is spatial, not uniform).

RandomEffect

test/unit/light/unit_RandomEffect.cpp

  • A single frame on a fresh black buffer lights exactly ONE light (one setRGB per frame).
  • Over many frames with light fade the sparkle field fills — more than one light ends up lit.
  • The effect runs at degenerate grid sizes without crashing (Effects-must-run-at-every-grid-size).

RandomMapModifier

test/unit/light/unit_RandomMapModifier.cpp Also touches: Layer.

  • A remap leaves the logical box unchanged.
  • The core property: a true bijection over [0, whd) — every destination index appears exactly once (no gaps, no duplicates).
  • Deterministic seed → reproducible permutation (what makes it testable).
  • Reshuffling (a beat) changes the mapping, still a bijection.
  • Robustness: an empty (0×0×0) box must not crash — it folds to a no-op.
  • A resize (different box count) rebuilds the permutation to the new size.
  • RandomMapModifier tick() reshuffles on a beat (bpm 60 ≈ 1/s)
  • RandomMapModifier tick() with bpm 0 never reshuffles (frozen)

RegionModifier

test/unit/light/unit_RegionModifier.cpp

  • Default region (0/100 on every axis) is the full box: identity size, no rejection.
  • Half of an axis, half-open: end=50 on 128 → region width 64, not 65.
  • Two abutting regions tile a 128-wide axis with no overlap and no gap.
  • A physical coord inside the region folds to region-local (subtract the start pixel); a coord outside is rejected.
  • Rounding rule on a small panel: start floors, end ceils to an exclusive pixel. start 33 / end 66 on a 4-wide axis → floor(1.32)=1 .. ceil(2.64)=3 → pixels 1,2.
  • A region that rounds to nothing still gets a 1-pixel floor.
  • OFF-SCREEN: a window slid half off the left edge keeps its FULL size (the effect renders at a fixed scale); only the visible half maps to physical lights. startX=-50 on 64 → window [−32, 32), span 64. Physical x 0..31 land at window-local 32..63 (the right half of the window — the visible part); the left half of the window (0..31) has no physical light, so it's dark. The effect isn't rescaled.
  • A window entirely off the box maps NO lights — the layer goes dark on that axis, which is how an effect is moved completely out of view. The box still has a valid size (the effect renders), nothing just reaches the screen.
  • A window stretched WIDER than the box (start<0 and end>100) renders the full span; the box shows the middle slice. startX=-50,endX=150 on 64 → window [−32, 96), span 128.
  • Degenerate axes don't crash: a 1-wide axis stays 1, a 0-extent axis yields 0.

Rings241Layout

test/unit/light/unit_Rings241Layout.cpp Also touches: SingleColumnLayout.

  • Indices are contiguous 0..240 whatever the wiring order: a gap or a repeat is a light the driver never writes, so the disc would carry a permanently dark pixel.
  • The default wires from the center out: light 0 IS the center LED (the 1-LED ring), and the last sixty lights are the outer ring. This is MoonLight's order, so an existing disc keeps its picture.
  • Outside in flips the ring SEQUENCE and nothing else: the first sixty lights are the outer ring in the same direction they were wired center-out, and the center LED is last. Asserted against the default's output ring by ring, so a reversal that also flipped the direction around a ring, or that moved a coordinate, is caught rather than passing on a symmetric disc.

RippleXZModifier

test/unit/light/unit_RippleXZModifier.cpp

  • Default collapses X only: size.x becomes 1, Y and Z keep their extent.
  • towardsZ collapses Z instead; both flags collapse X and Z, leaving Y as the only axis.
  • shrink=false is the identity: no axis collapses, no coordinate folds.
  • Degenerate boxes don't crash: a 0x0x0 box collapses its X to 1, and folding at the origin still accepts and folds x to 0.

RmtLedDriver

test/unit/light/unit_RmtLedDriver_lifecycle.cpp Also touches: Drivers, Correction.

  • RmtLedDriver sizes the symbol buffer in prepare
  • The resting status is "driving N of M lights" after a build, shown by DEFAULT (the way MoonLed does) — not only after the user touches a control. prepare() re-asserts it after the full build (pins + buffer + counts settled), so a driver that built cleanly always advertises its consumption. This also overwrites any stale transient (e.g. a prior loopback verdict), which must NOT linger as the resting status once the driver is driving lights.
  • The symbol buffer sizes to what the pins CLOCK OUT (txLightCount_), NOT the window. A small strip on one pin (ledsPerPin 64) inside a huge grid (window = all 5740 lights) must reserve symbols for 64, not 5740 — else it tries to alloc ~550 KB it never encodes, the alloc fails on a small-heap board, and the strip goes dark even though only 64 lights were wanted (the bug this pins; ParallelLedDriver already sizes its frame to the driven count, RmtLed did not). ledsPerPin caps the pin; tick() only encodes 64.
  • RmtLedDriver keeps the symbol buffer across a rebuild (reinit must not free it)
  • RmtLedDriver keeps the symbol buffer across a pins change
  • RmtLedDriver grows the symbol buffer when the grid grows
  • RmtLedDriver releases the symbol buffer on release
  • RmtLedDriver: disabling releases the resource, re-enabling re-acquires (applyState)
  • RmtLedDriver: a DISABLED driver does not acquire through the boot sweep
  • MoonModule contract: release reverses setup, so setup→release→setup→release cycles leave no residue — no leaked heap (ASAN in the test runner catches that), no stuck state. After each release the driver must look untouched: no symbol buffer, no status. Run several cycles to surface any accumulation.
  • Conditional control: loopbackRxPin is visible only while loopbackTest is on, hidden otherwise — but always bound (so a saved rxPin loads regardless). Same add-then-setHidden pattern as NetworkModule (architecture.md § Conditional controls). This pins the exact behavior that, with the old UI, showed the pin at the wrong times; a regression in the C++ flag now fails here.
  • loopbackTxPin is the optional TX override (transmit on it instead of pins[0] during the self-test). Like loopbackRxPin it's a conditional control: always bound (so a saved override loads), shown only while loopbackTest is on. The override's effect on the transmitted pin is hardware-only (rmtTxChannels==0 on desktop), but the conditional-visibility contract is host-testable here.
  • Editing pins while the loopback test is ON must refresh the parsed config before the self-test runs — onControlChanged fires before the prepareTree sweep re-parses, so without the in-branch parseConfig() the test would transmit on the OLD pin and show a verdict for it. Mirrors the fix in ParallelLedDriver; this pins the RMT sibling that the dedup left behind. Host-observable via pinCount(): the refresh re-parses to the new pin set even though the platform loopback itself is inert.

test/unit/light/unit_RmtLedDriver_pins.cpp Also touches: Drivers, Correction.

  • "18,17,16" parses to three pins in list order — the order defines the buffer slices.
  • A single pin (the default "18") and spaces around tokens are both fine.
  • A "lo-hi" token expands to the inclusive range — the same idiom as the IP-destination list, so a human types consecutive pins once. Single pins and ranges mix freely in one CSV.
  • A range that runs backwards, or overlaps an existing pin, or overruns the cap, is rejected — the same guards a flat list gets, applied to each expanded pin.
  • parsePinList rejects bad input with a static error message
  • maxPins is the chip's RMT TX-channel cap: 5 pins fail an S3-sized 4, fit a classic 8.
  • The same GPIO twice would double-drive one strand — rejected at parse time.
  • The crash guard (WROVER bench 2026-07-13): a value like 999 parses as a valid integer but is not a GPIO — handing it to IDF's gpio_func_sel() faults ("GPIO number error" → reset). parsePinList rejects the WHOLE list when any entry exceeds the chip's MM_MAX_GPIO ceiling, so a garbage pin never reaches hardware; the driver idles with "pin out of range for this chip" in its status. On the host, MM_MAX_GPIO defaults to 63, so 999 (and 64) are out of range, 63 is the last accepted pin.
  • Explicit "100,100,50" maps one count to each pin by position.
  • A SINGLE number broadcasts: that many on EVERY pin (the NumPy/CSS scalar idiom — "one number = that many each"), NOT "on the first pin only, split the rest".
  • Broadcast is clamped so the running sum never reads past the buffer: with 250 lights and 100/pin, pins 0+1 take 100 each, pin 2 gets the last 50.
  • A LIST shorter than the pin count still maps what it names, then even-splits the rest over the unlisted pins (distinguishes list-of-one-plus-comma from broadcast).
  • assignCounts with an empty list splits evenly, last pin takes the rounding remainder
  • assignCounts clamps so the sum never exceeds the buffer
  • assignCounts clamps a pin to the WS2812 ceiling and warns (drives 2048, not zero)
  • assignCounts handles a zero-light buffer (0×0×0 grid) as all-zero
  • assignCounts rejects a bad token
  • assignCounts ignores extra counts beyond the pin list
  • RmtLedDriver slices the buffer across pins (even split)
  • RmtLedDriver slices the buffer per ledsPerPin
  • RmtLedDriver idles with a status error on a bad pin list
  • RmtLedDriver with the empty default pins idles cleanly (no pin assumed)
  • RmtLedDriver re-slices when the source buffer changes
  • RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer
  • RmtLedDriver window: count 0 means the rest of the buffer from start
  • The DEFAULT window (count_ = kWindowAll = 65535) must mean "all lights", even on a buffer LARGER than 65535. nrOfLightsType is uint32 on a PSRAM board (the desktop test target), so a big grid can exceed 65535 — treating the default as a literal 65535 count would silently cap output there. Tested on the window slice directly (a driver's per-pin WS2812 cap would otherwise mask it).
  • RmtLedDriver window: a size-1 window at 0 is the onboard-LED case
  • RmtLedDriver window: a start past the buffer end yields an empty slice
  • tick() is a safe no-op across single-pin, multi-pin and zero-grid configs.
  • the default timing is the one that drives WS2812B and SK6812 alike
  • selecting 400 kHz doubles the bit cell, which is what a 12V WS2811 strip decodes
  • the WS2811 fast mode keeps the 1.25 us cell with narrower pulses
  • custom timing is taken as the user typed it
  • custom timing that no chip could decode is ordered rather than emitted
  • switching timing takes effect without reconfiguring the pins

test/unit/light/unit_RmtLedEncoder.cpp Also touches: Correction.

  • encoder: one byte, MSB-first, 0 and 1 bits get the right pulse widths
  • encoder: one light's channels emit channels*8 symbols in byte order
  • encoder: GRB ordering comes from Correction, encoder is order-agnostic
  • encoder: RGBW preset yields 32 symbols per light

RotateModifier

test/unit/light/unit_RotateModifier.cpp

  • RotateModifier advertises a live (per-frame) modifier
  • At the initial angle (0) the rotation matrix is the identity — every cell samples itself.
  • z passes through (2D rotation) — a 3D coord's z is untouched.
  • An empty box doesn't divide-by-zero or wrap: the remap is a no-op-ish transform that the Layer's live pass then treats as out-of-box (dark), never a crash.

RubiksCubeEffect

test/unit/light/unit_RubiksCubeEffect.cpp

  • The first frame scrambles a fresh cube and projects it onto the volume: with millis() past t=0 the init() path fires (doInit_ is set at construction), so the buffer holds a drawn cube, not black.
  • Every lit voxel carries exactly one of the six Rubik's face colors — the projection only ever writes COLOR_MAP entries, never a blended or arbitrary RGB.
  • turnsPerSecond=0 disables the turn pacing (tick() returns before rotating), but the cube is still drawn on the first frame — init() runs and paints before the turn gate is reached.
  • The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule): tick() bails on a zero extent and the buffer stays empty.

Scheduler

test/unit/core/unit_Scheduler_unique_names.cpp

  • A name with no collision is returned unchanged.
  • The second module with a duplicate name gets " 2" suffixed; the first keeps its original name.
  • Suffix counting increments past existing "-2" / "-3" suffixes ("Layer", "Layer-2", "Layer" → "Layer-3").
  • deduplicateNamesInTree() walks the entire module tree in one pass and disambiguates every duplicate (used after persistence load).
  • firstByName(name) returns the first match in DFS order, or nullptr if no module carries that name.
  • If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented).

ScratchBuffer

test/unit/core/unit_ScratchBuffer.cpp

  • resize(N) allocates N elements, count()/bytes() reflect it, data() is non-null, and the owner's dynamicBytes tracks the buffer's byte count.
  • (re)alloc zero-fills the buffer.
  • resize(0) frees the buffer and returns the owner's dynamicBytes to zero.
  • A shrink adjusts the owner's total by the signed delta, not by an absolute set.
  • sizeof(T) drives the byte math for non-uint8 element types.
  • release() on the owner frees every registered buffer (the disable-without-destroy path applyState() takes) — the buffer is emptied and the owner's total returns to zero.
  • Multiple buffers on one module each report independently; the owner's total is their sum, and release() frees them all (the StarSky/GameOfLife multi-buffer case).
  • A buffer's destructor frees its heap and deregisters from the still-alive owner — no leak, no dangling list node. (ASAN in the test build is the real guard; this pins the accounting.)

Services

test/unit/core/unit_Services.cpp

  • Services accepts service-role children; System accepts none
  • Services is a thin grouping node — a service child attaches and ticks

SineEffect

test/unit/light/unit_SineEffect.cpp

  • SineEffect writes non-zero RGB data
  • SineEffect amplitude 0 yields a black buffer
  • SineEffect varies across the x axis (R channel follows x)
  • SineEffect survives a 0x0x0 grid

SingleColumnLayout

test/unit/light/unit_SingleColumnLayout.cpp Also touches: GridLayout.

  • Indices are contiguous 0..N-1 and every light sits at the configured x — a gap or a repeat here is a light the driver would never write, so the strip would have a permanently dark pixel.
  • The two layouts must agree: a 1×10×1 grid and a 10-high column are the same physical strip, so they produce identical coordinates in identical order or one of them is wrong.
  • starting Y offsets the coordinates but NOT the indices: the driver writes light 0 first whatever the column's position in space, so an offset that shifted indices would leave the first start_y lights of the strip unwritten.
  • Reversed wiring flips which end of the strip is light 0 — the y values run high to low while the indices still start at 0 and stay contiguous.

SolidEffect

test/unit/light/unit_SolidEffect.cpp

  • Mode 0 (RGB(W)) fills the whole buffer with one uniform color: every light equals red/green/blue.
  • Brightness scales the flat color down per channel (channel * brightness / 255).
  • On an RGBW layer mode 0 writes the white channel too (white scaled by brightness).
  • The effect runs at a degenerate 0×0×0 grid and at every color mode without crashing.

Sort

test/unit/core/unit_Sort.cpp

  • insertionSort orders ints ascending
  • insertionSort with a custom (descending) comparator
  • insertionSort orders C-strings (the device-name use case)
  • insertionSort is stable — equal keys keep input order
  • insertionSort handles empty and single-element arrays

SphereLayout

test/unit/light/unit_SphereLayout.cpp

  • lightCount() must equal the number of points placeLights emits: they share one shell predicate, so allocation and fill can never disagree.
  • The sphere is HOLLOW: the centre lattice point (r,r,r) is never emitted, and neither is any interior point (distance < radius-0.5 from centre).
  • radius = 1 is the smallest hollow sphere: the 6 axis neighbours (d^2=1) plus the 12 edge points (d^2=2) of the centre — 18 lights, no centre.
  • The shell is symmetric about the centre: for every emitted point its mirror through the centre is also emitted (a sphere has no preferred direction).
  • Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights.
  • Default radius is a sensible small sphere (not 0, not huge).

SphereMoveEffect

test/unit/light/unit_SphereMoveEffect.cpp

  • The effect fully clears the buffer each frame, so a thin shell leaves the vast majority of a large volume black (it is a hollow surface, not a solid fill).
  • Every voxel the effect lights is a real palette color (non-black) — the shell is drawn, not left as leftover noise.
  • The effect is 3D-native: it declares D3 dimensions.
  • Hard rule: the effect must run at any grid size without crashing, including a 0×0×0 volume and a 1×1×1 volume (the loop guards w/h/d <= 0 and clamps speed so 100-speed is never zero).

SpiralEffect

test/unit/light/unit_effects_render.cpp Also touches: RingsEffect, RipplesEffect, LavaLampEffect.

  • LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead — same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders).
  • Across 10 frames at bpm=60, at least one frame shows two distinct colors somewhere in the buffer (blobs move and the field varies).
  • RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders).
  • At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict).
  • RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront — assert it renders something and varies across the surface.
  • Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colors (wavefront vs background) — scan the whole buffer, corner-pair would be too strict.

SpscRing

test/unit/core/unit_SpscRing.cpp

  • Elements come out in the order they went in, across many times the capacity (the indices wrap).
  • A full ring drops the NEWEST data: push reports how much was accepted and what was already queued is untouched, the consumer never sees a gap in the middle, only a truncated tail.
  • Two real threads, producer faster than consumer at times and vice versa: every value that push() accepted arrives exactly once, in order, the acquire/release pairing at work.
  • Null buffers degrade to zero-count no-ops (the never-crash floor for a core construct).

StarFieldEffect

test/unit/light/unit_StarFieldEffect.cpp

  • A frame past the speed throttle interval lights at least one star (greyscale, so every lit pixel is a pure grey R==G==B) — the field advances and re-projects stars onto the panel.
  • speed=0 pauses the field: the buffer stays fully black no matter how much virtual time passes.
  • The palette variant lights on-panel stars in color (not forced grey) — usePalette drives hue.
  • Hard rule: the effect runs at a degenerate 0×0×0 grid without crashing (it allocates nothing and the loop bails on the zero dimensions).

StarSkyEffect

test/unit/light/unit_StarSkyEffect.cpp

  • A field of stars lights at least some pixels on a populated 3D grid after a frame.
  • White stars (usePalette=false) paint only greyscale: every lit pixel has R==G==B.
  • A zero fill ratio still seeds a pool (nb_stars = ratio*count/10000 + 1) so a lit pixel appears.
  • The effect survives degenerate grids (0x0x0 and 1x1x1) without crashing — the every-grid-size rule.

SystemModule

test/unit/core/unit_SystemModule.cpp

  • On the desktop platform (MAC DE:AD:BE:EF:CA:FE), the auto-generated device name is "MM-CAFE" (last two MAC bytes).
  • deviceName is bound as a Text control to the MAC-derived default ("MM-CAFE" on the desktop platform).
  • deviceName is the single network identity, so SystemModule keeps it a valid hostname. A live edit to an invalid value ("My Room!") is coerced on the next tick1s tick (mm::sanitizeHostname), the same path mDNS/AP/DHCP read — so they never see spaces.
  • An all-invalid name collapses to empty after sanitising; the MAC fallback then fills it, so deviceName is never empty (mDNS/AP/DHCP always have a name to register).
  • An already-valid name is left untouched (idempotent) — a normal user name survives.
  • The bootReason control is populated from platform::resetReason; on desktop it reports "OK".
  • System is fixed infrastructure — it accepts no user-added children (they live under the Services container). Its own children (Tasks, I2cScan) are wired by code.
  • Regression: SystemModule overrides setup() and tick1s(); both must chain to MoonModule's base so a wired-by-code child's setup()/tick1s() actually fire. Without the chain a fixed child (Tasks/I2cScan) would never init or poll (the "children miss callbacks" trap from history/decisions.md). tick20ms() isn't overridden, so the base default already propagates it.
  • roleName maps the Service enum to its lowercase API string.

test/unit/core/unit_sanitizeHostname.cpp Also touches: NetworkModule.

  • sanitizeHostname leaves a valid hostname unchanged (idempotent)
  • sanitizeHostname replaces spaces with a single dash
  • sanitizeHostname strips punctuation and other invalid chars
  • sanitizeHostname trims leading and trailing dashes / invalid runs
  • sanitizeHostname yields empty for all-invalid input (caller falls back)

TasksModule

test/unit/core/unit_TasksModule.cpp

  • TasksModule: exposes a tasks list + core0/core1, no separate modules list
  • TasksModule: a fixed System module (Generic role, no delete affordance)
  • TasksModule: the empty desktop snapshot is safe (no RTOS on host)
  • TasksModule: the tasks list renders the injected RTOS tasks with their fields
  • The row order: projectMM's OWN tasks (render "main" + "mm"-prefixed workers) float to the top so the user sees them first, RTOS system tasks sink below, alphabetical within each group — regardless of the (unstable) order the RTOS snapshot returns them in. Feed a deliberately-jumbled snapshot and pin the exact resulting order.
  • TasksModule: the render task's detail nests the modules + the ∑/tick cross-check

TetrixEffect

test/unit/light/unit_TetrixEffect.cpp

  • During the initial 2 s start delay every column is idle-waiting, so the very first frame renders nothing: the buffer is entirely black even though the effect is enabled and built.
  • Once virtual time advances past the start delay, columns spawn bricks that fall and render: after a span of frames at least one light is lit, and every lit light carries a real (non-black) RGB color pulled from the palette rather than partial/garbage channels.
  • Effects must run at every grid size: a degenerate 0×0×0 grid and a 1×1 grid both survive a build + several frames across advancing time without crashing (no allocation, no out-of-range write).

TextEffect

test/unit/light/unit_TextEffect.cpp

  • Static text renders glyph pixels top-left. On a grid tall/wide enough for one line of the 6x8 font, a non-empty string lights some pixels; an empty string lights none.
  • The hue default is 128 (mid-palette), not 0: palette index 0 is BLACK in several palettes, so a hue-0 default would render invisible text on those. Pinning the default guards against a silent regression back to 0.
  • A multi-line string wraps: the second line renders on a lower row (font-height down), so a two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6).
  • Scroll mode advances the text over time and never crashes; on a degenerate grid it's a safe no-op.

TransposeModifier

test/unit/light/unit_TransposeModifier.cpp

  • Default (XY on): x and y swap on both the box and the coordinate; z is untouched.
  • XZ swaps x and z; YZ swaps y and z. Only the selected pair moves.
  • inverse flips an axis back-to-front within the TRANSPOSED box: x -> size.x-1-x. With the default XY swap, inverse X flips the (post-swap) x axis, whose span is the original box height. On a {128,64,z} box the transposed x span is 64.
  • Degenerate boxes don't crash and the swap still applies to whatever extent exists.

Uncategorized

test/unit/light/unit_Layer_persistence.cpp

  • Layer: buffer persists across frames (no per-frame clear)
  • A trail decays with TIME, not with frames. Ticking repeatedly inside the same millisecond therefore fades almost nothing: the frames are real (a fast device draws the motion more smoothly) but no time has passed for the decay to spend. This is the property that makes a tail the same length on a 470 fps board and a 140,000 fps desktop, and it is the whole reason the Layer scales the requested rate rather than applying it once per frame.
  • Layer: multiple fade requests combine with MIN (gentlest wins, longest trail)
  • Layer: collected fade resets after it is consumed
  • Layer: prepare clears the buffer (a rebuild wipes stale pixels)
  • A SCRIPT asking for the same fade a compiled effect asks for. This is the end-to-end seam: fade(amt) in the script text reaches the layer's collected fade through the binding, so a scripted effect gets trails on exactly the terms a C++ effect does. Needs a JIT backend, since nothing runs without one.
  • What a trail actually IS, and the trap behind it: the buffer persists, so a fade applied EVERY frame decays a pixel by the frame rate, not by the motion. A desktop renders thousands of frames while a slow dot sits in one pixel, so a per-frame fade erases the tail long before the dot moves and the effect reads as "no trail" though every part works. A script that fades only when its subject MOVES gets the same trail on any renderer.

test/unit/core/unit_PartitionTables.cpp

  • the CSV number parser rejects signed and uint32-overflowing values
  • every partition table describes a layout that fits its flash without overlaps
  • app partitions are 64 KB aligned, as the MMU requires
  • a table carries either two OTA slots or one slot plus a recovery app, never a mix

WaveEffect

test/unit/light/unit_WaveEffect.cpp

  • WaveEffect: sawtooth ramps 0→top across the phase
  • WaveEffect: triangle peaks in the middle and returns
  • WaveEffect: sine sits mid at the zero crossings
  • WaveEffect: square is low then high
  • WaveEffect: every type stays within the grid bounds
  • WaveEffect: a zero-height grid never reads out of bounds

WheelLayout

test/unit/light/unit_WheelLayout.cpp

  • WheelLayout lightCount = spokes * ledsPerSpoke and matches the iterator
  • WheelLayout indices are dense [0, count)
  • WheelLayout coordinates are non-negative (centre-shifted into address space)
  • WheelLayout different spoke counts give different layouts

WledAudioSyncPacket

test/unit/light/unit_WledAudioSyncPacket.cpp

  • build produces a 44-byte v2 packet with the exact WLED layout
  • WLED clamps every band to 254 on send (constrain(fftResult[i], 0, 254)), so 255 never appears on the wire. A receiver written against WLED may treat 255 as a value real data cannot carry. The magnitude crosses in WLED's units and comes back in ours, so a projectMM pair round-trips exactly while a WLED peer reads the value its own effects expect.
  • A real WLED source reaches ~9500, far past our 0..255. Clamping keeps a received frame from driving effects harder than a locally analyzed one ever could.
  • bands are clamped to 254, the way WLED sends them
  • build -> parse round-trips every AudioFrame field
  • parse rejects wrong length, wrong header, v1, and null
  • parse clamps NaN / out-of-range floats instead of undefined casts
  • golden vector — the exact bytes on the wire (the compatibility contract)

WledPacket

test/unit/core/unit_WledPacket.cpp

  • WledPacket::build produces a valid WLED header (token/id/size)
  • WledPacket::readName round-trips the device name
  • WledPacket marker is set only when stamped, and stays WLED-valid
  • WledPacket::isValid rejects short / wrong-magic / null input
  • WledPacket::readName truncates a long name to the buffer, never overruns

crc

test/unit/core/unit_crc.cpp

  • CRC-16/CCITT-FALSE has a well-known check value: "123456789" → 0x29B1. Pinning it proves the polynomial/init/reflection match the standard variant (so a fingerprint computed here matches any other CCITT-FALSE implementation).
  • A change-detector: different content → (almost always) different CRC; identical content → same.
  • Empty span returns the init value (no bytes processed).

draw

test/unit/light/unit_Bar.cpp Also touches: Canvas, GEQEffect, AudioSpectrumEffect.

  • The VU-meter direction: a horizontal bar lit left-to-right, the AudioSpectrum level row.
  • The GEQ direction, and the one most likely to be got backwards: row 0 is the TOP of the grid, so a bar rising from the floor walks toward DECREASING y.
  • bars grow down and left from their origin
  • The reason the color is a callback: every real call site varies color ALONG the bar. The index is the distance from the origin, which is the number those formulas already computed.
  • Robustness: a magnitude that maps past the grid must not write outside it. This is the case a hand-rolled loop gets wrong when the band value is at full scale.
  • a bar of zero or negative length draws nothing
  • a filled rectangle covers its whole area
  • a filled rectangle colors by row
  • a rectangle outline draws its border and leaves the middle dark
  • The degenerate rectangles a control value can produce at its extremes.
  • The dimension-generic rule: a meter on a 1D strip is a normal case, not an error.
  • Meters run on non-RGB fixtures too; a bar must write only the channels a light has.

test/unit/light/unit_Canvas.cpp Also touches: EffectBase, Layer.

  • The depth guard, which is the whole reason sixteen effects carry a private helper: a 2D layer reports depth 0, and an unguarded z stride of 0 collapses every z onto the same plane.
  • Canvas addresses x fastest, then y, then z
  • Out-of-grid coordinates report the buffer size, which every draw call treats as "skip" — the clipping contract, expressed once instead of at each call site.
  • Canvas pixel writes land where get reads them, and clip outside
  • A 4-channel (RGBW) surface: the W channel belongs to the driver, so a pixel write leaves it alone — the same contract the (Buffer&, dims) form already has, preserved through Canvas.
  • The Canvas and (Buffer&, dims) forms must address identically, or the migration would silently move pixels. This is the property the golden-frame tests depend on.
  • EffectBase::canvas() is what removes the per-effect preamble, so it must report the layer's live extents — including the depth guard — rather than anything cached.

test/unit/light/unit_Circle.cpp Also touches: Canvas.

  • a circle outline is symmetric about its center
  • a circle outline is hollow and sits at the radius
  • a zero-radius circle is a single pixel
  • a filled circle is solid from center to rim
  • A disc must cover strictly more than its outline — the check that catches a fill that only painted the rim.
  • a filled circle colors by its row offset from the center
  • Robustness: a circle bigger than the grid draws the part that lands, and writes nothing outside.
  • a negative radius draws nothing
  • The reason lineAA exists: a diagonal that is not at 45 degrees lands between cells, and Wu splits it rather than snapping. A perfectly diagonal line has nothing to split.
  • an anti-aliased line reaches both endpoints
  • Conservation: splitting light between two cells must not create any. Each step contributes one pixel's worth, so the total tracks the line's length rather than its slope.
  • an anti-aliased line handles steep and reversed directions
  • an anti-aliased line of zero length is a single point
  • an anti-aliased line running off the grid clips instead of overflowing

test/unit/light/unit_Scroll.cpp Also touches: Canvas, FreqMatrixEffect.

  • scrolling right moves content toward increasing x and darkens the vacated edge
  • scrolling left moves content toward decreasing x
  • The FreqMatrix case: a column shifted one step away from the source end, new content painted at the freed row. This is the loop the effect used to write by hand.
  • scrolling up moves rows toward the top
  • A wrap loses nothing: it is a rotation, so the same lights are present in new places.
  • a wrapping scroll the length of the axis leaves the grid unchanged
  • a wrapping column scroll rotates rows
  • Overshoot: a shift longer than the axis leaves nothing behind, and must not read out of bounds.
  • a scroll of zero steps changes nothing
  • Each row scrolls independently: a 2D shift must not drag one row's content into the next.
  • scrolling z moves whole slices in a volume
  • scrolling a degenerate axis does nothing
  • Non-RGB fixtures scroll too; a light must carry exactly its own channels.
  • A wrapping scroll on a STRIDED axis (a column, not a row) rotates cells that are not adjacent in memory, and must move a light's whole channel set whatever that count is — a DMX moving head can carry far more than RGBW. The rotation swaps cells in place rather than copying through a temporary, so there is no fixed-size buffer to overflow and no channel ceiling to pick; this case pins that a wide fixture rotates intact.
  • a strided wrap conserves every light, whatever the rotation

test/unit/light/unit_Sdf.cpp Also touches: math16.

  • The squared form is the one effects reach for by default (measured: ~14 cycles/pixel against ~108 for the sqrt on an ESP32-S3), so its sign contract matters most.
  • sdCircle returns a true distance in sub-pixel units
  • The dimension-generic claim in concrete form: the same expression is a circle here and a sphere in a volume, because only the length term changes. A grid of samples must agree with the radius.
  • sdBox is negative inside and grows with distance outside
  • A box's half-extents are independent per axis, which is what makes it a rectangle rather than a square — the bar/rect primitives build on this.
  • sdSegment measures distance to the nearest point on the line
  • sdSegment treats a zero-length segment as a point
  • smin is what makes SDFs worth having over a rasteriser: two shapes merge into one form instead of simply overlapping.
  • smin pulls two nearby shapes together below either alone
  • A large blend radius is reachable from a control on a big fixture, and the intermediate k * h * (256 - h) overflows int32 past ~131000 sub-units. An overflow makes smin return MORE than both inputs, inverting the blend — so the invariant to pin is that it never exceeds the smaller input. Found by review. Two equal distances put the blend at its deepest, where k * h * (256 - h) is largest — the term that overflows int32 once k passes ~131000 sub-units (512 pixels), a radius a control reaches on a large fixture. At h = 128 the dip is exactly k/4, so the exact value is what catches a wrap: the 32-bit form returned a dip of 9464 at k=300000 where k/4 is 75000, and wandered rather than grew (34464, 59464, then back to 18928). Found by review.
  • smin leaves distant shapes alone
  • Coverage is the anti-aliasing an SDF gives for free: the edge pixel is lit in proportion to how much of it the shape covers, which is what stops a curve reading as a staircase.
  • coverage with a zero-width edge is a hard threshold
  • The composition the SDF family exists for: a shape, an outline of it, and a glow, all read off the same distance without a second algorithm.

test/unit/light/unit_Splat.cpp Also touches: Canvas.

  • splat on an exact pixel lights only that pixel
  • The point of the primitive: a position between pixels lights both, in proportion.
  • splat weights follow the distance to each neighbour
  • Conservation, the property that keeps motion smooth: total light is the same wherever the point sits, so a moving dot does not pulse as it crosses pixel boundaries.
  • splat is additive, so two points on one pixel brighten it
  • splat saturates rather than wrapping to black
  • Clipping: a point at or past the edge contributes only the part that lands on the grid, and never writes outside it.
  • A strand is the degenerate case the dimension-generic rule promises: the same call works, and the axis with extent 1 simply has no second neighbour to share with.
  • splat spreads over eight neighbours in a volume
  • toPixel floors so a negative fraction lands in the pixel that contains it
  • An effect must write only the channels its lights have. Three bytes into a 1- or 2-channel buffer stays in bounds but spills into the NEXT light, which no crash test can see — the bug class that hid in WaveEffect and SolidEffect until a reviewer pointed at it. A canary in the light after the written one is what detects it.

test/unit/light/unit_draw.cpp

  • mm::draw::pixel() writes inside the grid and silently clips outside it (no out-of-bounds write).
  • A 1D line (a row): every pixel from a.x to b.x inclusive is lit.
  • A 2D diagonal: endpoints are lit and the line is contiguous (one pixel per step on the main diagonal of a square).
  • A 3D line: drives all three axes, endpoints lit, no out-of-bounds on a small cube.
  • A line running off the grid clips: it draws the on-grid part and stops, no crash.
  • The shorten parameter pulls the far endpoint back toward a by shorten/255 (with WLEDMM 2 rounding), so an effect can sweep a partial segment. For a→b = (0,0)→(8,0): shorten 255 draws the whole line (tip at 8), 128 ≈ half (tip at (16128/255+1)/2 = 4), 1 = just the start pixel (tip 0), 0 = nothing. This pins the rounding of the shorten branch.
  • draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same behavior as FastLED blur1d / MoonLight blurRows), and is symmetric around a centerd bright pixel.
  • blur runs separably on every axis with extent>1: a 2D blur spreads a center pixel to all four orthogonal neighbors; a 3D blur reaches the z neighbors too. And it never writes out of bounds.
  • A glyph blits in the correct orientation: neither X-mirrored (a 'b' as a 'd') nor Y-flipped. 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter upright and un-mirrored.
  • draw::sprite, the multi-color sibling of glyph: palette-indexed frames with index 0 as the transparent key. These pin the contract a screensaver effect stands on: the right frame at the right place, holes where the key is, silence at the edges and on bad indices.
  • draw::sprite clips at every edge and survives bad frame and palette indices
  • Art that faces one way serves both: flipX mirrors the sprite's READ, so the blit still lands at the same (x, y) with the same footprint rather than needing a mirrored copy of every frame.
  • decay dims a plane by half over one half-life at a realistic frame time
  • a 16-bit trail plane decays at the same rate whatever the framerate
  • decay leaves a plane alone when no time has passed, and clears it after a long stall
  • advect carries the picture along the flow, one whole pixel at a time
  • a uniform field survives being advected, so a flow does not dim what it carries
  • the edge rule decides whether a flow loops the grid or leaves it
  • advect moves every slice of a volume, so a cube flows like a panel
  • a disc lights its interior and softens its edge
  • two overlapping discs brighten where they meet, because light adds
  • a sphere fills a volume, so a cube gets a ball rather than a stack of discs
  • the wind blows every point the same way
  • a radial flow points away from its center, and inward when reversed
  • a spiral is a radial flow with a turn added, so it both circles and escapes
  • truncating a slow gradient bands it, and dithering restores its true mean
  • temporal dithering resolves neighbors that a single frame cannot
  • a dithered value never wraps past full, so a bright light cannot flash black
  • the ordered pattern differs per z, so a volume does not repeat one texture
  • an upscaled plane keeps a uniform value, so a flat field does not gain texture
  • an upscaled ramp stays monotonic, so a gradient does not gain steps or reversals
  • a flat field stretches across a volume's depth without z work
  • a volumetric field interpolates along z as well, so a coarse cube fills a fine one
  • an upscaled saddle stays inside the values it was given, so a field that dips does not light up
  • upscale16 declines rather than writing when the caller's tap table is too small
  • a 16-bit plane carried by a whole-cell flow arrives intact, so light is transported rather than smeared away
  • a half-cell flow splits a 16-bit sample between the two cells it straddles, and loses none of it
  • a flow off the edge circulates under Wrap and carries the light out of the grid under Clamp
  • blit16 narrows a wide plane to the canvas, and dithering carries the error a truncation would drop
  • scrolling along y moves every column, and along z every cell of the volume

light_types

test/unit/light/unit_Coord3D.cpp

  • Coord3D arithmetic is per-axis
  • Coord3D modulo and divide fold per axis
  • Coord3D % and / guard a zero or degenerate axis
  • Coord3D equality

math16

test/unit/core/unit_math16.cpp Also touches: math8.

  • sin16 traces a full sine over one turn
  • The reason the 16-bit tier exists: on a large fixture an 8-bit sine steps visibly. Sampling finer than the 8-bit LUT's resolution must produce intermediate values, not a staircase.
  • Accuracy against the ideal sine: the claim in the design doc is ~0.2% of amplitude, which is what makes the zero-extra-flash implementation acceptable instead of a bigger table.
  • map32 maps a range and clamps outside it
  • The fencepost six effects each carried a comment about: mapping an audio band to a grid column must be able to reach the LAST column, which the naive (n-1)/(max-1) form loses.
  • Full-width ranges: an int32 subtraction of INT32_MIN from INT32_MAX overflows, so every operand widens before the arithmetic. A mapping engine that corrupts at the extremes would misplace pixels silently rather than crash, which is the worst failure mode.
  • constexpr: the contract is compile-time evaluable, so a table or a control default can be built from it without runtime cost.
  • BeatPhase keeps animating when frames are under a millisecond
  • BeatPhase advances proportionally to elapsed time and rate
  • BeatPhase holds still at rate zero and resets to zero
  • millis() wraps every ~49 days; unsigned subtraction gives the correct delta across the wrap, so a long-running device must not see the phase jump backwards or leap.
  • atan16 spans the circle in the right quadrants
  • atan16 puts the diagonals halfway between the axes
  • The reason for the 16-bit form: a sweep must be smooth. Sampling finer than the 8-bit atan2 can resolve has to produce distinct, increasing angles rather than a staircase.
  • A fitted polynomial was tried here first and measured 9.6 degrees of error at the octant boundary; the table form measures 0.015. The bound below is tight enough to catch a return to the polynomial, which is the regression worth pinning.
  • dist16 is a true radius, where dist8 approximates an octagon
  • dist16 does not saturate past the 8-bit ceiling
  • easing curves start at zero and finish at full
  • The property that makes an easing an easing: it never runs backwards, so motion through it cannot stutter or reverse.
  • In-out easings are slow at the ends and fast in the middle — the difference from a linear ramp, and the reason motion through them looks deliberate rather than mechanical.
  • ease out starts fast and settles
  • smoothFollow moves toward its target without overshooting
  • smoothFollow rate sets how quickly it converges
  • smoothFollow falls toward a lower target too
  • The asymmetry IS the meter: instant attack catches the transient, slow decay leaves something to read. A symmetric follower would show neither.
  • peakHold holds a peak for many frames after a transient
  • The supersync property: randomness addressed by POSITION, not drawn from a stream. Two devices computing the same pixel must get the same value regardless of frame count or light count.
  • hashInt gives neighboring pixels unrelated values
  • hashInt spreads across its range
  • hashInt separates its axes, so x and y are not interchangeable
  • A follower must converge from either direction. The shift form truncated toward zero, so a small rate moved DOWN by one but stalled going UP — a meter that could fall and never rise.
  • smoothFollow never overshoots its target
  • dist16 saturated at 65535 far inside normal range: two coordinates of 70000 square-and-sum past UINT32_MAX, so the 32-bit root reported 65535 for a distance of 70000.
  • dist16 handles the full int32 range without wrapping
  • isqrt64 roots values a 32-bit root cannot hold
  • kaleido: the review asked for coverage of non-divisor segment counts, where 65536/segments has a remainder and the last wedge is a different width.
  • kaleido is the identity below two segments
  • isqrt64 exists so a squared distance wider than 32 bits still has a root — a large contact radius in sub-pixel units squares past int32. The Newton iteration has to reach that top of range without its own arithmetic overflowing on the way.
  • atan16 folds into the first octant by taking the magnitude of each axis. INT32_MIN is the one value whose negation has no int32 representation, so the obvious fold is undefined behaviour at exactly one input per axis — and a coordinate reaches it whenever a caller passes an unclamped difference. The angles below are the correct quadrants: due-left, straight-down, and the diagonal between them.
  • The 8-bit waveforms cap at 255, which quantises coarsely across the fixtures this drives — a 128x128 wall indexed through a 0..255 ramp moves in steps, not smoothly. These are the same textbook shapes at full range, so a position scales to any axis length without rescaling.
  • a beat completes one full cycle per beat, at any tempo
  • a half-life decay loses exactly half its value over one half-life
  • two decay steps reach the same place as one step of twice the time
  • halving twice as often dims at the same rate, so framerate cannot change a trail

math8

test/unit/core/unit_math8.cpp

  • sin8: a 256-entry sine LUT centred on 128, peaking near 255 and 0 a quarter and three-quarters of the way round. cos8 is sin8 shifted a quarter turn.
  • triwave8: linear up 0→255 then down 255→0, peaking at the midpoint.
  • qadd8/qsub8 clamp at the 0..255 ends instead of wrapping.
  • nscale8 is the recognisable spelling of scale8 (n/256 channel scale), so nscale8(x,255)==x.
  • beat8: a sawtooth completing bpm cycles per minute. At t=0 it's 0; halfway through a beat ~128.
  • beatsin8: a sine oscillating in [low,high] at bpm. Stays in range across the cycle and actually moves (not stuck at one value).
  • Random8: a seeded PRNG — same seed gives the same sequence (determinism), and below(n) stays under n. Two different seeds diverge.
  • atan2_8 / dist8: the geometry helpers moved here from color.h still behave.
  • map8 rescales 0..255 onto [lo,hi] inclusively — the top of the input must REACH hi (FastLED's map8 == map(in,0,255,lo,hi)). Regression: an earlier scale8-based form left hi unreachable, so a one-step span (a bar height of 1) collapsed to 0 — the bug GEQ3D's height mapping hit.

noise

test/unit/core/unit_fields.cpp Also touches: math16.

  • Summing octaves must NORMALISE, not accumulate: without dividing by the total amplitude the sum would run past the field's range and clip. v <= 255 cannot show this — a uint8_t satisfies it by construction — so the check is that adding octaves never pushes the result outside the span its own samples occupy.
  • one octave of fbm is plain noise
  • The reason fbm exists: successive octaves add structure at scales the base field has none at. A single octave only varies over whole 256-unit cells, so at a sixteenth-cell step it is nearly a straight ramp; three octaves visibly bend between the same points.
  • fbm with no octaves is a flat field
  • A field must be a FIELD: neighboring points are similar, distant points are not. This is what separates noise from a raw hash, and it must survive the octave sum.
  • 3D fbm varies along z, so z can drive time
  • Turbulence creases the field at the midpoint; the creases are the billowing look. Folding around 128 means the result is built from magnitudes, so it sits low rather than centred.
  • Warp is the domain displacement: sampling through it must NOT give the same field back, or the displacement did nothing.
  • warp with zero strength is the field itself
  • warp stays a smooth field rather than becoming noise
  • kaleido folds the circle into the requested number of wedges
  • kaleido with fewer than two segments changes nothing
  • The property that makes a kaleidoscope: rotating by one full wedge gives the same output, which is what makes the pattern repeat around the circle.
  • The seam is where an off-by-one shows: wedge - within maps 0 to wedge, one past the end, so every boundary carried a one-unit jump. Stepping across each seam must move by ONE unit, the same as stepping anywhere else — a reviewer found the original off-by-one here.
  • Mirroring (rather than repeating) alternate wedges is what makes the seams join instead of showing a hard edge at every boundary.

test/unit/core/unit_noise.cpp

  • Determinism: the same coordinate always gives the same value (a pure function of position), so a field is reproducible frame to frame and across the 2D/3D entry points at z = 0.
  • Smoothness: neighboring positions WITHIN a cell (sub-256 steps) differ only a little: that's what makes it noise rather than a raw hash (which would jump randomly every step).
  • Range: output is a full byte; over a swept field it uses a wide span (not stuck near one value).
  • 16-bit noise fills the range instead of stepping through 256 levels
  • 16-bit interpolation stays exact across the full range
  • 16-bit noise is smooth where the 8-bit form would step
  • fbm keeps its full range however many octaves are summed
  • 16-bit fbm keeps its range too
  • The contract that makes the field library dimension-generic: a 2D call is the 3D call with the missing axis at zero. Without it a volumetric fixture and a panel would sample different fields for the same coordinates, and an effect could not simply pass z through.
  • the z axis actually changes the field, rather than being carried and ignored
  • a curl field has no sources or sinks, so what it carries cannot pile up

oscillators

test/unit/core/unit_Oscillators.cpp Also touches: math16.

  • an oscillator stays inside the range the effect asked for
  • a range given backwards runs the shape backwards
  • two oscillators at the same rate hold their phase relationship indefinitely
  • a rate of zero holds the picture still
  • changing the rate continues from where the phase stands, without jumping
  • the four waveforms have the shapes their names promise
  • a square wave is only ever fully on or fully off
  • an out-of-range oscillator index reads as zero rather than crashing the device
  • the first frame establishes the time base instead of jumping the phase
  • reset restarts the motion without losing the configuration

particles

test/unit/light/unit_Particles.cpp Also touches: draw, math16.

  • a fresh pool is valid and entirely dead
  • spawning brings a particle to life and consumes a slot
  • A pool is a fixed budget: an emitter must be told when it is full rather than overwriting a living particle, which would make bursts eat each other.
  • a particle spawned with no life still appears for one frame
  • step moves a particle by its velocity
  • a particle with no velocity stays where it was put
  • The reason for semi-implicit Euler: under a constant force the particle must ACCELERATE, each frame covering more ground than the last. An integrator that applied the old velocity would move at a constant rate for the first frame and lag permanently.
  • a force pushes along both axes
  • drag slows a particle without reversing it
  • zero drag leaves velocity untouched
  • Drag must converge toward rest rather than oscillating or sticking at a nonzero floor.
  • aging kills a particle and frees its slot for reuse
  • aging never wraps past zero
  • a rate of zero makes the pool immortal
  • Dead particles must cost nothing but their slot: no movement, no force, no draw.
  • a particle bounces off a wall and reverses direction
  • restitution sets how much speed a bounce keeps
  • A ball dropped under gravity onto a lossy floor must settle, not gain energy — the classic integrator bug is a bounce that grows.
  • particles outside the grid can be killed instead of bounced
  • an angled burst emits the requested number of particles
  • a burst stops early rather than overflowing the pool
  • A burst must actually spread: particles all sharing one velocity would read as a single moving dot rather than an explosion.
  • The supersync property: the same seed must produce the same burst on any device, which a stream RNG could not guarantee.
  • an attractor pulls a particle toward it
  • The near-field clamp: a particle sitting on the attractor must not receive an unbounded impulse, which is where an unclamped inverse-square divides by zero.
  • rendering lights the grid where particles are, and nowhere else
  • an empty pool renders nothing
  • Brightness rides ttl, so a particle fades out as it dies without the effect tracking a second quantity.
  • A particle outside the grid must clip rather than write out of bounds — the robustness rule the draw primitives already follow, checked here because the pool is a new caller of them.
  • An invalid pool is a real state: an effect whose ScratchBuffer allocation failed must degrade, never crash.
  • The documented rounding trap: -1 >> 1 is -1, not 0, so a signed right shift rounds negative values away from zero and positive ones toward it. Applied per frame that asymmetry is a drift — particles moving left creep further than particles moving right. Scaling must be symmetric.
  • a bounce loses the same speed in either direction
  • The 3.4 accumulator: a force below one velocity unit per frame would truncate to nothing, so gentle wind and weak attractors would simply be invisible. The fraction has to build up and spill.
  • a sub-unit force does nothing on its first frame, then accumulates
  • a sub-unit force works in both directions
  • The accumulator is optional storage: an effect that does not need sub-unit forces should not have to allocate for it, and calling the function without it must degrade rather than crash.
  • The system rule (architecture.md): everything that changes over time is driven by elapsed time, never by the frame count. A pool advanced a fixed amount per frame has physics that are a property of the hardware — the same gravity is an explosion at 5000 fps and a drift at 60. Simulating the same span of real time at wildly different framerates must land a particle in the same place.
  • A frame faster than the millisecond timer reports dt == 0. Dropping those frames' share of a force would leave a very fast device with no gravity at all, so the remainder has to carry.
  • two particles approaching head-on bounce apart
  • particles too far apart do not interact
  • Already-separating pairs must be left alone, or a particle that has just bounced gets kicked again and the pair sticks together vibrating.
  • The documented trap: pushing BOTH particles apart makes each shove create the overlap the other resolves, so the pair jitters forever. Exactly one moves, and the pair must settle.
  • collisions with a zero radius do nothing
  • a spray emits the requested number of particles in varied directions
  • the same seed produces the same spray
  • A byte capped life at 255 reference frames — about 4.25 s at 60 Hz — so slow smoke, drifting snow and long fades were not expressible at all. WLED-PS uses the same width for the same reason.
  • Wrapping is the third wall behavior beside bounce and killOutside, and the one an endless field needs: snow, rain and marquees want to re-enter, not rattle in a box or fall off a cliff.
  • wrapping can be enabled per axis
  • Wrapping reduces by modulo rather than by repeated subtraction, so a particle thrown a long way out costs the same as one just over the line. These pin the exact landing points, including the two edges, because "somewhere back inside" would pass for an implementation that is off by a span.
  • The two edges are deliberately not symmetric: coming down from above stops AT the far edge, while climbing from below stops at 0. Both name the same point on a wrapped axis, and the wall passes agree with this, so it is pinned rather than left to drift.
  • wrapping leaves a particle already inside the grid untouched
  • Size is what makes a pool read as blobs rather than a scatter of points — the signature look of a particle system, and its absence is the likeliest "these aren't real particles" complaint.
  • a pool without size storage still renders every particle
  • FrameTime is the branch's shared answer to "how much of a reference frame did this frame cover". Its reference PERIOD has to be exact: deriving it as 1000 / referenceHz truncates to 16 ms for 60 Hz, which is a 62.5 Hz reference, and every 60-fps-calibrated setting in the codebase then runs about 4% fast — invisible per frame, a drift of seconds over a minute.
  • Slots map onto DISTINCT lanes at any count. Effects assign slots by species or role, and species differ in speed, so a straight stride sorts the scene and the fast ones bunch at one edge; the interleave exists to mix them. Its step must be coprime with the count, or the mapping collapses: (i * 5) % 5 is zero for every i, which stacked a five-character cast on one row (bench, PacmanEffect).
  • The interleave is the point: consecutive slots must not land on adjacent lanes, or assigning slots by species puts every fast one together regardless of the lanes being distinct.
  • audio-reactive sprites: the behavior a viewer judges is "it moves with the music, and it stops when the music stops". Both halves are pinned here because both were explicit requirements.
  • Without an audio source the sprites keep moving normally
  • Loud music moves the sprites faster than quiet music
  • The point of a per-sprite band rather than one overall volume: a bass-heavy moment moves the bass sprites and leaves the treble ones alone, so the scene never surges as a single block.
  • The bands must be spread over the sprites that EXIST, not over the pool's capacity. Passing the capacity (a Pool is allocated for the maximum, then partly filled) crowds every live sprite into the low bands and leaves the treble driving nothing at all: with 5 sprites in a pool of 12, i * 16 / 12 yields bands 0,1,2,4,5 - all bass. Caught in review after shipping; the earlier tests missed it because they all passed the live count as slots.
  • Pool::stepDriven is the shared entry point the sprite effects use, so the rules ride on it too.

platform

test/unit/core/unit_TcpConnect.cpp

  • connectStart/connectPoll — the NON-BLOCKING outbound connect MqttModule uses so it never stalls Scheduler::tick (reviewer #2). connectStart returns immediately; connectPoll reports Pending/Connected/Failed without blocking. Over loopback the connect completes within a few polls.
  • connectStart on a bad/empty host fails immediately (no hang); connectPoll on an unstarted connection reports Failed rather than blocking.

test/unit/core/unit_platform_clock.cpp

  • setTestNowMs freezes platform::millis() to the given value; passing 0 restores the real clock so subsequent test cases see fresh time.

test/unit/core/unit_platform_worker.cpp

  • worker seam: each notify wakes the task exactly once
  • worker seam: waitNotify times out and returns false when not notified
  • worker seam: stopPinnedTask joins — the fn has returned before it returns
  • TryLock: acquiring excludes a second acquirer, release re-opens it
  • TryLock: a second THREAD is refused while held, and never blocks
  • TryLock: LockGuard releases on scope exit, and no-ops when busy

polar

test/unit/light/unit_PolarLut.cpp Also touches: math16, ScratchBuffer.

  • the table gives the same angle a radial effect would compute per pixel
  • the 8-bit table is the same address at 256 steps, which is what it costs half the memory for
  • the radius runs from nothing at the center to full scale at the furthest corner
  • a wide panel fills to its edges instead of banding in a circle inside it
  • angles point the way the geometry says
  • opposite sides of the center face opposite ways
  • resizing the grid rebuilds the table for the new geometry
  • preparing the same geometry again costs nothing
  • switching precision live does not hold both tables
  • the tables are the module's memory, freed with it
  • an empty grid reports not ready instead of building a table of nothing
  • a single-pixel grid is its own center
  • the index form and the coordinate form address the same pixel
  • the table is refused rather than taking the last of a small heap
  • a refused table leaves nothing allocated behind
  • releasing the table gives the memory back and the next prepare rebuilds it
  • at one light deep every projection is the flat address, so no panel changes
  • cylindrical carries depth separately, so every slice reads the same address
  • radial measures distance from the center of the volume, so the field reads as shells
  • spherical adds an elevation, so a sphere maps evenly instead of pinching
  • only spherical pays for the third table
  • switching projection live rebuilds the table and frees what the new one does not need
  • the volumetric index matches the buffer's own ordering
  • an effect can bind the polar controls before it has a fixture

test/unit/light/unit_PolarLut_equivalence.cpp Also touches: PolarNoiseEffect.

  • the 16-bit table renders exactly what computing the address per pixel renders
  • the 8-bit table costs a quantized angle, and nothing else
  • an effect still renders when the polar table cannot be built
  • the computed fallback keeps the mapping the table would have used

shader

test/unit/light/unit_Shader.cpp Also touches: draw, math16.

  • clamp holds a value inside its range
  • mix interpolates between its endpoints
  • fract is what makes a pattern repeat: scaling then taking the fraction tiles a design with no branch and no modulo, so the same shader draws one shape or a thousand.
  • step is a hard threshold at the edge
  • smoothstep is the anti-aliasing workhorse: a hard edge run through it becomes a soft one of controllable width, which is how a shader avoids jaggies without supersampling.
  • smoothstep is flat at both ends, steep in the middle
  • smoothstep clamps outside its edges rather than running away
  • length measures distance from the origin
  • Rotating the COORDINATE is how a shader spins a whole design, however complex, for one operation.
  • a full turn returns a point to where it started
  • uv is the mapping every shader starts from; getting it wrong is why a design stretches on a non-square panel. The SHORT side spans -1..1 so a circle stays a circle.
  • uv keeps a circle circular on a wide panel
  • repeat is the operator that makes one shape into a lattice — the space folds, the objects do not multiply, so a thousand of them cost the same as one.
  • repeat with a zero cell leaves the coordinate alone
  • mirror folds space about the origin
  • The SDF operators: given two shapes as distances, produce a third. This is why an SDF scene is composed rather than drawn.
  • opShell hollows a shape into an outline
  • opRound grows a shape outward
  • rounding a box pulls its corner in
  • a polygon is negative inside and positive outside
  • a polygon with too few sides falls back to a circle
  • The cosine palette carries a whole colour ramp as twelve numbers rather than a table.
  • mixing colours interpolates each channel
  • The runner is what makes an effect a SHADER: one function of position and time, and the framework does the loop, the mapping and the write.
  • the shader runner gives each pixel a different coordinate
  • The whole of perspective is one divide: distance shrinks things in exact proportion. Three effects hand-rolled this before it was shared.
  • a point on the view axis projects to the centre
  • A point at or behind the viewer has no projection. Dividing anyway wraps it round to the front — the classic artifact of a missing near-plane check.
  • a longer lens magnifies
  • depth fade dims with distance and stops at the far plane
  • A point near the near plane projects arbitrarily far out. The divide is exact in 64 bits, but the result need not fit the int32 the caller gets back — and truncating it wraps a point off one edge of the screen to the other, which reads as geometry tearing across the panel.