Repository navigation
Play FSEQ shows from any mount, flash or SD alike - #148
TouchMyLight wants to merge 7 commits into
Conversation
FseqPlayerEffect plays pre-rendered FSEQ animations (the xLights/Falcon
Player format) from anywhere the file manager reaches, as a playlist with
shuffle/loop modes and a full transport (play/pause/stop/previous/next).
Zlib-compressed files decode on ESP32 only; zstd isn't decoded by any
target yet.
KPI: 256lights | Desktop:3093KB | tick:5/6/471/8/5/13/5/4/18/5us(FPS:200000/166666/2123/125000/200000/76923/200000/250000/55555/200000) | src:332(84020) | test:245(56823) | functions-over:350
Commit: src +979/-32 lines | test +874/-27 lines | duplicated lines 3586 -> 3586 (+0)
**Core**
- `platform::zlibInflateAll`: ESP32 decodes via the ROM's `tinfl_decompress` at zero flash cost; desktop and Teensy stub it to false, since neither links a decoder.
- `module_types.cpp` registers `FseqPlayerEffect`.
**Light domain**
- `src/light/util/FseqFile.h`: a pure, platform-agnostic FSEQ v1/v2 header and sparse-range-table parser, no I/O of its own.
- `src/light/effects/FseqPlayerEffect.h`: the player. Scans the generic filesystem root with no special case for `/sd` or any other mount, so a flash file plays exactly like an SD one. Decodes off the render tick (`tick20ms`, the same split `HttpServerModule::driveSdTransfers` uses for SD reads); `tick()` only blits the already-decoded frame. The playlist is `ListSource`/`EditableListSource`-backed, with shuffle, loop modes, and a file-authored or manual fps rate, capped at `tick20ms()`'s own 50 Hz.
**Tests**
- `unit_FseqFile.cpp`: the parser against hand-built and real xLights-exported bytes, including crafted headers pinning every validated bound (a non-advancing or past-`frameCount` block entry, a `channelDataOffset` inside the header's own tables, an overflow-adjacent block size).
- `unit_FseqPlayerEffect.cpp`: scan/playlist/decode/transport against real `.fseq` bytes on a test filesystem root, mounted-card and plain-flash-root alike.
**Docs/CI**
- `test/CMakeLists.txt` registers `unit_FseqFile.cpp`.
- `docs/assets/light/effects/FseqPlayerEffect.{gif,png}` refreshed to the current card.
- Generated metrics (`repo-health.json/.md`, `docgen.md`) deliberately left uncommitted per review item 4 below.
**Reviews** (🐇 CodeRabbit / 🧑 ewowi, from PR MoonModules#145)
1. Two PRs, player first → done: this commit is the player alone; the zstd compression commit stays on its own branch for its own PR.
2. Teensy build needs `zlibInflateAll`/`zstdInflateAll` stubs → done for `zlibInflateAll` (the only one this commit's code calls); `zstdInflateAll` belongs to the zstd commit.
3. A corrupt file must not write past a buffer → done: `validateBlockRange` refuses a non-advancing or past-`frameCount` block entry and guards the `channelCount` multiply against overflow before any buffer is sized; `channelDataOffset` is refused when it lands inside the header's own tables; `frameBuf_`'s allocation is checked for success rather than bounded, a deliberate degrade-not-crash choice documented in the header.
4. Leave out generated files → done: `repo-health.json/.md`, `code.md`, `docgen.md` and scenario observation blocks excluded from this commit.
5. Small (prose, `fileIndexOf`) → done: "currently" reworded, `fileIndexOf` returns -1 on no match with the UI showing no selection rather than the first file.
6. SD-only flash-read restriction (originally added after a hardware crash matching upstream espressif/esp-idf#5924) → removed: real-hardware reproduction (11,200+ continuous flash reads, then five real playback sessions over the network via the device's own web-UI API, including a full 180s/35fps real show) found no repeat of the crash. Flash and SD now play identically; the underlying ESP-IDF cache-disable-during-flash-read behavior remains a known architectural fact of this chip family, not something this effect special-cases around.
Pre-commit checks:
PASS check_specs
PASS check_prose (1 real finding fixed in added lines; the repo-wide prose.md ROSE is a stale-baseline artifact from vale only just being available this session, not a regression — prose.md left reverted)
PASS check_code --module FseqPlayerEffect (1 accepted finding: raw heap allocation in scanDir, a deliberate heap-per-level recursion documented in the header's own @technical section, matching FilesystemModule::removeTree's precedent)
PASS build_desktop (0 errors; 19 pre-existing GCC-16.2.1/Clang-23 warnings already accepted on misc_fix, none in these files)
PASS build_desktop --tests + test_desktop (2637/2637 cases, 146698/146698 assertions)
PASS check_docgen (1 pre-existing environmental error: help.json/libclang version mismatch, unrelated; both new test files fully clean; the two new headers carry only the "unreferenced appendix section" warning, a pattern already unfixed on several unrelated existing files, left as-is rather than forcing artificial @Xref links)
PASS check_esp32_built (recompiled and reflashed after this message's own docgen edits; confirmed fresh)
SKIP build_docs --strict, test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no .md, src/ui, moondeck/moonlive, or provisioning files touched)
Real-hardware verification: ESP32-P4-ETH (esp32p4rev1-eth), flashed and monitored clean (no panic) after every rebuild in this cycle; functional playback confirmed live over the network via the device's own web-UI API, both from internal flash and from a real SD-mounted xLights show.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Here the first FSEQ player commit, with just the player without zstd support. |
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hi @TouchMyLight, thanks for the second round! Splitting out zstd made this one much easier to review. The parser is careful and well pinned (every truncation offset, the block-table advance, the overflow guard), and the playlist on Three things to fix before we merge, in order of importance:
Worth doing, here or in a follow-up PR, your choice:
We'll finish these ourselves, nothing needed from you:
On size: it's over our 400-line guideline, but it's one topic and already the second round, so no need to split it. Thanks again, this is a big step toward playing xLights shows on MoonLight! |
|
Either way works for us: if you'd rather we take any of the three, say which and we'll do it on your branch, and if you want to do our list too, go ahead. |
|
Thanks for the feedback. I'll try to tackle all recommendations today/tomorrow and will let you know. I'll do them in separate commits on this branch for easier identification. |
FseqPlayerEffect copied its decoded frame into the layer's regular buffer, so the layout mapped it a second time: a serpentine panel, a projection, or a multi-panel wall would have shown every other row reversed. An FSEQ frame is already in wiring order, the same shape NetworkReceiveEffect's sender-mapped frames take, so it now goes through a ChannelDataSink instead and is handed to the output unmapped when the effect is alone on its layer, exactly like Network Receive. KPI: 256lights | tick:5/6/449/8/5/14/5/4/17/5us(FPS:200000/166666/2227/125000/200000/71428/200000/250000/58823/200000) | src:332(84035) | test:245(57054) | functions-over:350 Commit: src +45/-30 lines | test +55/-2 lines | duplicated lines 3586 -> 3586 (+0) **Light domain** - `FseqPlayerEffect.h`: holds a `ChannelDataSink`, sized in `prepare()`. `tick20ms()`'s decode writes the frame straight into the sink (with the sparse-channel expansion folded into the same step); `tick()` is now just `sink_.tick()`. `deliversWiringOrder()`/`copyWiringFrame()` delegate to the sink. Stopping writes one black frame into the sink on the transition instead of a per-tick `memset`; `blitFrameBuf()` is gone. **Tests** - `unit_FseqPlayerEffect.cpp`: added the serpentine-layout regression test this review asked for — a numbered frame on a serpentine `GridLayout`, read back through `copyWiringFrame`, pinning that light k shows what was authored for k, not the layout's remap. Verified it actually catches the bug: run against the pre-fix code first (checked out separately), where it failed as expected, then restored and confirmed green. The existing dense-frame decode test now reads the frame via `copyWiringFrame` too, since the fix no longer writes through `layer.buffer()`. **Reviews** (🧑 ewowi, from PR MoonModules#148, item 1 of 3) 1. Keep the frame in wiring order → done, as above. The other two "must fix" items (the zlib decoder's stack, and the three "unusual rather than broken" file cases) are separate, deliberately not touched here — each lands as its own commit. Pre-commit checks: PASS check_code --module FseqPlayerEffect (1 accepted finding, pre-existing: the documented heap-per-level scanDir recursion, unrelated to this change) PASS build_desktop --tests + test_desktop (2646/2646 cases, 147908/147908 assertions) PASS check_docgen (0 new errors; the one remaining error, help.json/libclang version mismatch, is the same pre-existing environmental issue noted on the prior commit) SKIP check_specs, check_prose, build_docs --strict, check_esp32_built, test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no .md, src/ui, moondeck/moonlive, or provisioning files touched; ESP32 binary staleness not re-checked, no ESP32 build run this cycle) Generated metrics (repo-health.json/.md, docgen.md) deliberately left uncommitted, regenerated centrally at merge per this PR's own review.
tinfl_decompress_mem_to_mem keeps its own tinfl_decompressor instance on the caller's stack. Reusing one heap-allocated instance instead removes that cost entirely, since every call already re-inits it from scratch. Core: platform_esp32_sdmmc.cpp's zlibInflateAll now calls tinfl_init + tinfl_decompress directly against one platform::alloc'd decompressor (PSRAM-backed on this board), allocated once and reused, rather than through the convenience wrapper's own stack-local instance. Reviews (🧑 ewowi, from PR MoonModules#148, item 2 of 3): done. Verified live on the real board (esp32p4rev1-eth): built a genuine zlib-compressed FSEQ v2 file (144000 raw bytes, 1454 compressed, 3000 frames), played it through to completion over the device's own web-UI API, and polled the main task's stack high-water mark throughout — flat at 7248 bytes free the entire time, no drop. Desktop build unaffected (this file is ESP32-only). Pre-commit checks: PASS check_docgen (0 new errors; same pre-existing help.json/libclang environmental error as the prior two commits) PASS check_esp32_built (rebuilt after the doc-wrap fix, confirmed fresh) SKIP check_specs, check_prose, build_desktop/test_desktop (this file is not compiled into the desktop target), build_docs --strict, test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no .md, src/ui, moondeck/moonlive, or provisioning files touched) Generated metrics (docgen.md) regenerated during checking and reverted, per this PR's own review (regenerated centrally at merge).
A show with 0 channels/frames, a path longer than the scan can hold, or a compressed file this build cannot decode each used to surface as a vague read failure or silent truncation. Each now gets its own named status. KPI: 256lights | tick:6/6/471/8/5/14/5/4/18/5us(FPS:166666/166666/2123/125000/200000/71428/200000/250000/55555/200000) | src:332(84088) | test:245(57156) | functions-over:350 Commit: src +50/-14 lines | test +107/-5 lines | duplicated lines 3586 -> 3586 (+0) **Core** - `FseqFile.h`: `ParseError::Empty` for 0 channels or 0 frames, checked before version-specific parsing so it covers both v1 and v2. **Light domain** - `FseqPlayerEffect.h`: `loadTrack` maps `Empty` to a named status; checks `platform::hasZlibInflate` so a zlib file on a platform without a real decoder is refused up front rather than failing mid-decode; refuses a zlib file whose compression block table is empty by name too (found during the corner-case sweep, not one of ewowi's three named items). Unified `kPathCap` (192, matching `FilesystemModule::removeTree`'s own budget) across `Track::file`, the scanned-file table and the scan's per-level buffer; `joinPath` now returns whether it truncated. A name or path that doesn't fit is skipped, not silently truncated into a different file, and `rescanFiles` reports one warning naming it. - `platform_config.h` (esp32/desktop/teensy): new `hasZlibInflate` flag (`= hasSdCard` on ESP32, pending the decoder's planned move out of the SD-card file; `false` on desktop/Teensy). **Tests** - `unit_FseqFile.cpp`: 0-channel and 0-frame headers, v1 and v2, each reporting `Empty`. - `unit_FseqPlayerEffect.cpp`: the empty-show status, the zlib-unsupported status, the zlib-no-blocks status, and the oversized-name skip+warning; `joinPathForTest`'s truncation return is now asserted too. **Reviews** (🧑 ewowi, from PR MoonModules#148, item 3 of 3): done, plus the explicit request to sweep for further corner cases (the empty compression block table). 🐇 CodeRabbit: none pending on this item. Pre-commit checks: PASS build_desktop --tests + test_desktop (2653/2653 cases, 147942/147942 assertions) PASS check_specs PASS check_prose (clean in added lines; the one flagged sentence fixed) PASS check_docgen (the two touched headers hold at their already-committed warning counts, both "unreferenced appendix section"; one new test-comment warning found and fixed, so that file now carries zero; the one remaining error, help.json/libclang, is the same pre-existing environmental issue noted on the prior two commits) PASS check_code (no regression attributable to this diff; the "raw allocation in light" drift traces to the prior two commits' already- committed code, not this one; code.md unmeasurable on this host, no Clang 20+) FAIL check_esp32_built (no firmware binary built for esp32s3-n16r8 this session) SKIP test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no src/ui, moondeck/moonlive, or provisioning files touched) Generated metrics (docgen.md, prose.md, repo-health.json/.md) regenerated during checking and reverted, per this PR's own review (regenerated centrally at merge).
A whole compressed block was read and decoded inside the same tick that needed it, stalling that tick for the block's full read cost. tick20ms now renders what decodeCurrentFrame() decoded last call before decoding the next one, so an expensive decode lands a tick early rather than on the tick whose render it would otherwise delay. KPI: 256lights | tick:5/7/493/10/6/15/6/5/19/6us(FPS:200000/142857/2028/100000/166666/66666/166666/200000/52631/166666) | src:332(84100) | test:245(57167) | functions-over:350 Commit: src +19/-7 lines | test +12/-1 lines | duplicated lines 3586 -> 3586 (+0) **Light domain** - `FseqPlayerEffect.h`: `decodeCurrentFrame()` only decodes into `frameBuf_` now, for either format; `tick20ms()` renders the previous call's frame (`aheadFrameReady_`) before checking end-of-file or decoding the next one. The end-of-file check moved ahead of the decode so the track's real last frame always gets its own full tick of visibility before the track switches, rather than being overwritten by the switch in the same call. `handleStop()` discards a pending frame (about to black out anyway); `loadTrack()`/`setHeaderForTest()` reset it on track (re)load. **Tests** - `unit_FseqPlayerEffect.cpp`: the four existing decode-sequence tests each gained one priming `decodeOneFrameForTest()` call; every subsequent frame-by-frame assertion holds unchanged. No new test for the zlib path specifically: it still can't run end-to-end on desktop (`hasZlibInflate` is false there), so this needed real hardware instead. **Reviews** (🧑 ewowi, from PR MoonModules#148, follow-up: "decoding one block ahead would remove the hitch"): done, as a one-frame pipeline rather than a block-ahead double buffer — simpler, and bounded to one extra frame of memory rather than a whole second compression block. Verified live on the real board (esp32p4rev1-eth): flashed, then let the board's own two-track playlist (a ~3000-frame zlib-compressed track and a ~6300-frame uncompressed SD track) run through two full loops under loopAll, polling position/status/task-stack over the HTTP API throughout. Both tracks completed and the track switch fired cleanly in both directions (zlib→uncompressed and back); no error status, no stall in position, no reboot (uptime climbed continuously to 5:09). main task stack free held flat per track (8240 → 7904 → 7792, each drop tied to a track switch's own buffer sizing, not a continuous decline). Pre-commit checks: PASS build_desktop --tests + test_desktop (2653/2653 cases, 147942/147942 assertions; same counts as before this change, confirming no behavior drift beyond the intended one-tick render shift) PASS check_specs PASS check_prose (clean in added lines) PASS check_docgen (the touched header holds at its already-committed warning count of 7, all pre-existing "unreferenced appendix section"; the one remaining error, help.json/libclang, is the same pre-existing environmental issue noted on every commit so far on this branch) PASS check_esp32_built (rebuilt and flashed this cycle, confirmed fresh) SKIP check_code (host lacks Clang 20+; code.md left unmeasured, as on every prior commit this session) SKIP test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no src/ui, moondeck/moonlive, or provisioning files touched) Generated metrics (repo-health.json/.md) regenerated during checking and reverted, per this PR's own review (regenerated centrally at merge).
Every FSEQ track change called rebuildControls() to refresh the position gauge's total, and since that total rode the schema hash, it tripped a full control resync to every browser on almost every track change. A Progress total is a value, not a schema trait, exactly like the value its own ptr already carries unhashed. KPI: 256lights | tick:4/5/423/7/4/11/5/4/15/5us(FPS:250000/200000/2364/142857/250000/90909/200000/250000/66666/200000) | src:332(84101) | test:245(57187) | functions-over:350 Commit: src +3/-2 lines | test +22/-2 lines | duplicated lines 3586 -> 3586 (+0) **Core** - `MoonModule.h`: `mixSchema()` excludes `ControlType::Progress` from the generic aux hash. `rebuildControls()` is unchanged: it still refreshes the total via `defineControls()`'s `addProgress`, it just no longer reads that refresh as a schema change. Every other `addProgress` caller (SystemModule, FirmwareUpdateModule, FileManagerModule, SdCardService) benefits the same way, though their totals rarely change in practice. **Tests** - `unit_MoonModule.cpp`: pins the fix directly against the `loadTrack()` pattern (change the total, `rebuildControls()`, check the signature holds and the total did refresh). Verified it fails against the pre-fix code (checked out separately via `git stash`) and passes with the fix. Two pre-existing over-long comments in the same file, now touched, were tightened to clear it from the docgen warning list. **Reviews** (🧑 ewowi, from PR MoonModules#148, follow-up: "every track change calls rebuildControls(), which pushes a full control resync to every browser"): done. The comment's other half, filtering file-upload rescans to `.fseq`, is a separate item, left for its own commit. Pre-commit checks: PASS build_desktop --tests + test_desktop (2654/2654 cases, 147944/147944 assertions) PASS check_specs PASS check_prose (clean in added lines, one wording fix) PASS check_docgen (both touched files carry zero findings; the one remaining error, help.json/libclang, is the same pre-existing environmental issue noted on every commit so far on this branch) SKIP check_code (host lacks Clang 20+; identical counts to the last commit, no new regression) FAIL check_esp32_built (MoonModule.h is newer than the last flashed binary; not rebuilt this cycle) SKIP test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no src/ui, moondeck/moonlive, or provisioning files touched) Generated metrics (repo-health.json/.md) regenerated during checking and reverted, per this PR's own review (regenerated centrally at merge).
onFileChanged() rescanned the whole filesystem tree on every file change, config writes and palette scripts included. Only a .fseq upload or delete can actually change the playlist's file options, so every other change now costs nothing. KPI: 256lights | tick:5/6/448/7/5/13/5/4/15/5us(FPS:200000/166666/2232/142857/200000/76923/200000/250000/66666/200000) | src:332(84104) | test:245(57191) | functions-over:350 Commit: src +5/-2 lines | test +15/-11 lines | duplicated lines 3586 -> 3586 (+0) **Light domain** - `FseqPlayerEffect.h`: `onFileChanged` skips `rescanFiles()` unless the changed path ends in `.fseq` (reusing the existing `hasExtension` helper), or the path is null (the conservative default, though no caller passes one today). Added a `@technical` line noting this. Known accepted gap: deleting a whole show folder (a path with no `.fseq` extension of its own) doesn't trigger an immediate rescan; it catches up on the next real `.fseq` change, the same graceful handling already in place for any file that stops scanning. **Tests** - `unit_FseqPlayerEffect.cpp`: replaced the old "any path reaches the playlist" test, whose premise this change makes false, with one that demonstrates the filter directly: an unrelated path first (the new file stays unseen in the option list), then the show's own path (it appears). Verified it fails against the pre-fix code (checked out separately via `git stash`) and passes with the fix. **Reviews** (🧑 ewowi, from PR MoonModules#148, follow-up: "any file upload rescans the whole tree; a filter on `.fseq` keeps that to shows"): done. This was the second half of the rebuildControls() comment; the first half (the resync itself) landed in the prior commit. Pre-commit checks: PASS build_desktop --tests + test_desktop (2654/2654 cases, 147943/147943 assertions) PASS check_specs PASS check_prose (clean in added lines) PASS check_docgen (the touched header holds at its already-committed warning count of 7; the one remaining error, help.json/libclang, is the same pre-existing environmental issue noted on every commit so far on this branch) SKIP check_code (host lacks Clang 20+; identical counts to the last commit, no new regression) FAIL check_esp32_built (FseqPlayerEffect.h is newer than the last flashed binary; not rebuilt this cycle) SKIP test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no src/ui, moondeck/moonlive, or provisioning files touched) Generated metrics (repo-health.json/.md) regenerated during checking and reverted, per this PR's own review (regenerated centrally at merge).
|
Here all changes except "We'll finish these ourselves". |
FseqPlayerEffect plays pre-rendered FSEQ animations (the xLights/Falcon Player format) from anywhere the file manager reaches, as a playlist with shuffle/loop modes and a full transport (play/pause/stop/previous/next). Zlib-compressed files decode on ESP32 only; zstd isn't decoded by any target yet.
KPI: 256lights | Desktop:3093KB | tick:5/6/471/8/5/13/5/4/18/5us(FPS:200000/166666/2123/125000/200000/76923/200000/250000/55555/200000) | src:332(84020) | test:245(56823) | functions-over:350
Commit: src +979/-32 lines | test +874/-27 lines | duplicated lines 3586 -> 3586 (+0)
Core
platform::zlibInflateAll: ESP32 decodes via the ROM'stinfl_decompressat zero flash cost; desktop and Teensy stub it to false, since neither links a decoder.module_types.cppregistersFseqPlayerEffect.Light domain
src/light/util/FseqFile.h: a pure, platform-agnostic FSEQ v1/v2 header and sparse-range-table parser, no I/O of its own.src/light/effects/FseqPlayerEffect.h: the player. Scans the generic filesystem root with no special case for/sdor any other mount, so a flash file plays exactly like an SD one. Decodes off the render tick (tick20ms, the same splitHttpServerModule::driveSdTransfersuses for SD reads);tick()only blits the already-decoded frame. The playlist isListSource/EditableListSource-backed, with shuffle, loop modes, and a file-authored or manual fps rate, capped attick20ms()'s own 50 Hz.Tests
unit_FseqFile.cpp: the parser against hand-built and real xLights-exported bytes, including crafted headers pinning every validated bound (a non-advancing or past-frameCountblock entry, achannelDataOffsetinside the header's own tables, an overflow-adjacent block size).unit_FseqPlayerEffect.cpp: scan/playlist/decode/transport against real.fseqbytes on a test filesystem root, mounted-card and plain-flash-root alike.Docs/CI
test/CMakeLists.txtregistersunit_FseqFile.cpp.docs/assets/light/effects/FseqPlayerEffect.{gif,png}refreshed to the current card.repo-health.json/.md,docgen.md) deliberately left uncommitted per review item 4 below.Reviews (🐇 CodeRabbit / 🧑 ewowi, from PR #145)
zlibInflateAll/zstdInflateAllstubs → done forzlibInflateAll(the only one this commit's code calls);zstdInflateAllbelongs to the zstd commit.validateBlockRangerefuses a non-advancing or past-frameCountblock entry and guards thechannelCountmultiply against overflow before any buffer is sized;channelDataOffsetis refused when it lands inside the header's own tables;frameBuf_'s allocation is checked for success rather than bounded, a deliberate degrade-not-crash choice documented in the header.repo-health.json/.md,code.md,docgen.mdand scenario observation blocks excluded from this commit.fileIndexOf) → done: "currently" reworded,fileIndexOfreturns -1 on no match with the UI showing no selection rather than the first file.Pre-commit checks:
PASS check_specs
PASS check_prose (1 real finding fixed in added lines; the repo-wide prose.md ROSE is a stale-baseline artifact from vale only just being available this session, not a regression — prose.md left reverted) PASS check_code --module FseqPlayerEffect (1 accepted finding: raw heap allocation in scanDir, a deliberate heap-per-level recursion documented in the header's own @technical section, matching FilesystemModule::removeTree's precedent) PASS build_desktop (0 errors; 19 pre-existing GCC-16.2.1/Clang-23 warnings already accepted on misc_fix, none in these files) PASS build_desktop --tests + test_desktop (2637/2637 cases, 146698/146698 assertions) PASS check_docgen (1 pre-existing environmental error: help.json/libclang version mismatch, unrelated; both new test files fully clean; the two new headers carry only the "unreferenced appendix section" warning, a pattern already unfixed on several unrelated existing files, left as-is rather than forcing artificial @Xref links) PASS check_esp32_built (recompiled and reflashed after this message's own docgen edits; confirmed fresh) SKIP build_docs --strict, test_host --python/--js, check_devices, check_firmwares, improv_smoke_test (no .md, src/ui, moondeck/moonlive, or provisioning files touched) Real-hardware verification: ESP32-P4-ETH (esp32p4rev1-eth), flashed and monitored clean (no panic) after every rebuild in this cycle; functional playback confirmed live over the network via the device's own web-UI API, both from internal flash and from a real SD-mounted xLights show.