Skip to content

Play FSEQ shows from any mount, flash or SD alike - #148

Open
TouchMyLight wants to merge 7 commits into
MoonModules:mainfrom
TouchMyLight:fseq_player4
Open

TouchMyLight wants to merge 7 commits into
MoonModules:mainfrom
TouchMyLight:fseq_player4

Conversation

@TouchMyLight

Copy link
Copy Markdown
Contributor

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 #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 Guru Meditation Error: Core 0 panic'ed (Cache disabled but cached memory region accessed) (IDFGH-4053) 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.

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>
@TouchMyLight

TouchMyLight commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Here the first FSEQ player commit, with just the player without zstd support.
I could not reproduce the cache issue for the internal flash, so this commit can read files from any source.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: MoonModules/MoonLight/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 9bef7bb4-4fdf-4667-b844-abc7b2d1873a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

❤️ Share

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

@ewowi

ewowi commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

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 EditableListSource with the full transport reads cleanly. Playing from any mount, flash or SD alike, is exactly the right shape.

Three things to fix before we merge, in order of importance:

  1. Keep the frame in wiring order. An FSEQ frame is the byte stream a controller sends, so it is already in wiring order. On main the player writes it through ChannelDataSink (sink_.prepare(true), sink_.tick(), deliversWiringOrder/copyWiringFrame), as Network Receive does, and the output takes it unmapped. This PR copies the frame into the layer's buffer instead, so the layout maps it a second time: a serpentine panel, a projection or a multi-panel wall shows every other row reversed. The tests use a plain 4×4 grid, where the double mapping changes nothing. Please bring the sink back: decode into sink_.write(0, frame, n), and blitFrameBuf, the stopped memset and the second copy can go. A test on a serpentine layout pins it.

  2. The zlib decoder's stack. tinfl_decompress_mem_to_mem keeps its tinfl_decompressor (about 11 KB) on the caller's stack, and the decode runs on the 12 KB main task. A compressed show is very likely to overflow it. Please use tinfl_init + tinfl_decompress with the decompressor allocated once on the heap (platform::alloc, PSRAM is fine). Could you also check the main task's stack high-water mark while a zlib track plays on the P4? That number tells us the margin.

  3. Files that are unusual rather than broken:

    • A file with 0 channels or 0 frames: please refuse it in the parser. Today it reports "out of memory", and a looping playlist then reopens it 50 times a second.
    • Long names: a file name over 39 characters disappears from the picker without a word, and a path over 95 characters is cut short. One budget of 192 for the name, the path and Track::file, as removeTree uses, with a status line when something is skipped.
    • A compressed file on desktop or Teensy currently says "file read failed mid-file". A hasZlibInflate flag in platform_config.h, checked in loadTrack, gives the clean "zlib plays on ESP32 only" status the header promises. One desktop test pins it.

Worth doing, here or in a follow-up PR, your choice:

  • A whole compressed block is read and decoded in one tick20ms, which runs between frames, so each block boundary stalls the render. Reading it in slices per tick, as driveTransfers does, and decoding one block ahead would remove the hitch.
  • Every track change calls rebuildControls(), which pushes a full control resync to every browser. And any file upload rescans the whole tree; a filter on .fseq keeps that to shows.

We'll finish these ourselves, nothing needed from you:

  • the decoder moves out of the SD-card file into platform_esp32.cpp, so a board without SD also plays a compressed show from flash
  • the duplicate unit_FseqPlayerEffect.cpp line in test/CMakeLists.txt
  • help lines for the transport and playlist controls, and @attribution FPP back on the class
  • the block-table read moves into FseqFile.h
  • the full check_code and check_docgen runs (neither ran fully on your machine, and the libclang mismatch meant the controls weren't checked)
  • the generated metrics at merge

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!

@ewowi

ewowi commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

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.

@TouchMyLight

Copy link
Copy Markdown
Contributor Author

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.

King Kévin added 6 commits October 19, 2026 13:00
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).
@TouchMyLight

Copy link
Copy Markdown
Contributor Author

Here all changes except "We'll finish these ourselves".
Let me know if you see other improvement I can take care of.
Once merged, I'll work on adding the zstd compression support in the next PR.
Don't hold back on commit changes to main (for example the ones from the "ourselves"), I'll make sure to rebase on main.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants