perf(l2): map tiles decode on the other core

- TileCache: request() hands z/x/y to a task on core 0, which renders into
  a spare buffer; poll() swaps it into the LRU slot (stale results dropped
  after drop()/invalidate()); the sim runs the job at once
- MapScreen / Home map place finished tiles as they arrive; the longest
  UI stall with the map opening goes from ~340 to ~170 ms
- LVGL allocates from the PSRAM heap (lv_mem_psram.h), safe from both
  tasks; LVGL's own pool is gone
- lv_conf: notes why LV_OS_FREERTOS with two draw units stays off
  (measured slower: drawing is bound by PSRAM bandwidth)
- UI_PERF_TEST: the map (tile read/decode, pan, redraw), build time and
  the longest loop pass per screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-30 00:24:57 +02:00
co-authored by Claude Opus 5.5
parent b3b77a1f6d
commit 247fab08d0
9 changed files with 230 additions and 60 deletions
@@ -765,8 +765,8 @@ void UITask::homeMapLayout() {
else lv_obj_add_flag(s_hint, LV_OBJ_FLAG_HIDDEN);
}
// From loop() while the map page is up: one tile decode per pass, a re-frame
// every few seconds.
// From loop() while the map page is up: tiles one at a time (decoded on the
// other core, placed here), a re-frame every few seconds.
void UITask::homeMapLoop() {
using namespace home::mini;
if (!s_area || home::s_touching) return;
@@ -774,7 +774,8 @@ void UITask::homeMapLoop() {
s_next_fit_ms = millis() + 5000;
if (homeMapFit()) homeMapLayout();
}
if (!s_pending) return;
if (mapview::pollTiles()) homeMapLayout();
if (!s_pending || mapview::s_cache.busy()) return;
int w = lv_obj_get_width(s_area), h = lv_obj_get_height(s_area);
double left = s_cx * mapview::TILE_PX - w / 2.0, top = s_cy * mapview::TILE_PX - h / 2.0;
int tx0 = (int)floor(left / mapview::TILE_PX), ty0 = (int)floor(top / mapview::TILE_PX);
@@ -785,12 +786,11 @@ void UITask::homeMapLoop() {
int px = (int)lround(tx * (double)mapview::TILE_PX - left), py = (int)lround(ty * (double)mapview::TILE_PX - top);
if (ty < 0 || ty >= n || px >= w || py >= h) continue;
mapview::TileCache::Slot* s = mapview::s_cache.find(s_z, wx, ty);
if (!s) { mapview::s_cache.load(*mapview::s_provider, s_z, wx, ty); homeMapLayout(); return; }
if (!s) { mapview::loadTile(s_z, wx, ty); return; }
if (s->present) continue;
int k, wz, ax, ay;
if (!mapview::ancestorFor(s_z, wx, ty, k, wz, ax, ay) && wz >= 0) {
mapview::s_cache.load(*mapview::s_provider, wz, ax, ay);
homeMapLayout();
mapview::loadTile(wz, ax, ay);
return;
}
}
+40 -7
View File
@@ -34,12 +34,17 @@ static TileDownloader s_dl("/sdcard/maps");
static AreaStore s_areas("/sdcard/maps");
static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate
// Decode a tile into the cache; with live tiles on, a base tile shown without
// its trails overlay has the overlay fetched.
static TileCache::Slot* loadTile(int z, int x, int y) {
TileCache::Slot* s = s_cache.load(*s_provider, z, x, y);
if (s_trails_on && s_provider == &s_raster && s_raster.overlayMissed()) s_dl.liveRequest(z, x, y, true);
return s;
// Hand a tile to the decoder (on the other core); false while one is in flight.
static bool loadTile(int z, int x, int y) { return s_cache.request(*s_provider, z, x, y); }
// A decoded tile placed in the cache: true when a view has something new.
// With live tiles on, a base tile shown without its trails overlay has the
// overlay fetched.
static bool pollTiles() {
TileCache::Done d;
if (!s_cache.poll(d)) return false;
if (s_trails_on && d.src == &s_raster && d.overlay_missed) s_dl.liveRequest(d.z, d.x, d.y, true);
return true;
}
static const int MIN_Z = 3, MAX_Z = 18;
@@ -279,6 +284,32 @@ double UITask::mapCenterBias() const {
return _map_nav ? navmap::BAR_H / 2.0 / mapview::TILE_PX : 0.0;
}
#ifdef UI_PERF_TEST
// UI_PERF_TEST (UITask::loop): reset = before the map opens; else report the
// tiles decoded since, then time a drag and a full redraw.
void UITask::perfMap(bool reset) {
using namespace mapview;
if (reset) { s_perf_tile_us = s_perf_tile_max = s_perf_tile_n = 0; return; }
while (s_cache.busy()) { delay(5); pollTiles(); } // a prefetch still decoding would share PSRAM with the drawing
Serial.printf("PERF tiles %lu decoded, avg %5.1f ms, max %5.1f ms (pending %d)\n", (unsigned long)s_perf_tile_n,
s_perf_tile_n ? s_perf_tile_us / 1000.0f / s_perf_tile_n : 0.0f, s_perf_tile_max / 1000.0f, (int)_map_pending);
if (s_perf_tile_n) Serial.printf("PERF of which read %5.1f ms (%lu B), decode %5.1f ms\n", s_raster.perf_read_us / 1000.0f / s_perf_tile_n,
(unsigned long)(s_raster.perf_bytes / s_perf_tile_n), s_raster.perf_dec_us / 1000.0f / s_perf_tile_n);
s_raster.perf_read_us = s_raster.perf_dec_us = s_raster.perf_bytes = 0;
uint32_t fl0 = lvport::s_flush_us, t = micros();
for (int i = 0; i < 20; i++) { // a drag: 4 px a frame, tiles already decoded
_map_cx += (i < 10 ? 4.0 : -4.0) / TILE_PX;
layoutMap();
lv_refr_now(NULL);
}
Serial.printf("PERF map pan frame %5.1f ms, flush %5.1f ms\n", (micros() - t) / 20000.0f, (lvport::s_flush_us - fl0) / 20000.0f);
fl0 = lvport::s_flush_us; t = micros();
for (int i = 0; i < 10; i++) { lv_obj_invalidate(lv_screen_active()); lv_refr_now(NULL); }
Serial.printf("PERF map full redraw %5.1f ms, flush %5.1f ms\n", (micros() - t) / 10000.0f, (lvport::s_flush_us - fl0) / 10000.0f);
s_perf_tile_us = s_perf_tile_max = s_perf_tile_n = 0;
}
#endif
void UITask::openMap(bool nav) {
_map_nav = nav;
showMap();
@@ -552,9 +583,11 @@ void UITask::mapLoop() {
refreshNavBar();
}
if (!_map_area) return;
if (mapview::pollTiles()) layoutMap(); // a tile decoded on the other core
uint32_t since_pan = millis() - mapview::s_last_pan_ms;
if (since_pan < mapview::PAN_SETTLE_MS) return; // mid-drag: keep it smooth, decode after
if (mapview::labels::loadOne()) { layoutMap(); return; } // names of the view first: a small file
if (mapview::s_cache.busy()) return; // one tile at a time
int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area);
double left = _map_cx * mapview::TILE_PX - w / 2.0, top = _map_cy * mapview::TILE_PX - h / 2.0;
if (!_map_pending) { // view complete: when idle, decode the next tile a pan would reveal
@@ -599,8 +632,8 @@ void UITask::mapLoop() {
}
}
if (bz >= 0) mapview::loadTile(bz, best_x, best_y);
else layoutMap(); // nothing left to decode: pending settles
}
layoutMap();
}
void UITask::rebuildMapMarkers() {
+18 -3
View File
@@ -82,6 +82,7 @@ template <class T> static T* psramBuf(size_t n) {
#include "Theme.h"
#include "Anim.h"
#include "LvglPort.h"
#include "lv_mem_psram.h"
#include "HistoryStore.h"
#include "map/LiveCache.h"
static histstore::SdArchive s_archive; // message history on the SD card
@@ -1031,6 +1032,10 @@ void UITask::loop() {
// display refresh took during the transition that followed (render+flush).
{
static uint32_t next = 15000, sum_us = 0, max_us = 0, frames = 0, t0 = 0;
static uint32_t last_pass_us = 0, max_gap_us = 0; // the longest the loop was away (a blocked UI)
uint32_t now_us = micros();
if (last_pass_us && now_us - last_pass_us > max_gap_us) max_gap_us = now_us - last_pass_us;
last_pass_us = now_us;
static int step = 0;
static const char* name = "";
static bool hooked = false;
@@ -1047,19 +1052,25 @@ void UITask::loop() {
if (millis() >= next) {
if (*name) Serial.printf("PERF %-10s frames %2lu avg %5.1f ms max %5.1f ms\n", name, (unsigned long)frames,
frames ? sum_us / 1000.0f / frames : 0.0f, max_us / 1000.0f);
if (*name) Serial.printf("PERF flush %5.1f ms per frame\n", frames ? lvport::s_flush_us / 1000.0f / frames : 0.0f);
if (*name) Serial.printf("PERF flush %5.1f ms per frame, longest loop pass %5.1f ms\n",
frames ? lvport::s_flush_us / 1000.0f / frames : 0.0f, max_gap_us / 1000.0f);
sum_us = max_us = frames = 0;
lvport::s_flush_us = 0;
max_gap_us = 0;
lv_display_trigger_activity(NULL); // no sleeping mid-test
next = millis() + 1200;
if (step == 0) unlockScreen(); // a PIN lock would be drawn over everything
if (step % 8 == 0 && step > 0) { // a static full-screen redraw of Home, for the baseline
if (step % 10 == 0 && step > 0) { // a static full-screen redraw of Home, for the baseline
uint32_t fl0 = lvport::s_flush_us, t = micros();
for (int i = 0; i < 10; i++) { lv_obj_invalidate(lv_screen_active()); lv_refr_now(NULL); }
Serial.printf("PERF full redraw (Home) %5.1f ms, flush %5.1f ms\n", (micros() - t) / 10000.0f,
(lvport::s_flush_us - fl0) / 10000.0f);
}
switch (step++ % 8) {
if (step % 10 == 9) perfMap(); // the map: tiles decoded while it opened, panning, a full redraw
uint32_t tb = micros();
switch (step++ % 10) {
case 8: name = "map"; perfMap(true); openMap(true); next = millis() + 8000; break;
case 9: name = "back-home"; back(); break;
case 0: name = "settings"; showSettings(); break;
case 1: name = "back-home"; back(); break;
case 2: name = "messages"; showChats(); break;
@@ -1069,6 +1080,7 @@ void UITask::loop() {
case 6: name = "page-2"; setHomePage(1); break;
case 7: name = "page-1"; setHomePage(0); break;
}
Serial.printf("PERF build %-10s %5.1f ms\n", name, (micros() - tb) / 1000.0f);
}
}
#endif
@@ -3598,6 +3610,9 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
// a message being read.
static constexpr uint32_t CPU_IDLE_MS = 2000;
bool UITask::cpuNeeded() {
#ifdef UI_PERF_TEST
return true; // measured at the full clock
#endif
if (lv_display_get_inactive_time(NULL) < CPU_IDLE_MS || lv_anim_count_running() > 0) return true;
if (wifiInUse() || mapview::s_dl.liveQueued() > 0) return true;
if (_screen == SCR_MAP && _map_pending) return true;
@@ -313,6 +313,9 @@ private:
void buildHomeMap(lv_obj_t* box);
void homeFieldText(uint8_t field, char* buf, int n);
bool homeMapFit();
#ifdef UI_PERF_TEST
void perfMap(bool reset = false);
#endif
void homeMapLayout();
void homeMapLoop();
void refreshHome();
+11 -9
View File
@@ -6,18 +6,20 @@
#define LV_COLOR_DEPTH 16
// Memory: LVGL's own allocator over one pool. On ESP32-S3 the pool lives in
// PSRAM so widgets / text never compete with BLE and the radio for internal RAM.
#define LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN
// Memory. On ESP32-S3: the PSRAM heap (lv_mem_psram.h), thread-safe -- map
// tiles decode on the other core. Elsewhere (the sim): LVGL's own pool.
#if defined(ESP32)
#define LV_USE_STDLIB_MALLOC LV_STDLIB_CUSTOM
#else
#define LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN
#define LV_MEM_SIZE (2048U * 1024U) // map tiles decode through it (~0.5 MB peak per PNG)
#endif
#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB
#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB
#define LV_MEM_SIZE (2048U * 1024U) // map tiles decode through it (~0.5 MB peak per PNG)
#if defined(ESP32)
#define LV_MEM_ADR 0
#define LV_MEM_POOL_INCLUDE "lv_psram_pool.h"
#define LV_MEM_POOL_ALLOC lv_psram_pool_alloc
#endif
// No OS: the loop draws. LV_OS_FREERTOS with two software draw units was
// measured slower on the L2 (map redraw ~105 ms against 55-80; one unit ~89):
// drawing here is bound by PSRAM bandwidth, which the two units share.
#define LV_USE_OS LV_OS_NONE
#define LV_DEF_REFR_PERIOD 20 // ms; the loop also services the radio
#define LV_DPI_DEF 130 // 2.8" 320x240
@@ -0,0 +1,37 @@
#pragma once
// LVGL's allocator on the ESP32-S3 (LV_STDLIB_CUSTOM in lv_conf.h): the heap
// in PSRAM, so widgets / text never compete with BLE and the radio for
// internal RAM, and -- unlike LVGL's own pool -- safe from two tasks at once:
// map tiles decode through LVGL's lodepng on the other core (TileCache).
// (LVGL's TLSF pool in PSRAM for the loop was measured: malloc+free ~3.5 us
// against ~8, yet screens built no faster -- widget cost is PSRAM and cache
// misses, not the allocator -- and it held 2 MB aside.)
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
#if defined(ESP32)
#include <esp_heap_caps.h>
extern "C" {
void lv_mem_init(void) {}
void lv_mem_deinit(void) {}
lv_mem_pool_t lv_mem_add_pool(void* mem, size_t bytes) { (void)mem; (void)bytes; return NULL; }
void lv_mem_remove_pool(lv_mem_pool_t pool) { (void)pool; }
void* lv_malloc_core(size_t size) { return heap_caps_malloc(size, MALLOC_CAP_SPIRAM); }
void* lv_realloc_core(void* p, size_t new_size) { return heap_caps_realloc(p, new_size, MALLOC_CAP_SPIRAM); }
void lv_free_core(void* p) { heap_caps_free(p); }
void lv_mem_monitor_core(lv_mem_monitor_t* mon) {
multi_heap_info_t i;
heap_caps_get_info(&i, MALLOC_CAP_SPIRAM);
mon->total_size = i.total_free_bytes + i.total_allocated_bytes;
mon->free_size = i.total_free_bytes;
mon->free_biggest_size = i.largest_free_block;
mon->free_cnt = i.free_blocks;
mon->used_cnt = i.allocated_blocks;
mon->max_used = i.total_free_bytes + i.total_allocated_bytes - i.minimum_free_bytes;
mon->used_pct = mon->total_size ? (uint8_t)(100 * i.total_allocated_bytes / mon->total_size) : 0;
mon->frag_pct = i.total_free_bytes ? (uint8_t)(100 - 100 * (uint64_t)i.largest_free_block / i.total_free_bytes) : 0;
}
lv_result_t lv_mem_test_core(void) { return LV_RESULT_OK; }
}
#endif
@@ -1,7 +0,0 @@
#pragma once
// LVGL's builtin allocator pool, placed in PSRAM (see lv_conf.h).
#include <esp_heap_caps.h>
static inline void* lv_psram_pool_alloc(size_t sz) {
return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM);
}
+100 -27
View File
@@ -15,6 +15,10 @@ extern "C" void lv_image_cache_drop(const void* src); // not exported through
namespace mapview {
#ifdef UI_PERF_TEST
static uint32_t s_perf_tile_us = 0, s_perf_tile_max = 0, s_perf_tile_n = 0; // renderTile() time
#endif
class TileCache {
public:
static const int SLOTS = 24; // 6 on screen at 320x218 + the 14-tile read-ahead ring + a few coarser (3 MB)
@@ -38,37 +42,63 @@ public:
return nullptr;
}
// Render z/x/y through `src` into the least recently used slot.
Slot* load(TileProvider& src, int z, int x, int y) {
Slot* s = &_slots[0];
for (Slot& c : _slots) if (c.used < s->used) s = &c;
if (!s->px) {
// Decoding runs off the UI loop: request() hands z/x/y to a task on core 0
// (ESP32; a tile takes ~130 ms to read and decode), which renders it into a
// spare buffer; poll() swaps that into the least recently used slot, so a
// slot on screen is never drawn half written. One tile at a time: false
// while one is in flight. In the sim the job runs at once, the rest alike.
bool busy() const { return _job_state != JOB_IDLE; }
bool request(TileProvider& src, int z, int x, int y) {
if (_job_state != JOB_IDLE) return false;
_job.src = &src;
_job.z = (int16_t)z; _job.x = x; _job.y = y;
_job_stale = false;
_job_state = JOB_RUNNING;
#if defined(ESP32)
s->px = (uint16_t*)heap_caps_malloc(TILE_PX * TILE_PX * 2, MALLOC_CAP_SPIRAM);
if (!_task && xTaskCreatePinnedToCore(jobTask, "tiles", 12288, this, tskIDLE_PRIORITY + 1, &_task, 0) != pdPASS) {
_task = nullptr;
runJob(); // no task: here, as before
return true;
}
xTaskNotifyGive(_task);
#else
s->px = (uint16_t*)malloc(TILE_PX * TILE_PX * 2);
runJob();
#endif
if (!s->px) return nullptr;
}
lv_image_cache_drop(&s->dsc); // same descriptor, new pixels
s->z = z; s->x = x; s->y = y;
s->used = ++_tick;
s->present = src.renderTile(z, x, y, s->px);
if (!s->present) { // remember the miss without holding a buffer slot
return true;
}
// A finished tile into its slot. True when one was placed (or found
// missing): the view has something new. `done` = which, and whether the
// raster provider went without its trails overlay.
struct Done { int z; int32_t x, y; TileProvider* src; bool overlay_missed; };
bool poll(Done& done) {
if (_job_state != JOB_DONE) return false;
done = { _job.z, _job.x, _job.y, _job.src, _job.overlay_missed };
bool keep = !_job_stale;
if (keep && !_job.present) { // remember the miss without holding a buffer slot
Missing& m = _missing[_miss_next++ % MISSING];
m.z = (int16_t)z; m.x = x; m.y = y;
s->z = -1; s->used = 0;
return &_miss_slot;
m.z = _job.z; m.x = _job.x; m.y = _job.y;
} else if (keep) {
Slot* s = &_slots[0];
for (Slot& c : _slots) if (c.used < s->used) s = &c;
lv_image_cache_drop(&s->dsc); // same descriptor, new pixels
uint16_t* old = s->px;
s->px = _spare;
_spare = old; // nullptr while slots are still unallocated: the task makes one
s->z = _job.z; s->x = _job.x; s->y = _job.y;
s->used = ++_tick;
s->present = true;
memset(&s->dsc, 0, sizeof(s->dsc));
s->dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
s->dsc.header.cf = LV_COLOR_FORMAT_RGB565;
s->dsc.header.w = TILE_PX;
s->dsc.header.h = TILE_PX;
s->dsc.header.stride = TILE_PX * 2;
s->dsc.data_size = TILE_PX * TILE_PX * 2;
s->dsc.data = (const uint8_t*)s->px;
}
memset(&s->dsc, 0, sizeof(s->dsc));
s->dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
s->dsc.header.cf = LV_COLOR_FORMAT_RGB565;
s->dsc.header.w = TILE_PX;
s->dsc.header.h = TILE_PX;
s->dsc.header.stride = TILE_PX * 2;
s->dsc.data_size = TILE_PX * TILE_PX * 2;
s->dsc.data = (const uint8_t*)s->px;
return s;
_job_state = JOB_IDLE;
return keep;
}
// Forget the "no tile here" answers (new tiles may have been written).
@@ -82,12 +112,55 @@ public:
void drop(int z, int x, int y) {
for (Slot& s : _slots) if (s.z == z && s.x == x && s.y == y) { s.z = -1; s.used = 0; }
forgetMissing(z, x, y);
if (_job_state != JOB_IDLE && _job.z == z && _job.x == x && _job.y == y) _job_stale = true;
}
// Forget everything (e.g. the card was swapped); keeps the buffers.
void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } forgetMissing(); }
void invalidate() {
for (Slot& s : _slots) { s.z = -1; s.used = 0; }
forgetMissing();
if (_job_state != JOB_IDLE) _job_stale = true; // rendered from what's gone
}
private:
enum : uint8_t { JOB_IDLE, JOB_RUNNING, JOB_DONE };
struct Job { TileProvider* src = nullptr; int16_t z = 0; int32_t x = 0, y = 0; bool present = false, overlay_missed = false; };
Job _job;
volatile uint8_t _job_state = JOB_IDLE; // RUNNING: the task owns _job and _spare
volatile bool _job_stale = false; // dropped / invalidated meanwhile: thrown away
uint16_t* _spare = nullptr; // the buffer the next tile is rendered into
#if defined(ESP32)
TaskHandle_t _task = nullptr;
static void jobTask(void* arg) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
static_cast<TileCache*>(arg)->runJob();
if (TaskHandle_t t = lvport::s_loop_task) xTaskNotifyGive(t); // the loop places it
}
}
#endif
void runJob() {
if (!_spare) {
#if defined(ESP32)
_spare = (uint16_t*)heap_caps_malloc(TILE_PX * TILE_PX * 2, MALLOC_CAP_SPIRAM);
#else
_spare = (uint16_t*)malloc(TILE_PX * TILE_PX * 2);
#endif
}
#ifdef UI_PERF_TEST
uint32_t t0 = micros();
#endif
_job.present = _spare && _job.src->renderTile(_job.z, _job.x, _job.y, _spare);
#ifdef UI_PERF_TEST
uint32_t dt = micros() - t0;
if (_job.present) { s_perf_tile_us += dt; s_perf_tile_n++; if (dt > s_perf_tile_max) s_perf_tile_max = dt; }
#endif
_job.overlay_missed = _job.src->overlayMissed();
#if defined(ESP32)
__sync_synchronize(); // the results before the state the loop reads
#endif
_job_state = JOB_DONE;
}
struct Missing { int16_t z = -1; int32_t x = 0, y = 0; };
static const int MISSING = 48;
Slot _slots[SLOTS];
@@ -93,6 +93,7 @@ public:
virtual bool available() = 0; // anything to draw at all
virtual bool renderTile(int z, int x, int y, uint16_t* out) = 0; // false: no tile there
virtual const char* attribution() const = 0;
virtual bool overlayMissed() const { return false; } // the last renderTile() went without its trails overlay
};
// Raster tiles under <root>/{z}/{x}/{y}.png -- the Meshtastic MUI layout, so
@@ -123,17 +124,30 @@ public:
return true;
}
#ifdef UI_PERF_TEST
uint32_t perf_read_us = 0, perf_dec_us = 0, perf_bytes = 0;
#endif
bool renderTile(int z, int x, int y, uint16_t* out) override {
_overlay_missed = false;
#ifdef UI_PERF_TEST
uint32_t t0 = micros();
#endif
uint32_t len = 0;
uint8_t* png = readLoose(_root, z, x, y, len);
if (!png) png = readPacked(z, x, y, len);
if (!png && _live) png = readLoose(_live, z, x, y, len);
if (!png) return false;
#ifdef UI_PERF_TEST
uint32_t t1 = micros();
perf_read_us += t1 - t0; perf_bytes += len;
#endif
lv_draw_buf_t* db = decodePng(png, len);
lv_free(png);
if (!db) return false;
#ifdef UI_PERF_TEST
perf_dec_us += micros() - t1;
#endif
for (int row = 0; row < TILE_PX; row++) {
const uint8_t* p = db->data + row * db->header.stride; // R, G, B, A
@@ -147,7 +161,7 @@ public:
}
// The last renderTile() found no trails file for its tile (not fetched yet).
bool overlayMissed() const { return _overlay_missed; }
bool overlayMissed() const override { return _overlay_missed; }
const char* attribution() const override {
const char* base = _attr[0] ? _attr : "\xC2\xA9 OpenStreetMap contributors";