Loading files
e5b871ed5This file is pinned to the selected release. Download the original to open it in your design tool.
#pragma once
#include <FFat.h>
#include <dirent.h>
#include <FS.h>
#include <esp_heap_caps.h>
#include <esp_partition.h>
#include <string.h>
#include <wear_levelling.h>
#include "src/core_encoders.h"
#include "src/core_module_loader.h"
// The pattern list has two halves, but they are no longer the same size.
//
// PRESETS compiled into firmware.bin. Origin alone, as the failsafe: it
// runs whether or not the filesystem mounts, so a board with an
// empty or broken FATFS still lights up instead of showing
// nothing. Everything else moved out — see the note by the
// includes below for why that is a memory decision.
// MODULES .pfm files on the FATFS partition, discovered at boot and appended
// after the preset. This is where a pattern from the community site
// lands — a ~6 KB upload instead of a 1 MB reflash — and where the
// old showcase now lives, as a pack you drop on /patterns.
//
// The hand-edited custom1..3 slots are gone: uploading a module is what they
// were for, and it no longer costs a rebuild. Their patterns live on under
// firmware/modules/. An empty region further down is still reserved for the web
// build service, which needs to compile a pattern in for devices running
// firmware older than the module loader — see CUSTOM SLOTS below.
// ── PRESETS (the failsafe only) ──
// Origin alone is compiled in. The rest of the showcase moved out to .pfm
// modules on FATFS — their sources are still here under presets/ as the
// editable originals, and port_preset.py turns them back into modules.
//
// This is not a tidy-up, it is a memory decision. Every compiled-in preset
// costs internal DRAM, and the web console needs roughly 10 KB of internal
// heap free to send a page: with every preset of the time (34) compiled in, a 128x64 board sat
// at ~11 KB, so three extra modules — or any new feature — pushed /patterns
// into the truncated "starved send" state core_patterns_http.h describes.
// Shipping one preset and letting people choose the rest is what makes the
// console reliable, and it is why the pack/deck flow exists.
#include "presets/preset_origin.h"
// NOT in presetPatterns[] below on purpose: the calibration test card is an
// overlay the tuner summons via /api/display, never a knob-browsable pattern.
// See the header's own comment for the full story.
#include "presets/preset_calib.h"
struct PatternEntry {
const char* name;
const char* const* knobLabels;
void (*setup)();
void (*update)(float dt, const InputFrame& input);
void (*draw)();
// Path to a .pfm on FATFS, or nullptr for a preset compiled into this image.
// The four members above are meaningless for a module: its code is not in
// firmware.bin, so the loader dispatches instead.
const char* modulePath;
// Reachable by name, skipped by the knob. For patterns that are a
// *state* something else switches to rather than something a person
// browses to: the show scheduler's night face is the panel going dark,
// not an entry in a carousel of things to look at.
bool hidden;
// Pattern maps the MQTT absolute 0..1000 bus via PFParams (Director /
// Show manager). Presets declare it as ns::ABSOLUTE_READY; modules carry
// it in their sidecar .json.
bool absoluteReady;
};
#define PATTERN_ENTRY(ns) \
{ ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr, false, ns::ABSOLUTE_READY }
// Same, but skipped when browsing with the knob (see PatternEntry::hidden).
#define PATTERN_ENTRY_HIDDEN(ns) \
{ ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr, true, ns::ABSOLUTE_READY }
// Presets a feature brings with it (PF_FEATURE_PRESETS). Included after the
// macro above, because the list expands to PATTERN_ENTRY calls.
#include "features/feature_presets.h"
// To add a pattern:
// - Module (the usual way now): build a .pfm and upload it — no rebuild.
// python firmware/toolchain/port_preset.py <pattern.h>
// python firmware/toolchain/build_module.py firmware/modules/<slug>
// - Preset (curated showcase): copy _TEMPLATE.h to presets/preset_<name>.h
// ("../src/..." includes) and add a PATTERN_ENTRY in presetPatterns[].
// Pattern 1 stays Origin; modules are appended after the last preset, so
// turning back from pattern 1 reaches them.
// ── PRESETS ──
// Pattern 1 is Origin, the failsafe: a board with an empty or unmountable
// FATFS still lights up. Everything else ships as modules — see the note by
// the includes above.
//
// Features may contribute presets of their own; the show feature brings Black,
// its night face. A build with no features has this list at Origin alone,
// which is the point: the core ships the failsafe and nothing else.
PatternEntry presetPatterns[] = {
PATTERN_ENTRY(Origin),
PF_FEATURE_PRESETS
};
const int NUM_PRESETS = sizeof(presetPatterns) / sizeof(presetPatterns[0]);
// ── CUSTOM SLOTS (legacy build-service path) ─────────────────────────
// Empty here by design, and not meant to be edited by hand — uploading a .pfm
// at /patterns is how a pattern gets on a device now.
//
// The web build service (web/src/lib/firmware/assemble.ts) rewrites everything
// between the two markers below to compile a submitted pattern into
// firmware.bin. That is the path for devices whose firmware predates the module
// loader: they cannot load a .pfm, so they still need a whole image. Keeping it
// alive costs these few lines and means a bug in the module path never leaves
// the community with no way to build at all.
//
// Leave the markers in place even when the region is empty. Removing them
// breaks "Send to build".
// PF_CUSTOM_SLOTS_BEGIN
#define PF_CUSTOM_SLOT_COUNT 0
// PF_CUSTOM_SLOTS_END
// ── MODULES (.pfm on FATFS) ──────────────────────────────────────────
// Names and paths need RAM because they come off the filesystem, unlike the
// preset entries which point straight at flash literals.
//
// The cap is a UX decision, not a memory one. A slot costs 136 bytes of PSRAM
// (PatternEntry 24 + name 40 + path 72) and the arrays are allocated at full
// capacity on boot whether or not the modules exist, so installing patterns is
// free at runtime: 128 slots is 17 KB out of 8 MB, and the /patterns partition
// holds ~1,500 modules at the measured median of 5.9 KB.
//
// What actually degrades first is scrolling the list on one knob (34 presets
// plus whatever is installed) and, further out, the per-module sidecar read in
// scanModules() at boot. 128 is roughly three times the whole community
// library as it stands, which is meant to be enough that nobody meets this
// number — if somebody does, raise it and fix the selection UI in the same
// breath.
constexpr int MAX_MODULE_PATTERNS = 128;
constexpr size_t MODULE_NAME_BYTES = 40;
constexpr size_t MODULE_PATH_BYTES = 72;
constexpr const char* MODULE_DIR = "/patterns";
// These three live in PSRAM, deliberately.
//
// Internal RAM is this board's scarce resource: HUB75's DMA buffers take ~150 KB
// of it at initDisplay(), and by the time the network services are up the stock
// firmware is down to ~15 KB. The web console needs a slice of that to send a
// response body — starve it and every endpoint answers with a status line and
// then hangs forever, which is exactly what a first cut of this file did by
// declaring these as plain statics (+7.3 KB).
//
// Nothing here is touched per-pixel or from an ISR: the pools are written at
// boot and read once per pattern switch, so PSRAM's extra latency is free, and
// there are 8 MB of it sitting idle.
char (*moduleNames)[MODULE_NAME_BYTES] = nullptr;
char (*modulePaths)[MODULE_PATH_BYTES] = nullptr;
int moduleCapacity = 0;
int numModules = 0;
bool moduleStorageMounted = false;
// Why it is not, in words; empty while it is. Set by diagnoseModuleStorage(),
// published as fsError on /api/status and returned by a Format that failed.
char moduleStorageError[96] = "";
bool moduleStorageDiagnosed = false;
// ── The sidecar cache ────────────────────────────────────────────────
//
// Rebuilding the list after an install or a delete used to open every
// module's .json twice — once for the name, once for absoluteReady. With
// forty modules that was two seconds, and the render stopped for all of it
// (3.9.1's loopSyncMaxUs is what finally put a number on it). The two facts
// a sidecar holds are kept here by module path. Everything that writes a
// module file — upload, delete, format, the library pull — forgets the entry
// it touched, so a rebuild reads only what changed. Boot still reads them
// all, once. On a board with no PSRAM there is no cache and every rebuild
// reads, as before.
char (*sidecarPaths)[MODULE_PATH_BYTES] = nullptr;
char (*sidecarNames)[MODULE_NAME_BYTES] = nullptr;
bool* sidecarAbs = nullptr;
int sidecarCount = 0;
inline int sidecarFind(const char* path) {
for (int i = 0; i < sidecarCount; i++) {
if (strcmp(sidecarPaths[i], path) == 0) return i;
}
return -1;
}
inline void sidecarForgetPath(const char* path) {
int i = sidecarFind(path);
if (i < 0) return;
int last = sidecarCount - 1;
if (i != last) {
snprintf(sidecarPaths[i], MODULE_PATH_BYTES, "%s", sidecarPaths[last]);
snprintf(sidecarNames[i], MODULE_NAME_BYTES, "%s", sidecarNames[last]);
sidecarAbs[i] = sidecarAbs[last];
}
sidecarCount = last;
}
// By slug, for the writers: they know the slug, the cache knows the path.
inline void sidecarForgetSlug(const char* slug) {
char path[MODULE_PATH_BYTES];
snprintf(path, sizeof(path), "%s/%s.pfm", MODULE_DIR, slug);
sidecarForgetPath(path);
}
inline void sidecarForgetAll() { sidecarCount = 0; }
inline void sidecarRemember(const char* path, const char* name, bool absReady) {
if (!sidecarPaths) return;
int i = sidecarFind(path);
if (i < 0) {
if (sidecarCount >= MAX_MODULE_PATTERNS) return;
i = sidecarCount++;
snprintf(sidecarPaths[i], MODULE_PATH_BYTES, "%s", path);
}
snprintf(sidecarNames[i], MODULE_NAME_BYTES, "%s", name);
sidecarAbs[i] = absReady;
}
// Modules have no compiled-in labels to show before they are loaded.
const char* const MODULE_KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"};
// Runtime list the device cycles through: presets first (pattern 1 = Origin),
// modules appended. Call buildPatternList() once in setup() before using it.
PatternEntry* patterns = nullptr;
int NUM_PATTERNS = 0;
int activePatternIdx = -1;
// Presets-only fallback for a board with no usable PSRAM. Same size the list
// was before modules existed, so the device degrades to old behaviour rather
// than to a null dereference.
PatternEntry presetsOnlyList[sizeof(presetPatterns) / sizeof(presetPatterns[0]) +
PF_CUSTOM_SLOT_COUNT];
inline void* allocPreferSpiram(size_t bytes) {
void* p = heap_caps_calloc(1, bytes, MALLOC_CAP_SPIRAM);
return p;
}
// Returns false when only the presets will fit; the caller still has a usable
// (module-less) pattern list in that case.
inline bool allocPatternStorage() {
if (patterns) return moduleCapacity > 0;
patterns = static_cast<PatternEntry*>(
allocPreferSpiram(sizeof(PatternEntry) *
(NUM_PRESETS + PF_CUSTOM_SLOT_COUNT + MAX_MODULE_PATTERNS)));
moduleNames = static_cast<char(*)[MODULE_NAME_BYTES]>(
allocPreferSpiram(MODULE_NAME_BYTES * MAX_MODULE_PATTERNS));
modulePaths = static_cast<char(*)[MODULE_PATH_BYTES]>(
allocPreferSpiram(MODULE_PATH_BYTES * MAX_MODULE_PATTERNS));
sidecarPaths = static_cast<char(*)[MODULE_PATH_BYTES]>(
allocPreferSpiram(MODULE_PATH_BYTES * MAX_MODULE_PATTERNS));
sidecarNames = static_cast<char(*)[MODULE_NAME_BYTES]>(
allocPreferSpiram(MODULE_NAME_BYTES * MAX_MODULE_PATTERNS));
sidecarAbs = static_cast<bool*>(allocPreferSpiram(MAX_MODULE_PATTERNS));
if (patterns && moduleNames && modulePaths && sidecarPaths && sidecarNames && sidecarAbs) {
moduleCapacity = MAX_MODULE_PATTERNS;
return true;
}
Serial.println("[PATTERNS] no PSRAM for the module list - presets only");
free(patterns);
free(moduleNames);
free(modulePaths);
free(sidecarPaths);
free(sidecarNames);
free(sidecarAbs);
moduleNames = nullptr;
modulePaths = nullptr;
sidecarPaths = nullptr;
sidecarNames = nullptr;
sidecarAbs = nullptr;
patterns = presetsOnlyList;
moduleCapacity = 0;
return false;
}
// "Cell Ripple" from "cell_ripple" — the fallback when a module ships without
// a .json sidecar carrying its real display name.
inline void displayNameFromSlug(const char* slug, char* out, size_t outSize) {
size_t n = 0;
bool upper = true;
while (*slug && n + 1 < outSize) {
char c = *slug++;
if (c == '_' || c == '-') {
out[n++] = ' ';
upper = true;
} else {
out[n++] = (upper && c >= 'a' && c <= 'z') ? c - ('a' - 'A') : c;
upper = false;
}
}
out[n] = '\0';
}
// Deliberately a substring scan rather than a JSON parser: the sidecar is our
// own generated file. One open for both facts it holds: the display name
// (left as it was when the sidecar has none) and whether the module was
// built against the absolute-param helpers — a missing sidecar or a missing
// key both mean "no", since every module built before the bus existed is
// delta-only by definition.
inline void readSidecar(const char* modulePath, char* nameOut, size_t nameSize,
bool& absReady) {
absReady = false;
char jsonPath[MODULE_PATH_BYTES];
snprintf(jsonPath, sizeof(jsonPath), "%s", modulePath);
char* extension = strrchr(jsonPath, '.');
if (!extension) return;
snprintf(extension, sizeof(jsonPath) - (extension - jsonPath), ".json");
File metadata = FFat.open(jsonPath, FILE_READ);
if (!metadata) return;
String json = metadata.readString();
metadata.close();
int key = json.indexOf("\"name\"");
if (key >= 0) {
int colon = json.indexOf(':', key + 6);
int open = colon < 0 ? -1 : json.indexOf('"', colon + 1);
int close = open < 0 ? -1 : json.indexOf('"', open + 1);
if (open >= 0 && close > open + 1) {
snprintf(nameOut, nameSize, "%s", json.substring(open + 1, close).c_str());
}
}
key = json.indexOf("\"absoluteReady\"");
if (key >= 0) {
int colon = json.indexOf(':', key + 15);
if (colon >= 0) {
String tail = json.substring(colon + 1);
tail.trim();
absReady = tail.startsWith("true");
}
}
}
// The cached answer for a path the scan has just been over, reading the
// sidecar only if the cache has no room for it.
inline bool sidecarAbsFor(const char* modulePath) {
int i = sidecarFind(modulePath);
if (i >= 0) return sidecarAbs[i];
char name[MODULE_NAME_BYTES];
bool absReady = false;
readSidecar(modulePath, name, sizeof(name), absReady);
return absReady;
}
// One open file at a time is all this ever needs (read a module, or write an
// upload). The default of 10 buys nothing and each slot costs internal heap,
// which on this board is the scarce kind — HUB75's DMA buffers live there too.
constexpr int MODULE_FS_MAX_FILES = 2;
inline void reportHeap(const char* stage) {
Serial.printf("[MEM] %-18s internal=%u largest=%u psram=%u\n", stage,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}
// Why a mount failed, read off the volume itself. The core's line is
// "Mounting FFat partition failed! Error: -1" whatever the cause — blank,
// corrupt, or a chip that dropped the writes — because FATFS's own reason is
// logged at a level this SDK compiles out (CONFIG_LOG_MAXIMUM_LEVEL 1). So
// read the boot sector through the wear-levelling layer, the path FATFS
// reads, and say what is there.
//
// Only after a failure, when nothing holds the partition (FFat.begin's own
// cleanup unmounts it), and at most once per boot plus once after each
// Format: a wl_mount/wl_unmount pair costs one wear-levelling move, a sector
// erase, and the mount is retried on every rescan.
inline void diagnoseModuleStorage(bool afterFormat) {
const char* stuck = afterFormat ? "format did not stick: " : "";
const esp_partition_t* part = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, "ffat");
if (!part) {
moduleStorageDiagnosed = true;
snprintf(moduleStorageError, sizeof(moduleStorageError), "no ffat partition");
return;
}
// Internal RAM, so the flash driver reads straight into it — a PSRAM
// buffer would go through a bounce copy, one more thing that could lie.
// Taken before wl_mount, so a shortage costs no wear-levelling move; it is
// reported as ours rather than blamed on the chip, and the next failure
// tries again.
uint8_t* s = (uint8_t*)heap_caps_malloc(512, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (!s) {
snprintf(moduleStorageError, sizeof(moduleStorageError),
"not mounted (no internal RAM left to check why)");
return;
}
moduleStorageDiagnosed = true;
wl_handle_t wl = WL_INVALID_HANDLE;
esp_err_t err = wl_mount(part, &wl);
if (err != ESP_OK) {
snprintf(moduleStorageError, sizeof(moduleStorageError),
"%swear levelling will not mount (0x%x)", stuck, (unsigned)err);
free(s);
return;
}
err = wl_read(wl, 0, s, 512);
wl_unmount(wl);
if (err != ESP_OK) {
snprintf(moduleStorageError, sizeof(moduleStorageError),
"%sboot sector unreadable (0x%x)", stuck, (unsigned)err);
} else {
bool blank = true;
for (int i = 0; i < 512 && blank; ++i) blank = s[i] == 0xFF;
// The test FATFS's check_fs() makes (R0.13c, this SDK's): the signature,
// then a jump opcode, then "FAT" or "FAT32" in the type field.
const bool fat = s[510] == 0x55 && s[511] == 0xAA &&
(s[0] == 0xEB || s[0] == 0xE9 || s[0] == 0xE8) &&
(memcmp(s + 54, "FAT", 3) == 0 || memcmp(s + 82, "FAT32", 5) == 0);
if (blank) {
snprintf(moduleStorageError, sizeof(moduleStorageError), "%s",
afterFormat ? "format did not stick: storage reads back blank"
: "not formatted");
} else if (fat) {
snprintf(moduleStorageError, sizeof(moduleStorageError),
"boot sector looks valid but does not mount");
} else {
snprintf(moduleStorageError, sizeof(moduleStorageError),
"%sno filesystem (boot sector %02x %02x %02x .. %02x %02x)", stuck,
s[0], s[1], s[2], s[510], s[511]);
}
}
free(s);
}
// Mount the partition the presets never needed. Label "ffat" is what the
// shipped partition table calls it; passing the wrong label mounts nothing and
// looks exactly like an empty filesystem.
//
// Never formats. A volume that will not mount stays unmounted until someone
// presses Format on /patterns — see formatModuleStorage for why the automatic
// fallback went (3.2.0). So a freshly erased board boots with the failure on
// serial, and "not formatted" as the reason.
inline bool mountModuleStorage(bool afterFormat = false) {
if (moduleStorageMounted) return true;
moduleStorageMounted = FFat.begin(false, "/ffat", MODULE_FS_MAX_FILES, "ffat");
if (moduleStorageMounted) {
moduleStorageError[0] = '\0';
// Diagnosed once is not diagnosed for good: that reason described a
// volume that has since mounted. A later failure gets a fresh look, or
// fsError would be the empty string this success leaves behind.
moduleStorageDiagnosed = false;
return true;
}
if (afterFormat || !moduleStorageDiagnosed) diagnoseModuleStorage(afterFormat);
Serial.printf("[PATTERNS] FATFS not mounted (%s) - presets only; "
"format from /patterns if this persists\n", moduleStorageError);
return false;
}
// The deliberate, user-initiated format. Destroys everything on the volume —
// modules AND .pfv clips — which is exactly why it only runs from an explicit
// button on /patterns and never as a fallback. An earlier revision formatted
// automatically when a mount failed; a crash mid-write corrupted the FAT, the
// next boot "helpfully" wiped it, and every installed module was lost.
inline bool formatModuleStorage() {
sidecarForgetAll();
FFat.end();
moduleStorageMounted = false;
bool ok = FFat.format(true, (char*)"ffat");
Serial.printf("[PATTERNS] format %s\n", ok ? "OK" : "FAILED");
if (!ok) {
snprintf(moduleStorageError, sizeof(moduleStorageError), "format failed");
return false;
}
// "OK" only means nothing returned an error. FFat.format() never reads back
// what it wrote, and the flash driver never checks that a program landed —
// on a chip that drops writes, every step reports success. This mount is
// the first read, so it decides whether the Format worked.
return mountModuleStorage(true);
}
// ── Running order (/patterns/catalog.txt) ────────────────────────────
// One module slug per line, in the order the device should cycle them; a
// missing file or an unlisted module falls back to the alphabetical sort
// below. This is how a deck keeps its arrangement: the deck export writes
// the file into the pack, the /patterns page writes it on drag-reorder, and
// either way the order is data on FATFS rather than a property of filenames.
//
// Listed modules come first, in file order; unlisted ones keep their
// alphabetical order after them — a pack installed on top of an existing
// library must not scramble what was already arranged.
inline void applyCatalogOrder() {
if (numModules < 2) return;
char catalogPath[MODULE_PATH_BYTES];
snprintf(catalogPath, sizeof(catalogPath), "%s/catalog.txt", MODULE_DIR);
File catalog = FFat.open(catalogPath, FILE_READ);
if (!catalog) return;
int placed = 0; // modules already moved into their catalog position
while (catalog.available() && placed < numModules) {
String line = catalog.readStringUntil('\n');
line.trim();
if (line.length() == 0 || line.startsWith("#")) continue;
for (int i = placed; i < numModules; i++) {
const char* filename = strrchr(modulePaths[i], '/');
filename = filename ? filename + 1 : modulePaths[i];
size_t stem = strlen(filename);
const char* dot = strrchr(filename, '.');
if (dot) stem = (size_t)(dot - filename);
if (line.length() != (int)stem || strncmp(filename, line.c_str(), stem) != 0) {
continue;
}
// Rotate [placed..i] one step right so i lands at `placed` and the
// slots between keep their relative order.
char name[MODULE_NAME_BYTES], path[MODULE_PATH_BYTES];
snprintf(name, sizeof(name), "%s", moduleNames[i]);
snprintf(path, sizeof(path), "%s", modulePaths[i]);
for (int j = i; j > placed; j--) {
snprintf(moduleNames[j], MODULE_NAME_BYTES, "%s", moduleNames[j - 1]);
snprintf(modulePaths[j], MODULE_PATH_BYTES, "%s", modulePaths[j - 1]);
}
snprintf(moduleNames[placed], MODULE_NAME_BYTES, "%s", name);
snprintf(modulePaths[placed], MODULE_PATH_BYTES, "%s", path);
placed++;
break;
}
}
catalog.close();
}
inline void scanModules() {
numModules = 0;
if (moduleCapacity == 0) return;
if (!mountModuleStorage()) return;
// readdir, not File::openNextFile. The Arduino iterator opens every entry
// it hands back in order to build a File, and on this FATFS that is ~10 ms
// a file - with forty modules and their sidecars, most of what a rebuild
// still cost once the sidecars were cached. A directory entry's name is
// all the scan needs.
char dirPath[MODULE_PATH_BYTES];
snprintf(dirPath, sizeof(dirPath), "/ffat%s", MODULE_DIR);
DIR* directory = opendir(dirPath);
if (!directory) {
Serial.printf("[PATTERNS] no %s directory - presets only\n", MODULE_DIR);
return;
}
for (struct dirent* entry = readdir(directory); entry; entry = readdir(directory)) {
if (numModules >= moduleCapacity) break;
if (entry->d_type == DT_DIR) continue;
const char* name = entry->d_name;
const size_t n = strlen(name);
if (n < 5 || strcmp(name + n - 4, ".pfm") != 0) continue;
const int slot = numModules;
snprintf(modulePaths[slot], MODULE_PATH_BYTES, "%s/%s", MODULE_DIR, name);
char slug[MODULE_NAME_BYTES];
const char* filename = strrchr(modulePaths[slot], '/');
snprintf(slug, sizeof(slug), "%s", filename ? filename + 1 : modulePaths[slot]);
char* extension = strrchr(slug, '.');
if (extension) *extension = '\0';
displayNameFromSlug(slug, moduleNames[slot], MODULE_NAME_BYTES);
int cached = sidecarFind(modulePaths[slot]);
if (cached >= 0) {
snprintf(moduleNames[slot], MODULE_NAME_BYTES, "%s", sidecarNames[cached]);
} else {
bool absReady = false;
readSidecar(modulePaths[slot], moduleNames[slot], MODULE_NAME_BYTES, absReady);
sidecarRemember(modulePaths[slot], moduleNames[slot], absReady);
}
numModules++;
}
closedir(directory);
// FAT hands back directory entries in whatever order it likes, and the index
// is what OSC addresses and the knob position mean — sort so a pattern keeps
// its number across reboots.
for (int i = 1; i < numModules; i++) {
char name[MODULE_NAME_BYTES], path[MODULE_PATH_BYTES];
snprintf(name, sizeof(name), "%s", moduleNames[i]);
snprintf(path, sizeof(path), "%s", modulePaths[i]);
int j = i - 1;
while (j >= 0 && strcmp(modulePaths[j], path) > 0) {
snprintf(moduleNames[j + 1], MODULE_NAME_BYTES, "%s", moduleNames[j]);
snprintf(modulePaths[j + 1], MODULE_PATH_BYTES, "%s", modulePaths[j]);
j--;
}
snprintf(moduleNames[j + 1], MODULE_NAME_BYTES, "%s", name);
snprintf(modulePaths[j + 1], MODULE_PATH_BYTES, "%s", path);
}
applyCatalogOrder();
Serial.printf("[PATTERNS] %d module(s) on FATFS\n", numModules);
}
inline void buildPatternList() {
allocPatternStorage();
NUM_PATTERNS = 0;
for (int i = 0; i < NUM_PRESETS; i++) patterns[NUM_PATTERNS++] = presetPatterns[i];
#if PF_CUSTOM_SLOT_COUNT > 0
// Present only in a build-service image; see the CUSTOM SLOTS note above.
for (int i = 0; i < PF_CUSTOM_SLOT_COUNT; i++) patterns[NUM_PATTERNS++] = customPatterns[i];
#endif
scanModules();
for (int i = 0; i < numModules; i++) {
patterns[NUM_PATTERNS++] = {
moduleNames[i], MODULE_KNOB_LABELS, nullptr, nullptr, nullptr, modulePaths[i],
false, // hidden - an installed pattern is always browsable
sidecarAbsFor(modulePaths[i]),
};
}
}
// Make `index` the running pattern. Presets were already set up at boot, so
// this only costs anything for a module: read the .pfm, relocate it, run its
// setup(). Returns false if a module failed to load, leaving nothing active.
// When the running pattern started running. The sketch's thumbnail capture
// wants a pattern to have been on the panel for a few seconds before its
// frame is worth keeping - a module's first frames are often its setup.
inline uint32_t activatedAtMs = 0;
// Storage mutations hold activation; selections retain identity across rescan.
inline bool patternLoadsHeld = false;
inline bool heldSelectionValid = false;
inline char heldPatternPath[MODULE_PATH_BYTES] = {};
inline int heldPresetIdx = -1;
inline void rememberHeldSelection(int index) {
heldSelectionValid = true;
heldPatternPath[0] = '\0';
heldPresetIdx = -1;
if (patterns[index].modulePath)
snprintf(heldPatternPath, sizeof(heldPatternPath), "%s", patterns[index].modulePath);
else heldPresetIdx = index;
}
inline bool activatePattern(int index) {
if (index < 0 || index >= NUM_PATTERNS) return false;
if (index == activePatternIdx) return true;
const PatternEntry& entry = patterns[index];
if (!entry.modulePath) {
// Hand the module's executable RAM back before running a preset.
if (PFModuleLoader::active) PFModuleLoader::unload();
activePatternIdx = index;
activatedAtMs = millis();
return true;
}
if (!PFModuleLoader::load(FFat, entry.modulePath)) {
Serial.printf("[PATTERNS] %s failed: %s\n", entry.modulePath, PFModuleLoader::error());
activePatternIdx = -1;
return false;
}
// The loaded descriptor is authoritative; the sidecar name was only a guess
// for the selection list.
// Module slot, not pattern index: the list is presets, then any build-service
// custom slots, THEN modules. Subtracting only NUM_PRESETS wrote the loaded
// name into the wrong slot on an image that has custom slots filled — which
// is exactly how a pattern ends up displaying somebody else's name.
const int moduleSlot = index - (NUM_PRESETS + PF_CUSTOM_SLOT_COUNT);
if (moduleSlot >= 0 && moduleSlot < numModules) {
snprintf(moduleNames[moduleSlot], MODULE_NAME_BYTES, "%s", PFModuleLoader::active->name);
}
activePatternIdx = index;
activatedAtMs = millis();
return true;
}
// ── Loading without stopping the frame ───────────────────────────────
// activatePattern() above holds the loop for as long as the module's
// setup() takes, and that is the pattern's own precomputation: 10 ms for a
// light one, 2.3 s for "Branched flow", 2.9 s for "Two-stream". Held on the
// loop task that is the panel standing still and the knob going dead - and
// a person who keeps turning meanwhile finds every detent applied at once
// when the loop comes back. Thumbnails hid the stillness; this removes it.
//
// The read, relocate and setup() run on a task of their own on Core 0
// (the network core, which has the room: the render never leaves Core 1).
// The loop task does the two things that touch what a frame is using -
// unloading the outgoing module before the task starts, and adopting the
// incoming one after it finishes - so nothing draws a module that is
// halfway in or out. Between the two the loop draws the incoming pattern's
// thumbnail. One load at a time; a request that arrives while one is in
// flight is remembered, and the LATEST such request is what loads next -
// a knob that moved on through five patterns loads the one it rests on.
//
// What the task may do concurrently with the frame: allocate (the heap is
// locked), read the volume (so is the VFS), write the canvas (a torn
// thumbnail for one frame, and setup() rarely draws). What it may not do
// is present() - core_canvas.h refuses that from any task but the loop.
// Handlers that evict or rebuild the list (uploads, deletes, format) run on
// the loop task through PFLoopSync and retry until tryFinishAsyncLoad() succeeds.
//
// Boot restore keeps the synchronous path: nothing is drawing yet, and a
// module that will not load must fail before loop() starts, where the
// boot latch expects it to.
// This stack and the incoming module's .text contend for the same internal RAM
// in the same window, so every byte over-reserved here is a byte a pattern
// cannot have. 12288 was a guess ("a margin over" the loop's 8 KB) and the
// instrumentation says it was 4 KB too generous: across Branched flow,
// Two-stream, 2D Burgers, ttt and Wave Cascade the high-water use is a flat
// 2,980 B. 8192 is both ~2.7x that and the size this same read + relocate +
// setup() work demonstrably ran on before the async loader existed. The
// high-water is published as moduleMemory.loaderStackMin; raise this if it ever
// falls below ~1024.
constexpr uint32_t LOAD_TASK_STACK = 8192;
inline volatile bool loadInFlight = false; // a task is loading loadTargetIdx
inline volatile bool loadFinished = false; // ...and has finished; the loop adopts the result
inline volatile bool loadResult = false;
inline int loadTargetIdx = -1;
inline int loadQueuedIdx = -1; // asked for while busy; latest wins
inline TaskHandle_t patternLoaderWorker = nullptr;
inline uint32_t patternLoaderStackMin = 0;
inline uint32_t patternLoaderRetries = 0;
inline void loadPatternJob() {
// The worker outlives any one load, so a stray notification must never index
// patterns[-1] on the way to dereferencing a module path.
if (loadTargetIdx < 0 || loadTargetIdx >= NUM_PATTERNS) {
loadResult = false;
__atomic_store_n(&loadFinished, true, __ATOMIC_RELEASE);
return;
}
uint32_t refusals = PFModuleMemory::refusals;
bool ok = PFModuleLoader::load(FFat, patterns[loadTargetIdx].modulePath);
// HTTP/UDP buffers can briefly consume the admission headroom. Retry
// only an allocation failure, on this worker, with a finite 1.5 s backoff.
// Corrupt ELF / unresolved symbols fail immediately; a successful setup
// is never run twice. Failed loads have released their partial sections.
constexpr uint32_t pauses[] = {50, 100, 200, 400, 750};
for (uint32_t pause : pauses) {
if (ok || PFModuleMemory::refusals == refusals) break;
++patternLoaderRetries;
vTaskDelay(pdMS_TO_TICKS(pause));
refusals = PFModuleMemory::refusals;
ok = PFModuleLoader::load(FFat, patterns[loadTargetIdx].modulePath);
}
patternLoaderStackMin = uxTaskGetStackHighWaterMark(nullptr);
loadResult = ok;
__atomic_store_n(&loadFinished, true, __ATOMIC_RELEASE);
}
inline void loaderTask(void*) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
loadPatternJob();
}
}
// Reserve once during boot, before modules fragment the internal heap.
// Reuse the same stack for every selection; no extra concurrent loader.
inline bool beginPatternLoader() {
if (patternLoaderWorker) return true;
return xTaskCreatePinnedToCore(loaderTask, "pf-load", LOAD_TASK_STACK, nullptr, 1,
&patternLoaderWorker, 0) == pdPASS;
}
// Loop task: the result of a finished load becomes the running pattern.
inline void finishAsyncLoad() {
const int index = loadTargetIdx;
if (loadResult) {
const int moduleSlot = index - (NUM_PRESETS + PF_CUSTOM_SLOT_COUNT);
if (moduleSlot >= 0 && moduleSlot < numModules && PFModuleLoader::active) {
snprintf(moduleNames[moduleSlot], MODULE_NAME_BYTES, "%s", PFModuleLoader::active->name);
}
activePatternIdx = index;
activatedAtMs = millis();
} else {
Serial.printf("[PATTERNS] %s failed: %s\n", patterns[index].modulePath, PFModuleLoader::error());
activePatternIdx = -1;
}
loadTargetIdx = -1;
loadFinished = false;
__atomic_store_n(&loadInFlight, false, __ATOMIC_RELEASE);
}
// Make `index` the running pattern without holding the frame. Presets are
// resident and switch at once; a module switches when its task is done.
// Task creation failure preserves the old module and returns false.
inline bool activatePatternAsync(int index) {
if (index < 0 || index >= NUM_PATTERNS) return false;
if (patternLoadsHeld) {
rememberHeldSelection(index);
return true;
}
if (loadInFlight) {
loadQueuedIdx = index;
return true;
}
if (index == activePatternIdx) return true;
const PatternEntry& entry = patterns[index];
if (!entry.modulePath) return activatePattern(index);
if (!beginPatternLoader()) {
return PFModuleLoader::fail("not enough RAM for loader task");
}
// The outgoing module leaves here, on the loop task, while nothing is
// drawing it. From this frame on the loop draws the thumbnail.
if (PFModuleLoader::active) PFModuleLoader::unload();
activePatternIdx = -1;
loadTargetIdx = index;
loadResult = false;
loadFinished = false;
__atomic_store_n(&loadInFlight, true, __ATOMIC_RELEASE);
xTaskNotifyGive(patternLoaderWorker);
return true;
}
// From loop(), every frame, before anything looks at the running pattern.
inline void serviceAsyncLoad() {
if (!loadInFlight || !__atomic_load_n(&loadFinished, __ATOMIC_ACQUIRE)) return;
finishAsyncLoad();
if (loadQueuedIdx >= 0) {
const int next = loadQueuedIdx;
loadQueuedIdx = -1;
if (next != activePatternIdx) activatePatternAsync(next);
}
}
// A short frame-boundary attempt: leave unfinished setup to its worker.
// Mutations get first refusal before serviceAsyncLoad starts a queued load.
inline bool tryFinishAsyncLoad() {
if (!loadInFlight) return true;
if (!__atomic_load_n(&loadFinished, __ATOMIC_ACQUIRE)) return false;
finishAsyncLoad();
return true;
}
// ── Naming a pattern from outside the list ───────────────────────────
// Two callers need to talk about a pattern by something other than its index:
// MQTT (which addresses patterns by name or slug on the wire) and the sketch's
// pattern persistence (which writes a slug to NVS, because an index means a
// different pattern the moment a module is installed or deleted). These used
// to live in core_mqtt.h, which made persistence depend on MQTT being compiled
// in — they are registry knowledge, so they live with the registry.
// "/patterns/cell_ripple.pfm" -> "cell_ripple". Empty for a preset.
inline void slugFromModulePath(const char* path, char* out, size_t n) {
if (!path || !path[0]) { out[0] = '\0'; return; }
const char* filename = strrchr(path, '/');
snprintf(out, n, "%s", filename ? filename + 1 : path);
char* extension = strrchr(out, '.');
if (extension) *extension = '\0';
}
// A module's slug, or a slugified display name for a preset (which has no file
// to take one from).
inline void patternSlugAt(int index, char* out, size_t n) {
out[0] = '\0';
if (index < 0 || index >= NUM_PATTERNS || !patterns) return;
slugFromModulePath(patterns[index].modulePath, out, n);
if (out[0]) return;
const char* name = patterns[index].name ? patterns[index].name : "";
size_t j = 0;
for (size_t i = 0; name[i] && j + 1 < n; ++i) {
char c = name[i];
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
if (c == ' ' || c == '-') c = '_';
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
out[j++] = c;
}
}
out[j] = '\0';
}
// Exact display name, then case-insensitive display name, then slug. The order
// matters: a pattern whose name happens to match another's slug should still
// resolve to itself.
inline int findPatternByName(const char* name) {
if (!name || !name[0] || !patterns) return -1;
for (int i = 0; i < NUM_PATTERNS; ++i) {
if (patterns[i].name && strcmp(patterns[i].name, name) == 0) return i;
}
for (int i = 0; i < NUM_PATTERNS; ++i) {
if (patterns[i].name && strcasecmp(patterns[i].name, name) == 0) return i;
}
char slug[MODULE_NAME_BYTES];
for (int i = 0; i < NUM_PATTERNS; ++i) {
slugFromModulePath(patterns[i].modulePath, slug, sizeof(slug));
if (slug[0] && strcasecmp(slug, name) == 0) return i;
}
return -1;
}
inline void updateActivePattern(float dt, const InputFrame& input) {
if (activePatternIdx < 0) return;
const PatternEntry& entry = patterns[activePatternIdx];
if (entry.modulePath) PFModuleLoader::update(dt, input);
else if (entry.update) entry.update(dt, input);
}
inline void drawActivePattern() {
if (activePatternIdx < 0) return;
const PatternEntry& entry = patterns[activePatternIdx];
if (entry.modulePath) PFModuleLoader::draw();
else if (entry.draw) entry.draw();
}
#undef PATTERN_ENTRY