From 78aa8634c419c1b789140618c99479a16e411353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 18 Oct 2026 18:29:41 +0200 Subject: [PATCH 01/10] Play FSEQ shows from any mount, flash or SD alike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../assets/light/effects/FseqPlayerEffect.gif | Bin 331361 -> 21660 bytes .../assets/light/effects/FseqPlayerEffect.png | Bin 16270 -> 24135 bytes src/light/effects/FseqPlayerEffect.h | 790 +++++++++++++++++- src/light/util/FseqFile.h | 197 +++++ src/module_types.cpp | 4 +- src/platform/desktop/platform_desktop.cpp | 3 + src/platform/esp32/platform_esp32_sdmmc.cpp | 11 + src/platform/platform.h | 3 + src/platform/teensy/platform_teensy_fs.cpp | 3 + test/CMakeLists.txt | 2 + test/unit/light/unit_FseqFile.cpp | 302 +++++++ test/unit/light/unit_FseqPlayerEffect.cpp | 597 ++++++++++++- 12 files changed, 1853 insertions(+), 59 deletions(-) create mode 100644 src/light/util/FseqFile.h create mode 100644 test/unit/light/unit_FseqFile.cpp diff --git a/docs/assets/light/effects/FseqPlayerEffect.gif b/docs/assets/light/effects/FseqPlayerEffect.gif index 35ccfaf22320939d2d039e28fe2d3c1811fa9dce..41ac0d2bfdcd3246a52f8be88b70e9dae49e4de2 100644 GIT binary patch literal 21660 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 diff --git a/docs/assets/light/effects/FseqPlayerEffect.png b/docs/assets/light/effects/FseqPlayerEffect.png index 9a4e9878f86e542cddd6a4dbd81a377316f57d67..2bfcce6b28651fbe1c676c3298a190e8fca00d7f 100644 GIT binary patch literal 24135 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 Q@XjqoSyh=*DU;y;0hhN`iU0rr literal 16270 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 +#include +#include // placement new: scanDir's heap-allocated ScanLevel + namespace mm { -/// An FSEQ show played from a file: the frame-by-frame channel data xLights and Falcon Player write. -/// @card FseqPlayerEffect.gif The reference sweep fseq_play writes, the show an FSEQ player plays +/// Plays FSEQ animation files from the filesystem onto this effect's layer, with a playlist and the standard transport controls. +/// @card FseqPlayerEffect.gif +/// +/// FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. +/// +/// @guide +/// +/// ## Compressed files need ESP32 +/// +/// A zlib-compressed FSEQ (xLights/FPP's default) plays only on ESP32 boards; desktop and Teensy builds show a clean error instead of playing it. +/// +/// Zstd-compressed files do not play on any target yet. +/// +/// Export a track uncompressed if it needs to play everywhere. +/// +/// @technical +/// +/// ## Prior art +/// +/// FPP's `FSEQ_Sequence_File_Format.txt` is the spec this plays against; the playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic. +/// +/// ## The rate comes from the file by default, or the fps slider when asked +/// +/// `fpsSource` picks `file` (the track's own `stepTimeMs`, default) or `manual` (the `fps` slider). Either is capped at 50 to match `tick20ms()` (@xref{why-decode-runs-in-tick20ms}), with a warning when a file asks for more. +/// +/// ## Why decode runs in tick20ms +/// +/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame into `frameBuf_` instead, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only blits what is there. +/// +/// ## Sequential reads replace random-offset ones +/// +/// FSEQ data appears in file order. So one open stream per track, read forward only, replaces every offset-based read the format would otherwise need; a track restart or change reopens it. /// -/// A show, uncompressed FSEQ v2, lands where a sender-mapped @ref NetworkReceiveEffect stream does, so alone on its layer it reaches the lights in one copy on any layout. Reading the file is the next step, and its status says so. +/// A file whose variable-length metadata pushes `channelDataOffset` past the buffered header region has that gap skipped, byte for byte, before frame 0's own read begins (`skipHeaderGap`). /// -/// @attribution FPP -class FseqPlayerEffect : public EffectBase { +/// ## Where animations live +/// +/// Every `.fseq` file anywhere in the file manager's tree is found walking from the filesystem root, bounded to `kMaxScanDepth` levels, with no special case for `/sd`. A flash file plays exactly like an SD one. +/// +/// ## Starts on its own +/// +/// Becoming enabled starts playback immediately when the playlist is non-empty, in `prepare()`, with no separate "press play" step. `release()` stops and closes the stream first, so a later re-enable never resumes into memory it just freed. +/// +/// ## tick(): stopped clears, paused re-blits the same frame +/// +/// A stopped transport goes black every tick rather than keep the previous effect's paint. A paused one re-blits the same decoded frame every tick instead of holding a stale buffer, so a clear elsewhere self-heals. +/// +/// ## Compression: zlib on ESP32 only, zstd nowhere yet +/// +/// Zlib (type 2) decodes through the ESP32 ROM's `tinfl_decompress` at zero flash cost and is ESP32-only to play (every target parses it). Zstd (type 1) has no decoder yet, reported as its own status, not a bug. +/// +/// ## A corrupt file degrades, never overflows +/// +/// `channelCount` sizes `frameBuf_` directly, however large; the allocation is checked for success, so a hostile value fails cleanly rather than writing into a null or short buffer. +/// +/// A compression block's table entry is validated (`fseq::validateBlockRange`) before any buffer is sized by it: a non-advancing entry or an overflowing byte count is refused rather than read. +/// +/// ## The scan is depth-bounded recursion over a heap-per-level buffer +/// +/// `scanDir` mirrors `FilesystemModule::removeTree`'s shape for the same problem (an oversized stack frame): one heap-allocated `platform::fsList` level per recursion, bounded in depth, not a member-held breadth-first queue. +class FseqPlayerEffect : public EffectBase, public ListSource { public: + /// The transport's three states. + enum class PlayState : uint8_t { Stopped, Playing, Paused }; + /// The three loopMode values, public so a test can select one by name. + static constexpr uint8_t kLoopOff = 0, kLoopOne = 1, kLoopAll = 2; + /// The two fpsSource values, public so a test can select one by name. + static constexpr uint8_t kFpsFromFile = 0, kFpsManual = 1; + /// Catalog tags: a recorded show, played back. const char* tags() const override { return "🖼️"; } - /// The show's path, an `.fseq` file on flash or under `/sd`. - char file[64] = ""; - /// Start again at the end. - bool loop = true; - /// The playback speed, in percent of the file's step time. - uint8_t speed = 100; - - /// Publish the file, looping and the speed. + + /// Bind the transport buttons, the shuffle/loop controls, the playlist and the position gauge. void defineControls() override { - controls_.addText("file", file, sizeof(file)); // a path: the file picker opens a text editor, which a binary show has no use for - controls_.addControl("loop", loop); - controls_.addControl("speed", speed, 1, 255); + controls_.addButton("play"); + controls_.addButton("pause"); + controls_.addButton("stop"); + controls_.addButton("previous"); + controls_.addButton("next"); + controls_.addControl("shuffle", shuffle_); + controls_.addSelect("loopMode", loopMode_, kLoopModeOptions, 3); + controls_.addSelect("fpsSource", fpsSource_, kFpsSourceOptions, 2); + controls_.addControl("fps", fps_, 1, kMaxFps); + controls_.setHidden(controls_.count() - 1, fpsSource_ != kFpsManual); + controls_.addList("playlist", *this); + controls_.addProgress("position", frameIndex_, frameCount_); // a frame count, not bytes; ProgressUnit has no plain-count variant yet + controls_.addButton("rescan"); } - /// A new file sizes the sink again. - bool affectsPrepare(const char* name) const override { return std::strcmp(name, "file") == 0; } + /// Scan the filesystem for animations once. + void setup() override { + EffectBase::setup(); + rescanFiles(); + } - /// Size the sink to the layer in wiring order, a frame of the file being one light per physical light. + /// Start playing on its own once the effect becomes enabled. void prepare() override { - sink_.prepare(/*senderMapped=*/true); - setStatus("reads no FSEQ file yet: playback is the next step"); + if (state_ == PlayState::Stopped && playlistCount_ > 0) { + visited_ = 0; + // Set before loadTrack: its own refreshStatus() must see Playing already, though loadTrack corrects this back on its own failure paths. + state_ = PlayState::Playing; + loadTrack(cursor_); + } } - /// Let the sink place the held frame. - void tick() MM_NONBLOCKING override { sink_.tick(); } + /// Close the open stream and force a clean stop before the base frees the frame buffers. + void release() override { + closeTrackStream(); + state_ = PlayState::Stopped; + EffectBase::release(); + } - /// An FSEQ frame is in wiring order. - bool deliversWiringOrder() const MM_NONBLOCKING override { return sink_.wiringOrder(); } - /// The sink hands its held frame to the output. - void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } + /// Decode the next due frame off the render tick; a blocking file read, bounded to one frame per call. + void tick20ms() MM_NONBLOCKING override { + if (state_ != PlayState::Playing) return; + const uint32_t now = elapsed(); + const uint32_t stepMs = effectiveStepMs(); + if (now - lastFrameMs_ < stepMs) return; + lastFrameMs_ += stepMs; // advance by one step, not to `now`: a stall catches up one frame at a time + if (!decodeCurrentFrame()) return; // a read failure above already stopped playback + frameIndex_++; + if (frameIndex_ >= header_.frameCount) handleEndOfFile(); + } + + /// Blit the already-decoded frame, or hold black while stopped. Pure memory work, no I/O. + void tick() MM_NONBLOCKING override { + if (state_ == PlayState::Stopped) { + uint8_t* buf = buffer(); + if (buf) std::memset(buf, 0, static_cast(nrOfLights()) * channelsPerLight()); + return; + } + blitFrameBuf(); + } + + /// Dispatch a transport button through a name-to-handler table, a Command row per button rather than a branch. + void onControlChanged(const char* controlName) override { + if (controlName) { + for (const auto& row : kButtonHandlers) { + if (std::strcmp(controlName, row.name) == 0) { (this->*row.handler)(); break; } + } + } + EffectBase::onControlChanged(controlName); + } + + /// Rescan when any file lands or leaves, so an upload through the File Manager reaches the playlist unasked. + void onFileChanged(const char* path) override { + if (path) rescanFiles(); + } + + // --- ListSource (editable): the playlist ------------------------------------------- + + /// How many rows the playlist holds. + uint8_t listRowCount() const override { return playlistCount_; } + + /// Write one row, which is also the persisted form: the stable id and the chosen file. + void writeListRow(JsonSink& sink, uint8_t row) const override { + sink.appendf("{\"id\":%lu,\"file\":", static_cast(tracks_[row].id)); + sink.writeJsonString(tracks_[row].file); + sink.append("}"); + } + + /// Emitted once per list: the full paths the scan found. + void writeListOptionSets(JsonSink& sink) const override { + sink.append("\"files\":["); + for (uint8_t i = 0; i < fileCount_; i++) { + if (i) sink.append(","); + sink.writeJsonString(fileOptions_[i]); + } + sink.append("]"); + } + + /// One row's editable fields: the file picker and a "play this row now" button. + void writeListRowDetail(JsonSink& sink, uint8_t row) const override { + sink.append("{\"fields\":["); + sink.appendf("{\"name\":\"file\",\"type\":\"select\",\"value\":%d,\"optionsRef\":\"files\"},", + fileIndexOf(tracks_[row].file)); + sink.append("{\"name\":\"play\",\"type\":\"button\"}"); + sink.append("]}"); + } + + /// The playlist accepts add, delete, reorder and field edits. + bool isEditableList() const override { return true; } + + /// Append a row, defaulting to the first scanned file if one exists. + bool addListRow(uint32_t& outId) override { + if (playlistCount_ >= kMaxTracks) return false; + Track& t = tracks_[playlistCount_]; + t = Track{}; + t.id = nextId_++; + if (fileCount_ > 0) copyBounded(t.file, sizeof(t.file), fileNames_[0]); + playlistCount_++; + outId = t.id; + return true; + } + + /// Remove a row by id, clamping the cursor back onto the shrunk list. + bool deleteListRow(uint32_t id) override { + const int i = indexOf(id); + if (i < 0) return false; + for (uint8_t j = static_cast(i); j + 1 < playlistCount_; j++) tracks_[j] = tracks_[j + 1]; + playlistCount_--; + if (cursor_ >= playlistCount_ && playlistCount_ > 0) cursor_ = static_cast(playlistCount_ - 1); + return true; + } + + /// Move a row by id, which never changes that id. + bool moveListRow(uint32_t id, uint8_t to) override { + const int i = indexOf(id); + if (i < 0) return false; + if (to >= playlistCount_) to = static_cast(playlistCount_ - 1); + const Track moved = tracks_[i]; + if (to > i) for (int j = i; j < to; j++) tracks_[j] = tracks_[j + 1]; + else for (int j = i; j > to; j--) tracks_[j] = tracks_[j - 1]; + tracks_[to] = moved; + return true; + } + + /// Edit a row's file, or act on its "play" field: jump to this row and start playing now. + bool setListRowField(uint32_t id, const char* field, const char* valueJson) override { + const int i = indexOf(id); + if (i < 0) return false; + if (std::strcmp(field, "file") == 0) { + const int idx = mm::json::parseInt(valueJson, "value"); + if (idx < 0 || idx >= fileCount_) return false; + copyBounded(tracks_[i].file, sizeof(tracks_[i].file), fileNames_[idx]); + return true; + } + if (std::strcmp(field, "play") == 0) { + cursor_ = static_cast(i); + visited_ = 0; + state_ = PlayState::Playing; // set before loadTrack; see prepare()'s own comment + loadTrack(cursor_); + return true; + } + return false; + } + + /// Restore the playlist from persisted JSON, keeping each row's stable id. + bool restoreList(const char* json, const char* key) override { + mm::json::JsonDoc doc; + if (!mm::json::parse(json, doc)) return false; + const mm::json::JsonNode* arr = mm::json::member(doc, doc.rootNode(), key); + if (!arr || arr->type != mm::json::JsonType::Array) return false; + playlistCount_ = 0; + const int n = mm::json::arraySize(doc, arr); + for (int r = 0; r < n && playlistCount_ < kMaxTracks; r++) { + const mm::json::JsonNode* row = mm::json::element(doc, arr, r); + Track& t = tracks_[playlistCount_]; + t = Track{}; + t.id = static_cast(mm::json::readInt(mm::json::member(doc, row, "id"), 0)); + mm::json::readString(mm::json::member(doc, row, "file"), t.file, sizeof(t.file)); + if (t.id >= nextId_) nextId_ = t.id + 1; // never reissue a persisted id + playlistCount_++; + } + return true; + } + + // --- Test seams: inject state directly, since every real file call needs a test root to succeed on desktop --- + + /// Test seam: inject a parsed header directly, skipping the real file read. + void setHeaderForTest(const fseq::FseqHeader& h) { + header_ = h; + frameCount_ = h.frameCount; + frameBuf_.resize(h.channelCount); + frameIndex_ = 0; + currentBlockIndex_ = 0; + currentBlockFirstFrame_ = 0; + currentBlockFrameCount_ = 0; + } + /// Force the transport state directly. + void setStateForTest(PlayState s) { state_ = s; } + /// The current transport state. + PlayState stateForTest() const { return state_; } + /// The playlist row loaded, or about to load. + uint8_t cursorForTest() const { return cursor_; } + /// The frame position within the current track. + uint32_t frameIndexForTest() const { return frameIndex_; } + /// Run end-of-file handling without waiting out the real frame cadence. + void handleEndOfFileForTest() { handleEndOfFile(); } + /// Whether a full shuffle+loopOff pass has visited every playlist row. + bool allTracksVisitedForTest() const { return allTracksVisited(); } + /// Mark a row visited directly, as if its track had just loaded for real. + void markVisitedForTest(uint8_t idx) { markVisited(idx); } + /// Force the loop mode directly, one of kLoopOff/kLoopOne/kLoopAll below. + void setLoopModeForTest(uint8_t m) { loopMode_ = m; } + /// Force the shuffle flag directly. + void setShuffleForTest(bool s) { shuffle_ = s; } + /// Force the playback rate directly. + void setFpsForTest(uint8_t fps) { fps_ = fps; } + /// Force the fps source directly, one of kFpsFromFile/kFpsManual above. + void setFpsSourceForTest(uint8_t source) { fpsSource_ = source; } + /// The milliseconds-per-frame decode is actually pacing on right now. + uint32_t effectiveStepMsForTest() const { return effectiveStepMs(); } + /// Run one tick20ms decode step without waiting out the real frame cadence. + void decodeOneFrameForTest() { lastFrameMs_ = elapsed() - effectiveStepMs(); tick20ms(); } + /// Test seam for the scan's directory-join, the one piece of the scan desktop can exercise directly. + static void joinPathForTest(const char* dir, const char* name, char* out, size_t outCap) { + joinPath(dir, name, out, outCap); + } private: - ChannelDataSink sink_{*this}; ///< where the frames land, held and placed + static constexpr const char* kLoopModeOptions[] = {"off", "one", "all"}; + static constexpr const char* kFpsSourceOptions[] = {"file", "manual"}; + static constexpr uint8_t kMaxTracks = 64; // a playlist, not a media library: bounded, no heap + static constexpr uint8_t kMaxFiles = 64; + static constexpr uint8_t kMaxScanDepth = 8; // matches FilesystemModule::removeTree's own bound + /// 50, not the usual control range: tick20ms's own ~50 Hz cadence is the decode ceiling (@xref{why-decode-runs-in-tick20ms}). + static constexpr uint8_t kMaxFps = 50; + /// The slowest step the decode cadence can honor: 1000 / kMaxFps, so a file-sourced rate above it clamps here. + static constexpr uint32_t kMinStepMs = 1000u / kMaxFps; + /// A ceiling against a corrupt block-length field: real exports ran up to ~300KB/block, so 4MB keeps headroom. + static constexpr size_t kMaxCompressedBlockBytes = 4 * 1024 * 1024; + + struct Track { uint32_t id = 0; char file[96] = {}; }; ///< the full filesystem path, not a bare name + Track tracks_[kMaxTracks] = {}; + uint8_t playlistCount_ = 0; + uint32_t nextId_ = 1; + uint8_t cursor_ = 0; + + bool shuffle_ = false; + uint8_t loopMode_ = kLoopAll; + uint8_t fpsSource_ = kFpsFromFile; ///< kFpsFromFile reads the track's own stepTimeMs; kFpsManual reads fps_ + uint8_t fps_ = 30; ///< the manual playback rate; 1000/fps milliseconds per frame + + PlayState state_ = PlayState::Stopped; + fseq::FseqHeader header_ = {}; + uint32_t frameIndex_ = 0; + uint32_t frameCount_ = 0; + uint32_t lastFrameMs_ = 0; + uint64_t visited_ = 0; ///< per-pass bitset over playlist rows, for shuffle+loopOff + Random8 rng_; + + /// The one open track stream, plus its buffered header: @xref{sequential-reads-replace-random-offset-ones}. + platform::FsReadStream stream_{}; + bool streamOpen_ = false; + uint8_t headBuf_[4096] = {}; ///< the header, block table and sparse table of virtually any real file + size_t headLen_ = 0; ///< bytes actually filled in headBuf_ + size_t headPos_ = 0; ///< how far into headBuf_ sequential reads have already consumed + + ScratchBuffer frameBuf_{*this}; ///< the one decoded frame tick() blits + ScratchBuffer compBuf_{*this}; ///< one compression block's compressed bytes + ScratchBuffer blockBuf_{*this}; ///< that block, decompressed + uint32_t currentBlockIndex_ = 0; ///< which compression-block-table entry is loaded + uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame + uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers + + char fileNames_[kMaxFiles][96] = {}; ///< full filesystem paths, found by the scan + const char* fileOptions_[kMaxFiles] = {}; + uint8_t fileCount_ = 0; + + /// Sized for "playing: " + a 95-char path + " (999/999)", with headroom; setStatus holds the pointer, so this outlives the call. + char statusBuf_[128] = {}; + + /// A plain bounded copy, not snprintf: GCC's -Wrestrict flags snprintf between two member arrays of the same `this`. + static void copyBounded(char* dst, size_t dstCap, const char* src) { + if (dstCap == 0) return; + size_t n = std::strlen(src); + if (n >= dstCap) n = dstCap - 1; + std::memcpy(dst, src, n); + dst[n] = '\0'; + } + /// dir + "/" + name, built by hand for the same -Wrestrict reason as copyBounded; skips the slash when dir already ends in one. + static void joinPath(const char* dir, const char* name, char* out, size_t outCap) { + const size_t dirLen = std::strlen(dir); + size_t n = dirLen < outCap ? dirLen : (outCap > 0 ? outCap - 1 : 0); + std::memcpy(out, dir, n); + const bool needsSlash = dirLen == 0 || dir[dirLen - 1] != '/'; + if (needsSlash && n < outCap) { out[n] = '/'; n++; } + if (n < outCap) copyBounded(out + n, outCap - n, name); + else if (outCap > 0) out[outCap - 1] = '\0'; + } + + int indexOf(uint32_t id) const { + for (uint8_t i = 0; i < playlistCount_; i++) if (tracks_[i].id == id) return i; + return -1; + } + int fileIndexOf(const char* file) const { + for (uint8_t i = 0; i < fileCount_; i++) if (std::strcmp(fileOptions_[i], file) == 0) return i; + return -1; // no scanned file matches, e.g. one deleted after a row picked it + } + void markVisited(uint8_t idx) { if (idx < 64) visited_ |= (uint64_t{1} << idx); } + + /// Milliseconds per frame: the manual fps slider, or the track's own stepTimeMs capped at kMaxFps. + uint32_t effectiveStepMs() const { + if (fpsSource_ == kFpsManual) { + if (fps_ == 0) return 1; // defensive only: the control's own min bound keeps this unreachable + const uint32_t ms = 1000u / fps_; + return ms ? ms : 1; + } + if (header_.stepTimeMs == 0) return kMinStepMs; // an unauthored rate defaults to the cap, not a division by zero + return header_.stepTimeMs < kMinStepMs ? kMinStepMs : header_.stepTimeMs; + } + + /// Warn when the file's own rate asks for more than kMaxFps, which file-sourced timing cannot honor; true if it did. + bool warnIfFileRateExceedsCap() { + if (fpsSource_ != kFpsFromFile || header_.stepTimeMs == 0 || header_.stepTimeMs >= kMinStepMs) return false; + const unsigned fileFps = 1000u / header_.stepTimeMs; + std::snprintf(statusBuf_, sizeof(statusBuf_), + "file requests %ufps, capped at %u", fileFps, static_cast(kMaxFps)); + setStatus(statusBuf_, Severity::Warning); + return true; + } + + bool allTracksVisited() const { + if (playlistCount_ == 0) return true; + const uint64_t full = playlistCount_ >= 64 ? ~uint64_t{0} : ((uint64_t{1} << playlistCount_) - 1); + return (visited_ & full) == full; + } + + /// The `play` button: start a stopped transport, or resume a paused one. + void handlePlay() { + if (state_ == PlayState::Stopped) { + if (playlistCount_ == 0) return; + visited_ = 0; + state_ = PlayState::Playing; // set before loadTrack; see prepare()'s own comment + loadTrack(cursor_); + return; + } + if (state_ != PlayState::Paused) return; + lastFrameMs_ = elapsed(); // rebase, so a long pause does not fire a catch-up burst + state_ = PlayState::Playing; + } + + /// The `pause` button: only takes effect while playing. + void handlePause() { + if (state_ != PlayState::Playing) return; + state_ = PlayState::Paused; + refreshStatus(); + } + + /// The `stop` button: close the stream and reset position, only while playing or paused. + void handleStop() { + if (state_ != PlayState::Playing && state_ != PlayState::Paused) return; + closeTrackStream(); // a later play reopens and reads frame 0 forward + state_ = PlayState::Stopped; + frameIndex_ = 0; + visited_ = 0; + refreshStatus(); + } + + /// The `previous` button, wrapped to the uniform no-argument shape `kButtonHandlers` rows need. + void handlePrevious() { moveCursor(-1); } + /// The `next` button, under the same wrapper as `handlePrevious`. + void handleNext() { moveCursor(+1); } + + /// Move the cursor by one step, shuffled or sequential. + void moveCursor(int direction) { + if (playlistCount_ == 0) return; + advanceCursor(direction); + if (state_ == PlayState::Stopped) return; + loadTrack(cursor_); // success leaves state_ untouched; failure forces it to Stopped + } + + /// Pick the next playlist row, shuffled or sequential, the same shape as DemoReelEffect::advance(). + void advanceCursor(int direction) { + if (playlistCount_ == 0) return; + uint8_t next; + if (shuffle_ && playlistCount_ > 1) { + do { next = rng_.below(playlistCount_); } while (next == cursor_); + } else { + next = static_cast((cursor_ + playlistCount_ + direction) % playlistCount_); + } + cursor_ = next; + } + + /// End of the current track: loop it, advance to the next, or stop a completed pass. + void handleEndOfFile() { + if (loopMode_ == kLoopOne) { + loadTrack(cursor_); // re-opens from frame 0: a forward-only stream cannot rewind in place + return; + } + if (loopMode_ == kLoopOff && allTracksVisited()) { + closeTrackStream(); + state_ = PlayState::Stopped; + frameIndex_ = 0; + visited_ = 0; + refreshStatus(); + return; + } + advanceCursor(+1); + loadTrack(cursor_); // loopAll wraps forever; loopOff stops on the NEXT end-of-file once every row has played + } + + /// Close the open stream, if any; safe to call when nothing is open. + void closeTrackStream() { + if (streamOpen_) platform::fsReadStreamClose(stream_); + streamOpen_ = false; + headLen_ = 0; + headPos_ = 0; + } + + /// Open `path` fresh and buffer its header region; false when the file can't be opened or has no bytes. + bool openTrackStream(const char* path) { + closeTrackStream(); + if (!platform::fsReadStreamOpen(stream_, path)) return false; + streamOpen_ = true; + size_t got = 0; + while (got < sizeof(headBuf_)) { + const int n = platform::fsReadStreamRead(stream_, reinterpret_cast(headBuf_) + got, sizeof(headBuf_) - got); + if (n <= 0) break; + got += static_cast(n); + } + headLen_ = got; + headPos_ = 0; + return got > 0; + } + + /// Pull `n` sequential file bytes into `dst`: the buffered header region first, the live stream after. + bool readBytes(uint8_t* dst, size_t n) { + size_t fromHead = 0; + if (headPos_ < headLen_) { + fromHead = n < (headLen_ - headPos_) ? n : (headLen_ - headPos_); + std::memcpy(dst, headBuf_ + headPos_, fromHead); + headPos_ += fromHead; + } + const size_t remain = n - fromHead; + if (remain == 0) return true; + if (!streamOpen_) return false; + size_t got = 0; + while (got < remain) { + const int r = platform::fsReadStreamRead(stream_, reinterpret_cast(dst + fromHead + got), remain - got); + if (r <= 0) return false; + got += static_cast(r); + } + return true; + } + + /// Discards `n` live-stream bytes between the buffered header region and `channelDataOffset`, for a file whose variable-length metadata outgrows `headBuf_`. + bool skipHeaderGap(size_t n) { + uint8_t discard[256]; + while (n > 0) { + const size_t chunk = n < sizeof(discard) ? n : sizeof(discard); + if (!readBytes(discard, chunk)) return false; + n -= chunk; + } + return true; + } + + /// Open and parse one playlist row's header; forces Stopped and reports the reason on failure. + bool loadTrack(uint8_t idx) { + if (idx >= playlistCount_) { state_ = PlayState::Stopped; return false; } + cursor_ = idx; + if (!openTrackStream(tracks_[cursor_].file)) { + setStatus("file read failed", Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + fseq::FseqHeader hdr; + const fseq::ParseError err = fseq::parseHeader(headBuf_, headLen_, hdr); + const char* errMsg = nullptr; + switch (err) { + case fseq::ParseError::BadMagic: errMsg = "not an FSEQ file"; break; + case fseq::ParseError::UnsupportedVersion: errMsg = "unsupported FSEQ version"; break; + case fseq::ParseError::UnsupportedCompression: errMsg = "compressed (zstd) FSEQ not supported yet"; break; + case fseq::ParseError::Truncated: errMsg = "FSEQ header truncated or unreadable"; break; + default: break; // None, or TooManySparseRanges (non-fatal, handled below) + } + if (errMsg) { + setStatus(errMsg, Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + header_ = hdr; + frameCount_ = header_.frameCount; + frameBuf_.resize(header_.channelCount); + if (!frameBuf_) { + setStatus("out of memory for this track's frame size", Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + headPos_ = header_.channelDataOffset; // skip the header, land exactly where frame 0 starts + if (headPos_ > headLen_ && !skipHeaderGap(headPos_ - headLen_)) { + setStatus("FSEQ header truncated or unreadable", Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + currentBlockIndex_ = 0; + currentBlockFirstFrame_ = 0; + currentBlockFrameCount_ = 0; + frameIndex_ = 0; + lastFrameMs_ = elapsed(); + markVisited(cursor_); + rebuildControls(); // the "position" progress total (frameCount_) just changed + if (!warnIfFileRateExceedsCap()) { + if (err == fseq::ParseError::TooManySparseRanges) setStatus("FSEQ sparse range table clipped", Severity::Warning); + else refreshStatus(); + } + return true; + } + + /// Decode frame `frameIndex_` into `frameBuf_`: the one blocking file read `tick20ms` makes, bounded to one frame or compression block. + bool decodeCurrentFrame() { + const uint8_t* rawFrame; + if (header_.compressionType == fseq::CompressionType::Zlib) { + rawFrame = frameFromCompressedBlock(frameIndex_); + if (!rawFrame) { + setStatus("file read failed mid-file", Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + std::memcpy(frameBuf_.data(), rawFrame, header_.channelCount); + } else if (!readBytes(frameBuf_.data(), header_.channelCount)) { + setStatus("file read failed mid-file", Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + return true; + } + + /// Blit the decoded frame onto the layer buffer, sparse or dense; a no-op before any frame has decoded. + void blitFrameBuf() { + if (!frameBuf_.data() || frameCount_ == 0) return; + uint8_t* buf = buffer(); + if (!buf) return; + const size_t bufBytes = static_cast(nrOfLights()) * channelsPerLight(); + if (header_.sparseRangeCount > 0) { + // Unmapped channels go dark: the sparse table only addresses the fixtures a show touches. + std::memset(buf, 0, bufBytes); + fseq::expandSparseFrame(header_, frameBuf_.data(), header_.channelCount, buf, bufBytes); + } else { + const size_t n = static_cast(header_.channelCount) < bufBytes ? header_.channelCount : bufBytes; + std::memcpy(buf, frameBuf_.data(), n); + } + } + + /// The raw bytes for `frameIndex` out of the current (or a freshly loaded) compression block. + const uint8_t* frameFromCompressedBlock(uint32_t frameIndex) { + while (frameIndex >= currentBlockFirstFrame_ + currentBlockFrameCount_) { + if (!loadNextBlock()) return nullptr; + } + // A block table not starting at frame 0 is malformed; fail rather than underflow this subtraction. + if (frameIndex < currentBlockFirstFrame_) return nullptr; + const size_t offset = static_cast(frameIndex - currentBlockFirstFrame_) * header_.channelCount; + return blockBuf_.data() + offset; + } + + /// Read, decompress and install the next compression block; its byte count sets this call's real cost. + bool loadNextBlock() { + if (currentBlockIndex_ >= header_.compressionBlockCount) return false; + const size_t entryOffset = 32 + static_cast(currentBlockIndex_) * 8; + if (entryOffset + 8 > headLen_) return false; // the block table lives inside the buffered header region + const uint32_t blockFirstFrame = fseq::detail::rdU32(headBuf_ + entryOffset); + const uint32_t blockByteLen = fseq::detail::rdU32(headBuf_ + entryOffset + 4); + + const bool hasNext = currentBlockIndex_ + 1 < header_.compressionBlockCount; + uint32_t nextFirstFrame = 0; + if (hasNext) { + if (entryOffset + 12 > headLen_) return false; + nextFirstFrame = fseq::detail::rdU32(headBuf_ + entryOffset + 8); + } + uint32_t blockFrameCount = 0; + size_t decompLen = 0; + if (!fseq::validateBlockRange(blockFirstFrame, header_.frameCount, hasNext, nextFirstFrame, + header_.channelCount, blockFrameCount, decompLen)) + return false; + if (blockByteLen == 0 || blockByteLen > kMaxCompressedBlockBytes || blockFrameCount == 0) return false; + + compBuf_.resize(blockByteLen); + if (!compBuf_ || !readBytes(compBuf_.data(), blockByteLen)) return false; + + blockBuf_.resize(decompLen); + if (!blockBuf_) return false; + size_t outLen = 0; + if (!platform::zlibInflateAll(compBuf_.data(), blockByteLen, blockBuf_.data(), decompLen, outLen) || + outLen != decompLen) + return false; + + currentBlockFirstFrame_ = blockFirstFrame; + currentBlockFrameCount_ = blockFrameCount; + currentBlockIndex_++; + return true; + } + + /// One directory level, collected: the same shape as FilesystemModule::removeTree's DirLevel. + struct ScanLevel { + static constexpr uint8_t kMax = 64; + char names[kMax][40]; + bool isDir[kMax]; + uint8_t count = 0; + }; + + static void collectScanEntry(const char* name, bool isDir, uint32_t, void* user) { + auto* lvl = static_cast(user); + if (lvl->count >= ScanLevel::kMax) return; + if (!name || name[0] == '.' || std::strlen(name) >= sizeof(lvl->names[0])) return; + std::snprintf(lvl->names[lvl->count], sizeof(lvl->names[0]), "%s", name); + lvl->isDir[lvl->count] = isDir; + lvl->count++; + } + + /// Recurse into `path`, depth-bounded, one heap-allocated level at a time (@xref{the-scan-is-depth-bounded-recursion-over-a-heap-per-level-buffer}). + void scanDir(const char* path, uint8_t depth) { + if (depth > kMaxScanDepth || fileCount_ >= kMaxFiles) return; + auto* raw = platform::alloc(sizeof(ScanLevel)); + if (!raw) return; + ScanLevel* lvlp = new (raw) ScanLevel; + ScanLevel& lvl = *lvlp; + struct Freer { ScanLevel* p; ~Freer() { p->~ScanLevel(); platform::free(p); } } freer{lvlp}; + platform::fsList(path, &collectScanEntry, &lvl); + for (uint8_t i = 0; i < lvl.count && fileCount_ < kMaxFiles; i++) { + char child[96]; + joinPath(path, lvl.names[i], child, sizeof(child)); + if (lvl.isDir[i]) { + scanDir(child, static_cast(depth + 1)); + } else if (hasExtension(lvl.names[i], ".fseq")) { + copyBounded(fileNames_[fileCount_++], sizeof(fileNames_[0]), child); + } + } + } + + /// Re-scan the filesystem root for `.fseq` files into the shared option set. + void rescanFiles() { + fileCount_ = 0; + scanDir("/", 0); + for (uint8_t i = 0; i < fileCount_; i++) fileOptions_[i] = fileNames_[i]; + rebuildControls(); + // Fires the resync directly: rebuildControls()'s own change-detection does not hash a List's row/option-set content. + notifySchemaChanged(); + } + + /// One row of `onControlChanged`'s dispatch table: a button's name and its handler. + struct ButtonHandler { const char* name; void (FseqPlayerEffect::*handler)(); }; + /// Every transport button, in no particular order, declared this late so rescanFiles is already in scope. + static constexpr ButtonHandler kButtonHandlers[] = { + {"play", &FseqPlayerEffect::handlePlay}, + {"pause", &FseqPlayerEffect::handlePause}, + {"stop", &FseqPlayerEffect::handleStop}, + {"previous", &FseqPlayerEffect::handlePrevious}, + {"next", &FseqPlayerEffect::handleNext}, + {"rescan", &FseqPlayerEffect::rescanFiles}, + }; + + /// Case-insensitive suffix check, since a file may carry ".FSEQ" from a different export tool. + static bool hasExtension(const char* name, const char* ext) { + const size_t nameLen = std::strlen(name); + const size_t extLen = std::strlen(ext); + if (nameLen < extLen) return false; + for (size_t i = 0; i < extLen; i++) { + char a = name[nameLen - extLen + i], b = ext[i]; + if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = static_cast(b - 'A' + 'a'); + if (a != b) return false; + } + return true; + } + + /// Show the transport state, the current file, and its position in the playlist. + void refreshStatus() { + if (playlistCount_ == 0) { setStatus("no tracks in playlist"); return; } + const char* label = state_ == PlayState::Playing ? "playing" + : state_ == PlayState::Paused ? "paused" + : "stopped"; + // Copied to a local first: GCC's -Wrestrict cannot see past two member arrays of the same `this`. + char fileCopy[sizeof(Track::file)]; + copyBounded(fileCopy, sizeof(fileCopy), tracks_[cursor_].file); + std::snprintf(statusBuf_, sizeof(statusBuf_), "%s: %s (%u/%u)", label, fileCopy, + static_cast(cursor_ + 1), static_cast(playlistCount_)); + setStatus(statusBuf_); + } }; -} // namespace mm +} // namespace mm diff --git a/src/light/util/FseqFile.h b/src/light/util/FseqFile.h new file mode 100644 index 000000000..f847dbe9b --- /dev/null +++ b/src/light/util/FseqFile.h @@ -0,0 +1,197 @@ +#pragma once + +/// Parses an FSEQ sequence file's fixed header, compression flags and sparse-range table. +/// +/// FSEQ is the Falcon Player/xLights animation file format: a flat, per-frame byte stream with no pixel/RGB semantics of its own. This header is pure and platform-agnostic: it reads caller-supplied byte buffers only, no `platform::` calls, no I/O. +/// +/// @technical +/// +/// ## Prior art +/// +/// FPP's `FSEQ_Sequence_File_Format.txt` is the spec; `core/util/FirmwareImage.h` is the structural template this header follows: a truncation-safe parser over a caller-supplied buffer, no I/O of its own. +/// +/// ## Mapping bytes onto pixels is the caller's job +/// +/// FSEQ carries no pixel/RGB semantics of its own, just a flat numbered byte stream per frame. Mapping a frame's bytes onto a light buffer is `FseqPlayerEffect`'s job, not this parser's. +/// +/// ## Compression +/// +/// A v2 file may compress channel data per-frame. Type 2 (zlib) decodes via the ESP32 ROM's `tinfl_decompress` at zero flash cost; type 1 (zstd) has no decoder yet, reported as its own error, not a bug. +/// +/// ## Sparse ranges +/// +/// A v2 file may carry only some of a show's channels. The sparse range table lists which absolute channel each raw-byte slice belongs to, and `expandSparseFrame` places it; a frame with no sparse ranges maps 1:1, skipping it. +/// +/// ## Why bytes 20 and 21 are read whole, not nibble-split +/// +/// A real xLights export disproved packing the compression block count across byte 20's high nibble and byte 21. `compressionBlockCount * 8 + 32` only lands on its own `variableHeaderOffset` when each byte is read whole. +/// +/// ## `parseHeader` never reads past `len` +/// +/// A buffer too short for a field or table the header claims to have returns `Truncated`. `out` is left however far parsing got, the same truncation-safe contract as `FirmwareImage.h`'s `identify()`. +/// +/// `channelDataOffset` is checked against where the header's own tables end, so frame 0 can never start inside them; before that end returns `Truncated` too. +/// +/// ## `parseHeader`'s two non-fatal errors +/// +/// `TooManySparseRanges` and `UnsupportedCompression` return only once `out` is otherwise fully populated (clipped to `kMaxSparseRanges` first), so a caller may still use the rest, e.g. for a status line. +/// +/// ## Why `frameByteOffset` returns `size_t`, not `uint32_t` +/// +/// `frameIndex * channelCount` overflows a 32-bit accumulator well within a real show's frame count once channel count is in the thousands. +/// +/// ## The layout `expandSparseFrame`'s `rawFrame` and `out` each assume +/// +/// `rawFrame` holds `hdr.sparseRangeCount` ranges back-to-back; each lands at `out[startChannel..startChannel+channelCount)`, clipped to `outCap`, leaving gaps the caller already zeroed, once per frame, untouched. + +#include +#include + +namespace mm::fseq { + +/// Why a header failed to parse, or (for the non-fatal cases) why it was accepted with a caveat. +enum class ParseError : uint8_t { + None, + BadMagic, ///< first 4 bytes are not "PSEQ"/"FSEQ" + UnsupportedVersion, ///< major version is neither 1 nor 2 + UnsupportedCompression, ///< zstd (type 1): no decoder exists in this build, not a malformed file + TooManySparseRanges, ///< non-fatal: the table was clipped to kMaxSparseRanges and parsed anyway + Truncated, ///< buffer ends before a field/table this header claims to have +}; + +/// A file's declared compression, read from header byte 20 (v2 only). +enum class CompressionType : uint8_t { None = 0, Zstd = 1, Zlib = 2 }; + +/// One contiguous slice of a sparse frame, placed at an absolute channel offset by `expandSparseFrame`. +struct SparseRange { + uint32_t startChannel = 0; ///< the absolute (unsparsed) channel this slice starts at + uint32_t channelCount = 0; ///< how many raw bytes belong to this slice +}; + +/// Bound on parsed sparse ranges: large enough for any real show, small enough to hold inline. +constexpr uint8_t kMaxSparseRanges = 32; + +/// The fields of an FSEQ header this player actually needs; metadata blocks are not retained. +struct FseqHeader { + uint16_t channelDataOffset = 0; ///< byte offset of frame 0 within the file + uint8_t minorVersion = 0; ///< the file's minor version, carried for diagnostics only + uint8_t majorVersion = 0; ///< 1 or 2; anything else is UnsupportedVersion + uint32_t channelCount = 0; ///< bytes per frame (sum of sparse-range counts, if any) + uint32_t frameCount = 0; ///< total frames in the file + uint8_t stepTimeMs = 0; ///< milliseconds per frame, the player's tick pacing + CompressionType compressionType = CompressionType::None; ///< None, Zlib (supported), or Zstd (not yet) + uint8_t compressionBlockCount = 0; ///< v2 only; used to locate the sparse range table, not for seeking + uint8_t sparseRangeCount = 0; ///< entries actually populated in sparseRanges (<= kMaxSparseRanges) + SparseRange sparseRanges[kMaxSparseRanges] = {}; ///< the sparse range table, if sparseRangeCount > 0 +}; + +namespace detail { +inline uint16_t rdU16(const uint8_t* p) { + return static_cast(p[0]) | static_cast(static_cast(p[1]) << 8); +} +inline uint32_t rdU32(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); +} +inline uint32_t rdU24(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | (static_cast(p[2]) << 16); +} +} // namespace detail + +/// Whether `buf` opens with the FSEQ magic, legacy "FSEQ" accepted alongside current "PSEQ". +inline bool isValidMagic(const uint8_t* buf) { + return (buf[0] == 'P' || buf[0] == 'F') && buf[1] == 'S' && buf[2] == 'E' && buf[3] == 'Q'; +} + +/// Parse the v2-only compression type, block count and sparse-range table into `out`; `buf`/`len` are the whole header buffer, unchanged from `parseHeader`. +inline ParseError parseV2Tail(const uint8_t* buf, size_t len, FseqHeader& out) { + // Byte 20 is the whole compression-type byte; byte 21 is the whole block count (see @technical above). + const uint8_t compressionByte = buf[20]; + out.compressionBlockCount = buf[21]; + const uint8_t sparseCountRaw = buf[22]; + + const size_t sparseTableOffset = 32 + static_cast(out.compressionBlockCount) * 8; + const uint8_t sparseCount = sparseCountRaw > kMaxSparseRanges ? kMaxSparseRanges : sparseCountRaw; + const size_t sparseTableEnd = sparseTableOffset + static_cast(sparseCount) * 6; + if (len < sparseTableEnd) return ParseError::Truncated; + if (out.channelDataOffset < sparseTableEnd) return ParseError::Truncated; + + for (uint8_t i = 0; i < sparseCount; i++) { + const uint8_t* entry = buf + sparseTableOffset + static_cast(i) * 6; + out.sparseRanges[i].startChannel = detail::rdU24(entry); + out.sparseRanges[i].channelCount = detail::rdU24(entry + 3); + } + out.sparseRangeCount = sparseCount; + + switch (compressionByte) { + case 0: out.compressionType = CompressionType::None; break; + case 2: out.compressionType = CompressionType::Zlib; break; + default: out.compressionType = CompressionType::Zstd; break; // 1, or any reserved value + } + + if (out.compressionType == CompressionType::Zstd) return ParseError::UnsupportedCompression; + if (sparseCountRaw > kMaxSparseRanges) return ParseError::TooManySparseRanges; + return ParseError::None; +} + +/// Parse the fixed header, compression flags and (v2) sparse-range table out of `buf`. +inline ParseError parseHeader(const uint8_t* buf, size_t len, FseqHeader& out) { + out = FseqHeader{}; + if (len < 32) return ParseError::Truncated; + if (!isValidMagic(buf)) return ParseError::BadMagic; + + out.channelDataOffset = detail::rdU16(buf + 4); + out.minorVersion = buf[6]; + out.majorVersion = buf[7]; + out.channelCount = detail::rdU32(buf + 10); + out.frameCount = detail::rdU32(buf + 14); + out.stepTimeMs = buf[18]; + + if (out.majorVersion != 1 && out.majorVersion != 2) return ParseError::UnsupportedVersion; + if (out.majorVersion == 1) { + if (out.channelDataOffset < 32) return ParseError::Truncated; + return ParseError::None; // v1 has no compression/sparse fields + } + return parseV2Tail(buf, len, out); +} + +/// The byte offset of frame `frameIndex`'s channel data within the file. +inline size_t frameByteOffset(const FseqHeader& hdr, uint32_t frameIndex) { + return static_cast(hdr.channelDataOffset) + + static_cast(frameIndex) * static_cast(hdr.channelCount); +} + +/// Validates one compression block's frame range against `frameCount` and the next table entry, and computes its decompressed byte length; false rejects it outright, before any buffer is sized by it. +inline bool validateBlockRange(uint32_t blockFirstFrame, uint32_t frameCount, bool hasNext, uint32_t nextFirstFrame, + uint32_t channelCount, uint32_t& outFrameCount, size_t& outDecompLen) { + if (blockFirstFrame >= frameCount) return false; + if (hasNext) { + if (nextFirstFrame <= blockFirstFrame) return false; // the table must strictly advance, or this subtraction underflows + outFrameCount = nextFirstFrame - blockFirstFrame; + } else { + outFrameCount = frameCount - blockFirstFrame; + } + if (channelCount != 0 && outFrameCount > SIZE_MAX / channelCount) return false; + outDecompLen = static_cast(outFrameCount) * channelCount; + return true; +} + +/// Place a sparse frame's concatenated raw bytes at their absolute channel offsets in `out`. +inline size_t expandSparseFrame(const FseqHeader& hdr, const uint8_t* rawFrame, size_t rawLen, + uint8_t* out, size_t outCap) { + size_t rawPos = 0; + for (uint8_t i = 0; i < hdr.sparseRangeCount && rawPos < rawLen; i++) { + const SparseRange& r = hdr.sparseRanges[i]; + size_t avail = rawLen - rawPos; + size_t n = static_cast(r.channelCount) < avail ? r.channelCount : avail; + if (r.startChannel < outCap) { + size_t room = outCap - r.startChannel; + size_t copy = n < room ? n : room; + for (size_t b = 0; b < copy; b++) out[r.startChannel + b] = rawFrame[rawPos + b]; + } + rawPos += n; + } + return rawPos; +} + +} // namespace mm::fseq diff --git a/src/module_types.cpp b/src/module_types.cpp index f78df79cc..211b5bf39 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -63,7 +63,6 @@ #include "light/effects/FixedPointEffect.h" #include "light/effects/MovingHeadEffect.h" #include "light/effects/PacmanEffect.h" -#include "light/effects/FseqPlayerEffect.h" #include "light/effects/PictureEffect.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/PulseEffect.h" @@ -121,6 +120,7 @@ #include "light/effects/TetrixEffect.h" #include "light/effects/TextEffect.h" #include "light/effects/FreqSawsEffect.h" +#include "light/effects/FseqPlayerEffect.h" #include "light/effects/BlurzEffect.h" #include "light/effects/FreqMatrixEffect.h" #include "light/effects/GEQEffect.h" @@ -251,6 +251,7 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("FixedRectangleEffect", "light/effects.md#fixedrectangle"); mm::ModuleFactory::registerType("FreqMatrixEffect", "light/effects.md#freqmatrix"); mm::ModuleFactory::registerType("FreqSawsEffect", "light/effects.md#freqsaws"); + mm::ModuleFactory::registerType("FseqPlayerEffect", "light/effects.md#fseqplayer"); mm::ModuleFactory::registerType("GameOfLifeEffect", "light/effects.md#gameoflife"); mm::ModuleFactory::registerType("GEQEffect", "light/effects.md#geq"); mm::ModuleFactory::registerType("GEQ3DEffect", "light/effects.md#geq3d"); @@ -264,7 +265,6 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("NoiseMeterEffect", "light/effects.md#noisemeter"); mm::ModuleFactory::registerType("PaintBrushEffect", "light/effects.md#paintbrush"); mm::ModuleFactory::registerType("ParticlesEffect", "light/effects.md#particles"); - mm::ModuleFactory::registerType("FseqPlayerEffect", "light/effects.md#fseqplayer"); mm::ModuleFactory::registerType("PictureEffect", "light/effects.md#picture"); mm::ModuleFactory::registerType("PlasmaEffect", "light/effects.md#plasma"); mm::ModuleFactory::registerType("PraxisEffect", "light/effects.md#praxis"); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index ca3822f03..eb508e898 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1172,6 +1172,9 @@ bool sdMount(const SdPinConfig& pins) { return true; } +// No decoder linked on desktop; the FSEQ player reports "unsupported" for a compressed file here. +bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } + bool sdCardMounted() { return sdMounted_; } SdMountError sdMountError() { return sdMountError_; } diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index c3e2223b0..c9a6203d1 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -59,6 +59,7 @@ #if SOC_SDMMC_IO_POWER_EXTERNAL #include "sd_pwr_ctrl_by_on_chip_ldo.h" #endif +#include "miniz.h" // tinfl_decompress_mem_to_mem: zlibInflateAll's ROM-resident decoder #endif namespace mm::platform { @@ -599,8 +600,18 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { return ok; } +/// The only real decoder this call ever reaches, since FseqPlayerEffect (its sole caller) is itself gated on `hasSdCard`. +bool zlibInflateAll(const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen) { + const size_t n = tinfl_decompress_mem_to_mem(dst, dstCap, src, srcLen, TINFL_FLAG_PARSE_ZLIB_HEADER); + if (n == TINFL_DECOMPRESS_MEM_TO_MEM_FAILED) return false; + outLen = n; + return true; +} + #else // !MM_HAS_SDMMC: every symbol below is inert so a board with no SDMMC host still links. +bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } + bool sdCardMounted() { return false; } bool sdCardPresent() { return false; } bool sdMount(const SdPinConfig&) { return false; } diff --git a/src/platform/platform.h b/src/platform/platform.h index ba4d90d53..7076f8d08 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -339,6 +339,9 @@ inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { return ok && committed; } +/// Inflate one self-contained zlib block in one call; `outLen` is set only on success. Real decoder only on ESP32 (the ROM's `tinfl_decompress`, zero flash cost); desktop and Teensy both stub to false, no decoder linked on either. +bool zlibInflateAll(const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen); + // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. /// Whether `path` names the filesystem's root: "" or "/", the two forms a listing of the root arrives as. diff --git a/src/platform/teensy/platform_teensy_fs.cpp b/src/platform/teensy/platform_teensy_fs.cpp index bea2e7ff9..26b5e9dea 100644 --- a/src/platform/teensy/platform_teensy_fs.cpp +++ b/src/platform/teensy/platform_teensy_fs.cpp @@ -15,6 +15,9 @@ extern unsigned long _flashimagelen; namespace mm::platform { +// No zlib/zstd decoder on Teensy: FseqPlayerEffect's compressed-block path never succeeds here. +bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } + // --- Filesystem: LittleFS on the top of program flash ------------------------------------------- namespace { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 12e670b0a..331fc69dc 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -136,6 +136,8 @@ add_executable(mm_tests unit/light/unit_FixedRectangleEffect.cpp unit/light/unit_FreqMatrixEffect.cpp unit/light/unit_FreqSawsEffect.cpp + unit/light/unit_FseqFile.cpp + unit/light/unit_FseqPlayerEffect.cpp unit/light/unit_GEQ3DEffect.cpp unit/light/unit_GEQEffect.cpp unit/light/unit_GridBlacksLayout.cpp diff --git a/test/unit/light/unit_FseqFile.cpp b/test/unit/light/unit_FseqFile.cpp new file mode 100644 index 000000000..a547cab81 --- /dev/null +++ b/test/unit/light/unit_FseqFile.cpp @@ -0,0 +1,302 @@ +/// @module FseqFile +/// @also FseqPlayerEffect + +/// Pins the FSEQ header/table parser against hand-built bytes: truncation at every offset, and sparse-range placement across a gap between two ranges. + +#include "doctest.h" + +#include "light/util/FseqFile.h" + +#include +#include +#include + +using namespace mm::fseq; + +namespace { + +void putU16(std::vector& b, size_t at, uint16_t v) { + b[at] = static_cast(v & 0xFF); + b[at + 1] = static_cast(v >> 8); +} +void putU32(std::vector& b, size_t at, uint32_t v) { + for (int i = 0; i < 4; i++) b[at + i] = static_cast(v >> (8 * i)); +} +void putU24(std::vector& b, size_t at, uint32_t v) { + for (int i = 0; i < 3; i++) b[at + i] = static_cast(v >> (8 * i)); +} + +/// A v1 header: no compression block table, no sparse range table, legacy bytes 20-31 unused. +std::vector makeV1(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount, + uint8_t stepTime) { + std::vector b(channelDataOffset, 0); + std::memcpy(b.data(), "PSEQ", 4); + putU16(b, 4, channelDataOffset); + b[6] = 0; // minor + b[7] = 1; // major + putU32(b, 10, channelCount); + putU32(b, 14, frameCount); + b[18] = stepTime; + return b; +} + +/// A v2 header, with an explicit compression block count and sparse range table; `sparse` entries are laid out right after `compressionBlockCount * 8` reserved bytes. +std::vector makeV2(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount, + uint8_t stepTime, uint8_t compressionType, uint8_t compressionBlockCount, + const std::vector& sparse) { + const size_t sparseTableOffset = 32 + static_cast(compressionBlockCount) * 8; + const size_t minLen = sparseTableOffset + sparse.size() * 6; + const size_t len = channelDataOffset > minLen ? channelDataOffset : minLen; + std::vector b(len, 0); + std::memcpy(b.data(), "PSEQ", 4); + putU16(b, 4, channelDataOffset); + b[6] = 0; // minor + b[7] = 2; // major + putU32(b, 10, channelCount); + putU32(b, 14, frameCount); + b[18] = stepTime; + // A whole byte each, not nibble-split, matching a real xLights v2 export. + b[20] = compressionType; + b[21] = compressionBlockCount; + b[22] = static_cast(sparse.size()); + for (size_t i = 0; i < sparse.size(); i++) { + const size_t at = sparseTableOffset + i * 6; + putU24(b, at, sparse[i].startChannel); + putU24(b, at + 3, sparse[i].channelCount); + } + return b; +} + +} // namespace + +TEST_CASE("a valid v1 header parses the fields the player needs") { + const auto buf = makeV1(/*channelDataOffset=*/32, /*channelCount=*/450, /*frameCount=*/600, + /*stepTime=*/25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.channelDataOffset == 32); + CHECK(hdr.majorVersion == 1); + CHECK(hdr.channelCount == 450); + CHECK(hdr.frameCount == 600); + CHECK(hdr.stepTimeMs == 25); + CHECK(hdr.compressionType == CompressionType::None); + CHECK(hdr.sparseRangeCount == 0); +} + +TEST_CASE("a v2 header with no compression and no sparse ranges behaves like v1") { + const auto buf = makeV2(32, 450, 600, 25, /*compressionType=*/0, /*compressionBlockCount=*/0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::None); + CHECK(hdr.sparseRangeCount == 0); +} + +TEST_CASE("a legacy FSEQ magic is accepted alongside the current PSEQ magic") { + auto buf = makeV1(32, 10, 1, 25); + std::memcpy(buf.data(), "FSEQ", 4); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); +} + +TEST_CASE("a wrong magic is refused") { + auto buf = makeV1(32, 10, 1, 25); + std::memcpy(buf.data(), "ZZZZ", 4); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::BadMagic); +} + +TEST_CASE("an unknown major version is refused") { + auto buf = makeV1(32, 10, 1, 25); + buf[7] = 3; + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedVersion); +} + +TEST_CASE("zstd compression is its own error, not a generic failure") { + // Type 1 is zstd: no decoder exists in this build, so it is reported distinctly from a malformed file. + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/1, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedCompression); + CHECK(hdr.compressionType == CompressionType::Zstd); +} + +TEST_CASE("zlib compression parses cleanly, since a ROM decoder exists for it") { + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/2, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::Zlib); +} + +TEST_CASE("a reserved compression type byte is treated as unsupported rather than silently accepted") { + const auto buf = makeV2(32, 100, 10, 25, /*compressionType=*/7, 0, {}); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::UnsupportedCompression); +} + +TEST_CASE("sparse ranges land in the header in file order") { + const std::vector ranges = {{0, 100}, {500, 50}}; + const auto buf = makeV2(32 + 2 * 6, 150, 10, 25, 0, 0, ranges); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + REQUIRE(hdr.sparseRangeCount == 2); + CHECK(hdr.sparseRanges[0].startChannel == 0); + CHECK(hdr.sparseRanges[0].channelCount == 100); + CHECK(hdr.sparseRanges[1].startChannel == 500); + CHECK(hdr.sparseRanges[1].channelCount == 50); +} + +TEST_CASE("a compression block table displaces the sparse range table, and parsing follows it") { + // compressionBlockCount=3 means 24 reserved bytes between the fixed header and the sparse table. + const std::vector ranges = {{10, 5}}; + const auto buf = makeV2(32 + 3 * 8 + 6, 50, 10, 25, 2, /*compressionBlockCount=*/3, ranges); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::None); + CHECK(hdr.compressionBlockCount == 3); + REQUIRE(hdr.sparseRangeCount == 1); + CHECK(hdr.sparseRanges[0].startChannel == 10); + CHECK(hdr.sparseRanges[0].channelCount == 5); +} + +TEST_CASE("a real xLights v2 export's header parses with the right block count") { + // Pinned against a real export that once misread compressionBlockCount as 336 instead of 21; padded to 200 bytes since parseHeader needs the whole compression-block table. + std::vector head(200, 0); + const uint8_t fixed[32] = { + 'P', 'S', 'E', 'Q', + 0xcf, 0x00, // channelDataOffset = 207 + 0x00, 0x02, // minor=0, major=2 + 0xc8, 0x00, // variableHeaderOffset = 200 + 0xc0, 0x5d, 0x00, 0x00, // channelCount = 24000 + 0x34, 0x08, 0x00, 0x00, // frameCount = 2100 + 0x1d, // stepTimeMs = 29 + 0x00, + 0x02, // compressionType = 2 (zlib) + 0x15, // compressionBlockCount = 21 + 0x00, // sparseRangeCount = 0 + 0x00, + 0, 0, 0, 0, 0, 0, 0, 0 // unique id, unused + }; + std::memcpy(head.data(), fixed, sizeof(fixed)); + FseqHeader hdr; + CHECK(parseHeader(head.data(), head.size(), hdr) == ParseError::None); + CHECK(hdr.compressionType == CompressionType::Zlib); + CHECK(hdr.compressionBlockCount == 21); + CHECK(hdr.channelCount == 24000); + CHECK(hdr.frameCount == 2100); + CHECK(hdr.channelDataOffset == 207); + CHECK(32 + static_cast(hdr.compressionBlockCount) * 8 == 200); // == variableHeaderOffset +} + +TEST_CASE("more sparse ranges than the cap are clipped, not overflowed, and reported non-fatally") { + std::vector ranges; + for (uint8_t i = 0; i < kMaxSparseRanges + 5; i++) ranges.push_back({static_cast(i * 10), 1}); + const auto buf = makeV2(32 + static_cast(ranges.size() * 6), 300, 10, 25, 0, 0, ranges); + FseqHeader hdr; + // The buffer only reserves room for the clipped count: parseHeader must not read past that. + std::vector clippedBuf(buf.begin(), buf.begin() + static_cast(32 + static_cast(kMaxSparseRanges) * 6)); + CHECK(parseHeader(clippedBuf.data(), clippedBuf.size(), hdr) == ParseError::TooManySparseRanges); + CHECK(hdr.sparseRangeCount == kMaxSparseRanges); +} + +TEST_CASE("a buffer truncated at every interesting point reports Truncated rather than reading past it") { + const std::vector ranges = {{0, 100}, {500, 50}}; + const auto full = makeV2(32 + 2 * 6 + 10, 150, 10, 25, 0, 0, ranges); + for (size_t n : {size_t{0}, size_t{1}, size_t{31}, size_t{32}, size_t{37}, size_t{40}}) { + CAPTURE(n); + FseqHeader hdr; + const auto err = parseHeader(full.data(), n, hdr); + if (n < 32) { CHECK(err == ParseError::Truncated); continue; } + // n==32..43 is mid-sparse-table (table spans [32, 44)); every one of those must truncate. + CHECK(err == ParseError::Truncated); + } + // One byte short of the full sparse table still truncates; the exact end succeeds. + FseqHeader hdrShort; + CHECK(parseHeader(full.data(), 32 + 2 * 6 - 1, hdrShort) == ParseError::Truncated); + FseqHeader hdrExact; + CHECK(parseHeader(full.data(), 32 + 2 * 6, hdrExact) == ParseError::None); +} + +TEST_CASE("channelDataOffset overlapping the v1 fixed header is refused") { + auto buf = makeV1(/*channelDataOffset=*/32, 10, 1, 25); + putU16(buf, 4, 20); // claims frame 0 starts inside the 32-byte fixed header + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Truncated); +} + +TEST_CASE("channelDataOffset overlapping a v2 file's compression/sparse tables is refused") { + const std::vector ranges = {{0, 100}}; + auto buf = makeV2(32 + 6, 150, 10, 25, 0, 0, ranges); // tables end at 32+6=38 + putU16(buf, 4, 35); // claims frame 0 starts inside the sparse range table + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Truncated); +} + +TEST_CASE("frameByteOffset uses size_t arithmetic, so a large show does not overflow a 32-bit accumulator") { + FseqHeader hdr; + hdr.channelDataOffset = 32; + hdr.channelCount = 300000; // a large 3D voxel show + CHECK(frameByteOffset(hdr, 0) == 32); + CHECK(frameByteOffset(hdr, 1) == 32u + 300000u); + // frameIndex * channelCount alone exceeds UINT32_MAX by frame ~14310: size_t keeps this exact. + const uint32_t bigFrame = 20000; + const size_t expected = size_t{32} + size_t{bigFrame} * size_t{300000}; + CHECK(expected > 0xFFFFFFFFULL); + CHECK(frameByteOffset(hdr, bigFrame) == expected); +} + +TEST_CASE("validateBlockRange accepts a well-formed entry and computes its decompressed length") { + uint32_t frameCount = 0; + size_t decompLen = 0; + CHECK(validateBlockRange(/*blockFirstFrame=*/10, /*frameCount=*/100, /*hasNext=*/true, + /*nextFirstFrame=*/40, /*channelCount=*/50, frameCount, decompLen)); + CHECK(frameCount == 30); + CHECK(decompLen == 30u * 50u); + + // The last entry has no next: its span runs to the file's own frameCount. + CHECK(validateBlockRange(40, 100, /*hasNext=*/false, 0, 50, frameCount, decompLen)); + CHECK(frameCount == 60); +} + +TEST_CASE("validateBlockRange refuses a block table that does not strictly advance") { + uint32_t frameCount = 0; + size_t decompLen = 0; + // The next entry equal to this one would underflow the frame-count subtraction. + CHECK_FALSE(validateBlockRange(40, 100, /*hasNext=*/true, /*nextFirstFrame=*/40, 50, frameCount, decompLen)); + // The next entry going backwards is refused the same way. + CHECK_FALSE(validateBlockRange(40, 100, /*hasNext=*/true, /*nextFirstFrame=*/10, 50, frameCount, decompLen)); +} + +TEST_CASE("validateBlockRange refuses a block starting at or past the file's own frameCount") { + uint32_t frameCount = 0; + size_t decompLen = 0; + CHECK_FALSE(validateBlockRange(100, 100, false, 0, 50, frameCount, decompLen)); + CHECK_FALSE(validateBlockRange(150, 100, false, 0, 50, frameCount, decompLen)); +} + +// validateBlockRange's overflow guard only trips on a 32-bit size_t (two uint32_t values can't multiply past 64-bit); only real ESP32 hardware can exercise that branch. + +TEST_CASE("expandSparseFrame places each range at its absolute channel offset, leaving gaps alone") { + FseqHeader hdr; + hdr.sparseRangeCount = 2; + hdr.sparseRanges[0] = {0, 3}; // channels 0..2 + hdr.sparseRanges[1] = {10, 2}; // channels 10..11, a gap at 3..9 + const uint8_t raw[5] = {1, 2, 3, 9, 8}; // ranges concatenated with no padding + uint8_t out[16]; + std::memset(out, 0xAA, sizeof(out)); // a sentinel: gaps must stay untouched by this call + const size_t consumed = expandSparseFrame(hdr, raw, sizeof(raw), out, sizeof(out)); + CHECK(consumed == 5); + CHECK(out[0] == 1); CHECK(out[1] == 2); CHECK(out[2] == 3); + CHECK(out[3] == 0xAA); // the gap: caller's own zeroing (or lack of it) is preserved + CHECK(out[10] == 9); CHECK(out[11] == 8); +} + +TEST_CASE("expandSparseFrame clips to the output capacity rather than overflowing it") { + FseqHeader hdr; + hdr.sparseRangeCount = 1; + hdr.sparseRanges[0] = {14, 4}; // would write channels 14..17 into a 16-byte buffer + const uint8_t raw[4] = {1, 2, 3, 4}; + uint8_t out[16]; + std::memset(out, 0, sizeof(out)); + expandSparseFrame(hdr, raw, sizeof(raw), out, sizeof(out)); + CHECK(out[14] == 1); + CHECK(out[15] == 2); // bytes 3 and 4 fell outside outCap and were dropped, not overrun +} diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index 3f82ed683..99a324016 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -1,46 +1,589 @@ /// @module FseqPlayerEffect -/// @also ChannelDataSink +/// @also FseqFile -/// The FSEQ player as it stands: its settings, its frames landing where a sender-mapped stream's do, and a status saying it reads no file yet. +/// Pins the FSEQ player: the transport state machine against injected state, and the scan/decode/blit path against real `.fseq` bytes under a test filesystem root, mounted or plain flash alike. #include "doctest.h" + #include "light/effects/FseqPlayerEffect.h" #include "light/layouts/GridLayout.h" -#include "light/layouts/Layouts.h" +#include "core/util/JsonSink.h" +#include #include +#include + +using namespace mm; namespace { -struct FseqRig { - mm::Layouts layouts; - mm::GridLayout grid; - mm::Layer layer; - mm::FseqPlayerEffect fx; - FseqRig() { - grid.width = 4; - grid.height = 4; - grid.depth = 1; - grid.serpentine = true; + +fseq::FseqHeader makeHeader(uint32_t channelCount, uint32_t frameCount, uint8_t stepTimeMs = 25) { + fseq::FseqHeader h; + h.channelDataOffset = 32; + h.channelCount = channelCount; + h.frameCount = frameCount; + h.stepTimeMs = stepTimeMs; + return h; +} + +// A real v1 FSEQ file's bytes: frame i's channels all hold the byte value i, so a test can tell which frame decoded. +std::vector makeFseqV1Bytes(uint32_t channelCount, uint32_t frameCount, uint8_t stepTimeMs = 25) { + std::vector b(32 + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = 32; b[5] = 0; // channelDataOffset = 32 + b[6] = 0; b[7] = 1; // minor=0, major=1 + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = stepTimeMs; + for (uint32_t f = 0; f < frameCount; f++) + std::memset(b.data() + 32 + static_cast(f) * channelCount, static_cast(f), channelCount); + return b; +} + +// A fresh temp dir as the flash root for one test (the unit_HttpServerModule_sdcard.cpp shape), mounting a card by default; pass false for flash-root-only. +struct SdFixture { + char root[256]; + bool mounted; + explicit SdFixture(bool mountSd = true) : mounted(mountSd) { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_fseq_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + platform::fsSetRoot(root); + if (mounted) { + platform::SdPinConfig pins{}; + pins.clk = 43; pins.cmd = 44; pins.d0 = 39; + REQUIRE(platform::sdMount(pins)); + } + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~SdFixture() noexcept { + if (mounted) platform::sdUnmount(); + platform::fsSetRoot(""); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + void writeFseq(const char* path, const std::vector& bytes) { + REQUIRE(platform::fsWriteAtomic(path, reinterpret_cast(bytes.data()), bytes.size())); + } +}; + +// A real 4x4 Layer the effect is parented to, the same fixture shape unit_DemoReelEffect.cpp uses so elapsed()/buffer() resolve through the parent. +struct Scene { + Layouts layouts; + GridLayout grid; + Layer layer; + FseqPlayerEffect effect; + Scene() { + grid.width = 4; grid.height = 4; grid.depth = 1; layouts.addChild(&grid); layer.setLayouts(&layouts); layer.setChannelsPerLight(3); - layer.addChild(&fx); - fx.defineControls(); - layer.applyState(); + layer.addChild(&effect); } }; -} // namespace -// An FSEQ frame is in wiring order, so alone on its layer it goes to the output as it is, on a serpentine grid too. -TEST_CASE("an FSEQ player alone on its layer hands its frame to the output") { - FseqRig r; - CHECK(r.fx.deliversWiringOrder()); - CHECK(r.layer.wiringSource() == &r.fx); +// A v1 file whose channelDataOffset is pushed past the header's own 32 fixed bytes, the way a real export's variable-length metadata block would; the padding is arbitrary (zero). +std::vector makeFseqV1BytesWithOffset(uint16_t channelDataOffset, uint32_t channelCount, uint32_t frameCount) { + std::vector b(static_cast(channelDataOffset) + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = static_cast(channelDataOffset & 0xFF); + b[5] = static_cast(channelDataOffset >> 8); + b[6] = 0; b[7] = 1; + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = 25; + for (uint32_t f = 0; f < frameCount; f++) + std::memset(b.data() + channelDataOffset + static_cast(f) * channelCount, static_cast(f), channelCount); + return b; +} + +// Two tracks written, scanned and added as playlist rows, for the two tests exercising a second row. +void twoTrackPlaylist(SdFixture& sd, FseqPlayerEffect& e, uint32_t& id1, uint32_t& id2) { + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); + e.setup(); + e.addListRow(id1); + e.addListRow(id2); +} + +} // namespace + +// Pins the independence the product owner asked for directly: no SD-specific check or status, ever. +TEST_CASE("setup scans silently, with no SD-specific check or status") { + Scene s; + auto& e = s.effect; + e.setup(); + CHECK(e.status() == nullptr); +} + +TEST_CASE("rescan finds a real .fseq file at the flash root, with no SD mount at all") { + SdFixture flash(/*mountSd=*/false); + flash.writeFseq("/show.fseq", makeFseqV1Bytes(9, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + // addListRow defaults to the first scanned file, so a non-empty default proves the scan found it at the flash root. + CHECK(e.listRowCount() == 1); +} + +TEST_CASE("playing a flash-found file decodes and blits its frames, same as an SD one") { + SdFixture flash(/*mountSd=*/false); + flash.writeFseq("/show.fseq", makeFseqV1Bytes(/*channelCount=*/48, /*frameCount=*/3)); // 48 = 4x4x1x3 + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("playing", 0) == 0); + + const uint8_t* buf = s.layer.buffer().data(); + const size_t bytes = s.layer.buffer().count() * 3; + + e.decodeOneFrameForTest(); // frame 0: every byte is 0 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); +} + +TEST_CASE("setup with a mounted but empty card scans cleanly to an empty playlist") { + SdFixture sd; + Scene s; + auto& e = s.effect; + e.setup(); + CHECK(e.listRowCount() == 0); +} + +TEST_CASE("rescan finds a real .fseq file, case-insensitively, in a nested folder, skipping dotfiles") { + SdFixture sd; + sd.writeFseq("/sd/show.FSEQ", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/shows/nested.fseq", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/.hidden.fseq", makeFseqV1Bytes(9, 3)); + sd.writeFseq("/sd/notes.txt", {1, 2, 3}); + Scene s; + auto& e = s.effect; + e.setup(); // rescans once on its own + + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + // addListRow defaults to the first scanned file, so a non-empty default proves the scan found something real. + CHECK(e.listRowCount() == 1); +} + +TEST_CASE("onFileChanged rescans on any path, so an upload anywhere reaches the playlist unasked") { + SdFixture sd; + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t idBefore = 0; + REQUIRE(e.addListRow(idBefore)); // nothing scanned yet: defaults to an empty file + e.deleteListRow(idBefore); + + sd.writeFseq("/sd/new.fseq", makeFseqV1Bytes(9, 3)); + e.onFileChanged("/sd/new.fseq"); + + uint32_t idAfter = 0; + REQUIRE(e.addListRow(idAfter)); + CHECK(e.listRowCount() == 1); + // This effect has no path it specifically cares about: any changed path triggers the same rescan, with no crash from a path that names no real file. + e.onFileChanged("/.config/SomeModule.json"); +} + +TEST_CASE("prepare() starts playing on its own once there is a playlist to play") { + Scene s; + auto& e = s.effect; + // Empty playlist: nothing to start, and no crash from trying anyway. + e.prepare(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + uint32_t id = 0; + e.addListRow(id); + e.prepare(); + // The row's file is empty: nothing was ever scanned. An error status (not silence) proves prepare() tried to auto-start. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("playing a real file decodes and blits its frames in order") { + SdFixture sd; + sd.writeFseq("/sd/show.fseq", makeFseqV1Bytes(/*channelCount=*/48, /*frameCount=*/3)); // 48 = 4x4x1x3 + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + // Pins a real bug: loadTrack's own refreshStatus() must see the state this call transitions to, not the one it leaves, or a successful play reports "stopped". + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("playing", 0) == 0); + + const uint8_t* buf = s.layer.buffer().data(); + const size_t bytes = s.layer.buffer().count() * 3; + + e.decodeOneFrameForTest(); // frame 0: every byte is 0 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); + + e.decodeOneFrameForTest(); // frame 2, the last one + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 2); + + // Frame 3 does not exist: handleEndOfFile already ran inside the decode above (loopAll default: reloads frame 0). + CHECK(e.frameIndexForTest() == 0); + + // Pause updates status too, not just state: a pre-existing gap found and fixed alongside the play-status bug above. + e.onControlChanged("pause"); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).rfind("paused", 0) == 0); +} + +TEST_CASE("release() forces a clean stop, so a later re-enable never resumes into freed buffers") { + Scene s; + auto& e = s.effect; + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.release(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("a stopped player owns its background (clears to black), but a paused one re-blits the same frame") { + Scene s; + auto& e = s.effect; + uint8_t* buf = const_cast(s.layer.buffer().data()); + const size_t bytes = s.layer.buffer().count() * 3; + constexpr uint8_t kStale = 0xA7; // a value no real frame would coincidentally produce + + std::memset(buf, kStale, bytes); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); // the default, no playlist + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); // owns its background: goes black + + std::memset(buf, kStale, bytes); + e.setStateForTest(FseqPlayerEffect::PlayState::Paused); + e.tick(); + // No frame has ever decoded (frameBuf_ was never allocated): blitFrameBuf's own guard makes this a no-op, not a crash. + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == kStale); +} + +TEST_CASE("the playlist is an editable list: add, delete, move, edit") { + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0, id3 = 0; + CHECK(e.isEditableList()); + CHECK(e.addListRow(id1)); + CHECK(e.addListRow(id2)); + CHECK(e.listRowCount() == 2); + CHECK(id1 != id2); + + CHECK(e.deleteListRow(id1)); + CHECK(e.listRowCount() == 1); + CHECK_FALSE(e.deleteListRow(id1)); // already gone + + CHECK(e.addListRow(id3)); + CHECK(e.listRowCount() == 2); + CHECK(e.moveListRow(id3, 0)); // id3 now row 0, id2 row 1 + + // No SD files have been scanned, so the file picker's option index is out of range. + CHECK_FALSE(e.setListRowField(id2, "file", "{\"value\":0}")); + CHECK_FALSE(e.setListRowField(999999, "file", "{\"value\":0}")); // an unknown id + CHECK_FALSE(e.setListRowField(id2, "nonsense", "{\"value\":0}")); // an unknown field } -// Until playback lands the player says so, rather than showing black with no reason. -TEST_CASE("an FSEQ player says it reads no file yet") { - FseqRig r; - REQUIRE(r.fx.status() != nullptr); - CHECK(std::string(r.fx.status()) == "reads no FSEQ file yet: playback is the next step"); +TEST_CASE("play with an empty playlist does nothing") { + Scene s; + auto& e = s.effect; + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("play with no real file to open fails gracefully rather than pretending to start") { + Scene s; + auto& e = s.effect; + uint32_t id = 0; + e.addListRow(id); + e.onControlChanged("play"); + // The row's file is empty: loadTrack's open fails, so playback never actually starts. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("pause only takes effect while playing") { + Scene s; + auto& e = s.effect; + e.setStateForTest(FseqPlayerEffect::PlayState::Stopped); + e.onControlChanged("pause"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.onControlChanged("pause"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Paused); + + e.onControlChanged("pause"); // already paused: no further change + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Paused); +} + +TEST_CASE("stop resets the frame position and only applies while playing or paused") { + Scene s; + auto& e = s.effect; + e.onControlChanged("stop"); // Stopped -> Stopped: a no-op, not a crash + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.setHeaderForTest(makeHeader(10, 100)); + e.onControlChanged("stop"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + CHECK(e.frameIndexForTest() == 0); +} + +TEST_CASE("previous and next move the cursor only, while the transport is stopped") { + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0, id3 = 0; + e.addListRow(id1); + e.addListRow(id2); + e.addListRow(id3); + REQUIRE(e.cursorForTest() == 0); + + e.onControlChanged("next"); + CHECK(e.cursorForTest() == 1); + e.onControlChanged("next"); + CHECK(e.cursorForTest() == 2); + e.onControlChanged("next"); // wraps past the last row + CHECK(e.cursorForTest() == 0); + e.onControlChanged("previous"); // wraps back + CHECK(e.cursorForTest() == 2); + + // Stopped, so moving the cursor never touches playback state. + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); +} + +TEST_CASE("shuffle's cursor never repeats the row it just left") { + Scene s; + auto& e = s.effect; + uint32_t ids[5] = {}; + for (uint32_t& id : ids) e.addListRow(id); + e.setShuffleForTest(true); + + uint8_t prev = e.cursorForTest(); + for (int i = 0; i < 200; i++) { + e.onControlChanged("next"); + const uint8_t now = e.cursorForTest(); + CHECK(now < 5); + CHECK(now != prev); // the do-while non-repeat guard in advanceCursor + prev = now; + } +} + +TEST_CASE("the recursive scan's path join never doubles the slash at the SD root, and truncates safely") { + char out[32]; + FseqPlayerEffect::joinPathForTest("/sd", "show.fseq", out, sizeof(out)); + CHECK(std::string(out) == "/sd/show.fseq"); + + FseqPlayerEffect::joinPathForTest("/sd/shows", "intro.fseq", out, sizeof(out)); + CHECK(std::string(out) == "/sd/shows/intro.fseq"); + + FseqPlayerEffect::joinPathForTest("/sd/shows/", "intro.fseq", out, sizeof(out)); + CHECK(std::string(out) == "/sd/shows/intro.fseq"); // trailing slash on dir: no double "//" + + char small[6]; + FseqPlayerEffect::joinPathForTest("/sd/shows", "averylongname.fseq", small, sizeof(small)); + CHECK(std::strlen(small) < sizeof(small)); // truncated, still NUL-terminated, never overrun +} + +TEST_CASE("fpsSource=manual: the fps slider sets the playback rate directly, ignoring the file's own step time") { + Scene s; + auto& e = s.effect; + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + // The file's stepTimeMs (25ms, i.e. 40fps) deliberately differs from every fps set below, to prove the slider wins. + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/25)); + e.setFpsForTest(30); + CHECK(e.effectiveStepMsForTest() == 33); // 1000/30, rounded down + + e.setFpsForTest(20); + CHECK(e.effectiveStepMsForTest() == 50); // 1000/20 + e.setFpsForTest(1); + CHECK(e.effectiveStepMsForTest() == 1000); + e.setFpsForTest(50); + CHECK(e.effectiveStepMsForTest() == 20); // the ceiling the `fps` control allows: tick20ms's own cadence +} + +TEST_CASE("fpsSource=file is the default, and reads the track's own stepTimeMs directly") { + Scene s; + auto& e = s.effect; + // Reading the file's rate without ever touching setFpsSourceForTest proves kFpsFromFile is the real default. + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/25)); + CHECK(e.effectiveStepMsForTest() == 25); // the file's own rate, not the fps_ default of 30 (33ms) + + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/100)); + CHECK(e.effectiveStepMsForTest() == 100); // 10fps, well under the cap +} + +TEST_CASE("fpsSource=file caps a rate faster than 50fps rather than honoring it") { + Scene s; + auto& e = s.effect; + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/10)); // 100fps authored, double the cap + CHECK(e.effectiveStepMsForTest() == 20); // clamped to 1000/50, tick20ms's own cadence + + e.setHeaderForTest(makeHeader(10, 50, /*stepTimeMs=*/0)); // an unauthored rate: default to the cap, not a /0 + CHECK(e.effectiveStepMsForTest() == 20); +} + +TEST_CASE("loading a file-sourced track faster than 50fps warns, naming the rate it asked for") { + SdFixture sd; + // stepTimeMs=10 is 100fps, double the cap this rate source can honor. + sd.writeFseq("/sd/fast.fseq", makeFseqV1Bytes(10, 3, /*stepTimeMs=*/10)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() == MoonModule::Severity::Warning); + CHECK(std::string(e.status()).find("100") != std::string::npos); + CHECK(std::string(e.status()).find("50") != std::string::npos); +} + +TEST_CASE("a manual-mode load never warns about the file's own rate, however fast it is authored") { + SdFixture sd; + sd.writeFseq("/sd/fast.fseq", makeFseqV1Bytes(10, 3, /*stepTimeMs=*/10)); + Scene s; + auto& e = s.effect; + e.setup(); + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.status() != nullptr); + CHECK(e.severity() != MoonModule::Severity::Warning); +} + +TEST_CASE("loopOne restarts the same track at frame 0 without advancing the cursor") { + SdFixture sd; + sd.writeFseq("/sd/show.fseq", makeFseqV1Bytes(10, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + e.addListRow(id); + e.setLoopModeForTest(FseqPlayerEffect::kLoopOne); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + + e.handleEndOfFileForTest(); + CHECK(e.frameIndexForTest() == 0); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // stays playing: it is the same track + CHECK(e.cursorForTest() == 0); // never advanced +} + +TEST_CASE("loopOff stops once every row has already played this pass, with no further read needed") { + Scene s; + auto& e = s.effect; + uint32_t ids[3] = {}; + for (uint32_t& id : ids) e.addListRow(id); + e.setStateForTest(FseqPlayerEffect::PlayState::Playing); + e.setLoopModeForTest(FseqPlayerEffect::kLoopOff); + e.setHeaderForTest(makeHeader(10, 50)); + // Simulate a pass that already touched every row (as loadTrack would have, had the reads succeeded). + e.markVisitedForTest(0); + e.markVisitedForTest(1); + e.markVisitedForTest(2); + REQUIRE(e.allTracksVisitedForTest()); + + e.handleEndOfFileForTest(); // this branch returns before ever touching loadTrack/the card + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + CHECK(e.frameIndexForTest() == 0); + CHECK_FALSE(e.allTracksVisitedForTest()); // the bitset is cleared for the next pass +} + +TEST_CASE("loopAll advances to the next real track and keeps playing") { + SdFixture sd; + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0; + twoTrackPlaylist(sd, e, id1, id2); + e.setLoopModeForTest(FseqPlayerEffect::kLoopAll); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + REQUIRE(e.cursorForTest() == 0); + + e.handleEndOfFileForTest(); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // the next row's open succeeded + CHECK(e.cursorForTest() == 1); + CHECK(e.frameIndexForTest() == 0); +} + +TEST_CASE("a channelDataOffset past the buffered header region is skipped, landing frame data correctly") { + SdFixture sd; + // channelDataOffset (5000) exceeds the 4096-byte buffered header region: skipHeaderGap must consume the gap from the live stream so frame 0 still starts at the right byte. + sd.writeFseq("/sd/gap.fseq", makeFseqV1BytesWithOffset(5000, /*channelCount=*/48, /*frameCount=*/3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + + const uint8_t* buf = s.layer.buffer().data(); + const size_t bytes = s.layer.buffer().count() * 3; + e.decodeOneFrameForTest(); // frame 0: every byte is 0, same as the no-gap decode test + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); + e.decodeOneFrameForTest(); // frame 1: every byte is 1 + e.tick(); + for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); +} + +TEST_CASE("a playlist row whose file no longer scans is rendered with no selection, not the first file") { + SdFixture sd; + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); // defaults to the one scanned file, "/sd/a.fseq" + + std::filesystem::remove(std::string(sd.root) + "/sd/a.fseq"); // gone from disk; a rescan drops it from the option set + e.onFileChanged("/sd/a.fseq"); + CHECK(e.listRowCount() == 1); // the row survives rescans; only the file options shrink + + JsonSink sink; + e.writeListRowDetail(sink, 0); + CHECK(std::string(sink.data()).find("\"value\":-1,\"optionsRef\":\"files\"") != std::string::npos); +} + +TEST_CASE("a playlist row's own play field jumps to that row and starts a fresh pass") { + SdFixture sd; + Scene s; + auto& e = s.effect; + uint32_t id1 = 0, id2 = 0; + twoTrackPlaylist(sd, e, id1, id2); + e.markVisitedForTest(0); // pretend row 0 already played this pass + + e.setListRowField(id2, "play", "{}"); + CHECK(e.cursorForTest() == 1); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // the row's own file opened fine } From f9e8f69a5b8b7058cfd8ce0b18d0b248cb13fa1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 13:00:51 +0200 Subject: [PATCH 02/10] Keep the FSEQ frame in wiring order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- src/light/effects/FseqPlayerEffect.h | 75 ++++++++++++++--------- test/unit/light/unit_FseqPlayerEffect.cpp | 57 ++++++++++++++++- 2 files changed, 100 insertions(+), 32 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 381e6b1fa..28ce070ce 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -1,5 +1,6 @@ #pragma once +#include "light/effects/ChannelDataSink.h" // where the decoded frame lands, held and placed in wiring order #include "light/effects/EffectBase.h" #include "light/util/FseqFile.h" // the FSEQ header/sparse-range parser this effect plays back @@ -40,7 +41,11 @@ namespace mm { /// /// ## Why decode runs in tick20ms /// -/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame into `frameBuf_` instead, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only blits what is there. +/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame and writes it straight into `sink_`, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only calls `sink_.tick()`. +/// +/// ## An FSEQ frame is already in wiring order +/// +/// FSEQ carries the raw byte stream a controller sends, the same shape @ref NetworkReceiveEffect's sender-mapped frames take. `ChannelDataSink` places it unmapped, alone on its layer, so a serpentine layout is not applied twice. /// /// ## Sequential reads replace random-offset ones /// @@ -56,9 +61,9 @@ namespace mm { /// /// Becoming enabled starts playback immediately when the playlist is non-empty, in `prepare()`, with no separate "press play" step. `release()` stops and closes the stream first, so a later re-enable never resumes into memory it just freed. /// -/// ## tick(): stopped clears, paused re-blits the same frame +/// ## Stopping writes one black frame; pausing just stops decoding /// -/// A stopped transport goes black every tick rather than keep the previous effect's paint. A paused one re-blits the same decoded frame every tick instead of holding a stale buffer, so a clear elsewhere self-heals. +/// `sink_` holds the last frame written until something writes another, so a stopped transport writes one all-zero frame on the stop transition rather than going black every tick. A paused transport simply stops calling `tick20ms()`; the sink keeps showing what it already held, unchanged. /// /// ## Compression: zlib on ESP32 only, zstd nowhere yet /// @@ -108,8 +113,9 @@ class FseqPlayerEffect : public EffectBase, public ListSource { rescanFiles(); } - /// Start playing on its own once the effect becomes enabled. + /// Size the sink to the layer, then start playing on its own once the effect becomes enabled. void prepare() override { + sink_.prepare(/*senderMapped=*/true); // an FSEQ frame is already in wiring order: @xref{an-fseq-frame-is-already-in-wiring-order} if (state_ == PlayState::Stopped && playlistCount_ > 0) { visited_ = 0; // Set before loadTrack: its own refreshStatus() must see Playing already, though loadTrack corrects this back on its own failure paths. @@ -137,15 +143,13 @@ class FseqPlayerEffect : public EffectBase, public ListSource { if (frameIndex_ >= header_.frameCount) handleEndOfFile(); } - /// Blit the already-decoded frame, or hold black while stopped. Pure memory work, no I/O. - void tick() MM_NONBLOCKING override { - if (state_ == PlayState::Stopped) { - uint8_t* buf = buffer(); - if (buf) std::memset(buf, 0, static_cast(nrOfLights()) * channelsPerLight()); - return; - } - blitFrameBuf(); - } + /// Hand the sink's held frame to the output; tick20ms() is the only place new data reaches it. + void tick() MM_NONBLOCKING override { sink_.tick(); } + + /// An FSEQ frame is in wiring order. + bool deliversWiringOrder() const MM_NONBLOCKING override { return sink_.wiringOrder(); } + /// The sink hands its held frame to the output. + void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } /// Dispatch a transport button through a name-to-handler table, a Command row per button rather than a branch. void onControlChanged(const char* controlName) override { @@ -352,9 +356,11 @@ class FseqPlayerEffect : public EffectBase, public ListSource { size_t headLen_ = 0; ///< bytes actually filled in headBuf_ size_t headPos_ = 0; ///< how far into headBuf_ sequential reads have already consumed - ScratchBuffer frameBuf_{*this}; ///< the one decoded frame tick() blits - ScratchBuffer compBuf_{*this}; ///< one compression block's compressed bytes - ScratchBuffer blockBuf_{*this}; ///< that block, decompressed + ChannelDataSink sink_{*this}; ///< where the decoded frame lands, held and placed in wiring order + ScratchBuffer frameBuf_{*this}; ///< the one decoded frame, read straight off the file or a compression block + ScratchBuffer expandBuf_{*this}; ///< a sparse frame expanded to the sink's own size, before the one write into it + ScratchBuffer compBuf_{*this}; ///< one compression block's compressed bytes + ScratchBuffer blockBuf_{*this}; ///< that block, decompressed uint32_t currentBlockIndex_ = 0; ///< which compression-block-table entry is loaded uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers @@ -450,6 +456,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { state_ = PlayState::Stopped; frameIndex_ = 0; visited_ = 0; + writeBlackFrame(); refreshStatus(); } @@ -489,6 +496,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { state_ = PlayState::Stopped; frameIndex_ = 0; visited_ = 0; + writeBlackFrame(); refreshStatus(); return; } @@ -607,7 +615,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return true; } - /// Decode frame `frameIndex_` into `frameBuf_`: the one blocking file read `tick20ms` makes, bounded to one frame or compression block. + /// Decode frame `frameIndex_` and write it straight into `sink_`, in wiring order: the one blocking file read `tick20ms` makes, bounded to one frame or compression block. bool decodeCurrentFrame() { const uint8_t* rawFrame; if (header_.compressionType == fseq::CompressionType::Zlib) { @@ -625,22 +633,29 @@ class FseqPlayerEffect : public EffectBase, public ListSource { frameIndex_ = 0; return false; } + writeFrameToSink(); return true; } - /// Blit the decoded frame onto the layer buffer, sparse or dense; a no-op before any frame has decoded. - void blitFrameBuf() { - if (!frameBuf_.data() || frameCount_ == 0) return; - uint8_t* buf = buffer(); - if (!buf) return; - const size_t bufBytes = static_cast(nrOfLights()) * channelsPerLight(); - if (header_.sparseRangeCount > 0) { - // Unmapped channels go dark: the sparse table only addresses the fixtures a show touches. - std::memset(buf, 0, bufBytes); - fseq::expandSparseFrame(header_, frameBuf_.data(), header_.channelCount, buf, bufBytes); - } else { - const size_t n = static_cast(header_.channelCount) < bufBytes ? header_.channelCount : bufBytes; - std::memcpy(buf, frameBuf_.data(), n); + /// Place the just-decoded frame into `sink_`, sparse or dense. + void writeFrameToSink() { + if (header_.sparseRangeCount == 0) { + sink_.write(0, frameBuf_.data(), header_.channelCount); + return; + } + // Unmapped channels go dark: the sparse table only addresses the fixtures a show touches. + if (!expandBuf_.resize(sink_.bytes())) return; + std::memset(expandBuf_.data(), 0, expandBuf_.bytes()); + fseq::expandSparseFrame(header_, frameBuf_.data(), header_.channelCount, expandBuf_.data(), expandBuf_.bytes()); + sink_.write(0, expandBuf_.data(), expandBuf_.bytes()); + } + + /// Write one all-zero frame into the sink, through a small static chunk: the stop transition's one-time black-out. + void writeBlackFrame() { + static constexpr uint8_t kZero[256] = {}; + for (size_t off = 0; off < sink_.bytes(); off += sizeof(kZero)) { + const size_t n = sink_.bytes() - off < sizeof(kZero) ? sink_.bytes() - off : sizeof(kZero); + sink_.write(off, kZero, n); } } diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index 99a324016..bdd56aeeb 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -26,6 +26,20 @@ fseq::FseqHeader makeHeader(uint32_t channelCount, uint32_t frameCount, uint8_t return h; } +// A one-frame v1 FSEQ file whose light k's red byte is k + 1, as a real exporter writes wiring order, so a test can tell which wiring position a byte reached. +std::vector makeFseqV1BytesNumbered(uint16_t lights) { + const uint32_t channelCount = static_cast(lights) * 3; + std::vector b(32 + channelCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = 32; b[5] = 0; + b[6] = 0; b[7] = 1; + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + b[14] = 1; // frameCount = 1 + b[18] = 25; + for (uint16_t k = 0; k < lights; k++) b[32 + static_cast(k) * 3] = static_cast(k + 1); + return b; +} + // A real v1 FSEQ file's bytes: frame i's channels all hold the byte value i, so a test can tell which frame decoded. std::vector makeFseqV1Bytes(uint32_t channelCount, uint32_t frameCount, uint8_t stepTimeMs = 25) { std::vector b(32 + static_cast(channelCount) * frameCount, 0); @@ -221,6 +235,7 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { Scene s; auto& e = s.effect; e.setup(); + e.prepare(); // sizes the sink; empty playlist here, so this alone does not start playback uint32_t id = 0; REQUIRE(e.addListRow(id)); @@ -229,20 +244,24 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { // Pins a real bug: loadTrack's own refreshStatus() must see the state this call transitions to, not the one it leaves, or a successful play reports "stopped". REQUIRE(e.status() != nullptr); CHECK(std::string(e.status()).rfind("playing", 0) == 0); + REQUIRE(e.deliversWiringOrder()); // an FSEQ frame is already in wiring order, and this effect is alone on its layer - const uint8_t* buf = s.layer.buffer().data(); - const size_t bytes = s.layer.buffer().count() * 3; + const size_t bytes = static_cast(s.layer.physicalLightCount()) * 3; + std::vector buf(bytes); e.decodeOneFrameForTest(); // frame 0: every byte is 0 e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); e.decodeOneFrameForTest(); // frame 1: every byte is 1 e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 1); e.decodeOneFrameForTest(); // frame 2, the last one e.tick(); + e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 2); // Frame 3 does not exist: handleEndOfFile already ran inside the decode above (loopAll default: reloads frame 0). @@ -254,6 +273,40 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { CHECK(std::string(e.status()).rfind("paused", 0) == 0); } +// A real exporter already writes wiring order; alone on a serpentine layout the frame must reach the output unmapped, not mapped a second time. +TEST_CASE("playing on a serpentine layout keeps the frame in wiring order") { + SdFixture sd; + sd.writeFseq("/sd/wiring.fseq", makeFseqV1BytesNumbered(16)); + + Layouts layouts; + GridLayout grid; + grid.width = 4; grid.height = 4; grid.depth = 1; + grid.serpentine = true; // the mapping a second application would scramble + layouts.addChild(&grid); + Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + FseqPlayerEffect e; + layer.addChild(&e); + + e.setup(); + e.defineControls(); + layer.applyState(); // builds the serpentine LUT too, not just this effect's own prepare() + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.deliversWiringOrder()); + REQUIRE(layer.lut().hasLUT()); // serpentine: the mapping is not the identity + REQUIRE(layer.wiringSource() == &e); // alone on its layer, sender-mapped: straight to the output, no layer remap + + e.decodeOneFrameForTest(); + e.tick(); + + std::vector out(16 * 3, 0); + e.copyWiringFrame(out.data(), out.size(), /*force=*/true); + for (uint8_t k = 0; k < 16; k++) CHECK(out[k * 3] == k + 1); // light k shows what was authored for k, not the serpentine's remap +} + TEST_CASE("release() forces a clean stop, so a later re-enable never resumes into freed buffers") { Scene s; auto& e = s.effect; From aff3424f6864bafc3b9375baab97780eba033d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 13:20:27 +0200 Subject: [PATCH 03/10] Move the zlib decompressor off the main task's stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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). --- src/platform/esp32/platform_esp32_sdmmc.cpp | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index c9a6203d1..c7d873e80 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -600,12 +600,29 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { return ok; } +static tinfl_decompressor* g_tinflDecomp = nullptr; ///< heap-allocated once, reused every call: ~2.5KB this main task's 12KB stack cannot spare + /// The only real decoder this call ever reaches, since FseqPlayerEffect (its sole caller) is itself gated on `hasSdCard`. +/// +/// `tinfl_decompress_mem_to_mem` keeps its own `tinfl_decompressor` on the caller's stack, which this task's 12KB has no room for. +/// This calls `tinfl_init`/`tinfl_decompress` directly instead, against one heap-allocated instance reused every call rather than freed, since each call fully re-inits it anyway. bool zlibInflateAll(const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen) { - const size_t n = tinfl_decompress_mem_to_mem(dst, dstCap, src, srcLen, TINFL_FLAG_PARSE_ZLIB_HEADER); - if (n == TINFL_DECOMPRESS_MEM_TO_MEM_FAILED) return false; - outLen = n; - return true; + if (!g_tinflDecomp) { + g_tinflDecomp = static_cast(platform::alloc(sizeof(tinfl_decompressor))); + if (!g_tinflDecomp) return false; + } + tinfl_init(g_tinflDecomp); + size_t inPos = 0, outPos = 0; + for (;;) { + size_t inBytes = srcLen - inPos; + size_t outBytes = dstCap - outPos; + const tinfl_status status = tinfl_decompress(g_tinflDecomp, src + inPos, &inBytes, dst, dst + outPos, &outBytes, + TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); + inPos += inBytes; + outPos += outBytes; + if (status == TINFL_STATUS_DONE) { outLen = outPos; return true; } + if (status != TINFL_STATUS_HAS_MORE_OUTPUT) return false; // a real decode error, or no input left to satisfy NEEDS_MORE_INPUT + } } #else // !MM_HAS_SDMMC: every symbol below is inert so a board with no SDMMC host still links. From 1724cd6f66a9c8754fe7546f651fb00bc62b3ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 14:20:27 +0200 Subject: [PATCH 04/10] Refuse unusual FSEQ files cleanly instead of degrading mid-playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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). --- src/light/effects/FseqPlayerEffect.h | 54 ++++++++++---- src/light/util/FseqFile.h | 2 + src/platform/desktop/platform_config.h | 3 + src/platform/esp32/platform_config.h | 2 + src/platform/teensy/platform_config.h | 3 + test/unit/light/unit_FseqFile.cpp | 22 ++++++ test/unit/light/unit_FseqPlayerEffect.cpp | 90 +++++++++++++++++++++-- 7 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 28ce070ce..eeafb94cf 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -69,12 +69,20 @@ namespace mm { /// /// Zlib (type 2) decodes through the ESP32 ROM's `tinfl_decompress` at zero flash cost and is ESP32-only to play (every target parses it). Zstd (type 1) has no decoder yet, reported as its own status, not a bug. /// -/// ## A corrupt file degrades, never overflows +/// `platform::hasZlibInflate` is checked in `loadTrack`, so a zlib file on desktop or Teensy is refused up front with its own status rather than failing mid-playback on the first compressed block. +/// +/// ## A corrupt or unusual file degrades, never overflows /// /// `channelCount` sizes `frameBuf_` directly, however large; the allocation is checked for success, so a hostile value fails cleanly rather than writing into a null or short buffer. /// /// A compression block's table entry is validated (`fseq::validateBlockRange`) before any buffer is sized by it: a non-advancing entry or an overflowing byte count is refused rather than read. /// +/// A header with 0 channels or 0 frames is refused by the parser itself, not left to surface as "out of memory" and a looping playlist reopening it every tick. +/// +/// A zlib file whose compression block table is empty is refused by name in `loadTrack`, rather than surfacing as a generic mid-file read failure on the first decode. +/// +/// A scanned name or joined path past `kPathCap` is skipped rather than silently truncated into a different file, with one status line naming that something was. +/// /// ## The scan is depth-bounded recursion over a heap-per-level buffer /// /// `scanDir` mirrors `FilesystemModule::removeTree`'s shape for the same problem (an oversized stack frame): one heap-allocated `platform::fsList` level per recursion, bounded in depth, not a member-held breadth-first queue. @@ -313,8 +321,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// Run one tick20ms decode step without waiting out the real frame cadence. void decodeOneFrameForTest() { lastFrameMs_ = elapsed() - effectiveStepMs(); tick20ms(); } /// Test seam for the scan's directory-join, the one piece of the scan desktop can exercise directly. - static void joinPathForTest(const char* dir, const char* name, char* out, size_t outCap) { - joinPath(dir, name, out, outCap); + static bool joinPathForTest(const char* dir, const char* name, char* out, size_t outCap) { + return joinPath(dir, name, out, outCap); } private: @@ -330,7 +338,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// A ceiling against a corrupt block-length field: real exports ran up to ~300KB/block, so 4MB keeps headroom. static constexpr size_t kMaxCompressedBlockBytes = 4 * 1024 * 1024; - struct Track { uint32_t id = 0; char file[96] = {}; }; ///< the full filesystem path, not a bare name + /// One path budget for a scanned name, a joined path and `Track::file` alike, matching `FilesystemModule::removeTree`'s own. + static constexpr size_t kPathCap = 192; + + struct Track { uint32_t id = 0; char file[kPathCap] = {}; }; ///< the full filesystem path, not a bare name Track tracks_[kMaxTracks] = {}; uint8_t playlistCount_ = 0; uint32_t nextId_ = 1; @@ -365,12 +376,13 @@ class FseqPlayerEffect : public EffectBase, public ListSource { uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers - char fileNames_[kMaxFiles][96] = {}; ///< full filesystem paths, found by the scan + char fileNames_[kMaxFiles][kPathCap] = {}; ///< full filesystem paths, found by the scan const char* fileOptions_[kMaxFiles] = {}; uint8_t fileCount_ = 0; + bool scanSkipped_ = false; ///< a name or joined path did not fit kPathCap during the last scan - /// Sized for "playing: " + a 95-char path + " (999/999)", with headroom; setStatus holds the pointer, so this outlives the call. - char statusBuf_[128] = {}; + /// Sized for "playing: " + a kPathCap-1-char path + " (999/999)", with headroom; setStatus holds the pointer, so this outlives the call. + char statusBuf_[kPathCap + 32] = {}; /// A plain bounded copy, not snprintf: GCC's -Wrestrict flags snprintf between two member arrays of the same `this`. static void copyBounded(char* dst, size_t dstCap, const char* src) { @@ -380,15 +392,17 @@ class FseqPlayerEffect : public EffectBase, public ListSource { std::memcpy(dst, src, n); dst[n] = '\0'; } - /// dir + "/" + name, built by hand for the same -Wrestrict reason as copyBounded; skips the slash when dir already ends in one. - static void joinPath(const char* dir, const char* name, char* out, size_t outCap) { + /// dir + "/" + name, built by hand for the same -Wrestrict reason as copyBounded; skips the slash when dir already ends in one. False when the joined path did not fit and was truncated. + static bool joinPath(const char* dir, const char* name, char* out, size_t outCap) { const size_t dirLen = std::strlen(dir); + const size_t needsSlash = dirLen == 0 || dir[dirLen - 1] != '/' ? 1 : 0; + const size_t fits = dirLen + needsSlash + std::strlen(name) < outCap; size_t n = dirLen < outCap ? dirLen : (outCap > 0 ? outCap - 1 : 0); std::memcpy(out, dir, n); - const bool needsSlash = dirLen == 0 || dir[dirLen - 1] != '/'; if (needsSlash && n < outCap) { out[n] = '/'; n++; } if (n < outCap) copyBounded(out + n, outCap - n, name); else if (outCap > 0) out[outCap - 1] = '\0'; + return fits; } int indexOf(uint32_t id) const { @@ -577,8 +591,15 @@ class FseqPlayerEffect : public EffectBase, public ListSource { case fseq::ParseError::UnsupportedVersion: errMsg = "unsupported FSEQ version"; break; case fseq::ParseError::UnsupportedCompression: errMsg = "compressed (zstd) FSEQ not supported yet"; break; case fseq::ParseError::Truncated: errMsg = "FSEQ header truncated or unreadable"; break; + case fseq::ParseError::Empty: errMsg = "empty show: 0 channels or frames"; break; default: break; // None, or TooManySparseRanges (non-fatal, handled below) } + if (!errMsg && hdr.compressionType == fseq::CompressionType::Zlib && hdr.compressionBlockCount == 0) { + errMsg = "zlib file declares no compression blocks"; + } + if (!errMsg && hdr.compressionType == fseq::CompressionType::Zlib && !platform::hasZlibInflate) { + errMsg = "zlib plays on ESP32 only"; + } if (errMsg) { setStatus(errMsg, Severity::Error); state_ = PlayState::Stopped; @@ -710,15 +731,17 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// One directory level, collected: the same shape as FilesystemModule::removeTree's DirLevel. struct ScanLevel { static constexpr uint8_t kMax = 64; - char names[kMax][40]; + char names[kMax][kPathCap]; bool isDir[kMax]; uint8_t count = 0; + bool skipped = false; ///< an entry's own name alone did not fit kPathCap }; static void collectScanEntry(const char* name, bool isDir, uint32_t, void* user) { auto* lvl = static_cast(user); if (lvl->count >= ScanLevel::kMax) return; - if (!name || name[0] == '.' || std::strlen(name) >= sizeof(lvl->names[0])) return; + if (!name || name[0] == '.') return; + if (std::strlen(name) >= sizeof(lvl->names[0])) { lvl->skipped = true; return; } std::snprintf(lvl->names[lvl->count], sizeof(lvl->names[0]), "%s", name); lvl->isDir[lvl->count] = isDir; lvl->count++; @@ -733,9 +756,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { ScanLevel& lvl = *lvlp; struct Freer { ScanLevel* p; ~Freer() { p->~ScanLevel(); platform::free(p); } } freer{lvlp}; platform::fsList(path, &collectScanEntry, &lvl); + if (lvl.skipped) scanSkipped_ = true; for (uint8_t i = 0; i < lvl.count && fileCount_ < kMaxFiles; i++) { - char child[96]; - joinPath(path, lvl.names[i], child, sizeof(child)); + char child[kPathCap]; + if (!joinPath(path, lvl.names[i], child, sizeof(child))) { scanSkipped_ = true; continue; } if (lvl.isDir[i]) { scanDir(child, static_cast(depth + 1)); } else if (hasExtension(lvl.names[i], ".fseq")) { @@ -747,11 +771,13 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// Re-scan the filesystem root for `.fseq` files into the shared option set. void rescanFiles() { fileCount_ = 0; + scanSkipped_ = false; scanDir("/", 0); for (uint8_t i = 0; i < fileCount_; i++) fileOptions_[i] = fileNames_[i]; rebuildControls(); // Fires the resync directly: rebuildControls()'s own change-detection does not hash a List's row/option-set content. notifySchemaChanged(); + if (scanSkipped_) setStatus("some .fseq names or paths too long, skipped", Severity::Warning); } /// One row of `onControlChanged`'s dispatch table: a button's name and its handler. diff --git a/src/light/util/FseqFile.h b/src/light/util/FseqFile.h index f847dbe9b..da71700bf 100644 --- a/src/light/util/FseqFile.h +++ b/src/light/util/FseqFile.h @@ -57,6 +57,7 @@ enum class ParseError : uint8_t { UnsupportedCompression, ///< zstd (type 1): no decoder exists in this build, not a malformed file TooManySparseRanges, ///< non-fatal: the table was clipped to kMaxSparseRanges and parsed anyway Truncated, ///< buffer ends before a field/table this header claims to have + Empty, ///< 0 channels or 0 frames: a real header, nothing to play }; /// A file's declared compression, read from header byte 20 (v2 only). @@ -148,6 +149,7 @@ inline ParseError parseHeader(const uint8_t* buf, size_t len, FseqHeader& out) { out.stepTimeMs = buf[18]; if (out.majorVersion != 1 && out.majorVersion != 2) return ParseError::UnsupportedVersion; + if (out.channelCount == 0 || out.frameCount == 0) return ParseError::Empty; if (out.majorVersion == 1) { if (out.channelDataOffset < 32) return ParseError::Truncated; return ParseError::None; // v1 has no compression/sparse fields diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 49223ec12..be1e6a4bc 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -125,6 +125,9 @@ constexpr bool hasSdCard = true; /// True: no slot is wired, so SdCardService offers every board preset, for the tests to exercise them. constexpr bool previewsSdPresets = true; +/// False: no zlib decoder is linked here; `zlibInflateAll` is a stub. +constexpr bool hasZlibInflate = false; + } // namespace mm::platform // 1 on arm64 and x86-64; any other host ISA falls through and compiles scripts to a clean failure. diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 599ea23f0..0dcd70878 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -259,6 +259,8 @@ constexpr bool hasSdCard = true; #else constexpr bool hasSdCard = false; #endif +/// `zlibInflateAll`'s real decoder lives in the SD-card source file, so it is linked only where hasSdCard is; a flash-only board gets the inert stub. Tracked separately, not this flag's job to fix. +constexpr bool hasZlibInflate = hasSdCard; /// False: SdCardService offers only the board presets this chip can wire. constexpr bool previewsSdPresets = false; diff --git a/src/platform/teensy/platform_config.h b/src/platform/teensy/platform_config.h index c93b48818..e6f062212 100644 --- a/src/platform/teensy/platform_config.h +++ b/src/platform/teensy/platform_config.h @@ -106,6 +106,9 @@ constexpr bool hasSdCard = true; /// False: SdCardService offers only the socket this board wires. constexpr bool previewsSdPresets = false; +/// False: no zlib decoder is linked here; `zlibInflateAll` is a stub. +constexpr bool hasZlibInflate = false; + /// The bits a call to emitted code adds to its address. Thumb state: a Cortex-M7 runs only Thumb code, and a call address carries it in bit 0. constexpr uintptr_t codeAddressBits = 1; diff --git a/test/unit/light/unit_FseqFile.cpp b/test/unit/light/unit_FseqFile.cpp index a547cab81..fd3105b55 100644 --- a/test/unit/light/unit_FseqFile.cpp +++ b/test/unit/light/unit_FseqFile.cpp @@ -215,6 +215,28 @@ TEST_CASE("a buffer truncated at every interesting point reports Truncated rathe CHECK(parseHeader(full.data(), 32 + 2 * 6, hdrExact) == ParseError::None); } +TEST_CASE("a v1 header with 0 channels is Empty, not a crash waiting to happen") { + const auto buf = makeV1(32, /*channelCount=*/0, /*frameCount=*/10, 25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Empty); +} + +TEST_CASE("a v1 header with 0 frames is Empty") { + const auto buf = makeV1(32, /*channelCount=*/100, /*frameCount=*/0, 25); + FseqHeader hdr; + CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Empty); +} + +TEST_CASE("a v2 header with 0 channels or 0 frames is Empty before any v2-only field is read") { + const auto buf1 = makeV2(32, /*channelCount=*/0, /*frameCount=*/10, 25, 0, 0, {}); + FseqHeader hdr1; + CHECK(parseHeader(buf1.data(), buf1.size(), hdr1) == ParseError::Empty); + + const auto buf2 = makeV2(32, /*channelCount=*/100, /*frameCount=*/0, 25, 0, 0, {}); + FseqHeader hdr2; + CHECK(parseHeader(buf2.data(), buf2.size(), hdr2) == ParseError::Empty); +} + TEST_CASE("channelDataOffset overlapping the v1 fixed header is refused") { auto buf = makeV1(/*channelDataOffset=*/32, 10, 1, 25); putU16(buf, 4, 20); // claims frame 0 starts inside the 32-byte fixed header diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index bdd56aeeb..c8876c838 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -26,7 +26,7 @@ fseq::FseqHeader makeHeader(uint32_t channelCount, uint32_t frameCount, uint8_t return h; } -// A one-frame v1 FSEQ file whose light k's red byte is k + 1, as a real exporter writes wiring order, so a test can tell which wiring position a byte reached. +// A one-frame v1 FSEQ file whose light k's red byte is k + 1, matching a real exporter's wiring order; a test can tell which position a byte reached. std::vector makeFseqV1BytesNumbered(uint16_t lights) { const uint32_t channelCount = static_cast(lights) * 3; std::vector b(32 + channelCount, 0); @@ -113,6 +113,23 @@ std::vector makeFseqV1BytesWithOffset(uint16_t channelDataOffset, uint3 return b; } +// A v2 file declaring zlib compression, with `blockCount` (empty) entries in its compression block table. +std::vector makeFseqV2ZlibBytes(uint32_t channelCount, uint32_t frameCount, uint8_t blockCount) { + const size_t channelDataOffset = 32 + static_cast(blockCount) * 8; + std::vector b(channelDataOffset + static_cast(channelCount) * frameCount, 0); + std::memcpy(b.data(), "PSEQ", 4); + b[4] = static_cast(channelDataOffset & 0xFF); + b[5] = static_cast(channelDataOffset >> 8); + b[6] = 0; b[7] = 2; // minor=0, major=2 + for (int i = 0; i < 4; i++) b[10 + i] = static_cast(channelCount >> (8 * i)); + for (int i = 0; i < 4; i++) b[14 + i] = static_cast(frameCount >> (8 * i)); + b[18] = 25; + b[20] = 2; // compressionType = zlib + b[21] = blockCount; + b[22] = 0; // sparseRangeCount + return b; +} + // Two tracks written, scanned and added as playlist rows, for the two tests exercising a second row. void twoTrackPlaylist(SdFixture& sd, FseqPlayerEffect& e, uint32_t& id1, uint32_t& id2) { sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); @@ -446,20 +463,83 @@ TEST_CASE("shuffle's cursor never repeats the row it just left") { TEST_CASE("the recursive scan's path join never doubles the slash at the SD root, and truncates safely") { char out[32]; - FseqPlayerEffect::joinPathForTest("/sd", "show.fseq", out, sizeof(out)); + CHECK(FseqPlayerEffect::joinPathForTest("/sd", "show.fseq", out, sizeof(out))); CHECK(std::string(out) == "/sd/show.fseq"); - FseqPlayerEffect::joinPathForTest("/sd/shows", "intro.fseq", out, sizeof(out)); + CHECK(FseqPlayerEffect::joinPathForTest("/sd/shows", "intro.fseq", out, sizeof(out))); CHECK(std::string(out) == "/sd/shows/intro.fseq"); - FseqPlayerEffect::joinPathForTest("/sd/shows/", "intro.fseq", out, sizeof(out)); + CHECK(FseqPlayerEffect::joinPathForTest("/sd/shows/", "intro.fseq", out, sizeof(out))); CHECK(std::string(out) == "/sd/shows/intro.fseq"); // trailing slash on dir: no double "//" char small[6]; - FseqPlayerEffect::joinPathForTest("/sd/shows", "averylongname.fseq", small, sizeof(small)); + CHECK_FALSE(FseqPlayerEffect::joinPathForTest("/sd/shows", "averylongname.fseq", small, sizeof(small))); CHECK(std::strlen(small) < sizeof(small)); // truncated, still NUL-terminated, never overrun } +TEST_CASE("a header with 0 channels or 0 frames fails to load with its own status, not out of memory") { + SdFixture sd; + sd.writeFseq("/sd/empty.fseq", makeFseqV1Bytes(/*channelCount=*/10, /*frameCount=*/0)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()) == "empty show: 0 channels or frames"); + CHECK(e.severity() == MoonModule::Severity::Error); +} + +TEST_CASE("a zlib file fails to load on desktop with its own status, not a generic read failure") { + SdFixture sd; + sd.writeFseq("/sd/z.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/2)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()) == "zlib plays on ESP32 only"); +} + +TEST_CASE("a zlib file declaring no compression blocks is refused by name, before the platform check") { + SdFixture sd; + sd.writeFseq("/sd/z0.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/0)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + + e.onControlChanged("play"); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()) == "zlib file declares no compression blocks"); +} + +TEST_CASE("a scanned name past kPathCap is skipped, not truncated into a different file, with a named warning") { + SdFixture sd; + sd.writeFseq("/sd/good.fseq", makeFseqV1Bytes(10, 2)); + const std::string longName(200, 'x'); // past kPathCap (192) + sd.writeFseq(("/sd/" + longName + ".fseq").c_str(), makeFseqV1Bytes(10, 2)); + + Scene s; + auto& e = s.effect; + e.setup(); // rescans once on its own + + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + CHECK(e.listRowCount() == 1); // the oversized name was skipped; the short one still scanned + REQUIRE(e.status() != nullptr); + CHECK(std::string(e.status()).find("too long") != std::string::npos); +} + TEST_CASE("fpsSource=manual: the fps slider sets the playback rate directly, ignoring the file's own step time") { Scene s; auto& e = s.effect; From caaf1783c9434cd255d3abf629580f0032a0c6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 15:15:41 +0200 Subject: [PATCH 05/10] Decode each FSEQ frame one tick ahead of its render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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). --- src/light/effects/FseqPlayerEffect.h | 26 +++++++++++++++++------ test/unit/light/unit_FseqPlayerEffect.cpp | 13 +++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index eeafb94cf..912e0a1a1 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -41,7 +41,13 @@ namespace mm { /// /// ## Why decode runs in tick20ms /// -/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame and writes it straight into `sink_`, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only calls `sink_.tick()`. +/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame each call, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only calls `sink_.tick()`. +/// +/// ## A frame decodes one tick ahead of its render +/// +/// `tick20ms()` renders whatever `decodeCurrentFrame()` decoded last call, before decoding the next one, rather than writing what it decoded immediately. An expensive decode (a zlib track's compression-block boundary) then costs its tick a tick early, not the one whose render it would otherwise delay. +/// +/// Reaching end of file is checked right after that render, before any new decode. The track's real last frame is always shown for one full tick before the track switches, never overwritten by it in the same call. /// /// ## An FSEQ frame is already in wiring order /// @@ -139,16 +145,17 @@ class FseqPlayerEffect : public EffectBase, public ListSource { EffectBase::release(); } - /// Decode the next due frame off the render tick; a blocking file read, bounded to one frame per call. + /// Render last tick's decoded frame, then decode the next due one; a blocking file read, bounded to one frame per call. void tick20ms() MM_NONBLOCKING override { if (state_ != PlayState::Playing) return; const uint32_t now = elapsed(); const uint32_t stepMs = effectiveStepMs(); if (now - lastFrameMs_ < stepMs) return; lastFrameMs_ += stepMs; // advance by one step, not to `now`: a stall catches up one frame at a time + if (aheadFrameReady_) { writeFrameToSink(); aheadFrameReady_ = false; } + if (frameIndex_ >= header_.frameCount) { handleEndOfFile(); return; } // last frame already rendered above if (!decodeCurrentFrame()) return; // a read failure above already stopped playback frameIndex_++; - if (frameIndex_ >= header_.frameCount) handleEndOfFile(); } /// Hand the sink's held frame to the output; tick20ms() is the only place new data reaches it. @@ -293,6 +300,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { currentBlockIndex_ = 0; currentBlockFirstFrame_ = 0; currentBlockFrameCount_ = 0; + aheadFrameReady_ = false; } /// Force the transport state directly. void setStateForTest(PlayState s) { state_ = s; } @@ -375,6 +383,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { uint32_t currentBlockIndex_ = 0; ///< which compression-block-table entry is loaded uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers + bool aheadFrameReady_ = false; ///< frameBuf_ holds a frame decoded one tick ahead, not yet handed to sink_ char fileNames_[kMaxFiles][kPathCap] = {}; ///< full filesystem paths, found by the scan const char* fileOptions_[kMaxFiles] = {}; @@ -470,6 +479,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { state_ = PlayState::Stopped; frameIndex_ = 0; visited_ = 0; + aheadFrameReady_ = false; // about to black out anyway: no point rendering it first writeBlackFrame(); refreshStatus(); } @@ -501,6 +511,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// End of the current track: loop it, advance to the next, or stop a completed pass. void handleEndOfFile() { + // tick20ms() already rendered any pending frame before calling here; this only guards a direct call (handleEndOfFileForTest). + if (aheadFrameReady_) { writeFrameToSink(); aheadFrameReady_ = false; } if (loopMode_ == kLoopOne) { loadTrack(cursor_); // re-opens from frame 0: a forward-only stream cannot rewind in place return; @@ -625,6 +637,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { currentBlockIndex_ = 0; currentBlockFirstFrame_ = 0; currentBlockFrameCount_ = 0; + aheadFrameReady_ = false; frameIndex_ = 0; lastFrameMs_ = elapsed(); markVisited(cursor_); @@ -636,11 +649,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return true; } - /// Decode frame `frameIndex_` and write it straight into `sink_`, in wiring order: the one blocking file read `tick20ms` makes, bounded to one frame or compression block. + /// Decode frame `frameIndex_` into `frameBuf_`, one tick ahead of render (@xref{a-frame-decodes-one-tick-ahead-of-its-render}). The one blocking read `tick20ms` makes, bounded to one frame or block. bool decodeCurrentFrame() { - const uint8_t* rawFrame; if (header_.compressionType == fseq::CompressionType::Zlib) { - rawFrame = frameFromCompressedBlock(frameIndex_); + const uint8_t* rawFrame = frameFromCompressedBlock(frameIndex_); if (!rawFrame) { setStatus("file read failed mid-file", Severity::Error); state_ = PlayState::Stopped; @@ -654,7 +666,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { frameIndex_ = 0; return false; } - writeFrameToSink(); + aheadFrameReady_ = true; return true; } diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index c8876c838..7316236c8 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -178,6 +178,9 @@ TEST_CASE("playing a flash-found file decodes and blits its frames, same as an S const uint8_t* buf = s.layer.buffer().data(); const size_t bytes = s.layer.buffer().count() * 3; + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + e.decodeOneFrameForTest(); // frame 0: every byte is 0 e.tick(); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); @@ -266,6 +269,9 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { const size_t bytes = static_cast(s.layer.physicalLightCount()) * 3; std::vector buf(bytes); + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + e.decodeOneFrameForTest(); // frame 0: every byte is 0 e.tick(); e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); @@ -281,7 +287,7 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { e.copyWiringFrame(buf.data(), buf.size(), /*force=*/true); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 2); - // Frame 3 does not exist: handleEndOfFile already ran inside the decode above (loopAll default: reloads frame 0). + // Frame 3 does not exist: the call above rendered frame 2 then ran handleEndOfFile (loopAll default: reloads frame 0). CHECK(e.frameIndexForTest() == 0); // Pause updates status too, not just state: a pre-existing gap found and fixed alongside the play-status bug above. @@ -316,6 +322,9 @@ TEST_CASE("playing on a serpentine layout keeps the frame in wiring order") { REQUIRE(layer.lut().hasLUT()); // serpentine: the mapping is not the identity REQUIRE(layer.wiringSource() == &e); // alone on its layer, sender-mapped: straight to the output, no layer remap + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); + e.decodeOneFrameForTest(); e.tick(); @@ -682,6 +691,8 @@ TEST_CASE("a channelDataOffset past the buffered header region is skipped, landi const uint8_t* buf = s.layer.buffer().data(); const size_t bytes = s.layer.buffer().count() * 3; + e.decodeOneFrameForTest(); // primes the one-tick-ahead pipeline: decodes frame 0, renders nothing yet + e.tick(); e.decodeOneFrameForTest(); // frame 0: every byte is 0, same as the no-gap decode test e.tick(); for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == 0); From cfcaf46ef235562d921b73bc269c1e72da0c1989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 16:23:07 +0200 Subject: [PATCH 06/10] Exclude a Progress total from the schema-resync hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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). --- src/core/module/MoonModule.h | 5 +++-- test/unit/core/unit_MoonModule.cpp | 24 ++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/core/module/MoonModule.h b/src/core/module/MoonModule.h index 91f8dc127..6b3fd2972 100644 --- a/src/core/module/MoonModule.h +++ b/src/core/module/MoonModule.h @@ -155,8 +155,9 @@ class MoonModule { // Hash the strings, so an in-place rename still changes the signature. const char* const* opts = reinterpret_cast(c.aux); for (int32_t o = 0; o < c.max; o++) mixStr(opts[o]); - } else { - mix(static_cast(c.aux)); // a Progress total or another aux value, so a change differs + } else if (c.type != ControlType::Progress) { + // A Progress's aux is its total, a value as live as c.ptr's, so it rides the value patch too: never a resync by itself. + mix(static_cast(c.aux)); // a structural aux word (e.g. ReadOnlyInt's unit, Palette's option fn), so a change differs } } for (uint8_t i = 0; i < childCount_; i++) children_[i]->mixSchema(f); diff --git a/test/unit/core/unit_MoonModule.cpp b/test/unit/core/unit_MoonModule.cpp index ff116cc94..2363f60fa 100644 --- a/test/unit/core/unit_MoonModule.cpp +++ b/test/unit/core/unit_MoonModule.cpp @@ -150,6 +150,26 @@ TEST_CASE("schemaSignature: value change is invisible, schema change is not") { CHECK(m.schemaSignature() != base); } +namespace { +// A module whose Progress total changes at runtime, the way a track change moves FseqPlayerEffect's frameCount_. +class ProgressModule : public mm::MoonModule { +public: + uint32_t position = 0; + uint32_t total = 100; + void defineControls() override { controls_.addProgress("position", position, total); } +}; +} // namespace + +// Pins the fix for the PR #148 follow-up: a Progress total is a value, not schema, so changing it and rebuilding must not trip a full browser resync. +TEST_CASE("schemaSignature: a Progress total change alone is invisible, same as its value") { + ProgressModule m; m.defineControls(); + const uint32_t base = m.schemaSignature(); + m.total = 9999; + m.rebuildControls(); // re-snapshots aux for the new total, the same call loadTrack() makes on a track change + CHECK(m.schemaSignature() == base); + CHECK(m.controls()[0].aux == 9999); // the total did refresh; only the resync-trigger is suppressed +} + TEST_CASE("schemaSignature: recurses into children (a child schema change is caught)") { auto* parent = new mm::MoonModule(); auto* child = new SelectModule(); @@ -257,7 +277,7 @@ TEST_CASE("Bool control binding") { CHECK(*static_cast(ctrl.ptr) == false); } -// 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. +// appearsInUi() defaults to true (every module shows); overridable to false so infrastructure (FilesystemModule, HttpServerModule) hides, the flag the state serializer reads to skip a module. TEST_CASE("MoonModule appearsInUi defaults true, overridable false") { TestModule visible; CHECK(visible.appearsInUi()); // ordinary module: shown @@ -270,7 +290,7 @@ TEST_CASE("MoonModule appearsInUi defaults true, overridable false") { CHECK_FALSE(asBase->appearsInUi()); } -// 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. +// readBool/readUint8, the shared generic control reader (reviewer #8): one implementation, so HttpServerModule and MqttModule can't disagree on Drivers.on's absent-control default. TEST_CASE("MoonModule readBool/readUint8 return the value, or the default when absent") { TestModule mod; mod.defineControls(); // binds brightness(Uint8), speed(Uint8), enabled(Bool) From ea44137b10ca2af5bee62b0bbf5c801056c7656f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 19 Oct 2026 16:54:25 +0200 Subject: [PATCH 07/10] Skip the FSEQ rescan for a file that isn't one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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). --- src/light/effects/FseqPlayerEffect.h | 7 ++++-- test/unit/light/unit_FseqPlayerEffect.cpp | 26 +++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 912e0a1a1..5dc03b4f9 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -63,6 +63,8 @@ namespace mm { /// /// Every `.fseq` file anywhere in the file manager's tree is found walking from the filesystem root, bounded to `kMaxScanDepth` levels, with no special case for `/sd`. A flash file plays exactly like an SD one. /// +/// `onFileChanged` only walks that tree for a `.fseq` path, so an unrelated upload (a config file, a palette script) costs this effect nothing. +/// /// ## Starts on its own /// /// Becoming enabled starts playback immediately when the playlist is non-empty, in `prepare()`, with no separate "press play" step. `release()` stops and closes the stream first, so a later re-enable never resumes into memory it just freed. @@ -176,9 +178,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { EffectBase::onControlChanged(controlName); } - /// Rescan when any file lands or leaves, so an upload through the File Manager reaches the playlist unasked. + /// Rescan when a show lands or leaves, so an upload through the File Manager reaches the playlist unasked. An unrelated file skips the whole-tree walk. void onFileChanged(const char* path) override { - if (path) rescanFiles(); + if (path && !hasExtension(path, ".fseq")) return; + rescanFiles(); } // --- ListSource (editable): the playlist ------------------------------------------- diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index 7316236c8..e857a9c73 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -214,23 +214,27 @@ TEST_CASE("rescan finds a real .fseq file, case-insensitively, in a nested folde CHECK(e.listRowCount() == 1); } -TEST_CASE("onFileChanged rescans on any path, so an upload anywhere reaches the playlist unasked") { +TEST_CASE("onFileChanged rescans only for a .fseq path, skipping an unrelated upload") { SdFixture sd; Scene s; auto& e = s.effect; - e.setup(); - uint32_t idBefore = 0; - REQUIRE(e.addListRow(idBefore)); // nothing scanned yet: defaults to an empty file - e.deleteListRow(idBefore); + e.setup(); // scans the still-empty card sd.writeFseq("/sd/new.fseq", makeFseqV1Bytes(9, 3)); - e.onFileChanged("/sd/new.fseq"); - - uint32_t idAfter = 0; - REQUIRE(e.addListRow(idAfter)); - CHECK(e.listRowCount() == 1); - // This effect has no path it specifically cares about: any changed path triggers the same rescan, with no crash from a path that names no real file. + // An unrelated file changed (and names no real file either): no crash, and no whole-tree rescan, so new.fseq stays unseen. e.onFileChanged("/.config/SomeModule.json"); + { + JsonSink sink; + e.writeListOptionSets(sink); + CHECK(std::string(sink.data()).find("new.fseq") == std::string::npos); + } + + e.onFileChanged("/sd/new.fseq"); // the show's own path: this one does rescan + { + JsonSink sink; + e.writeListOptionSets(sink); + CHECK(std::string(sink.data()).find("new.fseq") != std::string::npos); + } } TEST_CASE("prepare() starts playing on its own once there is a playlist to play") { From 6eddaa456fc86cb0d1221fe7744b6d8e2075bcab Mon Sep 17 00:00:00 2001 From: ewowi Date: Mon, 19 Oct 2026 23:53:44 +0200 Subject: [PATCH 08/10] FSEQ player: the maintainer round, a disabled player holds no memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FSEQ player now costs only its playlist while disabled: its frame and block buffers, the file header and the zlib workspace are taken when a track opens, the folder scan while it is enabled, and all of them are freed when it is disabled, and the player object shrinks from about 30 KB to 2 KB. A corrupt compressed block can no longer hang the device, and zlib shows play on every ESP32, not only boards with an SD card. - **Core**: reverts cfcaf46e, since a Progress total travels only in the schema, so leaving it out of the hash kept an open browser on the previous total; a `ProgressUnit::Count` for a gauge that counts things rather than bytes. - **Platform**: the zlib decoder moves from the SD-card file into platform_esp32.cpp, unconditional, and inflates a block in one call that fails on anything but done; its workspace is the caller's (`zlibWorkspaceBytes()`), so no global stays allocated. - **Light domain**: FseqPlayerEffect keeps each playlist path sized to its content, takes the header and the block buffers as ScratchBuffers sized once per track and the scan cache as one held while enabled, shows the fps slider when manual is chosen, seeds its shuffle, and labels its position as a count; the block-table read moves into FseqFile.h; help lines for every control and `@attribution FPP`. - **UI**: a count gauge shows a plain number. - **Tests**: the fps slider, the playlist through add, move, delete and release, the block-table entry; the three refusal cases share one helper; cfcaf46e's test goes with it. - **Docs/CI**: help.json regenerated; both appendices cut into member lines; backlog: a Progress total that rides the value patch. check_code rises by 8 over main, each named: the three file reads and frame writes in tick20ms, the third copy of the list-restore block (a core helper is a follow-up), the scan and playlist-path allocations, and the large test file main already carries. - **Reviews**: 👾 maintainer review of round two: the zlib loop, cfcaf46e, the fps slider, per-block reallocation, the decoder's home, the ratchets, the disabled player's memory → done. Co-Authored-By: Claude Opus 5.5 --- docs/help/help.json | 80 +++-- docs/reference/metrics/code.md | 32 +- docs/reference/metrics/docgen.md | 20 +- docs/reference/metrics/prose.md | 8 +- docs/work/future/backlog-core.md | 6 + src/core/module/Control.cpp | 3 +- src/core/module/Control.h | 3 +- src/core/module/MoonModule.h | 5 +- src/light/effects/FseqPlayerEffect.h | 318 +++++++++----------- src/light/util/FseqFile.h | 63 +--- src/platform/desktop/platform_desktop.cpp | 3 +- src/platform/esp32/platform_config.h | 4 +- src/platform/esp32/platform_esp32.cpp | 17 ++ src/platform/esp32/platform_esp32_sdmmc.cpp | 28 -- src/platform/platform.h | 4 +- src/platform/teensy/platform_teensy_fs.cpp | 3 +- src/ui/app.js | 3 +- test/CMakeLists.txt | 1 - test/js/ui-progress-label.test.mjs | 4 + test/unit/core/unit_MoonModule.cpp | 22 +- test/unit/light/unit_FseqFile.cpp | 21 +- test/unit/light/unit_FseqPlayerEffect.cpp | 142 ++++++--- 22 files changed, 399 insertions(+), 391 deletions(-) diff --git a/docs/help/help.json b/docs/help/help.json index d16312029..85badacfb 100644 --- a/docs/help/help.json +++ b/docs/help/help.json @@ -892,6 +892,62 @@ "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FreqSawsEffect.h", "tests": "https://github.com/MoonModules/MoonLight/blob/main/test/unit/light/unit_FreqSawsEffect.cpp" }, + "FseqPlayerEffect": { + "line": "Plays FSEQ animation files from the filesystem onto this effect's layer, with a playlist and the standard transport controls.", + "summary": "FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. The playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic.", + "attribution": "[FPP](https://github.com/FalconChristmas/fpp)", + "guide": [ + { + "title": "Compressed files need ESP32", + "text": "A zlib-compressed FSEQ (xLights/FPP's default) plays only on ESP32 boards; desktop and Teensy builds show a clean error instead of playing it.\n\nZstd-compressed files do not play on any target yet.\n\nExport a track uncompressed if it needs to play everywhere." + } + ], + "image": "https://moonmodules.org/MoonLight/assets/light/effects/FseqPlayerEffect.gif", + "controls": { + "play": { + "line": "Starts a stopped transport, or resumes a paused one." + }, + "pause": { + "line": "Holds the current frame on screen." + }, + "stop": { + "line": "Stops playback, resets the position and blanks the lights." + }, + "previous": { + "line": "Jumps to the previous track in the playlist." + }, + "next": { + "line": "Jumps to the next track in the playlist." + }, + "shuffle": { + "line": "Picks the next track at random instead of in playlist order." + }, + "loopMode": { + "line": "At a track's end: stop, repeat it, or repeat the playlist." + }, + "fpsSource": { + "line": "where the rate comes from: the file's step time, or fps_" + }, + "fps": { + "line": "the manual playback rate; 1000/fps milliseconds per frame" + }, + "playlist": { + "line": "The tracks to play, in order; each row picks a scanned .fseq file." + }, + "position": { + "line": "How far into the current track playback is, in frames." + }, + "rescan": { + "line": "Looks through the filesystem for .fseq files again." + } + }, + "role": "effect", + "tags": "🖼️", + "dim": 3, + "doc": "light/effects.md#fseqplayer", + "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FseqPlayerEffect.h", + "tests": "https://github.com/MoonModules/MoonLight/blob/main/test/unit/light/unit_FseqPlayerEffect.cpp" + }, "GameOfLifeEffect": { "line": "Conway's cellular automaton generalized to 2D and 3D, with selectable rulesets and a custom `B#/S#` rule.", "summary": "Cells live, die and are born by counting their live neighbors, on a panel or through a cube. New cells take a neighbor's palette color, and dead ones blur toward the background.", @@ -1259,30 +1315,6 @@ "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/ParticlesEffect.h", "tests": "https://github.com/MoonModules/MoonLight/blob/main/test/unit/light/unit_ParticlesEffect.cpp" }, - "FseqPlayerEffect": { - "line": "An FSEQ show played from a file: the frame-by-frame channel data xLights and Falcon Player write.", - "summary": "A show, uncompressed FSEQ v2, lands where a sender-mapped [NetworkReceive](https://moonmodules.org/MoonLight/moonmodules/light/effects.html#networkreceive) stream does, so alone on its layer it reaches the lights in one copy on any layout. Reading the file is the next step, and its status says so.", - "attribution": "[FPP](https://github.com/FalconChristmas/fpp)", - "image": "https://moonmodules.org/MoonLight/assets/light/effects/FseqPlayerEffect.gif", - "alt": "The reference sweep fseq_play writes, the show an FSEQ player plays", - "controls": { - "file": { - "line": "The show's path, an `.fseq` file on flash or under `/sd`." - }, - "loop": { - "line": "Start again at the end." - }, - "speed": { - "line": "The playback speed, in percent of the file's step time." - } - }, - "role": "effect", - "tags": "🖼️", - "dim": 3, - "doc": "light/effects.md#fseqplayer", - "source": "https://github.com/MoonModules/MoonLight/blob/main/src/light/effects/FseqPlayerEffect.h", - "tests": "https://github.com/MoonModules/MoonLight/blob/main/test/unit/light/unit_FseqPlayerEffect.cpp" - }, "PictureEffect": { "line": "The active [Picture service](https://moonmodules.org/MoonLight/moonmodules/core/services.html#picture)'s picture on the layer, so a picture of any size shows on a layout of any size.", "summary": "A modifier rotates, mirrors or tiles it as it does any effect, and a cube shows it on every slice.", diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 68918ff26..9a378e6ff 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,22 +4,22 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1611 finding(s)** across 320 file(s). 2.60% of all lines are duplicated, 3586 lines. +**1619 finding(s)** across 322 file(s). 2.41% of all lines are duplicated, 3348 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 742 | +| duplicated block | 744 | | complex function | 297 | -| blocking call on the render path | 191 | +| blocking call on the render path | 194 | | deeply nested | 111 | | module type in the UI | 78 | | long function | 49 | | long parameter list | 35 | | large stack frame | 33 | -| raw allocation in light | 28 | -| large file | 25 | +| raw allocation in light | 30 | +| large file | 26 | | name spread | 22 | ## How a finding is solved @@ -44,8 +44,8 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| -| `test` | 558 | -| `light` | 402 | +| `test` | 559 | +| `light` | 409 | | `core` | 365 | | `platform` | 174 | | `ui` | 81 | @@ -63,7 +63,7 @@ The next function worth simplifying is at the top of each file's list. | `src/ui/migrate.js` | 44 | `line 31: 'OscService'`, module type in the UI 1 | | `src/core/moonlive/MoonLiveCompiler.cpp` | 41 | `moonlive::Parser::parseDecl`, complex function 54 | | `src/platform/desktop/platform_desktop.cpp` | 40 | `platform::spawnEncoderProcess`, complex function 58 | -| `src/ui/app.js` | 35 | `app.js`, large file 9144 | +| `src/ui/app.js` | 35 | `app.js`, large file 9143 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | | `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | @@ -107,19 +107,20 @@ The next function worth simplifying is at the top of each file's list. | `src/light/drivers/PanelCardDriver.h` | 9 | `lines 188-196, also in src/light/drivers/NetworkSendDriver.h:236`, duplicated block 9 | | `src/platform/esp32/platform_esp32_i80.cpp` | 9 | `platform::createState`, complex function 27 | | `test/unit/light/unit_ParallelLedDriver_swap.cpp` | 9 | `lines 176-184, also in test/unit/light/unit_ParallelLedDriver_swap.cpp:112`, duplicated block 9 | -| `src/core/system/WiFiModule.h` | 8 | `lines 367-374, also in src/light/drivers/FixtureProfilesModule.h:254`, duplicated block 8 | +| `src/core/system/WiFiModule.h` | 8 | `lines 367-374, also in src/light/effects/FseqPlayerEffect.h:229`, duplicated block 8 | | `src/light/effects/FixedPointEffect.h` | 8 | `FixedPointEffect::drawBoids`, complex function 19 | | `src/light/effects/FlyingToastersEffect.h` | 8 | `lines 59-68, also in src/light/effects/FlyingToastersEffect.h:39`, duplicated block 10 | | `src/light/effects/SpaceInvadersEffect.h` | 8 | `lines 438-446, also in src/light/effects/SpriteCast.h:54`, duplicated block 9 | | `test/unit/light/unit_GEQEffect.cpp` | 8 | `lines 159-177, also in test/unit/light/unit_BlurzEffect.cpp:137`, duplicated block 19 | | `test/unit/light/unit_LissajousEffect.cpp` | 8 | `lines 81-97, also in test/unit/light/unit_LissajousEffect.cpp:13`, duplicated block 17 | | `test/unit/light/unit_MovingHeadEffect.cpp` | 8 | `lines 107-118, also in test/unit/light/unit_MovingHeadEffect.cpp:45`, duplicated block 12 | -| `test/unit/light/unit_NoiseMeterEffect.cpp` | 8 | `lines 100-120, also in test/unit/light/unit_NoiseMeterEffect.cpp:49`, duplicated block 21 | +| `test/unit/light/unit_NoiseMeterEffect.cpp` | 8 | `lines 102-122, also in test/unit/light/unit_NoiseMeterEffect.cpp:49`, duplicated block 21 | | `test/unit/light/unit_draw.cpp` | 8 | `lines 352-358, also in test/unit/light/unit_draw.cpp:334`, duplicated block 7 | | `src/core/moonlive/MoonLiveSpill.cpp` | 7 | `moonlive::spillToBudget`, complex function 128 | | `src/core/system/SystemModule.h` | 7 | `SystemModule in 31 files`, name spread 31 | | `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 | | `src/light/effects/FishTankEffect.h` | 7 | `lines 239-253, also in src/light/effects/PacmanEffect.h:239`, duplicated block 15 | +| `src/light/effects/FseqPlayerEffect.h` | 7 | `lines 229-236, also in src/core/system/WiFiModule.h:367`, duplicated block 8 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_rmt.cpp` | 7 | `platform::rmtWs2812LoopbackFrame`, complex function 31 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | @@ -127,7 +128,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | | `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | -| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 254-261, also in src/core/system/WiFiModule.h:367`, duplicated block 8 | +| `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 254-259, also in src/light/effects/FseqPlayerEffect.h:229`, duplicated block 6 | | `src/light/drivers/NetworkSendDriver.h` | 6 | `lines 236-244, also in src/light/drivers/PanelCardDriver.h:188`, duplicated block 9 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 556-563, also in src/platform/desktop/moonlive_asm_x86_64.cpp:514`, duplicated block 8 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | @@ -157,7 +158,6 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | | `test/unit/light/unit_FreqSawsEffect.cpp` | 5 | `lines 19-29, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 11 | | `test/unit/light/unit_GEQ3DEffect.cpp` | 5 | `lines 58-75, also in test/unit/light/unit_PaintBrushEffect.cpp:44`, duplicated block 18 | -| `test/unit/light/unit_MoonLiveScripts.cpp` | 5 | `lines 277-285, also in test/unit/light/unit_MoonLiveScripts.cpp:249`, duplicated block 9 | | `test/unit/light/unit_RandomEffect.cpp` | 5 | `lines 10-22, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 13 | | `src/core/module/Scheduler.cpp` | 4 | `Scheduler::getControlWide`, complex function 18 | | `src/core/moonlive/MoonLive.cpp` | 4 | `moonlive::MoonLive::ensureArena`, complex function 21 | @@ -181,8 +181,10 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:159`, duplicated block 19 | | `test/unit/light/unit_BouncingBallsEffect.cpp` | 4 | `lines 75-88, also in test/unit/light/unit_RubiksCubeEffect.cpp:103`, duplicated block 14 | | `test/unit/light/unit_FixedRectangleEffect.cpp` | 4 | `lines 90-103, also in test/unit/light/unit_SolidEffect.cpp:9`, duplicated block 14 | +| `test/unit/light/unit_FseqPlayerEffect.cpp` | 4 | `lines 699-706, also in test/unit/light/unit_FseqPlayerEffect.cpp:189`, duplicated block 8 | | `test/unit/light/unit_Layer_extrude.cpp` | 4 | `lines 155-168, also in test/unit/light/unit_Layer_extrude.cpp:124`, duplicated block 14 | | `test/unit/light/unit_Layer_live_modifier.cpp` | 4 | `lines 96-105, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 10 | +| `test/unit/light/unit_MoonLiveScripts.cpp` | 4 | `lines 277-285, also in test/unit/light/unit_MoonLiveScripts.cpp:249`, duplicated block 9 | | `test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp` | 4 | `lines 49-59, also in test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp:18`, duplicated block 11 | | `test/unit/light/unit_NetworkSendDriver_packet.cpp` | 4 | `lines 346-354, also in test/unit/light/unit_NetworkSendDriver_packet.cpp:292`, duplicated block 9 | | `test/unit/light/unit_ParallelSlots.cpp` | 4 | `lines 522-532, also in test/unit/light/unit_ParallelSlots.cpp:493`, duplicated block 11 | @@ -207,7 +209,6 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/teensy/platform_teensy_ota.cpp` | 3 | `platform::otaWriteStream`, complex function 16 | | `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 | | `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 | -| `test/unit/light/unit_MoonLiveLayout.cpp` | 3 | `lines 685-935, also in test/unit/light/unit_MoonLiveLayout.cpp:403`, duplicated block 251 | | `test/unit/light/unit_PolarLut_equivalence.cpp` | 3 | `lines 40-55, also in test/unit/light/unit_NoiseEffect.cpp:33`, duplicated block 16 | | `test/unit/light/unit_PongEffect.cpp` | 3 | `lines 80-91, also in test/unit/light/unit_PongEffect.cpp:10`, duplicated block 12 | | `test/unit/light/unit_RmtLedDriver_lifecycle.cpp` | 3 | `lines 19-30, also in test/unit/light/unit_RmtLedDriver_pins.cpp:17`, duplicated block 12 | @@ -300,7 +301,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 | | `src/core/services/Services.h` | 1 | `Services in 17 files`, name spread 17 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | -| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | +| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 26 files`, name spread 26 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/HostResolver.h` | 1 | `HostResolver::lookup`, complex function 13 | @@ -346,7 +347,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::mountCard`, complex function 13 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | | `src/platform/esp32/platform_esp32_usb_midi.cpp` | 1 | `lines 253-259, also in src/platform/teensy/platform_teensy.cpp:767`, duplicated block 7 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1448 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1453 | | `src/platform/teensy/platform_teensy_gpio_dma.cpp` | 1 | `lines 310-315, also in src/platform/esp32/platform_esp32_parlio.cpp:286`, duplicated block 6 | | `src/platform/teensy/platform_teensy_improv.cpp` | 1 | `platform::improvProvisioningInit`, long parameter list 9 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | @@ -372,6 +373,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_GridLayout.cpp` | 1 | `lines 8-21, also in test/unit/light/unit_SingleColumnLayout.cpp:12`, duplicated block 14 | | `test/unit/light/unit_Layer_persistence.cpp` | 1 | `lines 45-51, also in test/unit/light/unit_TextEffect.cpp:18`, duplicated block 7 | | `test/unit/light/unit_Layer_phase_animation.cpp` | 1 | `lines 77-85, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 9 | +| `test/unit/light/unit_MoonLiveLayout.cpp` | 1 | `unit_MoonLiveLayout.cpp`, large file 1113 | | `test/unit/light/unit_NetworkReceiveEffect.cpp` | 1 | `lines 23-44, also in test/unit/light/unit_NetworkReceiveEffect_protocols.cpp:23`, duplicated block 22 | | `test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp` | 1 | `lines 93-104, also in test/unit/light/unit_ParallelLedDriver_pinexpander.cpp:94`, duplicated block 12 | | `test/unit/light/unit_Rings241Layout.cpp` | 1 | `lines 18-30, also in test/unit/light/unit_SingleColumnLayout.cpp:19`, duplicated block 13 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 4414954e8..4318a7045 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **3080 warning(s)** across 290 page(s). +**0 error(s)** and **3078 warning(s)** across 289 page(s). **4398 appendix line(s)** after `@technical`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,7 +16,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| over-wide comment lines | 0 | 1565 | +| over-wide comment lines | 0 | 1563 | | split paragraphs | 0 | 856 | | multi-line comment blocks | 0 | 311 | | appendix sections nothing refers to | 0 | 199 | @@ -30,8 +30,8 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 1956 | -| `platform/index.md` | 0 | 645 | +| `(tests, no card)` | 0 | 1953 | +| `platform/index.md` | 0 | 646 | | `core/supporting.md` | 0 | 113 | | `light/power-functions.md` | 0 | 112 | | `light/drivers.md` | 0 | 66 | @@ -47,7 +47,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **1956 warning(s)** across 161 file(s). +**0 error(s)** and **1953 warning(s)** across 160 file(s). | Findings | File | | |---:|---|---| @@ -61,13 +61,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 46 | `test/unit/light/unit_ParallelSlots.cpp` | warning | | 45 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | | 43 | `test/unit/light/unit_MoonLiveScripts.cpp` | warning | -| 1-42 each | *151 more warning files, 1219 findings* | | +| 1-42 each | *150 more warning files, 1216 findings* | | -By rule, warnings: 1350 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 76 over-long sentences, 39 member deep dives, 34 over-wide doc lines, 8 appendix sections nothing refers to. +By rule, warnings: 1347 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 76 over-long sentences, 39 member deep dives, 34 over-wide doc lines, 8 appendix sections nothing refers to. ### platform/index.md -**0 error(s)** and **645 warning(s)** across 37 file(s). +**0 error(s)** and **646 warning(s)** across 37 file(s). | Findings | File | | |---:|---|---| @@ -79,11 +79,11 @@ By rule, warnings: 1350 over-wide comment lines, 253 split paragraphs, 196 multi | 33 | `src/platform/desktop/moonlive_asm_arm64.cpp` | warning | | 29 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning | | 27 | `src/platform/esp32/platform_esp32_parlio.cpp` | warning | -| 23 | `src/platform/desktop/platform_desktop.cpp` | warning | +| 24 | `src/platform/desktop/platform_desktop.cpp` | warning | | 17 | `src/platform/esp32/platform_esp32_gpio.cpp` | warning | | 1-17 each | *27 more warning files, 145 findings* | | -By rule, warnings: 308 split paragraphs, 214 over-wide comment lines, 114 multi-line comment blocks, 9 appendix sections nothing refers to. +By rule, warnings: 308 split paragraphs, 215 over-wide comment lines, 114 multi-line comment blocks, 9 appendix sections nothing refers to. ### core/supporting.md diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md index 2be3ab9b4..40206c06d 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,14 +9,14 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a `#` in a script are counted beside a `///` in a header. -**2572 finding(s)** across 340 file(s). +**2587 finding(s)** across 343 file(s). | rule | findings | |---|---| | `MoonLight.EmDash` | 1264 | -| `MoonLight.Weasel` | 636 | -| `MoonLight.SentenceLength` | 394 | -| `MoonLight.Temporal` | 197 | +| `MoonLight.Weasel` | 650 | +| `MoonLight.SentenceLength` | 393 | +| `MoonLight.Temporal` | 199 | | `MoonLight.Spelling` | 42 | | `MoonLight.SelfReference` | 26 | | `MoonLight.NegatedHeading` | 13 | diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md index a5ad81317..44c6f761a 100644 --- a/docs/work/future/backlog-core.md +++ b/docs/work/future/backlog-core.md @@ -2,6 +2,12 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/core/`, `src/platform/`, build, CI, network, persistence, UI). The light-domain counterpart is [backlog-light.md](backlog-light.md); items that genuinely span both are in [backlog-mixed.md](backlog-mixed.md). Index + overview: [README.md](index.md). Completed items are removed. +### A Progress total rides the value patch (2026-10-19) + +A Progress control's total travels only in `writeControlMetadata` (Control.cpp), so a changed total needs a schema resync, which `rebuildControls()` triggers because `aux` is in the schema hash. +One line in `writeControlValue` and one in `app.js`'s patch handler would let the total ride the value, and the hash could then skip it, so a track change in FseqPlayerEffect stops resyncing every browser. +Pin it with a test that changes the total, patches, and finds the browser-side max moved with no resync. + ### An SD card inserted after boot mounts only when its module restarts (2026-10-12) A mount attempt on an empty slot runs the SDMMC card-init sequence to its timeouts, so `SdCardModule` never retries one on the render thread: a card inserted later mounts when the module is switched off and on, on a pin edit, or at boot. A pull is still noticed within a second through a cheap presence probe. Detecting an insertion without that stall needs a card-detect pin where the slot has one, or a mount attempt off the render thread. The timed retry PR #136 carried was dropped for this reason before its merge. diff --git a/src/core/module/Control.cpp b/src/core/module/Control.cpp index 939516538..f185179bd 100644 --- a/src/core/module/Control.cpp +++ b/src/core/module/Control.cpp @@ -219,8 +219,9 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { } case ControlType::Progress: // The unit rides in min (see addProgress); the UI scales by it and picks KB, MB or GB itself. + static constexpr const char* kProgressUnitNames[] = {"B", "KiB", "count"}; // indexed by ProgressUnit sink.appendf(",\"total\":%lu,\"unit\":\"%s\"", static_cast(c.aux), - static_cast(c.min) == ProgressUnit::KiB ? "KiB" : "B"); + kProgressUnitNames[static_cast(c.min)]); return; case ControlType::List: { // The detail an expanded row shows, as an array parallel to the summary `value`, which stays small for the collapsed list. diff --git a/src/core/module/Control.h b/src/core/module/Control.h index f81f9abc9..72166e154 100644 --- a/src/core/module/Control.h +++ b/src/core/module/Control.h @@ -70,7 +70,8 @@ enum class ControlType : uint8_t { /// The unit a progress control's value and total count in; the UI sizes the label from it. enum class ProgressUnit : uint8_t { Bytes, ///< a byte count, which fits a uint32_t up to 4 GiB - KiB ///< kibibytes, for a store whose byte count overruns a uint32_t + KiB, ///< kibibytes, for a store whose byte count overruns a uint32_t + Count ///< a plain count (frames, items), shown as a number with no byte suffix }; class JsonSink; // forward-declared so the descriptor can hold a pointer diff --git a/src/core/module/MoonModule.h b/src/core/module/MoonModule.h index 6b3fd2972..91f8dc127 100644 --- a/src/core/module/MoonModule.h +++ b/src/core/module/MoonModule.h @@ -155,9 +155,8 @@ class MoonModule { // Hash the strings, so an in-place rename still changes the signature. const char* const* opts = reinterpret_cast(c.aux); for (int32_t o = 0; o < c.max; o++) mixStr(opts[o]); - } else if (c.type != ControlType::Progress) { - // A Progress's aux is its total, a value as live as c.ptr's, so it rides the value patch too: never a resync by itself. - mix(static_cast(c.aux)); // a structural aux word (e.g. ReadOnlyInt's unit, Palette's option fn), so a change differs + } else { + mix(static_cast(c.aux)); // a Progress total or another aux value, so a change differs } } for (uint8_t i = 0; i < childCount_; i++) children_[i]->mixSchema(f); diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 5dc03b4f9..0fde64eb1 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -10,14 +10,15 @@ #include #include -#include // placement new: scanDir's heap-allocated ScanLevel namespace mm { /// Plays FSEQ animation files from the filesystem onto this effect's layer, with a playlist and the standard transport controls. /// @card FseqPlayerEffect.gif /// -/// FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. +/// FSEQ is the Falcon Player/xLights sequence format: a flat, per-frame byte stream with no pixel semantics of its own. The playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic. +/// +/// @attribution FPP /// /// @guide /// @@ -28,72 +29,6 @@ namespace mm { /// Zstd-compressed files do not play on any target yet. /// /// Export a track uncompressed if it needs to play everywhere. -/// -/// @technical -/// -/// ## Prior art -/// -/// FPP's `FSEQ_Sequence_File_Format.txt` is the spec this plays against; the playlist follows `FixtureProfilesModule`'s `EditableListSource` shape, the transport `DemoReelEffect`'s cycle/shuffle logic. -/// -/// ## The rate comes from the file by default, or the fps slider when asked -/// -/// `fpsSource` picks `file` (the track's own `stepTimeMs`, default) or `manual` (the `fps` slider). Either is capped at 50 to match `tick20ms()` (@xref{why-decode-runs-in-tick20ms}), with a warning when a file asks for more. -/// -/// ## Why decode runs in tick20ms -/// -/// A frame read blocks, which `tick()` must never do. So `tick20ms()` decodes one due frame each call, the same split `HttpServerModule::driveSdTransfers` uses for SD reads; `tick()` only calls `sink_.tick()`. -/// -/// ## A frame decodes one tick ahead of its render -/// -/// `tick20ms()` renders whatever `decodeCurrentFrame()` decoded last call, before decoding the next one, rather than writing what it decoded immediately. An expensive decode (a zlib track's compression-block boundary) then costs its tick a tick early, not the one whose render it would otherwise delay. -/// -/// Reaching end of file is checked right after that render, before any new decode. The track's real last frame is always shown for one full tick before the track switches, never overwritten by it in the same call. -/// -/// ## An FSEQ frame is already in wiring order -/// -/// FSEQ carries the raw byte stream a controller sends, the same shape @ref NetworkReceiveEffect's sender-mapped frames take. `ChannelDataSink` places it unmapped, alone on its layer, so a serpentine layout is not applied twice. -/// -/// ## Sequential reads replace random-offset ones -/// -/// FSEQ data appears in file order. So one open stream per track, read forward only, replaces every offset-based read the format would otherwise need; a track restart or change reopens it. -/// -/// A file whose variable-length metadata pushes `channelDataOffset` past the buffered header region has that gap skipped, byte for byte, before frame 0's own read begins (`skipHeaderGap`). -/// -/// ## Where animations live -/// -/// Every `.fseq` file anywhere in the file manager's tree is found walking from the filesystem root, bounded to `kMaxScanDepth` levels, with no special case for `/sd`. A flash file plays exactly like an SD one. -/// -/// `onFileChanged` only walks that tree for a `.fseq` path, so an unrelated upload (a config file, a palette script) costs this effect nothing. -/// -/// ## Starts on its own -/// -/// Becoming enabled starts playback immediately when the playlist is non-empty, in `prepare()`, with no separate "press play" step. `release()` stops and closes the stream first, so a later re-enable never resumes into memory it just freed. -/// -/// ## Stopping writes one black frame; pausing just stops decoding -/// -/// `sink_` holds the last frame written until something writes another, so a stopped transport writes one all-zero frame on the stop transition rather than going black every tick. A paused transport simply stops calling `tick20ms()`; the sink keeps showing what it already held, unchanged. -/// -/// ## Compression: zlib on ESP32 only, zstd nowhere yet -/// -/// Zlib (type 2) decodes through the ESP32 ROM's `tinfl_decompress` at zero flash cost and is ESP32-only to play (every target parses it). Zstd (type 1) has no decoder yet, reported as its own status, not a bug. -/// -/// `platform::hasZlibInflate` is checked in `loadTrack`, so a zlib file on desktop or Teensy is refused up front with its own status rather than failing mid-playback on the first compressed block. -/// -/// ## A corrupt or unusual file degrades, never overflows -/// -/// `channelCount` sizes `frameBuf_` directly, however large; the allocation is checked for success, so a hostile value fails cleanly rather than writing into a null or short buffer. -/// -/// A compression block's table entry is validated (`fseq::validateBlockRange`) before any buffer is sized by it: a non-advancing entry or an overflowing byte count is refused rather than read. -/// -/// A header with 0 channels or 0 frames is refused by the parser itself, not left to surface as "out of memory" and a looping playlist reopening it every tick. -/// -/// A zlib file whose compression block table is empty is refused by name in `loadTrack`, rather than surfacing as a generic mid-file read failure on the first decode. -/// -/// A scanned name or joined path past `kPathCap` is skipped rather than silently truncated into a different file, with one status line naming that something was. -/// -/// ## The scan is depth-bounded recursion over a heap-per-level buffer -/// -/// `scanDir` mirrors `FilesystemModule::removeTree`'s shape for the same problem (an oversized stack frame): one heap-allocated `platform::fsList` level per recursion, bounded in depth, not a member-held breadth-first queue. class FseqPlayerEffect : public EffectBase, public ListSource { public: /// The transport's three states. @@ -103,35 +38,51 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// The two fpsSource values, public so a test can select one by name. static constexpr uint8_t kFpsFromFile = 0, kFpsManual = 1; + /// Free the playlist's path strings, the one memory a disabled player keeps. + ~FseqPlayerEffect() override { clearTracks(); } + /// Catalog tags: a recorded show, played back. const char* tags() const override { return "🖼️"; } /// Bind the transport buttons, the shuffle/loop controls, the playlist and the position gauge. void defineControls() override { + /// Starts a stopped transport, or resumes a paused one. controls_.addButton("play"); + /// Holds the current frame on screen. controls_.addButton("pause"); + /// Stops playback, resets the position and blanks the lights. controls_.addButton("stop"); + /// Jumps to the previous track in the playlist. controls_.addButton("previous"); + /// Jumps to the next track in the playlist. controls_.addButton("next"); + /// Picks the next track at random instead of in playlist order. controls_.addControl("shuffle", shuffle_); + /// At a track's end: stop, repeat it, or repeat the playlist. controls_.addSelect("loopMode", loopMode_, kLoopModeOptions, 3); controls_.addSelect("fpsSource", fpsSource_, kFpsSourceOptions, 2); controls_.addControl("fps", fps_, 1, kMaxFps); controls_.setHidden(controls_.count() - 1, fpsSource_ != kFpsManual); + /// The tracks to play, in order; each row picks a scanned .fseq file. controls_.addList("playlist", *this); - controls_.addProgress("position", frameIndex_, frameCount_); // a frame count, not bytes; ProgressUnit has no plain-count variant yet + /// How far into the current track playback is, in frames. + controls_.addProgress("position", frameIndex_, frameCount_, ProgressUnit::Count); + /// Looks through the filesystem for .fseq files again. controls_.addButton("rescan"); } /// Scan the filesystem for animations once. void setup() override { EffectBase::setup(); - rescanFiles(); + rng_.seed(platform::micros()); // a different shuffle order each boot + if (enabled()) rescanFiles(); } /// Size the sink to the layer, then start playing on its own once the effect becomes enabled. void prepare() override { - sink_.prepare(/*senderMapped=*/true); // an FSEQ frame is already in wiring order: @xref{an-fseq-frame-is-already-in-wiring-order} + sink_.prepare(/*senderMapped=*/true); // an FSEQ frame is already in wiring order, the same shape NetworkReceiveEffect's sender-mapped frames take, so a serpentine layout is not applied twice + sizeExpandBuf(); + if (!fileNames_) rescanFiles(); // the scan cache is freed on release, so an enabled effect rebuilds it if (state_ == PlayState::Stopped && playlistCount_ > 0) { visited_ = 0; // Set before loadTrack: its own refreshStatus() must see Playing already, though loadTrack corrects this back on its own failure paths. @@ -144,10 +95,11 @@ class FseqPlayerEffect : public EffectBase, public ListSource { void release() override { closeTrackStream(); state_ = PlayState::Stopped; + fileCount_ = 0; // the names below are freed with the scratch buffers EffectBase::release(); } - /// Render last tick's decoded frame, then decode the next due one; a blocking file read, bounded to one frame per call. + /// Render last tick's decoded frame, then decode the next due one: the blocking read lives here, never in `tick()`, at most one frame per call. void tick20ms() MM_NONBLOCKING override { if (state_ != PlayState::Playing) return; const uint32_t now = elapsed(); @@ -168,9 +120,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// The sink hands its held frame to the output. void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } - /// Dispatch a transport button through a name-to-handler table, a Command row per button rather than a branch. + /// Dispatch a transport button through a name-to-handler table, a Command row per button rather than a branch; a changed rate source rebuilds the controls so `fps` shows or hides. void onControlChanged(const char* controlName) override { if (controlName) { + if (std::strcmp(controlName, "fpsSource") == 0) rebuildControls(); for (const auto& row : kButtonHandlers) { if (std::strcmp(controlName, row.name) == 0) { (this->*row.handler)(); break; } } @@ -178,9 +131,9 @@ class FseqPlayerEffect : public EffectBase, public ListSource { EffectBase::onControlChanged(controlName); } - /// Rescan when a show lands or leaves, so an upload through the File Manager reaches the playlist unasked. An unrelated file skips the whole-tree walk. + /// Rescan when a show lands or leaves, so an upload through the File Manager reaches the playlist unasked. An unrelated file, or a disabled effect, skips the whole-tree walk. void onFileChanged(const char* path) override { - if (path && !hasExtension(path, ".fseq")) return; + if (!enabled() || (path && !hasExtension(path, ".fseq"))) return; rescanFiles(); } @@ -192,7 +145,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// Write one row, which is also the persisted form: the stable id and the chosen file. void writeListRow(JsonSink& sink, uint8_t row) const override { sink.appendf("{\"id\":%lu,\"file\":", static_cast(tracks_[row].id)); - sink.writeJsonString(tracks_[row].file); + sink.writeJsonString(trackFile(row)); sink.append("}"); } @@ -201,7 +154,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { sink.append("\"files\":["); for (uint8_t i = 0; i < fileCount_; i++) { if (i) sink.append(","); - sink.writeJsonString(fileOptions_[i]); + sink.writeJsonString(fileName(i)); } sink.append("]"); } @@ -210,7 +163,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { void writeListRowDetail(JsonSink& sink, uint8_t row) const override { sink.append("{\"fields\":["); sink.appendf("{\"name\":\"file\",\"type\":\"select\",\"value\":%d,\"optionsRef\":\"files\"},", - fileIndexOf(tracks_[row].file)); + fileIndexOf(trackFile(row))); sink.append("{\"name\":\"play\",\"type\":\"button\"}"); sink.append("]}"); } @@ -222,9 +175,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { bool addListRow(uint32_t& outId) override { if (playlistCount_ >= kMaxTracks) return false; Track& t = tracks_[playlistCount_]; - t = Track{}; + if (fileCount_ > 0 && !setTrackFile(t, fileName(0))) return false; t.id = nextId_++; - if (fileCount_ > 0) copyBounded(t.file, sizeof(t.file), fileNames_[0]); playlistCount_++; outId = t.id; return true; @@ -234,8 +186,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { bool deleteListRow(uint32_t id) override { const int i = indexOf(id); if (i < 0) return false; + platform::free(tracks_[i].file); for (uint8_t j = static_cast(i); j + 1 < playlistCount_; j++) tracks_[j] = tracks_[j + 1]; playlistCount_--; + tracks_[playlistCount_] = Track{}; // the vacated slot no longer owns the path it was shifted from if (cursor_ >= playlistCount_ && playlistCount_ > 0) cursor_ = static_cast(playlistCount_ - 1); return true; } @@ -259,8 +213,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { if (std::strcmp(field, "file") == 0) { const int idx = mm::json::parseInt(valueJson, "value"); if (idx < 0 || idx >= fileCount_) return false; - copyBounded(tracks_[i].file, sizeof(tracks_[i].file), fileNames_[idx]); - return true; + return setTrackFile(tracks_[i], fileName(static_cast(idx))); } if (std::strcmp(field, "play") == 0) { cursor_ = static_cast(i); @@ -278,14 +231,15 @@ class FseqPlayerEffect : public EffectBase, public ListSource { if (!mm::json::parse(json, doc)) return false; const mm::json::JsonNode* arr = mm::json::member(doc, doc.rootNode(), key); if (!arr || arr->type != mm::json::JsonType::Array) return false; - playlistCount_ = 0; + clearTracks(); const int n = mm::json::arraySize(doc, arr); for (int r = 0; r < n && playlistCount_ < kMaxTracks; r++) { const mm::json::JsonNode* row = mm::json::element(doc, arr, r); Track& t = tracks_[playlistCount_]; - t = Track{}; t.id = static_cast(mm::json::readInt(mm::json::member(doc, row, "id"), 0)); - mm::json::readString(mm::json::member(doc, row, "file"), t.file, sizeof(t.file)); + char path[kPathCap]; + mm::json::readString(mm::json::member(doc, row, "file"), path, sizeof(path)); + if (path[0]) setTrackFile(t, path); if (t.id >= nextId_) nextId_ = t.id + 1; // never reissue a persisted id playlistCount_++; } @@ -299,12 +253,17 @@ class FseqPlayerEffect : public EffectBase, public ListSource { header_ = h; frameCount_ = h.frameCount; frameBuf_.resize(h.channelCount); + sizeExpandBuf(); frameIndex_ = 0; currentBlockIndex_ = 0; currentBlockFirstFrame_ = 0; currentBlockFrameCount_ = 0; aheadFrameReady_ = false; } + /// The scratch bytes held right now across every runtime buffer: zero once released. + size_t scratchBytesForTest() const { + return fileNames_.bytes() + headBuf_.bytes() + frameBuf_.bytes() + expandBuf_.bytes() + compBuf_.bytes() + blockBuf_.bytes() + zlibWork_.bytes(); + } /// Force the transport state directly. void setStateForTest(PlayState s) { state_ = s; } /// The current transport state. @@ -341,8 +300,9 @@ class FseqPlayerEffect : public EffectBase, public ListSource { static constexpr const char* kFpsSourceOptions[] = {"file", "manual"}; static constexpr uint8_t kMaxTracks = 64; // a playlist, not a media library: bounded, no heap static constexpr uint8_t kMaxFiles = 64; + static constexpr size_t kHeadBytes = 4096; static constexpr uint8_t kMaxScanDepth = 8; // matches FilesystemModule::removeTree's own bound - /// 50, not the usual control range: tick20ms's own ~50 Hz cadence is the decode ceiling (@xref{why-decode-runs-in-tick20ms}). + /// 50, not the usual control range: tick20ms's own ~50 Hz cadence is the decode ceiling. static constexpr uint8_t kMaxFps = 50; /// The slowest step the decode cadence can honor: 1000 / kMaxFps, so a file-sourced rate above it clamps here. static constexpr uint32_t kMinStepMs = 1000u / kMaxFps; @@ -352,7 +312,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// One path budget for a scanned name, a joined path and `Track::file` alike, matching `FilesystemModule::removeTree`'s own. static constexpr size_t kPathCap = 192; - struct Track { uint32_t id = 0; char file[kPathCap] = {}; }; ///< the full filesystem path, not a bare name + /// One playlist row: its stable id and its full filesystem path, a heap string sized to the path (null while the row names no file). + struct Track { uint32_t id = 0; char* file = nullptr; }; Track tracks_[kMaxTracks] = {}; uint8_t playlistCount_ = 0; uint32_t nextId_ = 1; @@ -360,7 +321,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { bool shuffle_ = false; uint8_t loopMode_ = kLoopAll; - uint8_t fpsSource_ = kFpsFromFile; ///< kFpsFromFile reads the track's own stepTimeMs; kFpsManual reads fps_ + uint8_t fpsSource_ = kFpsFromFile; ///< where the rate comes from: the file's step time, or fps_ uint8_t fps_ = 30; ///< the manual playback rate; 1000/fps milliseconds per frame PlayState state_ = PlayState::Stopped; @@ -371,10 +332,10 @@ class FseqPlayerEffect : public EffectBase, public ListSource { uint64_t visited_ = 0; ///< per-pass bitset over playlist rows, for shuffle+loopOff Random8 rng_; - /// The one open track stream, plus its buffered header: @xref{sequential-reads-replace-random-offset-ones}. + /// The one open track stream, read forward only, plus its buffered header; a track restart or change reopens it. platform::FsReadStream stream_{}; bool streamOpen_ = false; - uint8_t headBuf_[4096] = {}; ///< the header, block table and sparse table of virtually any real file + ScratchBuffer headBuf_{*this}; ///< the header, block table and sparse table of virtually any real file: kHeadBytes, sized when a track opens size_t headLen_ = 0; ///< bytes actually filled in headBuf_ size_t headPos_ = 0; ///< how far into headBuf_ sequential reads have already consumed @@ -383,13 +344,13 @@ class FseqPlayerEffect : public EffectBase, public ListSource { ScratchBuffer expandBuf_{*this}; ///< a sparse frame expanded to the sink's own size, before the one write into it ScratchBuffer compBuf_{*this}; ///< one compression block's compressed bytes ScratchBuffer blockBuf_{*this}; ///< that block, decompressed + ScratchBuffer zlibWork_{*this}; ///< the decoder's state, lent to platform::zlibInflateAll for a zlib track uint32_t currentBlockIndex_ = 0; ///< which compression-block-table entry is loaded uint32_t currentBlockFirstFrame_ = 0; ///< that block's first frame uint32_t currentBlockFrameCount_ = 0; ///< how many frames blockBuf_ covers bool aheadFrameReady_ = false; ///< frameBuf_ holds a frame decoded one tick ahead, not yet handed to sink_ - char fileNames_[kMaxFiles][kPathCap] = {}; ///< full filesystem paths, found by the scan - const char* fileOptions_[kMaxFiles] = {}; + ScratchBuffer fileNames_{*this}; ///< kMaxFiles full filesystem paths of kPathCap bytes each, found by the scan; held while enabled uint8_t fileCount_ = 0; bool scanSkipped_ = false; ///< a name or joined path did not fit kPathCap during the last scan @@ -417,12 +378,34 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return fits; } + /// Row `i`'s path, empty while it names no file. + const char* trackFile(uint8_t i) const { return tracks_[i].file ? tracks_[i].file : ""; } + /// Scanned file `i`'s full path. + const char* fileName(uint8_t i) const { return fileNames_.data() + static_cast(i) * kPathCap; } + /// Give `t` its own copy of `path` (at most kPathCap - 1 bytes), keeping the old path when the allocation fails. + static bool setTrackFile(Track& t, const char* path) { + size_t n = std::strlen(path); + if (n >= kPathCap) n = kPathCap - 1; + char* copy = static_cast(platform::alloc(n + 1)); + if (!copy) return false; + std::memcpy(copy, path, n); + copy[n] = '\0'; + platform::free(t.file); + t.file = copy; + return true; + } + /// Free every row's path and empty the playlist. + void clearTracks() { + for (uint8_t i = 0; i < kMaxTracks; i++) { platform::free(tracks_[i].file); tracks_[i] = Track{}; } + playlistCount_ = 0; + } + int indexOf(uint32_t id) const { for (uint8_t i = 0; i < playlistCount_; i++) if (tracks_[i].id == id) return i; return -1; } int fileIndexOf(const char* file) const { - for (uint8_t i = 0; i < fileCount_; i++) if (std::strcmp(fileOptions_[i], file) == 0) return i; + for (uint8_t i = 0; i < fileCount_; i++) if (std::strcmp(fileName(i), file) == 0) return i; return -1; // no scanned file matches, e.g. one deleted after a row picked it } void markVisited(uint8_t idx) { if (idx < 64) visited_ |= (uint64_t{1} << idx); } @@ -544,11 +527,11 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// Open `path` fresh and buffer its header region; false when the file can't be opened or has no bytes. bool openTrackStream(const char* path) { closeTrackStream(); - if (!platform::fsReadStreamOpen(stream_, path)) return false; + if (!headBuf_.resize(kHeadBytes) || !platform::fsReadStreamOpen(stream_, path)) return false; streamOpen_ = true; size_t got = 0; - while (got < sizeof(headBuf_)) { - const int n = platform::fsReadStreamRead(stream_, reinterpret_cast(headBuf_) + got, sizeof(headBuf_) - got); + while (got < kHeadBytes) { + const int n = platform::fsReadStreamRead(stream_, reinterpret_cast(headBuf_.data()) + got, kHeadBytes - got); if (n <= 0) break; got += static_cast(n); } @@ -562,7 +545,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { size_t fromHead = 0; if (headPos_ < headLen_) { fromHead = n < (headLen_ - headPos_) ? n : (headLen_ - headPos_); - std::memcpy(dst, headBuf_ + headPos_, fromHead); + std::memcpy(dst, headBuf_.data() + headPos_, fromHead); headPos_ += fromHead; } const size_t remain = n - fromHead; @@ -588,18 +571,21 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return true; } + /// Report why a track did not load or play, stop the transport and rewind; always false, so a failure path returns it. + bool failLoad(const char* message) { + setStatus(message, Severity::Error); + state_ = PlayState::Stopped; + frameIndex_ = 0; + return false; + } + /// Open and parse one playlist row's header; forces Stopped and reports the reason on failure. bool loadTrack(uint8_t idx) { if (idx >= playlistCount_) { state_ = PlayState::Stopped; return false; } cursor_ = idx; - if (!openTrackStream(tracks_[cursor_].file)) { - setStatus("file read failed", Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; - } + if (!openTrackStream(trackFile(cursor_))) return failLoad("file read failed"); fseq::FseqHeader hdr; - const fseq::ParseError err = fseq::parseHeader(headBuf_, headLen_, hdr); + const fseq::ParseError err = fseq::parseHeader(headBuf_.data(), headLen_, hdr); const char* errMsg = nullptr; switch (err) { case fseq::ParseError::BadMagic: errMsg = "not an FSEQ file"; break; @@ -615,28 +601,13 @@ class FseqPlayerEffect : public EffectBase, public ListSource { if (!errMsg && hdr.compressionType == fseq::CompressionType::Zlib && !platform::hasZlibInflate) { errMsg = "zlib plays on ESP32 only"; } - if (errMsg) { - setStatus(errMsg, Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; - } + if (errMsg) return failLoad(errMsg); header_ = hdr; frameCount_ = header_.frameCount; frameBuf_.resize(header_.channelCount); - if (!frameBuf_) { - setStatus("out of memory for this track's frame size", Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; - } + if (!frameBuf_ || !sizeBlockBuffers() || !sizeExpandBuf()) return failLoad("out of memory for this track, or its block table is invalid"); headPos_ = header_.channelDataOffset; // skip the header, land exactly where frame 0 starts - if (headPos_ > headLen_ && !skipHeaderGap(headPos_ - headLen_)) { - setStatus("FSEQ header truncated or unreadable", Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; - } + if (headPos_ > headLen_ && !skipHeaderGap(headPos_ - headLen_)) return failLoad("FSEQ header truncated or unreadable"); currentBlockIndex_ = 0; currentBlockFirstFrame_ = 0; currentBlockFrameCount_ = 0; @@ -652,22 +623,46 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return true; } - /// Decode frame `frameIndex_` into `frameBuf_`, one tick ahead of render (@xref{a-frame-decodes-one-tick-ahead-of-its-render}). The one blocking read `tick20ms` makes, bounded to one frame or block. + /// Size the block buffers and decoder workspace once for the track's largest block, so the 50 Hz path never allocates. + bool sizeBlockBuffers() { + size_t maxCompressed = 0, maxDecompressed = 0; + if (header_.compressionType == fseq::CompressionType::Zlib) { + for (uint32_t i = 0; i < header_.compressionBlockCount; i++) { + uint32_t firstFrame = 0, byteLen = 0, frames = 0; + size_t decompLen = 0; + if (!blockExtent(i, firstFrame, byteLen, frames, decompLen)) return false; + if (byteLen > maxCompressed) maxCompressed = byteLen; + if (decompLen > maxDecompressed) maxDecompressed = decompLen; + } + } + const bool zlib = header_.compressionType == fseq::CompressionType::Zlib; + return compBuf_.resize(maxCompressed) && blockBuf_.resize(maxDecompressed) && zlibWork_.resize(zlib ? platform::zlibWorkspaceBytes() : 0); + } + + /// Size the sparse expansion buffer to the sink, once per track and per layer resize; a dense track frees it. + bool sizeExpandBuf() { + return expandBuf_.resize(header_.sparseRangeCount ? sink_.bytes() : 0); + } + + /// One compression block's table entry, validated: where it starts, its compressed length, and how many frames and decompressed bytes it covers. + bool blockExtent(uint32_t index, uint32_t& firstFrame, uint32_t& byteLen, uint32_t& frames, size_t& decompLen) const { + if (index >= header_.compressionBlockCount || !fseq::blockEntry(headBuf_.data(), headLen_, index, firstFrame, byteLen)) return false; + const bool hasNext = index + 1 < header_.compressionBlockCount; + uint32_t nextFirstFrame = 0, nextByteLen = 0; + if (hasNext && !fseq::blockEntry(headBuf_.data(), headLen_, index + 1, nextFirstFrame, nextByteLen)) return false; + if (!fseq::validateBlockRange(firstFrame, header_.frameCount, hasNext, nextFirstFrame, header_.channelCount, frames, decompLen)) + return false; + return byteLen != 0 && byteLen <= kMaxCompressedBlockBytes && frames != 0; + } + + /// Decode frame `frameIndex_` into `frameBuf_`, one tick ahead of its render. The one blocking read `tick20ms` makes, bounded to one frame or block. bool decodeCurrentFrame() { if (header_.compressionType == fseq::CompressionType::Zlib) { const uint8_t* rawFrame = frameFromCompressedBlock(frameIndex_); - if (!rawFrame) { - setStatus("file read failed mid-file", Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; - } + if (!rawFrame) return failLoad("file read failed mid-file"); std::memcpy(frameBuf_.data(), rawFrame, header_.channelCount); } else if (!readBytes(frameBuf_.data(), header_.channelCount)) { - setStatus("file read failed mid-file", Severity::Error); - state_ = PlayState::Stopped; - frameIndex_ = 0; - return false; + return failLoad("file read failed mid-file"); } aheadFrameReady_ = true; return true; @@ -680,7 +675,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return; } // Unmapped channels go dark: the sparse table only addresses the fixtures a show touches. - if (!expandBuf_.resize(sink_.bytes())) return; + if (expandBuf_.bytes() != sink_.bytes()) return; // sized by sizeExpandBuf(), never here on the render path std::memset(expandBuf_.data(), 0, expandBuf_.bytes()); fseq::expandSparseFrame(header_, frameBuf_.data(), header_.channelCount, expandBuf_.data(), expandBuf_.bytes()); sink_.write(0, expandBuf_.data(), expandBuf_.bytes()); @@ -706,39 +701,17 @@ class FseqPlayerEffect : public EffectBase, public ListSource { return blockBuf_.data() + offset; } - /// Read, decompress and install the next compression block; its byte count sets this call's real cost. + /// Read, decompress and install the next compression block into the buffers `loadTrack` sized; its byte count sets this call's real cost. bool loadNextBlock() { - if (currentBlockIndex_ >= header_.compressionBlockCount) return false; - const size_t entryOffset = 32 + static_cast(currentBlockIndex_) * 8; - if (entryOffset + 8 > headLen_) return false; // the block table lives inside the buffered header region - const uint32_t blockFirstFrame = fseq::detail::rdU32(headBuf_ + entryOffset); - const uint32_t blockByteLen = fseq::detail::rdU32(headBuf_ + entryOffset + 4); - - const bool hasNext = currentBlockIndex_ + 1 < header_.compressionBlockCount; - uint32_t nextFirstFrame = 0; - if (hasNext) { - if (entryOffset + 12 > headLen_) return false; - nextFirstFrame = fseq::detail::rdU32(headBuf_ + entryOffset + 8); - } - uint32_t blockFrameCount = 0; + uint32_t firstFrame = 0, byteLen = 0, frames = 0; size_t decompLen = 0; - if (!fseq::validateBlockRange(blockFirstFrame, header_.frameCount, hasNext, nextFirstFrame, - header_.channelCount, blockFrameCount, decompLen)) - return false; - if (blockByteLen == 0 || blockByteLen > kMaxCompressedBlockBytes || blockFrameCount == 0) return false; - - compBuf_.resize(blockByteLen); - if (!compBuf_ || !readBytes(compBuf_.data(), blockByteLen)) return false; - - blockBuf_.resize(decompLen); - if (!blockBuf_) return false; + if (!blockExtent(currentBlockIndex_, firstFrame, byteLen, frames, decompLen)) return false; + if (byteLen > compBuf_.bytes() || decompLen > blockBuf_.bytes() || !readBytes(compBuf_.data(), byteLen)) return false; size_t outLen = 0; - if (!platform::zlibInflateAll(compBuf_.data(), blockByteLen, blockBuf_.data(), decompLen, outLen) || - outLen != decompLen) - return false; + if (!platform::zlibInflateAll(zlibWork_.data(), compBuf_.data(), byteLen, blockBuf_.data(), decompLen, outLen) || outLen != decompLen) return false; - currentBlockFirstFrame_ = blockFirstFrame; - currentBlockFrameCount_ = blockFrameCount; + currentBlockFirstFrame_ = firstFrame; + currentBlockFrameCount_ = frames; currentBlockIndex_++; return true; } @@ -762,14 +735,15 @@ class FseqPlayerEffect : public EffectBase, public ListSource { lvl->count++; } - /// Recurse into `path`, depth-bounded, one heap-allocated level at a time (@xref{the-scan-is-depth-bounded-recursion-over-a-heap-per-level-buffer}). + /// Recurse into `path`, depth-bounded, one heap-allocated level at a time, the shape of `FilesystemModule::removeTree`. void scanDir(const char* path, uint8_t depth) { if (depth > kMaxScanDepth || fileCount_ >= kMaxFiles) return; auto* raw = platform::alloc(sizeof(ScanLevel)); if (!raw) return; - ScanLevel* lvlp = new (raw) ScanLevel; - ScanLevel& lvl = *lvlp; - struct Freer { ScanLevel* p; ~Freer() { p->~ScanLevel(); platform::free(p); } } freer{lvlp}; + ScanLevel& lvl = *static_cast(raw); // plain data: the two counters are set here, the arrays are written before they are read + lvl.count = 0; + lvl.skipped = false; + struct Freer { void* p; ~Freer() { platform::free(p); } } freer{raw}; platform::fsList(path, &collectScanEntry, &lvl); if (lvl.skipped) scanSkipped_ = true; for (uint8_t i = 0; i < lvl.count && fileCount_ < kMaxFiles; i++) { @@ -778,7 +752,7 @@ class FseqPlayerEffect : public EffectBase, public ListSource { if (lvl.isDir[i]) { scanDir(child, static_cast(depth + 1)); } else if (hasExtension(lvl.names[i], ".fseq")) { - copyBounded(fileNames_[fileCount_++], sizeof(fileNames_[0]), child); + copyBounded(fileNames_.data() + static_cast(fileCount_++) * kPathCap, kPathCap, child); } } } @@ -787,8 +761,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { void rescanFiles() { fileCount_ = 0; scanSkipped_ = false; - scanDir("/", 0); - for (uint8_t i = 0; i < fileCount_; i++) fileOptions_[i] = fileNames_[i]; + if (fileNames_.resize(static_cast(kMaxFiles) * kPathCap)) scanDir("/", 0); + else setStatus("out of memory for the file list", Severity::Error); rebuildControls(); // Fires the resync directly: rebuildControls()'s own change-detection does not hash a List's row/option-set content. notifySchemaChanged(); @@ -828,8 +802,8 @@ class FseqPlayerEffect : public EffectBase, public ListSource { : state_ == PlayState::Paused ? "paused" : "stopped"; // Copied to a local first: GCC's -Wrestrict cannot see past two member arrays of the same `this`. - char fileCopy[sizeof(Track::file)]; - copyBounded(fileCopy, sizeof(fileCopy), tracks_[cursor_].file); + char fileCopy[kPathCap]; + copyBounded(fileCopy, sizeof(fileCopy), trackFile(cursor_)); std::snprintf(statusBuf_, sizeof(statusBuf_), "%s: %s (%u/%u)", label, fileCopy, static_cast(cursor_ + 1), static_cast(playlistCount_)); setStatus(statusBuf_); diff --git a/src/light/util/FseqFile.h b/src/light/util/FseqFile.h index da71700bf..be3c77c15 100644 --- a/src/light/util/FseqFile.h +++ b/src/light/util/FseqFile.h @@ -2,47 +2,9 @@ /// Parses an FSEQ sequence file's fixed header, compression flags and sparse-range table. /// -/// FSEQ is the Falcon Player/xLights animation file format: a flat, per-frame byte stream with no pixel/RGB semantics of its own. This header is pure and platform-agnostic: it reads caller-supplied byte buffers only, no `platform::` calls, no I/O. +/// FSEQ is the Falcon Player/xLights animation file format: a flat, per-frame byte stream with no pixel semantics of its own. /// -/// @technical -/// -/// ## Prior art -/// -/// FPP's `FSEQ_Sequence_File_Format.txt` is the spec; `core/util/FirmwareImage.h` is the structural template this header follows: a truncation-safe parser over a caller-supplied buffer, no I/O of its own. -/// -/// ## Mapping bytes onto pixels is the caller's job -/// -/// FSEQ carries no pixel/RGB semantics of its own, just a flat numbered byte stream per frame. Mapping a frame's bytes onto a light buffer is `FseqPlayerEffect`'s job, not this parser's. -/// -/// ## Compression -/// -/// A v2 file may compress channel data per-frame. Type 2 (zlib) decodes via the ESP32 ROM's `tinfl_decompress` at zero flash cost; type 1 (zstd) has no decoder yet, reported as its own error, not a bug. -/// -/// ## Sparse ranges -/// -/// A v2 file may carry only some of a show's channels. The sparse range table lists which absolute channel each raw-byte slice belongs to, and `expandSparseFrame` places it; a frame with no sparse ranges maps 1:1, skipping it. -/// -/// ## Why bytes 20 and 21 are read whole, not nibble-split -/// -/// A real xLights export disproved packing the compression block count across byte 20's high nibble and byte 21. `compressionBlockCount * 8 + 32` only lands on its own `variableHeaderOffset` when each byte is read whole. -/// -/// ## `parseHeader` never reads past `len` -/// -/// A buffer too short for a field or table the header claims to have returns `Truncated`. `out` is left however far parsing got, the same truncation-safe contract as `FirmwareImage.h`'s `identify()`. -/// -/// `channelDataOffset` is checked against where the header's own tables end, so frame 0 can never start inside them; before that end returns `Truncated` too. -/// -/// ## `parseHeader`'s two non-fatal errors -/// -/// `TooManySparseRanges` and `UnsupportedCompression` return only once `out` is otherwise fully populated (clipped to `kMaxSparseRanges` first), so a caller may still use the rest, e.g. for a status line. -/// -/// ## Why `frameByteOffset` returns `size_t`, not `uint32_t` -/// -/// `frameIndex * channelCount` overflows a 32-bit accumulator well within a real show's frame count once channel count is in the thousands. -/// -/// ## The layout `expandSparseFrame`'s `rawFrame` and `out` each assume -/// -/// `rawFrame` holds `hdr.sparseRangeCount` ranges back-to-back; each lands at `out[startChannel..startChannel+channelCount)`, clipped to `outCap`, leaving gaps the caller already zeroed, once per frame, untouched. +/// This header follows FPP's `FSEQ_Sequence_File_Format.txt` and is pure: it reads caller-supplied byte buffers only, with no `platform::` calls and no I/O. #include #include @@ -69,6 +31,10 @@ struct SparseRange { uint32_t channelCount = 0; ///< how many raw bytes belong to this slice }; +/// Where the compression block table starts, right after the fixed header, and the bytes each entry (first frame, compressed length) takes. +constexpr size_t kBlockTableOffset = 32; +constexpr size_t kBlockEntryBytes = 8; + /// Bound on parsed sparse ranges: large enough for any real show, small enough to hold inline. constexpr uint8_t kMaxSparseRanges = 32; @@ -106,12 +72,12 @@ inline bool isValidMagic(const uint8_t* buf) { /// Parse the v2-only compression type, block count and sparse-range table into `out`; `buf`/`len` are the whole header buffer, unchanged from `parseHeader`. inline ParseError parseV2Tail(const uint8_t* buf, size_t len, FseqHeader& out) { - // Byte 20 is the whole compression-type byte; byte 21 is the whole block count (see @technical above). + // Byte 20 is the whole compression-type byte; byte 21 is the whole block count, since only that lands `compressionBlockCount * 8 + 32` on a real export's `variableHeaderOffset`. const uint8_t compressionByte = buf[20]; out.compressionBlockCount = buf[21]; const uint8_t sparseCountRaw = buf[22]; - const size_t sparseTableOffset = 32 + static_cast(out.compressionBlockCount) * 8; + const size_t sparseTableOffset = kBlockTableOffset + static_cast(out.compressionBlockCount) * kBlockEntryBytes; const uint8_t sparseCount = sparseCountRaw > kMaxSparseRanges ? kMaxSparseRanges : sparseCountRaw; const size_t sparseTableEnd = sparseTableOffset + static_cast(sparseCount) * 6; if (len < sparseTableEnd) return ParseError::Truncated; @@ -135,7 +101,7 @@ inline ParseError parseV2Tail(const uint8_t* buf, size_t len, FseqHeader& out) { return ParseError::None; } -/// Parse the fixed header, compression flags and (v2) sparse-range table out of `buf`. +/// Parse the fixed header, compression flags and (v2) sparse-range table out of `buf`; it never reads past `len`. inline ParseError parseHeader(const uint8_t* buf, size_t len, FseqHeader& out) { out = FseqHeader{}; if (len < 32) return ParseError::Truncated; @@ -157,10 +123,13 @@ inline ParseError parseHeader(const uint8_t* buf, size_t len, FseqHeader& out) { return parseV2Tail(buf, len, out); } -/// The byte offset of frame `frameIndex`'s channel data within the file. -inline size_t frameByteOffset(const FseqHeader& hdr, uint32_t frameIndex) { - return static_cast(hdr.channelDataOffset) + - static_cast(frameIndex) * static_cast(hdr.channelCount); +/// Reads compression block table entry `index` out of the header buffer: the block's first frame and compressed byte length; false when the entry lies past `len`. +inline bool blockEntry(const uint8_t* buf, size_t len, uint32_t index, uint32_t& firstFrame, uint32_t& byteLen) { + const size_t at = kBlockTableOffset + static_cast(index) * kBlockEntryBytes; + if (at + kBlockEntryBytes > len) return false; + firstFrame = detail::rdU32(buf + at); + byteLen = detail::rdU32(buf + at + 4); + return true; } /// Validates one compression block's frame range against `frameCount` and the next table entry, and computes its decompressed byte length; false rejects it outright, before any buffer is sized by it. diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index eb508e898..a9d5b8d4c 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1173,7 +1173,8 @@ bool sdMount(const SdPinConfig& pins) { } // No decoder linked on desktop; the FSEQ player reports "unsupported" for a compressed file here. -bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +bool zlibInflateAll(void*, const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +size_t zlibWorkspaceBytes() { return 0; } bool sdCardMounted() { return sdMounted_; } diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 0dcd70878..6fc2b068f 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -259,8 +259,8 @@ constexpr bool hasSdCard = true; #else constexpr bool hasSdCard = false; #endif -/// `zlibInflateAll`'s real decoder lives in the SD-card source file, so it is linked only where hasSdCard is; a flash-only board gets the inert stub. Tracked separately, not this flag's job to fix. -constexpr bool hasZlibInflate = hasSdCard; +/// True: `zlibInflateAll` decodes through the ROM's tinfl on every ESP32 board. +constexpr bool hasZlibInflate = true; /// False: SdCardService offers only the board presets this chip can wire. constexpr bool previewsSdPresets = false; diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 005e1d805..c0524796f 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -117,6 +117,7 @@ #include "esp_log.h" #include "esp_rom_sys.h" // esp_rom_delay_us (delayUs) #include "mdns.h" +#include "miniz.h" // tinfl, the decoder zlibInflateAll calls #include "driver/gpio.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -2244,4 +2245,20 @@ void TcpServer::close() { // irRead (IR receive) lives in platform_esp32_ir.cpp, an RMT NEC decoder. +size_t zlibWorkspaceBytes() { return sizeof(tinfl_decompressor); } + +/// Inflate one zlib block in a single `tinfl_decompress` call over the whole input and output, against the caller's workspace rather than this task's stack. +bool zlibInflateAll(void* workspace, const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen) { + if (!workspace) return false; + auto* decomp = static_cast(workspace); + tinfl_init(decomp); + size_t inBytes = srcLen; + size_t outBytes = dstCap; + const tinfl_status status = tinfl_decompress(decomp, src, &inBytes, dst, dst, &outBytes, + TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); + if (status != TINFL_STATUS_DONE) return false; // HAS_MORE_OUTPUT: the block inflates past dstCap, a file that lied about its size + outLen = outBytes; + return true; +} + } // namespace mm::platform diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index c7d873e80..c3e2223b0 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -59,7 +59,6 @@ #if SOC_SDMMC_IO_POWER_EXTERNAL #include "sd_pwr_ctrl_by_on_chip_ldo.h" #endif -#include "miniz.h" // tinfl_decompress_mem_to_mem: zlibInflateAll's ROM-resident decoder #endif namespace mm::platform { @@ -600,35 +599,8 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { return ok; } -static tinfl_decompressor* g_tinflDecomp = nullptr; ///< heap-allocated once, reused every call: ~2.5KB this main task's 12KB stack cannot spare - -/// The only real decoder this call ever reaches, since FseqPlayerEffect (its sole caller) is itself gated on `hasSdCard`. -/// -/// `tinfl_decompress_mem_to_mem` keeps its own `tinfl_decompressor` on the caller's stack, which this task's 12KB has no room for. -/// This calls `tinfl_init`/`tinfl_decompress` directly instead, against one heap-allocated instance reused every call rather than freed, since each call fully re-inits it anyway. -bool zlibInflateAll(const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen) { - if (!g_tinflDecomp) { - g_tinflDecomp = static_cast(platform::alloc(sizeof(tinfl_decompressor))); - if (!g_tinflDecomp) return false; - } - tinfl_init(g_tinflDecomp); - size_t inPos = 0, outPos = 0; - for (;;) { - size_t inBytes = srcLen - inPos; - size_t outBytes = dstCap - outPos; - const tinfl_status status = tinfl_decompress(g_tinflDecomp, src + inPos, &inBytes, dst, dst + outPos, &outBytes, - TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); - inPos += inBytes; - outPos += outBytes; - if (status == TINFL_STATUS_DONE) { outLen = outPos; return true; } - if (status != TINFL_STATUS_HAS_MORE_OUTPUT) return false; // a real decode error, or no input left to satisfy NEEDS_MORE_INPUT - } -} - #else // !MM_HAS_SDMMC: every symbol below is inert so a board with no SDMMC host still links. -bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } - bool sdCardMounted() { return false; } bool sdCardPresent() { return false; } bool sdMount(const SdPinConfig&) { return false; } diff --git a/src/platform/platform.h b/src/platform/platform.h index 7076f8d08..a831de477 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -340,7 +340,9 @@ inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { } /// Inflate one self-contained zlib block in one call; `outLen` is set only on success. Real decoder only on ESP32 (the ROM's `tinfl_decompress`, zero flash cost); desktop and Teensy both stub to false, no decoder linked on either. -bool zlibInflateAll(const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen); +bool zlibInflateAll(void* workspace, const uint8_t* src, size_t srcLen, uint8_t* dst, size_t dstCap, size_t& outLen); +/// The bytes of decoder state the caller lends `zlibInflateAll` as its `workspace`, held only while it plays zlib; 0 where no decoder is linked. +size_t zlibWorkspaceBytes(); // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. diff --git a/src/platform/teensy/platform_teensy_fs.cpp b/src/platform/teensy/platform_teensy_fs.cpp index 26b5e9dea..fd4876214 100644 --- a/src/platform/teensy/platform_teensy_fs.cpp +++ b/src/platform/teensy/platform_teensy_fs.cpp @@ -16,7 +16,8 @@ extern unsigned long _flashimagelen; namespace mm::platform { // No zlib/zstd decoder on Teensy: FseqPlayerEffect's compressed-block path never succeeds here. -bool zlibInflateAll(const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +bool zlibInflateAll(void*, const uint8_t*, size_t, uint8_t*, size_t, size_t&) { return false; } +size_t zlibWorkspaceBytes() { return 0; } // --- Filesystem: LittleFS on the top of program flash ------------------------------------------- diff --git a/src/ui/app.js b/src/ui/app.js index f5c1d05fd..dcdbe30c1 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -4752,8 +4752,9 @@ function fmtTime(sec) { return parts.join(" "); } -// "used / total", each sized by fmtBytes from the unit the device declares. +// "used / total", each sized by fmtBytes from the unit the device declares; a count unit shows plain numbers. function fmtProgressLabel(ctrl) { + if (ctrl.unit === "count") return (Number(ctrl.value) || 0) + " / " + (Number(ctrl.total) || 0); const scale = ctrl.unit === "KiB" ? 1024 : 1; return fmtBytes((Number(ctrl.value) || 0) * scale) + " / " + fmtBytes((Number(ctrl.total) || 0) * scale); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 331fc69dc..0cc99ba71 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -147,7 +147,6 @@ add_executable(mm_tests unit/light/unit_NoiseMeterEffect.cpp unit/light/unit_PaintBrushEffect.cpp unit/light/unit_PictureEffect.cpp - unit/light/unit_FseqPlayerEffect.cpp unit/light/unit_PraxisEffect.cpp unit/light/unit_RandomEffect.cpp unit/light/unit_RippleXZModifier.cpp diff --git a/test/js/ui-progress-label.test.mjs b/test/js/ui-progress-label.test.mjs index 4e99b8a1d..c3fd57da4 100644 --- a/test/js/ui-progress-label.test.mjs +++ b/test/js/ui-progress-label.test.mjs @@ -17,6 +17,10 @@ test("a KiB gauge scales first, so a 32 GB card reads as gigabytes", () => { assert.equal(fmtProgressLabel({ value: 1536, total: 31166976, unit: "KiB" }), "1.5MB / 30GB"); }); +test("a count gauge shows plain numbers, never a byte suffix", () => { + assert.equal(fmtProgressLabel({ value: 3000, total: 6300, unit: "count" }), "3000 / 6300"); +}); + test("a missing value reads as zero rather than NaN", () => { assert.equal(fmtProgressLabel({ unit: "B" }), "0B / 0B"); }); diff --git a/test/unit/core/unit_MoonModule.cpp b/test/unit/core/unit_MoonModule.cpp index 2363f60fa..8b5489f50 100644 --- a/test/unit/core/unit_MoonModule.cpp +++ b/test/unit/core/unit_MoonModule.cpp @@ -150,26 +150,6 @@ TEST_CASE("schemaSignature: value change is invisible, schema change is not") { CHECK(m.schemaSignature() != base); } -namespace { -// A module whose Progress total changes at runtime, the way a track change moves FseqPlayerEffect's frameCount_. -class ProgressModule : public mm::MoonModule { -public: - uint32_t position = 0; - uint32_t total = 100; - void defineControls() override { controls_.addProgress("position", position, total); } -}; -} // namespace - -// Pins the fix for the PR #148 follow-up: a Progress total is a value, not schema, so changing it and rebuilding must not trip a full browser resync. -TEST_CASE("schemaSignature: a Progress total change alone is invisible, same as its value") { - ProgressModule m; m.defineControls(); - const uint32_t base = m.schemaSignature(); - m.total = 9999; - m.rebuildControls(); // re-snapshots aux for the new total, the same call loadTrack() makes on a track change - CHECK(m.schemaSignature() == base); - CHECK(m.controls()[0].aux == 9999); // the total did refresh; only the resync-trigger is suppressed -} - TEST_CASE("schemaSignature: recurses into children (a child schema change is caught)") { auto* parent = new mm::MoonModule(); auto* child = new SelectModule(); @@ -248,9 +228,11 @@ TEST_CASE("a progress control declares its unit to the UI, bytes unless told oth uint32_t used = 1, usedKiB = 1; mod.controls().addProgress("flash", used, 4096); mod.controls().addProgress("card", usedKiB, 31166976, mm::ProgressUnit::KiB); + mod.controls().addProgress("frames", used, 6300, mm::ProgressUnit::Count); const auto meta = [&](uint8_t i) { mm::JsonSink sink; mm::writeControlMetadata(sink, mod.controls()[i]); return std::string(sink.data(), sink.size()); }; CHECK(meta(0) == ",\"total\":4096,\"unit\":\"B\""); CHECK(meta(1) == ",\"total\":31166976,\"unit\":\"KiB\""); + CHECK(meta(2) == ",\"total\":6300,\"unit\":\"count\""); } // enabled defaults to true; setEnabled flips the universal enable gate (Scheduler and parent containers respect it). diff --git a/test/unit/light/unit_FseqFile.cpp b/test/unit/light/unit_FseqFile.cpp index fd3105b55..1797add30 100644 --- a/test/unit/light/unit_FseqFile.cpp +++ b/test/unit/light/unit_FseqFile.cpp @@ -252,17 +252,16 @@ TEST_CASE("channelDataOffset overlapping a v2 file's compression/sparse tables i CHECK(parseHeader(buf.data(), buf.size(), hdr) == ParseError::Truncated); } -TEST_CASE("frameByteOffset uses size_t arithmetic, so a large show does not overflow a 32-bit accumulator") { - FseqHeader hdr; - hdr.channelDataOffset = 32; - hdr.channelCount = 300000; // a large 3D voxel show - CHECK(frameByteOffset(hdr, 0) == 32); - CHECK(frameByteOffset(hdr, 1) == 32u + 300000u); - // frameIndex * channelCount alone exceeds UINT32_MAX by frame ~14310: size_t keeps this exact. - const uint32_t bigFrame = 20000; - const size_t expected = size_t{32} + size_t{bigFrame} * size_t{300000}; - CHECK(expected > 0xFFFFFFFFULL); - CHECK(frameByteOffset(hdr, bigFrame) == expected); +TEST_CASE("blockEntry reads one table entry's first frame and compressed length, and refuses an entry past the buffer") { + std::vector buf(32 + 2 * 8, 0); + putU32(buf, 32, 0); putU32(buf, 36, 1234); + putU32(buf, 40, 60); putU32(buf, 44, 5678); + uint32_t firstFrame = 99, byteLen = 99; + REQUIRE(blockEntry(buf.data(), buf.size(), 1, firstFrame, byteLen)); + CHECK(firstFrame == 60); + CHECK(byteLen == 5678); + CHECK_FALSE(blockEntry(buf.data(), buf.size(), 2, firstFrame, byteLen)); // the third entry would end past the buffer + CHECK_FALSE(blockEntry(buf.data(), buf.size() - 1, 1, firstFrame, byteLen)); } TEST_CASE("validateBlockRange accepts a well-formed entry and computes its decompressed length") { diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index e857a9c73..df5ba26be 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -139,9 +139,18 @@ void twoTrackPlaylist(SdFixture& sd, FseqPlayerEffect& e, uint32_t& id1, uint32_ e.addListRow(id2); } +// Scans the fixture, adds its first file as the only playlist row and presses play. +void playFirstScannedFile(FseqPlayerEffect& e) { + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); +} + } // namespace -// Pins the independence the product owner asked for directly: no SD-specific check or status, ever. +// A flash-only board scans exactly like one with a card: no SD-specific check or status, ever. TEST_CASE("setup scans silently, with no SD-specific check or status") { Scene s; auto& e = s.effect; @@ -166,12 +175,7 @@ TEST_CASE("playing a flash-found file decodes and blits its frames, same as an S flash.writeFseq("/show.fseq", makeFseqV1Bytes(/*channelCount=*/48, /*frameCount=*/3)); // 48 = 4x4x1x3 Scene s; auto& e = s.effect; - e.setup(); - uint32_t id = 0; - REQUIRE(e.addListRow(id)); - - e.onControlChanged("play"); - REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + playFirstScannedFile(e); REQUIRE(e.status() != nullptr); CHECK(std::string(e.status()).rfind("playing", 0) == 0); @@ -294,7 +298,7 @@ TEST_CASE("playing a real file decodes and blits its frames in order") { // Frame 3 does not exist: the call above rendered frame 2 then ran handleEndOfFile (loopAll default: reloads frame 0). CHECK(e.frameIndexForTest() == 0); - // Pause updates status too, not just state: a pre-existing gap found and fixed alongside the play-status bug above. + // Pause updates the status line too, not just the state. e.onControlChanged("pause"); REQUIRE(e.status() != nullptr); CHECK(std::string(e.status()).rfind("paused", 0) == 0); @@ -360,7 +364,7 @@ TEST_CASE("a stopped player owns its background (clears to black), but a paused std::memset(buf, kStale, bytes); e.setStateForTest(FseqPlayerEffect::PlayState::Paused); e.tick(); - // No frame has ever decoded (frameBuf_ was never allocated): blitFrameBuf's own guard makes this a no-op, not a crash. + // No frame has ever decoded, so a paused transport has nothing to show: the sink stays as it was, no crash. for (size_t i = 0; i < bytes; i++) CHECK(buf[i] == kStale); } @@ -490,9 +494,10 @@ TEST_CASE("the recursive scan's path join never doubles the slash at the SD root CHECK(std::strlen(small) < sizeof(small)); // truncated, still NUL-terminated, never overrun } -TEST_CASE("a header with 0 channels or 0 frames fails to load with its own status, not out of memory") { +// Plays `bytes` as the only playlist row and checks loadTrack refuses it: transport stopped, `status` named, severity Error. +static void expectRefused(const char* path, const std::vector& bytes, const char* status) { SdFixture sd; - sd.writeFseq("/sd/empty.fseq", makeFseqV1Bytes(/*channelCount=*/10, /*frameCount=*/0)); + sd.writeFseq(path, bytes); Scene s; auto& e = s.effect; e.setup(); @@ -502,38 +507,20 @@ TEST_CASE("a header with 0 channels or 0 frames fails to load with its own statu e.onControlChanged("play"); CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); REQUIRE(e.status() != nullptr); - CHECK(std::string(e.status()) == "empty show: 0 channels or frames"); + CHECK(std::string(e.status()) == status); CHECK(e.severity() == MoonModule::Severity::Error); } -TEST_CASE("a zlib file fails to load on desktop with its own status, not a generic read failure") { - SdFixture sd; - sd.writeFseq("/sd/z.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/2)); - Scene s; - auto& e = s.effect; - e.setup(); - uint32_t id = 0; - REQUIRE(e.addListRow(id)); +TEST_CASE("a header with 0 channels or 0 frames fails to load with its own status, not out of memory") { + expectRefused("/sd/empty.fseq", makeFseqV1Bytes(/*channelCount=*/10, /*frameCount=*/0), "empty show: 0 channels or frames"); +} - e.onControlChanged("play"); - CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); - REQUIRE(e.status() != nullptr); - CHECK(std::string(e.status()) == "zlib plays on ESP32 only"); +TEST_CASE("a zlib file fails to load on desktop with its own status, not a generic read failure") { + expectRefused("/sd/z.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/2), "zlib plays on ESP32 only"); } TEST_CASE("a zlib file declaring no compression blocks is refused by name, before the platform check") { - SdFixture sd; - sd.writeFseq("/sd/z0.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/0)); - Scene s; - auto& e = s.effect; - e.setup(); - uint32_t id = 0; - REQUIRE(e.addListRow(id)); - - e.onControlChanged("play"); - CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Stopped); - REQUIRE(e.status() != nullptr); - CHECK(std::string(e.status()) == "zlib file declares no compression blocks"); + expectRefused("/sd/z0.fseq", makeFseqV2ZlibBytes(/*channelCount=*/10, /*frameCount=*/5, /*blockCount=*/0), "zlib file declares no compression blocks"); } TEST_CASE("a scanned name past kPathCap is skipped, not truncated into a different file, with a named warning") { @@ -570,6 +557,27 @@ TEST_CASE("fpsSource=manual: the fps slider sets the playback rate directly, ign CHECK(e.effectiveStepMsForTest() == 20); // the ceiling the `fps` control allows: tick20ms's own cadence } +TEST_CASE("choosing fpsSource=manual shows the fps slider, and choosing file hides it again") { + Scene s; + auto& e = s.effect; + e.defineControls(); + const auto fpsHidden = [&e]() { + for (uint8_t i = 0; i < e.controls().count(); i++) + if (std::strcmp(e.controls()[i].name, "fps") == 0) return e.controls()[i].hidden; + FAIL("no fps control"); + return false; + }; + CHECK(fpsHidden()); // the file's own rate is the default, so the slider has nothing to say + + e.setFpsSourceForTest(FseqPlayerEffect::kFpsManual); + e.onControlChanged("fpsSource"); + CHECK_FALSE(fpsHidden()); + + e.setFpsSourceForTest(FseqPlayerEffect::kFpsFromFile); + e.onControlChanged("fpsSource"); + CHECK(fpsHidden()); +} + TEST_CASE("fpsSource=file is the default, and reads the track's own stepTimeMs directly") { Scene s; auto& e = s.effect; @@ -597,12 +605,7 @@ TEST_CASE("loading a file-sourced track faster than 50fps warns, naming the rate sd.writeFseq("/sd/fast.fseq", makeFseqV1Bytes(10, 3, /*stepTimeMs=*/10)); Scene s; auto& e = s.effect; - e.setup(); - uint32_t id = 0; - REQUIRE(e.addListRow(id)); - - e.onControlChanged("play"); - REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + playFirstScannedFile(e); REQUIRE(e.status() != nullptr); CHECK(e.severity() == MoonModule::Severity::Warning); CHECK(std::string(e.status()).find("100") != std::string::npos); @@ -686,12 +689,7 @@ TEST_CASE("a channelDataOffset past the buffered header region is skipped, landi sd.writeFseq("/sd/gap.fseq", makeFseqV1BytesWithOffset(5000, /*channelCount=*/48, /*frameCount=*/3)); Scene s; auto& e = s.effect; - e.setup(); - uint32_t id = 0; - REQUIRE(e.addListRow(id)); - - e.onControlChanged("play"); - REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + playFirstScannedFile(e); const uint8_t* buf = s.layer.buffer().data(); const size_t bytes = s.layer.buffer().count() * 3; @@ -735,3 +733,51 @@ TEST_CASE("a playlist row's own play field jumps to that row and starts a fresh CHECK(e.cursorForTest() == 1); CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); // the row's own file opened fine } + +TEST_CASE("a released player holds no scan cache or buffers, keeps its playlist, and rebuilds the cache when enabled again") { + SdFixture sd; + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 3)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id = 0; + REQUIRE(e.addListRow(id)); + e.onControlChanged("play"); + REQUIRE(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); + CHECK(e.scratchBytesForTest() > 0); + + e.release(); + CHECK(e.scratchBytesForTest() == 0); + CHECK(e.listRowCount() == 1); // the playlist is the module's settings and survives a disable + mm::JsonSink row; + e.writeListRow(row, 0); + CHECK(std::string(row.data(), row.size()).find("/sd/a.fseq") != std::string::npos); + + e.prepare(); // enabled again: the scan cache comes back and the stopped player starts on its own + CHECK(e.scratchBytesForTest() > 0); + CHECK(e.stateForTest() == FseqPlayerEffect::PlayState::Playing); +} + +TEST_CASE("deleting and moving playlist rows keeps each row's own path") { + SdFixture sd; + sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); + sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); + Scene s; + auto& e = s.effect; + e.setup(); + uint32_t id1 = 0, id2 = 0, id3 = 0; + REQUIRE(e.addListRow(id1)); + REQUIRE(e.addListRow(id2)); + REQUIRE(e.addListRow(id3)); + REQUIRE(e.setListRowField(id2, "file", "{\"value\":1}")); + const auto path = [&e](uint8_t row) { mm::JsonSink j; e.writeListRow(j, row); const std::string t(j.data(), j.size()); return t.substr(t.find("/sd/"), 10); }; + CHECK(path(0) == "/sd/a.fseq"); + CHECK(path(1) == "/sd/b.fseq"); + REQUIRE(e.moveListRow(id2, 0)); + CHECK(path(0) == "/sd/b.fseq"); + CHECK(path(1) == "/sd/a.fseq"); + REQUIRE(e.deleteListRow(id1)); + CHECK(e.listRowCount() == 2); + CHECK(path(0) == "/sd/b.fseq"); + CHECK(path(1) == "/sd/a.fseq"); +} From bff109c4cd86fcdcec5da55831b48f283207bd06 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 20 Oct 2026 00:12:10 +0200 Subject: [PATCH 09/10] FSEQ player: a failed compressed block says what failed A zlib track that stops mid-file now names the cause: an invalid block table entry, a failed read, or a block that does not inflate to its declared size, where every case used to read "file read failed mid-file". Verified on a P4 with a block that inflates past its size. - **Light domain**: `loadNextBlock` returns the failure's message, which `frameFromCompressedBlock` hands to the status. Co-Authored-By: Claude Opus 5.5 --- src/light/effects/FseqPlayerEffect.h | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 0fde64eb1..1ec0c7bc1 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -658,8 +658,9 @@ class FseqPlayerEffect : public EffectBase, public ListSource { /// Decode frame `frameIndex_` into `frameBuf_`, one tick ahead of its render. The one blocking read `tick20ms` makes, bounded to one frame or block. bool decodeCurrentFrame() { if (header_.compressionType == fseq::CompressionType::Zlib) { - const uint8_t* rawFrame = frameFromCompressedBlock(frameIndex_); - if (!rawFrame) return failLoad("file read failed mid-file"); + const char* why = nullptr; + const uint8_t* rawFrame = frameFromCompressedBlock(frameIndex_, why); + if (!rawFrame) return failLoad(why); std::memcpy(frameBuf_.data(), rawFrame, header_.channelCount); } else if (!readBytes(frameBuf_.data(), header_.channelCount)) { return failLoad("file read failed mid-file"); @@ -690,30 +691,33 @@ class FseqPlayerEffect : public EffectBase, public ListSource { } } - /// The raw bytes for `frameIndex` out of the current (or a freshly loaded) compression block. - const uint8_t* frameFromCompressedBlock(uint32_t frameIndex) { + /// The raw bytes for `frameIndex` out of the current (or a freshly loaded) compression block, or null with `why` naming what failed. + const uint8_t* frameFromCompressedBlock(uint32_t frameIndex, const char*& why) { while (frameIndex >= currentBlockFirstFrame_ + currentBlockFrameCount_) { - if (!loadNextBlock()) return nullptr; + why = loadNextBlock(); + if (why) return nullptr; } // A block table not starting at frame 0 is malformed; fail rather than underflow this subtraction. - if (frameIndex < currentBlockFirstFrame_) return nullptr; + if (frameIndex < currentBlockFirstFrame_) { why = "FSEQ block table does not start at frame 0"; return nullptr; } const size_t offset = static_cast(frameIndex - currentBlockFirstFrame_) * header_.channelCount; return blockBuf_.data() + offset; } - /// Read, decompress and install the next compression block into the buffers `loadTrack` sized; its byte count sets this call's real cost. - bool loadNextBlock() { + /// Read, decompress and install the next compression block into the buffers `loadTrack` sized, or name what failed; its byte count sets this call's real cost. + const char* loadNextBlock() { uint32_t firstFrame = 0, byteLen = 0, frames = 0; size_t decompLen = 0; - if (!blockExtent(currentBlockIndex_, firstFrame, byteLen, frames, decompLen)) return false; - if (byteLen > compBuf_.bytes() || decompLen > blockBuf_.bytes() || !readBytes(compBuf_.data(), byteLen)) return false; + if (!blockExtent(currentBlockIndex_, firstFrame, byteLen, frames, decompLen) || byteLen > compBuf_.bytes() || decompLen > blockBuf_.bytes()) + return "FSEQ block table entry invalid"; + if (!readBytes(compBuf_.data(), byteLen)) return "file read failed mid-file"; size_t outLen = 0; - if (!platform::zlibInflateAll(zlibWork_.data(), compBuf_.data(), byteLen, blockBuf_.data(), decompLen, outLen) || outLen != decompLen) return false; + if (!platform::zlibInflateAll(zlibWork_.data(), compBuf_.data(), byteLen, blockBuf_.data(), decompLen, outLen) || outLen != decompLen) + return "compressed block does not match its declared size"; currentBlockFirstFrame_ = firstFrame; currentBlockFrameCount_ = frames; currentBlockIndex_++; - return true; + return nullptr; } /// One directory level, collected: the same shape as FilesystemModule::removeTree's DirLevel. From 2e27034253ece5ac785b520a029cc738b2021010 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 20 Oct 2026 00:33:35 +0200 Subject: [PATCH 10/10] FSEQ player: the file picker lists files in sorted order A directory lists its entries in an order the filesystem chooses, alphabetical on macOS and not on Linux, so the picker showed files in a different order per board and a playlist test passed on one and failed on the other. The player now sorts the scanned files, the same everywhere; the playlist keeps each row's path, so its rows do not move. - **Light domain**: `rescanFiles` sorts the scan with `std::qsort`. - **Tests**: the playlist test writes `b.fseq` before `a.fseq`, so it shows the sort rather than the filesystem's order. Co-Authored-By: Claude Opus 5.5 --- src/light/effects/FseqPlayerEffect.h | 11 +++++++++-- test/unit/light/unit_FseqPlayerEffect.cpp | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h index 1ec0c7bc1..c2b346d5d 100644 --- a/src/light/effects/FseqPlayerEffect.h +++ b/src/light/effects/FseqPlayerEffect.h @@ -9,6 +9,7 @@ #include "core/util/JsonUtil.h" // restoreList: recursive reader for the persisted playlist #include +#include // qsort: the picker's file order #include namespace mm { @@ -765,8 +766,14 @@ class FseqPlayerEffect : public EffectBase, public ListSource { void rescanFiles() { fileCount_ = 0; scanSkipped_ = false; - if (fileNames_.resize(static_cast(kMaxFiles) * kPathCap)) scanDir("/", 0); - else setStatus("out of memory for the file list", Severity::Error); + if (fileNames_.resize(static_cast(kMaxFiles) * kPathCap)) { + scanDir("/", 0); + // A directory lists its entries in no order of its own (POSIX leaves it to the filesystem), so the picker sorts them, the same on every board. + std::qsort(fileNames_.data(), fileCount_, kPathCap, + [](const void* a, const void* b) { return std::strcmp(static_cast(a), static_cast(b)); }); + } else { + setStatus("out of memory for the file list", Severity::Error); + } rebuildControls(); // Fires the resync directly: rebuildControls()'s own change-detection does not hash a List's row/option-set content. notifySchemaChanged(); diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp index df5ba26be..f23f17977 100644 --- a/test/unit/light/unit_FseqPlayerEffect.cpp +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -760,8 +760,8 @@ TEST_CASE("a released player holds no scan cache or buffers, keeps its playlist, TEST_CASE("deleting and moving playlist rows keeps each row's own path") { SdFixture sd; + sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); // written second-name first: the picker sorts, whatever order the filesystem lists sd.writeFseq("/sd/a.fseq", makeFseqV1Bytes(10, 2)); - sd.writeFseq("/sd/b.fseq", makeFseqV1Bytes(10, 2)); Scene s; auto& e = s.effect; e.setup();