Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/assets/light/effects/FseqPlayerEffect.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/light/effects/FseqPlayerEffect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions src/core/module/MoonModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ class MoonModule {
// Hash the strings, so an in-place rename still changes the signature.
const char* const* opts = reinterpret_cast<const char* const*>(c.aux);
for (int32_t o = 0; o < c.max; o++) mixStr(opts[o]);
} else {
mix(static_cast<uint32_t>(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<uint32_t>(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);
Expand Down
836 changes: 811 additions & 25 deletions src/light/effects/FseqPlayerEffect.h

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions src/light/util/FseqFile.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#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 <cstddef>
#include <cstdint>

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
Empty, ///< 0 channels or 0 frames: a real header, nothing to play
};

/// 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)

Check failure on line 83 in src/light/util/FseqFile.h

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [MoonLight.Temporal] 'not yet' dates the sentence: describe what exists. Raw Output: {"message":"[MoonLight.Temporal] 'not yet' dates the sentence: describe what exists.","location":{"path":"src/light/util/FseqFile.h","range":{"start":{"line":83,"column":102},"end":{"line":83,"column":109}}},"severity":"ERROR","code":{"value":"MoonLight.Temporal"}}
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<uint16_t>(p[0]) | static_cast<uint16_t>(static_cast<uint16_t>(p[1]) << 8);
}
inline uint32_t rdU32(const uint8_t* p) {
return static_cast<uint32_t>(p[0]) | (static_cast<uint32_t>(p[1]) << 8) |
(static_cast<uint32_t>(p[2]) << 16) | (static_cast<uint32_t>(p[3]) << 24);
}
inline uint32_t rdU24(const uint8_t* p) {
return static_cast<uint32_t>(p[0]) | (static_cast<uint32_t>(p[1]) << 8) | (static_cast<uint32_t>(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<size_t>(out.compressionBlockCount) * 8;
const uint8_t sparseCount = sparseCountRaw > kMaxSparseRanges ? kMaxSparseRanges : sparseCountRaw;
const size_t sparseTableEnd = sparseTableOffset + static_cast<size_t>(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<size_t>(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.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
}
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<size_t>(hdr.channelDataOffset) +
static_cast<size_t>(frameIndex) * static_cast<size_t>(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<size_t>(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<size_t>(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
4 changes: 2 additions & 2 deletions src/module_types.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -251,6 +251,7 @@ void mm::registerModuleTypes() {
mm::ModuleFactory::registerType<mm::FixedRectangleEffect>("FixedRectangleEffect", "light/effects.md#fixedrectangle");
mm::ModuleFactory::registerType<mm::FreqMatrixEffect>("FreqMatrixEffect", "light/effects.md#freqmatrix");
mm::ModuleFactory::registerType<mm::FreqSawsEffect>("FreqSawsEffect", "light/effects.md#freqsaws");
mm::ModuleFactory::registerType<mm::FseqPlayerEffect>("FseqPlayerEffect", "light/effects.md#fseqplayer");
mm::ModuleFactory::registerType<mm::GameOfLifeEffect>("GameOfLifeEffect", "light/effects.md#gameoflife");
mm::ModuleFactory::registerType<mm::GEQEffect>("GEQEffect", "light/effects.md#geq");
mm::ModuleFactory::registerType<mm::GEQ3DEffect>("GEQ3DEffect", "light/effects.md#geq3d");
Expand All @@ -264,7 +265,6 @@ void mm::registerModuleTypes() {
mm::ModuleFactory::registerType<mm::NoiseMeterEffect>("NoiseMeterEffect", "light/effects.md#noisemeter");
mm::ModuleFactory::registerType<mm::PaintBrushEffect>("PaintBrushEffect", "light/effects.md#paintbrush");
mm::ModuleFactory::registerType<mm::ParticlesEffect>("ParticlesEffect", "light/effects.md#particles");
mm::ModuleFactory::registerType<mm::FseqPlayerEffect>("FseqPlayerEffect", "light/effects.md#fseqplayer");
mm::ModuleFactory::registerType<mm::PictureEffect>("PictureEffect", "light/effects.md#picture");
mm::ModuleFactory::registerType<mm::PlasmaEffect>("PlasmaEffect", "light/effects.md#plasma");
mm::ModuleFactory::registerType<mm::PraxisEffect>("PraxisEffect", "light/effects.md#praxis");
Expand Down
3 changes: 3 additions & 0 deletions src/platform/desktop/platform_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/platform/desktop/platform_desktop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_; }
Expand Down
2 changes: 2 additions & 0 deletions src/platform/esp32/platform_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
28 changes: 28 additions & 0 deletions src/platform/esp32/platform_esp32_sdmmc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -599,8 +600,35 @@ 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<tinfl_decompressor*>(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; }
Expand Down
3 changes: 3 additions & 0 deletions src/platform/platform.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/platform/teensy/platform_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 3 additions & 0 deletions src/platform/teensy/platform_teensy_fs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading