From 48cdbae184f20cb5c1bb8de6a3868d536f673dce Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 1 Aug 2026 20:43:15 -0700 Subject: [PATCH 01/16] fix(tpager): persist the SD map-tile source Signed-off-by: Pixel Perfect --- src/helpers/esp32/TouchPrefsStore.h | 3 +- src/ui-touch/UITask.cpp | 124 +++++++++++++++++++--------- 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index 1684d04..06dee71 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -103,8 +103,7 @@ bool touchPrefsGetUseMiles(); bool touchPrefsSetUseMiles(bool use_miles); /** Map tile source: false = tile server + on-device cache (default), true = read tiles off the - * microSD card (/tiles///.jpg). Persistent on T-Deck; the Pager currently resets - * this runtime choice to the server/cache mode at boot. */ + * microSD card (/tiles///.jpg). Supported and persisted on T-Deck and T-Pager. */ bool touchPrefsGetTilesFromSd(); bool touchPrefsSetTilesFromSd(bool from_sd); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 031540a..22b855f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -23910,6 +23910,13 @@ static inline void tileCacheMkdir(const char* rel) { char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); s_tile_fs->mkdir(p); } +static inline bool tileBackendIsSd() { +#if CAP_SD || defined(TLORA_PAGER) + return s_tile_fs == &SD; +#else + return false; +#endif +} // True when the LittleFS tile cache is nearly full. A FULL/fragmented LittleFS // FAULTS inside lfs_alloc during mkdir (coredump 2026-06-14: reboot while a @@ -25587,15 +25594,13 @@ static bool ensureHistFlushTaskRunning() { // tile was queued recently. Called from renderMapTiles after a SPIFFS // miss; the actual fetch happens off-thread. static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { - // microSD-tile mode: fetch missing tiles only when the cache lives ON the card - // (s_tile_fs == &SD) — then downloads merge into the SD library (#20). Without an - // SD-backed cache, stay read-only/offline as before. (WiFi-down guard below still - // keeps it fully offline when there's no link.) This applies to the T-Deck and Pager; - // boards without SD keep s_tiles_from_sd false, so the simple guard is equivalent. + // In microSD-tile mode, missing tiles normally merge into the SD library + // (#20). If that card is absent but a mounted internal cache is available, + // keep maps online through that live fallback until the card returns. // The SD-pack offline mode is OSM-only; topo has no SD packs, so it always // fetches from the proxy regardless of the microSD-tiles setting. #if CAP_SD || defined(TLORA_PAGER) - if (s_map_style == 0 && s_tiles_from_sd && s_tile_fs != &SD) return; + if (!s_tiles_fs_ready || !s_tile_fs) return; #else if (s_map_style == 0 && s_tiles_from_sd) return; #endif @@ -25739,7 +25744,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, (long)x, (long)y); #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache + if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache // Tile source = microSD: read straight off the card (fully offline, no server fetch). if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates) // Mounted check only — a render loop must never walk the multi-second @@ -25815,7 +25820,9 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, // cache so the render miss re-queues a fresh fetch; never touch read-only SD packs (can't re-fetch). if (n < 3 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF) { lvglPsramFree(buf); - if (!s_tiles_from_sd) tileCacheRemove(path); + // Never mutate a user's offline SD pack. An internal-cache fallback remains + // writable even while the saved preference still requests SD tiles. + if (!(s_tiles_from_sd && tileBackendIsSd())) tileCacheRemove(path); return false; } *out_data = buf; @@ -25846,28 +25853,36 @@ static void freeMapTiles() { // outage are retried instead of being remembered as in-flight forever // * re-renders immediately when the map is the visible tab static void mapNoteStorageChanged() { -#if defined(TLORA_PAGER) +#if CAP_SD || defined(TLORA_PAGER) if (!s_sd_mounted && s_tile_fs == &SD) { - s_tile_fs = nullptr; - s_tile_root[0] = '\0'; - if (s_tile_fs_default == &SD) { + if (s_tile_fs_default && s_tile_fs_default != &SD) { + // SD tile mode lost its card, but the dedicated LittleFS cache is still + // mounted. Keep maps online instead of disabling that healthy fallback. + s_tile_fs = s_tile_fs_default; + strlcpy(s_tile_root, s_tile_root_default, sizeof(s_tile_root)); + s_tiles_fs_ready = true; + } else { + // Launcher-style partition table: SD was the only tile backend. + s_tile_fs = nullptr; + s_tile_root[0] = '\0'; s_tile_fs_default = nullptr; s_tile_root_default[0] = '\0'; + s_tiles_fs_ready = false; } - s_tiles_fs_ready = false; - } else if (s_sd_mounted && !s_tiles_fs_ready) { + } else if (s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready)) { + // A fetch snapshots paths but still dereferences the global backend for + // each filesystem operation. Do not repoint it mid-request; sdHealthTick + // retries this transition as soon as the queue and worker are idle. + if (s_tile_fs != &SD && tileFetchPendingLoad() > 0) return; + // Reinserted while SD mode is selected, or no internal backend exists. + // Preserve an existing LittleFS default so toggling SD mode off remains a + // valid fallback after any number of remove/reinsert cycles. s_tile_fs = &SD; s_tile_root[0] = '\0'; - s_tile_fs_default = &SD; - s_tile_root_default[0] = '\0'; - s_tiles_fs_ready = true; - } -#elif CAP_SD - if (s_sd_mounted && !s_tiles_fs_ready) { - // No tile backend was resolved at boot; the card can serve as one now - // (same layout the boot fallback uses: SD ROOT /tiles///). - s_tile_fs = &SD; - s_tile_root[0] = '\0'; + if (!s_tile_fs_default) { + s_tile_fs_default = &SD; + s_tile_root_default[0] = '\0'; + } s_tiles_fs_ready = true; } #endif @@ -25881,7 +25896,7 @@ static void mapNoteStorageChanged() { // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd) return true; + if (s_tiles_from_sd && s_tile_fs == &SD) return s_sd_mounted; #endif return s_tiles_fs_ready; } @@ -25892,7 +25907,7 @@ static bool mapTileSourceReady() { // drew its tiles but the buttons reported "Max/Min zoom for this pack" offline. static bool tileExistsAt(uint8_t z, long x, long y) { #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below + if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates) if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg) char p[56]; @@ -26218,7 +26233,7 @@ static void renderMapTiles() { #if defined(ESP32) #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd) { + if (s_tiles_from_sd && s_tile_fs == &SD) { lv_label_set_text(s_map_status_lbl, TR("Map tiles: microSD\n\n" "/maps/osm/z/x/y.png\n" @@ -27029,7 +27044,15 @@ static void mapOptTilesSdCb(lv_event_t* e) { // the SD reader looks), so fetched tiles merge with the library; OFF -> the boot // default backend (LittleFS partition / SD fallback). if (on) { - if (fmSdTryMount() || SD.cardType() != CARD_NONE) { s_tile_fs = &SD; s_tile_root[0] = '\0'; } + if (fmSdTryMount() || SD.cardType() != CARD_NONE) { + s_tile_fs = &SD; + s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + if (!s_tile_fs_default) { + s_tile_fs_default = &SD; + s_tile_root_default[0] = '\0'; + } + } } else { s_tile_fs = s_tile_fs_default; strncpy(s_tile_root, s_tile_root_default, sizeof s_tile_root - 1); @@ -27057,6 +27080,8 @@ static void mapReloadVisibleTiles() { // Drop the in-RAM tile widgets so the next render re-reads from disk (and // misses, since we delete the files below) → re-queues the download. freeMapTiles(); + const bool read_only_sd_pack = + s_map_style == 0 && s_tiles_from_sd && tileBackendIsSd(); int n = 0; // Purge the on-disk cache for the visible band (current zoom ± 1) so a corrupt // tile is ALWAYS removed — even offline. This deletion used to sit behind the @@ -27073,8 +27098,12 @@ static void mapReloadVisibleTiles() { for (int dx = -s_map_grid_rx; dx <= s_map_grid_rx; ++dx) { const int32_t tx = zctx + dx, ty = zcty + dy; char path[48]; - snprintf(path, sizeof(path), "/tiles/%u/%ld/%ld.jpg", (unsigned)z, (long)tx, (long)ty); - if (s_tiles_fs_ready && tileCacheExists(path)) { tileCacheRemove(path); ++n; } + snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", + mapTileRoot(), (unsigned)z, (long)tx, (long)ty); + if (s_tiles_fs_ready && !read_only_sd_pack && tileCacheExists(path)) { + tileCacheRemove(path); + ++n; + } const uint32_t k = tileFetchDedupKey((uint8_t)z, tx, ty); // clear dedup so it re-queues for (int i = 0; i < k_tile_fetch_dedup_size; ++i) if (s_tile_fetch_dedup[i] == k) s_tile_fetch_dedup[i] = 0; @@ -27082,7 +27111,9 @@ static void mapReloadVisibleTiles() { } } if (g_lv.task) { - if (WiFi.status() == WL_CONNECTED) { + if (read_only_sd_pack) { + g_lv.task->showAlert(TR("SD pack tiles are read-only"), 1800); + } else if (WiFi.status() == WL_CONNECTED) { char msg[44]; snprintf(msg, sizeof(msg), "Reloading %d tiles\xE2\x80\xA6", n); g_lv.task->showAlert(msg, 1600); } else { @@ -46026,14 +46057,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (_sensors && _node_prefs && _node_prefs->gps_enabled) { _sensors->setSettingValue("gps", "1"); } -#if defined(TLORA_PAGER) - // Start the Pager in server/cache mode on every boot. Its runtime SD-tile - // switch is intentionally not persistent yet, so a card moved from a T-Deck - // cannot silently disable all online fetches. - s_tiles_from_sd = false; -#else + // Map options exposes the same source toggle on T-Deck and T-Pager. Restore + // that choice on both boards; forcing it off on Pager made a selected SD pack + // silently revert to the internal/server cache after every reboot. s_tiles_from_sd = touchPrefsGetTilesFromSd(); // map tile source: server (default) vs microSD -#endif s_map_night = touchPrefsGetMapNight(); // map night mode (render-time tile invert) s_map_show_coords = touchPrefsGetMapShowCoords(); // per-element map text/marker visibility s_map_show_tilexyz = touchPrefsGetMapShowTileXYZ(); @@ -46228,6 +46255,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (s_tiles_from_sd && (sdAdoptLiveMount() || fmSdTryMount())) { s_tile_fs = &SD; s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + if (!s_tile_fs_default) { + s_tile_fs_default = &SD; + s_tile_root_default[0] = '\0'; + } WIRE_DBG("[TILE] microSD-tile mode -> caching Wi-Fi tiles on SD /tiles (merges with library)"); } #endif @@ -48220,8 +48252,10 @@ static bool sdRuntimeLifecycleBusy() { s_sdinfo_busy || touchPrefsIoBusy(); #if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) - busy = busy || s_notify_playing || - (s_tile_fs == &SD && tileFetchPendingLoad() > 0); + busy = busy || s_notify_playing; +#endif +#if CAP_SD || defined(TLORA_PAGER) + busy = busy || (s_tile_fs == &SD && tileFetchPendingLoad() > 0); #endif return busy; } @@ -48333,6 +48367,15 @@ static void sdHealthTick() { return; } if (s_sd_format_pending) return; // format owns the card right now +#if CAP_SD || defined(TLORA_PAGER) + // Reinsertion may have completed while an internal-cache fetch was still + // active. mapNoteStorageChanged deliberately deferred the backend swap; make + // it effective at the first idle tick without invalidating that open File. + if (s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready) && + s_tile_fs != &SD && tileFetchPendingLoad() == 0) { + mapNoteStorageChanged(); + } +#endif #if defined(TLORA_PAGER) // Card detect is pure I2C, so it must keep running even while an SD worker is // busy. Once removal is confirmed, stop new SD tile jobs immediately; the @@ -49417,6 +49460,7 @@ void UITask::loop() { showAlert(done, 3500); } else { s_sd_mounted = false; + mapNoteStorageChanged(); // drop a stale SD tile backend after the failed teardown/remount showAlert(TR("Format failed (no card / SD error)"), 3500); } if (s_fm_list) fmShowRoots(); From e921c37fff3d75b43c895d40232a31e7efe28a31 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 1 Aug 2026 21:20:48 -0700 Subject: [PATCH 02/16] fix(map): keep writable SD cache repairable Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 22b855f..0e676d0 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -23910,14 +23910,6 @@ static inline void tileCacheMkdir(const char* rel) { char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); s_tile_fs->mkdir(p); } -static inline bool tileBackendIsSd() { -#if CAP_SD || defined(TLORA_PAGER) - return s_tile_fs == &SD; -#else - return false; -#endif -} - // True when the LittleFS tile cache is nearly full. A FULL/fragmented LittleFS // FAULTS inside lfs_alloc during mkdir (coredump 2026-06-14: reboot while a // route replay re-centred the map onto an un-cached area and the fetch task @@ -25816,13 +25808,11 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, } f.close(); // Reject a cached tile whose header isn't a JPEG SOI — a garbled/partial download that slipped - // through decodes to a blank/half "twilight zone" tile otherwise. Drop it from the writable online - // cache so the render miss re-queues a fresh fetch; never touch read-only SD packs (can't re-fetch). + // through decodes to a blank/half "twilight zone" tile otherwise. This generic path is the writable + // /tiles download cache; standard read-only /maps/osm packs return from the SD-pack branch above. if (n < 3 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF) { lvglPsramFree(buf); - // Never mutate a user's offline SD pack. An internal-cache fallback remains - // writable even while the saved preference still requests SD tiles. - if (!(s_tiles_from_sd && tileBackendIsSd())) tileCacheRemove(path); + tileCacheRemove(path); return false; } *out_data = buf; @@ -27080,8 +27070,6 @@ static void mapReloadVisibleTiles() { // Drop the in-RAM tile widgets so the next render re-reads from disk (and // misses, since we delete the files below) → re-queues the download. freeMapTiles(); - const bool read_only_sd_pack = - s_map_style == 0 && s_tiles_from_sd && tileBackendIsSd(); int n = 0; // Purge the on-disk cache for the visible band (current zoom ± 1) so a corrupt // tile is ALWAYS removed — even offline. This deletion used to sit behind the @@ -27100,7 +27088,7 @@ static void mapReloadVisibleTiles() { char path[48]; snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg", mapTileRoot(), (unsigned)z, (long)tx, (long)ty); - if (s_tiles_fs_ready && !read_only_sd_pack && tileCacheExists(path)) { + if (s_tiles_fs_ready && tileCacheExists(path)) { tileCacheRemove(path); ++n; } @@ -27111,9 +27099,7 @@ static void mapReloadVisibleTiles() { } } if (g_lv.task) { - if (read_only_sd_pack) { - g_lv.task->showAlert(TR("SD pack tiles are read-only"), 1800); - } else if (WiFi.status() == WL_CONNECTED) { + if (WiFi.status() == WL_CONNECTED) { char msg[44]; snprintf(msg, sizeof(msg), "Reloading %d tiles\xE2\x80\xA6", n); g_lv.task->showAlert(msg, 1600); } else { From 73996cddbe824ab6d1baa661e88cfc97e8a05106 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 1 Aug 2026 21:59:17 -0700 Subject: [PATCH 03/16] fix(map): defer backend switch after format failure Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 0e676d0..e487cb2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -48274,6 +48274,14 @@ static void sdHealthTick() { mapNoteStorageChanged(); return; } +#endif +#if CAP_SD || defined(TLORA_PAGER) + // A teardown may have completed while the tile worker still held its final + // File. Defer dropping the stale SD backend until that request is finished; + // this also closes the failed-format path below. + if (!s_sd_mounted && s_tile_fs == &SD && tileFetchPendingLoad() == 0) { + mapNoteStorageChanged(); + } #endif if (!s_sd_mounted) { #if defined(TLORA_PAGER) @@ -49446,7 +49454,9 @@ void UITask::loop() { showAlert(done, 3500); } else { s_sd_mounted = false; - mapNoteStorageChanged(); // drop a stale SD tile backend after the failed teardown/remount + // Do not repoint the global backend under the worker's open File. The + // next idle sdHealthTick drops it after the lifecycle count reaches 0. + if (tileFetchPendingLoad() == 0) mapNoteStorageChanged(); showAlert(TR("Format failed (no card / SD error)"), 3500); } if (s_fm_list) fmShowRoots(); From 3ecb6b7677d301f3097ae732e0489eb56a68f533 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 1 Aug 2026 21:59:34 -0700 Subject: [PATCH 04/16] fix(tpager): expose the persistent map source Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index e487cb2..33f371b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -23910,6 +23910,7 @@ static inline void tileCacheMkdir(const char* rel) { char p[80]; snprintf(p, sizeof p, "%s%s", s_tile_root, rel); s_tile_fs->mkdir(p); } + // True when the LittleFS tile cache is nearly full. A FULL/fragmented LittleFS // FAULTS inside lfs_alloc during mkdir (coredump 2026-06-14: reboot while a // route replay re-centred the map onto an un-cached area and the fetch task @@ -27011,7 +27012,7 @@ static void mapOptZoomButtonsCb(lv_event_t* e) { mapZoomControlsApply(); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Map tile source toggle (in the map options popup): ON = tiles live on the microSD // card — read the user's SD library AND cache Wi-Fi-fetched gaps there (#20), so the // library grows and downloads survive; OFF = tile server + internal LittleFS cache. @@ -27222,7 +27223,7 @@ static void openMapOptions() { lv_obj_set_pos(title, 0, 0); int y = 26; -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // Row: tile source — microSD (offline) vs the tile server. The important one, // so it sits at the very top. { From 0288ebb993ef8308b96236faefe4a522160f9966 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:18:00 -0700 Subject: [PATCH 05/16] fix(map): gate both backend swaps on tile-worker quiescence mapNoteStorageChanged() guarded only the adopt direction. The teardown direction repointed s_tile_fs away from &SD with no check at all, even though that is the side that strands an open SD File: the worker holds one for the whole of a download (up to the 12 s cap) and dereferences the global backend for every filesystem operation of the request. Every current caller happens to gate, but the format-failure path checks only tileFetchPendingLoad() and the format-success path checks nothing, so the invariant was one caller away from being violated. The adopt branch's early return was also too broad. It skipped the dedup clear, freeMapTiles() and the re-render that are the whole point of the call, so swapping cards while a fetch was in flight left the previous card's tiles on screen with the dedup ring suppressing the re-fetch until sdHealthTick's retry landed. Compute may_swap once, apply it to both branches, and always fall through to the invalidation. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 33f371b..4f4c44f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25845,7 +25845,16 @@ static void freeMapTiles() { // * re-renders immediately when the map is the visible tab static void mapNoteStorageChanged() { #if CAP_SD || defined(TLORA_PAGER) - if (!s_sd_mounted && s_tile_fs == &SD) { + // A fetch snapshots its paths but still dereferences the global backend for + // every filesystem operation of the request, so the pointer may only move + // while the queue and the worker are both idle. That governs BOTH directions: + // dropping a lost card strands an open SD File exactly as surely as adopting + // a new one does. When the swap has to wait, sdHealthTick retries it at the + // first idle tick — but the invalidation below still has to run now, or a + // swapped card keeps showing the previous card's tiles with the dedup ring + // suppressing the re-fetch until that retry lands. + const bool may_swap = (tileFetchPendingLoad() == 0); + if (may_swap && !s_sd_mounted && s_tile_fs == &SD) { if (s_tile_fs_default && s_tile_fs_default != &SD) { // SD tile mode lost its card, but the dedicated LittleFS cache is still // mounted. Keep maps online instead of disabling that healthy fallback. @@ -25860,11 +25869,7 @@ static void mapNoteStorageChanged() { s_tile_root_default[0] = '\0'; s_tiles_fs_ready = false; } - } else if (s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready)) { - // A fetch snapshots paths but still dereferences the global backend for - // each filesystem operation. Do not repoint it mid-request; sdHealthTick - // retries this transition as soon as the queue and worker are idle. - if (s_tile_fs != &SD && tileFetchPendingLoad() > 0) return; + } else if (may_swap && s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready)) { // Reinserted while SD mode is selected, or no internal backend exists. // Preserve an existing LittleFS default so toggling SD mode off remains a // valid fallback after any number of remove/reinsert cycles. From 095bd96ee39a9aa7b5d15da5981b4fee065da1c9 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:18:50 -0700 Subject: [PATCH 06/16] fix(sd): never format the card under a live SD consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmSdDoFormat() armed the deferred f_mkfs with no quiescence check, so a confirm tap while the tile worker held an open File (the whole of a download, up to the 12 s cap) ran SD.end() + sdcard_init + f_mkfs underneath it. The worker's next f.write()/f.close() then acted on a de-registered pdrv and a freshly rewritten volume. The format-failure path already comments that a worker File may be open; by then it had been invalid for tens of seconds. Gate the confirm on sdRuntimeLifecycleBusy() (the same predicate the Pager factory reset uses) and re-check on the tick before f_mkfs fires, since the two ticks that let the notice paint are also two ticks in which a map repaint can queue fetches. Bounded at 15 s so a stuck consumer cancels the format instead of hanging behind the "do not power off" notice. Also stop arming the countdown on M9 / V4-R8. Those builds offer the format button but carry no executor for it (the deferred f_mkfs block is HAS_TDECK_GT911-only), so a single tap left s_sd_format_pending set for the rest of the boot and sdHealthTick returned early at its s_sd_format_pending guard forever after — silently disabling wedge detection, remount and the SD tile-backend swap. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 4f4c44f..557d6b9 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -19070,11 +19070,29 @@ static void fmHideFormatOverlay() { // Confirm callback: paint the formatting notice, then defer the (blocking) // f_mkfs to UITask::loop so the notice is on-screen before the loop freezes. static void fmSdDoFormat() { +#if defined(HAS_TDECK_GT911) + // f_mkfs rewrites the volume under every open handle, and the SD.end() that + // precedes it unregisters the pdrv those handles point at. Never start that + // while an SD consumer is live — the tile worker can own an open File for a + // whole download — so apply the same gate the Pager factory reset uses. + if (sdRuntimeLifecycleBusy()) { + if (g_lv.task) + g_lv.task->showAlert(TR("SD is busy - close tools and retry"), 2600); + return; + } fmShowBusyOverlay("Formatting SD as MESHCOMOD (FAT32)\n\n" "Creates the core folders too.\n" "Do NOT power off, disconnect,\nor remove the card.\n\n" "This can take up to a minute..."); s_sd_format_pending = 2; +#else + // Only the T-Deck build carries the deferred f_mkfs executor in UITask::loop. + // Arming the countdown anywhere else parked sdHealthTick on its + // s_sd_format_pending guard for the rest of the boot, silently disabling + // wedge detection and remount, and never actually formatted anything. + if (g_lv.task) + g_lv.task->showAlert(TR("SD format isn't available on this device"), 2600); +#endif } // Tap on an unmounted SD row: try to mount, and if the card is unreadable // (e.g. exFAT, which this build can't read — only FAT16/FAT32) offer to format. @@ -49414,7 +49432,25 @@ void UITask::loop() { // all three, but this worker was T-Deck-only — on the others the formatting // notice stayed up forever with no format running, and the pending latch even // blocked remounting until reboot (#172). + // Re-check quiescence at fire time: the two ticks that let the notice paint + // are also two ticks in which a consumer can start (a map repaint queues tile + // fetches, history flushes, prefs snapshots). Hold the countdown rather than + // SD.end() under an open handle, but give up instead of waiting forever — + // the longest legitimate holder is a tile download, capped at 12 s. + static uint32_t s_sd_format_wait_until = 0; + if (s_sd_format_pending == 1 && sdRuntimeLifecycleBusy()) { + if (!s_sd_format_wait_until) s_sd_format_wait_until = millis() + 15000; + if ((int32_t)(millis() - s_sd_format_wait_until) < 0) { + s_sd_format_pending = 2; // re-arm; the notice stays on screen + } else { + s_sd_format_wait_until = 0; + s_sd_format_pending = 0; + fmHideFormatOverlay(); + showAlert(TR("SD stayed busy - format cancelled, retry"), 3000); + } + } if (s_sd_format_pending && --s_sd_format_pending == 0) { + s_sd_format_wait_until = 0; bool ok = false; { LoopWdtGuard loop_wdt_guard; From 8318456df5a206e1ed56a73bab9d32031b7398e7 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:19:18 -0700 Subject: [PATCH 07/16] fix(map): mirror the loader's source selection for topo loadTileJpeg() and tileExistsAt() both qualify the microSD branch with s_map_style == 0 because SD packs are OSM-only, but the status label and mapTileSourceReady() did not. With microSD tiles on and topo selected the empty-map notice told the user to install /maps/osm/z/x/y.png and suppressed the "Downloading map tiles... ok/fail/http" diagnostics, while the code was in fact fetching topo tiles into /tiles/topo on the card. Pre-existing on T-Deck; the persistent Pager source toggle newly exposes it there. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 557d6b9..3da6d1f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25910,7 +25910,9 @@ static void mapNoteStorageChanged() { // Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.) static bool mapTileSourceReady() { #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd && s_tile_fs == &SD) return s_sd_mounted; + // Mirror loadTileJpeg's source selection: SD packs are OSM-only, so topo + // reads the generic /tiles/topo cache and its readiness is s_tiles_fs_ready. + if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) return s_sd_mounted; #endif return s_tiles_fs_ready; } @@ -26247,7 +26249,10 @@ static void renderMapTiles() { #if defined(ESP32) #if CAP_SD || defined(TLORA_PAGER) - if (s_tiles_from_sd && s_tile_fs == &SD) { + // s_map_style == 0: SD packs are OSM-only. In topo the loader reads the + // online /tiles/topo cache, so pointing the user at /maps/osm would be wrong + // and would hide the download progress/diagnostics below. + if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { lv_label_set_text(s_map_status_lbl, TR("Map tiles: microSD\n\n" "/maps/osm/z/x/y.png\n" From e53f6586da8f58ef0f5733215633d8558799b33b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:19:49 -0700 Subject: [PATCH 08/16] fix(map): repair corrupt tiles in the SD writable cache The unconditional tileCacheRemove() on a bad JPEG header only covers the generic cache path, which the microSD branch never reaches in the mode this PR is about: with SD tiles on and OSM selected, /tiles///.jpg is read by the SD branch, returned as-is, and then fails in decodeJpegScaledToRgb565(). That path only does ++n_missing, so unlike a load miss it does not queue a re-fetch either -- a half-written tile sat there until a manual "Reload visible tiles". Apply the same 3-byte SOI check to that leg only. /maps/osm packs and PNG packs dropped into /tiles keep their read-only contract: they cannot be re-downloaded, so they are never removed. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 3da6d1f..abee05a 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25784,7 +25784,15 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, snprintf(ppath, sizeof(ppath), "/tiles/%u/%ld/%ld.PNG", (unsigned)z, (long)x, (long)y); fsd = SD.open(ppath, FILE_READ); } - if (!fsd) fsd = SD.open(path, FILE_READ); // legacy /tiles///.jpg + // /tiles///.jpg is the WRITABLE download cache (where the fetcher + // merges Wi-Fi tiles into the library), not a read-only pack — track that so + // a corrupt one can be dropped and re-fetched below. The /maps/osm and PNG + // legs above are user-supplied packs and must never be removed. + bool sd_writable_cache = false; + if (!fsd) { + fsd = SD.open(path, FILE_READ); + sd_writable_cache = (bool)fsd; + } if (!fsd) { sdReadFailedCardDead(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit) const size_t szsd = fsd.size(); if (szsd == 0 || szsd > 256 * 1024) { fsd.close(); return false; } // PNG tiles run larger than JPEG @@ -25793,6 +25801,17 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, const size_t nsd = fsd.read(bufsd, szsd); fsd.close(); if (nsd != szsd) { lvglPsramFree(bufsd); sdReadFailedCardDead(); return false; } // short read mid-tile = card died under us + // A garbled/partial download in the writable cache decodes to a blank/half + // tile and, because the decode failure path does not re-queue, stuck there + // until a manual "Reload visible tiles". Drop it so the render miss below + // re-queues a fresh fetch — same contract as the generic cache path, and + // still never applied to the read-only packs. + if (sd_writable_cache && + (nsd < 3 || bufsd[0] != 0xFF || bufsd[1] != 0xD8 || bufsd[2] != 0xFF)) { + lvglPsramFree(bufsd); + SD.remove(path); + return false; + } *out_data = bufsd; *out_len = szsd; return true; } From 5d6c031365c2267e99fad014bce4fdcd01f0c84b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:20:16 -0700 Subject: [PATCH 09/16] fix(map): don't adopt a card card-detect already flagged absent The deferred reinsert swap runs before the Pager removal handler, and s_sd_mounted stays true until that handler wins quiescence. A card yanked while the swap was still pending therefore got promoted to s_tile_fs = &SD, costing a full freeMapTiles() + renderMapTiles() on the loop task, before the same tick unmounted it and re-rendered again. Harmless in outcome -- the read path short-circuits on the s_sd_fail_note_ms that card-detect stamps -- but two wasted map passes on the UI thread per removal. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index abee05a..b243751 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -48413,7 +48413,15 @@ static void sdHealthTick() { // Reinsertion may have completed while an internal-cache fetch was still // active. mapNoteStorageChanged deliberately deferred the backend swap; make // it effective at the first idle tick without invalidating that open File. - if (s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready) && + // Skip it while card-detect has already confirmed removal: s_sd_mounted stays + // true until the teardown below wins quiescence, so adopting here would point + // the backend at a card that is physically gone and burn two full map + // re-renders on the loop task before the same tick undoes it. + bool sd_leaving = false; +#if defined(TLORA_PAGER) + sd_leaving = s_pager_sd_removal_pending; +#endif + if (!sd_leaving && s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready) && s_tile_fs != &SD && tileFetchPendingLoad() == 0) { mapNoteStorageChanged(); } From ef923d174046b1d42224803129795bc3d131bdc3 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:20:42 -0700 Subject: [PATCH 10/16] docs(map): refresh the stale queueTileForFetch guard comment The CAP_SD branch no longer references s_map_style or s_tiles_from_sd, so the OSM-vs-topo explanation above it described a guard that is not there. Describe what the two branches actually do. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b243751..16d98ab 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25605,11 +25605,12 @@ static bool ensureHistFlushTaskRunning() { // tile was queued recently. Called from renderMapTiles after a SPIFFS // miss; the actual fetch happens off-thread. static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) { - // In microSD-tile mode, missing tiles normally merge into the SD library - // (#20). If that card is absent but a mounted internal cache is available, - // keep maps online through that live fallback until the card returns. - // The SD-pack offline mode is OSM-only; topo has no SD packs, so it always - // fetches from the proxy regardless of the microSD-tiles setting. + // Boards with a microSD backend fetch whenever SOME cache is mounted, and the + // destination follows whatever s_tile_fs currently points at: the card in + // microSD-tile mode (downloads merge into the SD library, #20), or the + // internal cache while that card is absent, so maps stay online through the + // fallback until it returns. Boards without one keep the plain offline guard: + // no SD packs exist there, so microSD-tile mode can only mean "don't fetch". #if CAP_SD || defined(TLORA_PAGER) if (!s_tiles_fs_ready || !s_tile_fs) return; #else From dafdeefa0d603b57a5dcaf8b19eb1e08a5998f4e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 01:24:56 -0700 Subject: [PATCH 11/16] fix(map): don't paint from a backend whose swap is still deferred The quiescence gate drops the decoded tiles and clears the dedup ring unconditionally, which is right, but it also fell through to renderMapTiles() with s_tile_fs still pointing at the old backend. On the teardown side that means probing &SD for a card that is gone -- up to five opens per tile, each paying a full sd_diskio timeout, on the loop task. Return after the invalidation when the swap could not be applied. sdHealthTick renders as soon as it lands it. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 16d98ab..9c298d4 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25923,6 +25923,13 @@ static void mapNoteStorageChanged() { for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; s_tile_fetch_dedup_head = 0; freeMapTiles(); +#if CAP_SD || defined(TLORA_PAGER) + // Drop the stale tiles above either way, but do not paint from a backend we + // already know is wrong — that would read through &SD for a card that is gone + // and pay a full SPI timeout per probe on the loop task. sdHealthTick renders + // as soon as it lands the deferred swap. + if (!may_swap) return; +#endif if (g_lv.tabview && lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX) renderMapTiles(); } From aa64e84b19b2296a58577e5526d6a2ae407ec35f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 2 Aug 2026 11:58:30 -0700 Subject: [PATCH 12/16] fix(map): only skip the repaint when a swap was actually deferred The quiescence gate I added keyed the "don't paint from a stale backend" early return on may_swap alone, so mapNoteStorageChanged() dropped every decoded tile and then returned without repainting whenever ANY tile fetch was in flight -- including the common case where no backend swap was needed or deferred at all. A wedge-recovery remount or a reinsert while already on the card takes neither branch, so the backend is correct and the map should repaint immediately. Instead it went blank until the next unrelated pan/zoom/tab change, and neither sdHealthTick retry covers that case: both require a pending swap (s_tile_fs == &SD with the card gone, or s_tile_fs != &SD with it present), so neither fires when nothing needed swapping. Compute what the swap would actually change and skip the repaint only when that change was wanted and had to wait. Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 9c298d4..2c18ef4 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -25892,7 +25892,15 @@ static void mapNoteStorageChanged() { // swapped card keeps showing the previous card's tiles with the dedup ring // suppressing the re-fetch until that retry lands. const bool may_swap = (tileFetchPendingLoad() == 0); - if (may_swap && !s_sd_mounted && s_tile_fs == &SD) { + // Whether the backend actually has to move. Also decides whether a deferral + // leaves the pointer stale: most callers (a remount that changed nothing, a + // reinsert while already on the card) need no swap at all, and those must + // still repaint below even while a fetch is in flight. + const bool wants_teardown = !s_sd_mounted && s_tile_fs == &SD; + const bool wants_adopt = s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready); + const bool swap_deferred = + !may_swap && (wants_teardown || (wants_adopt && s_tile_fs != &SD)); + if (may_swap && wants_teardown) { if (s_tile_fs_default && s_tile_fs_default != &SD) { // SD tile mode lost its card, but the dedicated LittleFS cache is still // mounted. Keep maps online instead of disabling that healthy fallback. @@ -25907,7 +25915,7 @@ static void mapNoteStorageChanged() { s_tile_root_default[0] = '\0'; s_tiles_fs_ready = false; } - } else if (may_swap && s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready)) { + } else if (may_swap && wants_adopt) { // Reinserted while SD mode is selected, or no internal backend exists. // Preserve an existing LittleFS default so toggling SD mode off remains a // valid fallback after any number of remove/reinsert cycles. @@ -25924,11 +25932,12 @@ static void mapNoteStorageChanged() { s_tile_fetch_dedup_head = 0; freeMapTiles(); #if CAP_SD || defined(TLORA_PAGER) - // Drop the stale tiles above either way, but do not paint from a backend we - // already know is wrong — that would read through &SD for a card that is gone - // and pay a full SPI timeout per probe on the loop task. sdHealthTick renders - // as soon as it lands the deferred swap. - if (!may_swap) return; + // Drop the stale tiles above either way, but do not paint through a pointer + // we already know is wrong — that would probe &SD for a card that is gone and + // pay a full SPI timeout per tile on the loop task. Only skip when a swap was + // genuinely wanted and deferred; sdHealthTick repaints once it lands. When no + // swap was needed the backend is correct, so fall through and repaint now. + if (swap_deferred) return; #endif if (g_lv.tabview && lv_tabview_get_tab_act(g_lv.tabview) == MAP_TAB_INDEX) renderMapTiles(); } From 2f0d04d18d2812ea7f5322c5013ceb91a2f69dff Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 3 Aug 2026 23:29:00 -0700 Subject: [PATCH 13/16] fix(map): close rebased cache review gaps --- src/ui-touch/UITask.cpp | 58 ++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2c18ef4..c21796b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -19070,11 +19070,11 @@ static void fmHideFormatOverlay() { // Confirm callback: paint the formatting notice, then defer the (blocking) // f_mkfs to UITask::loop so the notice is on-screen before the loop freezes. static void fmSdDoFormat() { -#if defined(HAS_TDECK_GT911) // f_mkfs rewrites the volume under every open handle, and the SD.end() that // precedes it unregisters the pdrv those handles point at. Never start that // while an SD consumer is live — the tile worker can own an open File for a - // whole download — so apply the same gate the Pager factory reset uses. + // whole download — so apply the same gate on every board that carries the + // deferred formatter below (T-Deck, ThinkNode M9, and Heltec V4-R8). if (sdRuntimeLifecycleBusy()) { if (g_lv.task) g_lv.task->showAlert(TR("SD is busy - close tools and retry"), 2600); @@ -19085,14 +19085,6 @@ static void fmSdDoFormat() { "Do NOT power off, disconnect,\nor remove the card.\n\n" "This can take up to a minute..."); s_sd_format_pending = 2; -#else - // Only the T-Deck build carries the deferred f_mkfs executor in UITask::loop. - // Arming the countdown anywhere else parked sdHealthTick on its - // s_sd_format_pending guard for the rest of the boot, silently disabling - // wedge detection and remount, and never actually formatted anything. - if (g_lv.task) - g_lv.task->showAlert(TR("SD format isn't available on this device"), 2600); -#endif } // Tap on an unmounted SD row: try to mount, and if the card is unreadable // (e.g. exFAT, which this build can't read — only FAT16/FAT32) offer to format. @@ -25741,8 +25733,10 @@ static uint8_t* decodePngToRgb565(const uint8_t* png, size_t png_len, int* out_w // function just reads bytes — decode happens later in // decodeJpegToRgb565 (which is a misnomer now; it handles both). static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, - uint8_t** out_data, size_t* out_len) { + uint8_t** out_data, size_t* out_len, + bool* out_repairable_cache) { #if defined(ESP32) + if (out_repairable_cache) *out_repairable_cache = false; // Format support: // • JPEG (.jpg) — decoded by SJPG/TJpgDec straight to RGB565 in stripes (cheap). // The LittleFS online cache stores ONLY .jpg (the tiles.wadamesh.com @@ -25808,11 +25802,16 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, // re-queues a fresh fetch — same contract as the generic cache path, and // still never applied to the read-only packs. if (sd_writable_cache && - (nsd < 3 || bufsd[0] != 0xFF || bufsd[1] != 0xD8 || bufsd[2] != 0xFF)) { + (nsd < 4 || bufsd[0] != 0xFF || bufsd[1] != 0xD8 || bufsd[2] != 0xFF || + bufsd[nsd - 2] != 0xFF || bufsd[nsd - 1] != 0xD9)) { lvglPsramFree(bufsd); SD.remove(path); +#if defined(MULTI_TRANSPORT_COMPANION) + tileFetchForget(z, x, y); +#endif return false; } + if (out_repairable_cache) *out_repairable_cache = sd_writable_cache; *out_data = bufsd; *out_len = szsd; return true; } @@ -25846,19 +25845,26 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y, n += (size_t)r; } f.close(); - // Reject a cached tile whose header isn't a JPEG SOI — a garbled/partial download that slipped - // through decodes to a blank/half "twilight zone" tile otherwise. This generic path is the writable - // /tiles download cache; standard read-only /maps/osm packs return from the SD-pack branch above. - if (n < 3 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF) { + // Reject a cached tile without both JPEG framing markers — a garbled/partial + // download can retain its SOI while losing the EOI and otherwise decode to a + // blank/half "twilight zone" tile. This generic path is the writable /tiles + // download cache; standard read-only /maps/osm packs return above. + if (n < 4 || buf[0] != 0xFF || buf[1] != 0xD8 || buf[2] != 0xFF || + buf[n - 2] != 0xFF || buf[n - 1] != 0xD9) { lvglPsramFree(buf); tileCacheRemove(path); +#if defined(MULTI_TRANSPORT_COMPANION) + tileFetchForget(z, x, y); +#endif return false; } + if (out_repairable_cache) *out_repairable_cache = true; *out_data = buf; *out_len = n; return true; #else (void)z; (void)x; (void)y; (void)out_data; (void)out_len; + (void)out_repairable_cache; return false; #endif } @@ -26176,7 +26182,9 @@ static void renderMapTiles() { if (!dst->rgb565) { ++n_missing; continue; } // no buffer available this pass uint8_t* jpeg = nullptr; size_t jlen = 0; - if (!loadTileJpeg(s_map_zoom, wanted[i].tx, wanted[i].ty, &jpeg, &jlen)) { + bool repairable_cache = false; + if (!loadTileJpeg(s_map_zoom, wanted[i].tx, wanted[i].ty, + &jpeg, &jlen, &repairable_cache)) { #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) // Tile not on disk — queue an OSM download if Wi-Fi is up. No-op // when offline; if/when Wi-Fi comes up later, the next render @@ -26195,7 +26203,21 @@ static void renderMapTiles() { ? decodePngToRgb565(jpeg, jlen, &dw, &dh, dst->rgb565, kTileBufBytes) : decodeJpegScaledToRgb565(jpeg, jlen, &dw, &dh, 256, dst->rgb565, kTileBufBytes); lvglPsramFree(jpeg); - if (!rgb) { ++n_missing; continue; } // decode failed; buffer kept for reuse + if (!rgb) { +#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) + if (repairable_cache) { + char bad_path[48]; + snprintf(bad_path, sizeof(bad_path), "%s/%u/%ld/%ld.jpg", + mapTileRoot(), (unsigned)s_map_zoom, + (long)wanted[i].tx, (long)wanted[i].ty); + tileCacheRemove(bad_path); + tileFetchForget(s_map_zoom, wanted[i].tx, wanted[i].ty); + queueTileForFetch(s_map_zoom, wanted[i].tx, wanted[i].ty); + } +#endif + ++n_missing; + continue; // decode failed; buffer kept for reuse + } // Night mode: invert the decoded RGB565 in place (render-only — the on-disk // tile is untouched). ~p flips all three channels; light maps go dark. if (s_map_night) { From d7fb340c7cadeb688452773d228b257542bcfeda Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 4 Aug 2026 00:06:30 -0700 Subject: [PATCH 14/16] fix(map): hand queued downloads across remount --- src/ui-touch/UITask.cpp | 100 ++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c21796b..737e094 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -716,9 +716,9 @@ static void initTouchFontFallbacks() { } #if defined(MULTI_TRANSPORT_COMPANION) -// Cross-core tile lifecycle count. Increment before publishing a queue item so -// the worker cannot open an SD File during a false-zero window; every read and -// update is atomic because the producer and consumer run on different cores. +// Cross-core queued + in-flight tile count. This drives progress/memory gates; +// backend ownership is tracked separately beside s_tile_fs so a queued backlog +// does not unnecessarily delay a safe cache handoff. volatile uint16_t s_tile_fetch_pending = 0; static inline uint16_t tileFetchPendingLoad() { return __atomic_load_n(&s_tile_fetch_pending, __ATOMIC_ACQUIRE); @@ -23892,6 +23892,67 @@ static char s_tile_root[16] = ""; static fs::FS* s_tile_fs_default = nullptr; static char s_tile_root_default[16] = ""; +#if defined(MULTI_TRANSPORT_COMPANION) +// The fetch queue may contain dozens of requests, but only the request currently +// executing can dereference s_tile_fs or own a File. A backend swap requests a +// boundary pause, waits only for that one request, then lets the queued backlog +// resume against the new backend. The critical section closes both races: +// worker-start vs swap-request, and worker-finish vs the next queued request. +static portMUX_TYPE s_tile_backend_mux = portMUX_INITIALIZER_UNLOCKED; +static volatile bool s_tile_worker_active = false; +static volatile bool s_tile_backend_swap_requested = false; + +static bool tileBackendSwapTryBegin() { + bool ready; + portENTER_CRITICAL(&s_tile_backend_mux); + s_tile_backend_swap_requested = true; + ready = !s_tile_worker_active; + portEXIT_CRITICAL(&s_tile_backend_mux); + return ready; +} + +static void tileBackendSwapFinish() { + portENTER_CRITICAL(&s_tile_backend_mux); + s_tile_backend_swap_requested = false; + portEXIT_CRITICAL(&s_tile_backend_mux); +} + +static bool tileFetchWorkerActive() { + bool active; + portENTER_CRITICAL(&s_tile_backend_mux); + active = s_tile_worker_active; + portEXIT_CRITICAL(&s_tile_backend_mux); + return active; +} + +class TileBackendWorkerLease { + public: + TileBackendWorkerLease() { + for (;;) { + portENTER_CRITICAL(&s_tile_backend_mux); + if (!s_tile_backend_swap_requested) { + s_tile_worker_active = true; + _held = true; + } + portEXIT_CRITICAL(&s_tile_backend_mux); + if (_held) break; + vTaskDelay(1); + } + } + ~TileBackendWorkerLease() { + if (!_held) return; + portENTER_CRITICAL(&s_tile_backend_mux); + s_tile_worker_active = false; + portEXIT_CRITICAL(&s_tile_backend_mux); + } + TileBackendWorkerLease(const TileBackendWorkerLease&) = delete; + TileBackendWorkerLease& operator=(const TileBackendWorkerLease&) = delete; + + private: + bool _held = false; +}; +#endif + // When the tile cache is on the SD fallback (s_tile_fs != the LittleFS partition), // every access is real microSD I/O -> light the activity LED. On the flash // partition this is a no-op (the LED tracks SD only). @@ -25268,6 +25329,11 @@ static void tileFetchTaskFn(void* arg) { continue; } + // Serialize only the request that can actually touch the cache backend. + // A swap request pauses here between Files; queued requests remain intact + // and resume against the newly-selected filesystem after the pointer moves. + TileBackendWorkerLease backend_lease; + // Capture the active map style ONCE per fetch so the cache path and the // upstream URL can't disagree if the user toggles topo mid-download (both // pointers are static string literals, so they stay valid). @@ -25890,22 +25956,23 @@ static void freeMapTiles() { static void mapNoteStorageChanged() { #if CAP_SD || defined(TLORA_PAGER) // A fetch snapshots its paths but still dereferences the global backend for - // every filesystem operation of the request, so the pointer may only move - // while the queue and the worker are both idle. That governs BOTH directions: - // dropping a lost card strands an open SD File exactly as surely as adopting - // a new one does. When the swap has to wait, sdHealthTick retries it at the - // first idle tick — but the invalidation below still has to run now, or a - // swapped card keeps showing the previous card's tiles with the dedup ring - // suppressing the re-fetch until that retry lands. - const bool may_swap = (tileFetchPendingLoad() == 0); + // every filesystem operation of the request, so the pointer may only move at + // a worker request boundary. The ownership handshake pauses dequeueing there; + // queued downloads survive and resume on the new backend. When the current + // request is still active, sdHealthTick retries the swap at the first safe + // boundary — but the invalidation below still has to run now, or a swapped + // card keeps showing the previous card's tiles with the dedup ring suppressing + // the re-fetch until that retry lands. // Whether the backend actually has to move. Also decides whether a deferral // leaves the pointer stale: most callers (a remount that changed nothing, a // reinsert while already on the card) need no swap at all, and those must // still repaint below even while a fetch is in flight. const bool wants_teardown = !s_sd_mounted && s_tile_fs == &SD; const bool wants_adopt = s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready); + const bool wants_swap = wants_teardown || (wants_adopt && s_tile_fs != &SD); + const bool may_swap = !wants_swap || tileBackendSwapTryBegin(); const bool swap_deferred = - !may_swap && (wants_teardown || (wants_adopt && s_tile_fs != &SD)); + !may_swap && wants_swap; if (may_swap && wants_teardown) { if (s_tile_fs_default && s_tile_fs_default != &SD) { // SD tile mode lost its card, but the dedicated LittleFS cache is still @@ -25933,6 +26000,7 @@ static void mapNoteStorageChanged() { } s_tiles_fs_ready = true; } + if (may_swap) tileBackendSwapFinish(); #endif for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; s_tile_fetch_dedup_head = 0; @@ -26360,8 +26428,12 @@ static void renderMapTiles() { "Keep Wi-Fi connected.\nTiles appear as they arrive.", #if defined(HAS_TANMATSU) (s_tile_fs == &SD_MMC ? "SD cache" : "flash cache"), +#else +#if CAP_SD || defined(TLORA_PAGER) + (s_tile_fs == &SD ? "SD cache" : "flash cache"), #else (s_tile_root[0] ? "SD cache" : "flash cache"), +#endif #endif (unsigned)s_tile_fetch_ok, (unsigned)s_tile_fetch_failed, (int)s_tile_fetch_last_code, (char)s_tile_fetch_last_wr, @@ -48366,7 +48438,7 @@ static void sdHealthTick() { // A teardown may have completed while the tile worker still held its final // File. Defer dropping the stale SD backend until that request is finished; // this also closes the failed-format path below. - if (!s_sd_mounted && s_tile_fs == &SD && tileFetchPendingLoad() == 0) { + if (!s_sd_mounted && s_tile_fs == &SD && !tileFetchWorkerActive()) { mapNoteStorageChanged(); } #endif @@ -48461,7 +48533,7 @@ static void sdHealthTick() { sd_leaving = s_pager_sd_removal_pending; #endif if (!sd_leaving && s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready) && - s_tile_fs != &SD && tileFetchPendingLoad() == 0) { + s_tile_fs != &SD && !tileFetchWorkerActive()) { mapNoteStorageChanged(); } #endif From 45a74928fb59d3f8bdfd47daf3b5bfbee01c449e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 4 Aug 2026 00:32:17 -0700 Subject: [PATCH 15/16] fix(map): preserve queued tiles across SD lifecycle --- src/ui-touch/UITask.cpp | 88 +++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 737e094..6fa2a7c 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -23917,6 +23917,14 @@ static void tileBackendSwapFinish() { portEXIT_CRITICAL(&s_tile_backend_mux); } +static bool tileBackendSwapRequested() { + bool requested; + portENTER_CRITICAL(&s_tile_backend_mux); + requested = s_tile_backend_swap_requested; + portEXIT_CRITICAL(&s_tile_backend_mux); + return requested; +} + static bool tileFetchWorkerActive() { bool active; portENTER_CRITICAL(&s_tile_backend_mux); @@ -23939,11 +23947,13 @@ class TileBackendWorkerLease { vTaskDelay(1); } } - ~TileBackendWorkerLease() { + ~TileBackendWorkerLease() { release(); } + void release() { if (!_held) return; portENTER_CRITICAL(&s_tile_backend_mux); s_tile_worker_active = false; portEXIT_CRITICAL(&s_tile_backend_mux); + _held = false; } TileBackendWorkerLease(const TileBackendWorkerLease&) = delete; TileBackendWorkerLease& operator=(const TileBackendWorkerLease&) = delete; @@ -24055,8 +24065,8 @@ static TaskHandle_t s_tile_fetch_task = nullptr; static volatile bool s_tile_fetch_dirty = false; #if defined(TLORA_PAGER) // Card-detect sets this before the VFS can be unmounted. It stops producers and -// lets the worker discard its queued SD jobs, so the lifecycle gate converges -// instead of being kept busy forever by a map that is still requesting tiles. +// requests a worker pause at the next request boundary, so the current SD File +// can close while the queued backlog survives the fallback/remount handoff. static volatile bool s_pager_sd_removal_pending = false; #endif static volatile uint16_t s_tile_fetch_ok = 0; @@ -25309,15 +25319,6 @@ static void tileFetchTaskFn(void* arg) { // disk (the prefetch now enqueues without stat'ing) would otherwise be a // tight no-yield loop of tileCacheExists() and could starve core-0 IDLE. vTaskDelay(1); -#if defined(TLORA_PAGER) - if (s_pager_sd_removal_pending && s_tile_fs == &SD) { - s_tile_fetch_last_wr = 'R'; - ++s_tile_fetch_failed; - tileFetchForget(req.z, req.x, req.y); - tileFetchPendingDec(); - continue; - } -#endif if (WiFi.status() != WL_CONNECTED) { WIRE_DBG("[TILE] skip z=%u x=%ld y=%ld: WiFi down\n", (unsigned)req.z, (long)req.x, (long)req.y); @@ -25521,6 +25522,9 @@ static void tileFetchTaskFn(void* arg) { http.end(); client.stop(); tileFetchPendingDec(); + // No cache/File access follows. Release before the 500 ms rate-limit delay + // so a lifecycle handoff waits for I/O, not an unrelated pacing sleep. + backend_lease.release(); if (wrote) { WIRE_DBG("[TILE] -> wrote %s\n", path_jpg); @@ -26426,7 +26430,7 @@ static void renderMapTiles() { "ok %u fail %u http %d wr %c\n" "open-fail %u short-wr %u\n\n" "Keep Wi-Fi connected.\nTiles appear as they arrive.", -#if defined(HAS_TANMATSU) +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) (s_tile_fs == &SD_MMC ? "SD cache" : "flash cache"), #else #if CAP_SD || defined(TLORA_PAGER) @@ -27176,14 +27180,18 @@ static void mapOptZoomButtonsCb(lv_event_t* e) { // library grows and downloads survive; OFF = tile server + internal LittleFS cache. static void mapOptTilesSdCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; - // The worker dereferences the shared cache backend throughout a request. Do - // not repoint it while a queued/in-flight fetch may still own a File on the - // old filesystem; on Pager that would also hide the live SD handle from the - // VFS lifecycle gate and permit SD.end() underneath it. - if (tileFetchPendingLoad() > 0) { + // Pause at a request boundary before repointing the cache. A queued backlog is + // harmless and resumes on the new backend; only the request currently owning + // a File must finish. Do not steal a pause already owned by remount/removal. + const bool pause_owned_elsewhere = tileBackendSwapRequested(); + const bool pause_ready = !pause_owned_elsewhere && tileBackendSwapTryBegin(); + if (!pause_ready) { + // If this callback asserted the pause but found an active request, cancel + // only its own request. Lifecycle-owned pauses stay asserted for their retry. + if (!pause_owned_elsewhere) tileBackendSwapFinish(); if (s_tiles_from_sd) lv_obj_add_state(lv_event_get_target(e), LV_STATE_CHECKED); else lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); - if (g_lv.task) g_lv.task->showAlert(TR("Tile download in progress - retry"), 1800); + if (g_lv.task) g_lv.task->showAlert(TR("Current tile transfer is busy - retry"), 1800); return; } const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); @@ -27208,6 +27216,7 @@ static void mapOptTilesSdCb(lv_event_t* e) { } freeMapTiles(); // drop stale tile widgets → reload from the new source renderMapTiles(); + tileBackendSwapFinish(); // queued requests resume against the selected backend if (g_lv.task) g_lv.task->showAlert(on ? TR("Map tiles: microSD") : TR("Map tiles: server"), 1400); } #endif @@ -48400,7 +48409,15 @@ static bool sdRuntimeLifecycleBusy() { busy = busy || s_notify_playing; #endif #if CAP_SD || defined(TLORA_PAGER) - busy = busy || (s_tile_fs == &SD && tileFetchPendingLoad() > 0); + // Before a lifecycle owner requests a backend pause, keep the conservative + // queued+active gate used by format/reset paths. Once the pause is asserted, + // no queued request can acquire a new lease, so only the current lease owns + // the VFS; the preserved backlog is not a reason to delay SD.end(). + if (s_tile_fs == &SD) { + busy = busy || (tileBackendSwapRequested() + ? tileFetchWorkerActive() + : tileFetchPendingLoad() > 0); + } #endif return busy; } @@ -48539,9 +48556,9 @@ static void sdHealthTick() { #endif #if defined(TLORA_PAGER) // Card detect is pure I2C, so it must keep running even while an SD worker is - // busy. Once removal is confirmed, stop new SD tile jobs immediately; the - // worker discards its queued jobs and the normal lifecycle gate waits only - // for the genuinely in-flight operation before SD.end(). + // busy. Once removal is confirmed, pause the worker at a request boundary; + // the normal lifecycle gate then waits only for the genuinely in-flight + // operation before SD.end(), preserving the queued backlog. if (!s_pager_sd_removal_pending && (int32_t)(now - s_next_detect_ms) >= 0) { s_next_detect_ms = now + 500; const TLoraPagerBoard::SdCardState state = board.sdCardState(); @@ -48557,6 +48574,10 @@ static void sdHealthTick() { s_absent_samples = 0; s_absent_first_ms = 0; s_pager_sd_removal_pending = true; + // Stop the worker at the next request boundary. Its current File may + // finish, but the queued backlog stays intact while SD.end() and the + // fallback selection run below. + tileBackendSwapTryBegin(); sdNoteIoFailure(); } } @@ -48572,9 +48593,6 @@ static void sdHealthTick() { return; } #endif - // SD.end() under any open worker/UI file handle invalidates that handle. - // On a truly wedged card the worker's own ops fail fast, so this clears. - if (sdRuntimeLifecycleBusy()) return; #if defined(HAS_TDECK_GT911) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA if (s_sdfw_request) return; // worker streaming a firmware download to /BINS (T-Deck only) #endif @@ -48595,10 +48613,20 @@ static void sdHealthTick() { if (touchSleep::isSleeping()) return; if ((int32_t)(now - s_next_bg_probe_ms) < 0) return; } + // This tick is going to probe and may invalidate the VFS. Assert the backend + // pause before the lifecycle-busy check, so queued requests cannot repeatedly + // win the boundary while recovery waits. Other SD consumers still drain first. + tileBackendSwapTryBegin(); + if (sdRuntimeLifecycleBusy()) return; s_next_probe_ms = now + 5000; s_next_bg_probe_ms = now + 30000; s_sd_fail_note_ms = 0; - if (sdProbeAlive()) return; // transient failure (card full, bad path, ...) — not a wedge + if (sdProbeAlive()) { + // Transient failure (card full, bad path, ...) — keep the mount and let the + // preserved tile queue continue on it. + tileBackendSwapFinish(); + return; + } fmSdUnmount(); // SD.end() so a fresh begin re-runs the full card handshake if (fmSdTryMount()) { #if defined(TLORA_PAGER) @@ -49639,9 +49667,9 @@ void UITask::loop() { showAlert(done, 3500); } else { s_sd_mounted = false; - // Do not repoint the global backend under the worker's open File. The - // next idle sdHealthTick drops it after the lifecycle count reaches 0. - if (tileFetchPendingLoad() == 0) mapNoteStorageChanged(); + // The backend handshake waits for any current File and preserves queued + // requests, then drops the failed SD backend without leaving a pause set. + mapNoteStorageChanged(); showAlert(TR("Format failed (no card / SD error)"), 3500); } if (s_fm_list) fmShowRoots(); From 15d33e45bd8d608b77fb0261ed235bdb68e84cee Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 4 Aug 2026 00:54:08 -0700 Subject: [PATCH 16/16] fix(map): preserve lifecycle pause ownership Signed-off-by: Pixel Perfect --- src/ui-touch/UITask.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6fa2a7c..4d09916 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26004,7 +26004,10 @@ static void mapNoteStorageChanged() { } s_tiles_fs_ready = true; } - if (may_swap) tileBackendSwapFinish(); + // Only a real swap request acquired (or extended) the pause above. A no-op + // notification must not clear a pause owned by card removal/remount or the + // health probe while that lifecycle operation is still draining consumers. + if (wants_swap && may_swap) tileBackendSwapFinish(); #endif for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0; s_tile_fetch_dedup_head = 0;