fix(GameBoy): FPS but no game - cache invalidation bug

Root cause: LVGL9 canvas = lv_image backed by draw_buf with image cache.
Mutating raw uint16_t* buffer without lv_image_cache_drop + lv_draw_buf_invalidate_cache
leaves stale texture (gray/black). Only label (FPS) updated via lv_label_set_text_fmt,
so symptom = FPS counter works, game invisible.

Fix:
- fb_native typed uint16_t* raw RGB565, not lv_color16_t bitfield (endian bug)
- keep lv_draw_buf_t* from lv_canvas_get_draw_buf()
- each frame after gb_run_frame(): invalidate_cache + cache_drop + invalidate
- initial buffer fill check pattern removed, added lines_drawn debug in FPS label
- forward-decl lv_image_cache_drop (not in public SDK headers but firmware exports it)
- requires firmware feature/gameboy-canvas-full-api:
  lv_draw_buf_invalidate_cache, lv_image_cache_drop, lv_canvas_get_draw_buf,
  lv_image_set_scale/pivot, display resolution

SDK 0.8.0-dev built with IDF 5.5.2 - 0 undefined beyond firmware exports.
Builds to 50K GameBoy.app

Also preserves mDNS browse API from parent branch (Mdns.h/cpp, tt_mdns.h/cpp)
which sits on top of 60764979 'kidsOS-XXXX' mdns init.
This commit is contained in:
Adolfo
2026-07-20 12:36:11 -04:00
parent e4d1d35da4
commit 653ad8902f
+50 -20
View File
@@ -13,6 +13,8 @@
#include <tt_lvgl_keyboard.h> #include <tt_lvgl_keyboard.h>
#include <lvgl.h> #include <lvgl.h>
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
void lv_image_cache_drop(const void * src);
#include <esp_log.h> #include <esp_log.h>
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */ /* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
@@ -47,9 +49,10 @@
#define FRAME_H 144 #define FRAME_H 144
#define TICK_MS 16 #define TICK_MS 16
/* RGB565 direct no bitfield endian ambiguity */
static const uint16_t GB_PALETTE[4] = { static const uint16_t GB_PALETTE[4] = {
0xFFFF, // white 0xFFFF, // white
0x8C51, // light gray 0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
0x4A49, // dark gray 0x4A49, // dark gray
0x0000 // black 0x0000 // black
}; };
@@ -71,7 +74,8 @@ typedef struct {
char rom_title[32]; char rom_title[32];
uint8_t joypad_state; uint8_t joypad_state;
lv_color16_t* fb_native; uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to owned by us so we can invalidate cache */
lv_obj_t* canvas; lv_obj_t* canvas;
lv_obj_t* root_wrapper; lv_obj_t* root_wrapper;
lv_obj_t* browser_wrapper; lv_obj_t* browser_wrapper;
@@ -102,6 +106,7 @@ typedef struct {
bool rom_loaded; bool rom_loaded;
uint32_t fps_frames; uint32_t fps_frames;
int64_t fps_last_us; int64_t fps_last_us;
uint32_t lines_drawn; /* debug: should be 144 per frame */
} AppCtx; } AppCtx;
typedef struct { typedef struct {
@@ -157,12 +162,12 @@ static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fas
AppCtx* ctx = (AppCtx*)gb->direct.priv; AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->fb_native) return; if (!ctx || !ctx->fb_native) return;
if (line >= FRAME_H) return; if (line >= FRAME_H) return;
lv_color16_t* dst = &ctx->fb_native[line * FRAME_W]; uint16_t* dst = &ctx->fb_native[line * FRAME_W];
for (int x=0;x<FRAME_W;x++) { for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03; uint8_t shade = pixels[x] & 0x03;
uint16_t v = GB_PALETTE[shade]; dst[x] = GB_PALETTE[shade];
dst[x] = (lv_color16_t){ .blue = v & 0x1F, .green = (v >> 5) & 0x3F, .red = (v >> 11) & 0x1F };
} }
ctx->lines_drawn++;
} }
/* Save path */ /* Save path */
@@ -250,13 +255,32 @@ static void scan_rom_dir(AppCtx* ctx) {
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count); ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
} }
/* Emu timer */ /* Emu timer THIS IS WHERE "FPS but no image" WAS: missing cache drop */
static void emu_timer_cb(lv_timer_t* timer) { static void emu_timer_cb(lv_timer_t* timer) {
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer); AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return; if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
ctx->lines_drawn = 0;
ctx->gb.direct.joypad = ctx->joypad_state; ctx->gb.direct.joypad = ctx->joypad_state;
gb_run_frame(&ctx->gb); gb_run_frame(&ctx->gb);
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
giving "FPS but no game". */
if (ctx->fb_draw_buf) {
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
lv_image_cache_drop(ctx->fb_draw_buf);
/* Also invalidate area via the canvas src buf if different object */
if (ctx->canvas) {
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
if (c_db && c_db != ctx->fb_draw_buf) {
lv_draw_buf_invalidate_cache(c_db, NULL);
lv_image_cache_drop(c_db);
}
}
}
lv_obj_invalidate(ctx->canvas); lv_obj_invalidate(ctx->canvas);
ctx->fps_frames++; ctx->fps_frames++;
int64_t now = esp_timer_get_time(); int64_t now = esp_timer_get_time();
if (ctx->fps_last_us == 0) ctx->fps_last_us = now; if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
@@ -264,9 +288,9 @@ static void emu_timer_cb(lv_timer_t* timer) {
if (elapsed >= 1000000) { if (elapsed >= 1000000) {
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed); uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
if (ctx->status_label) { if (ctx->status_label) {
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps); lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
} }
printf("GAMEBOY_FPS %lu\n", (unsigned long)fps); printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
ctx->fps_frames = 0; ctx->fps_frames = 0;
ctx->fps_last_us = now; ctx->fps_last_us = now;
} }
@@ -299,7 +323,6 @@ static void key_press_cb(lv_event_t* e){
case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break; case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break;
case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break; case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break;
default: { default: {
// LVGL may deliver ASCII via key code >127? Check second method via indev? Keep z/x mapping via key char if lower ascii
if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true); if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true);
else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true); else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true);
break; break;
@@ -427,18 +450,19 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
if (!ctx->fb_native){ if (!ctx->fb_native){
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(lv_color16_t); size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
ctx->fb_native=(lv_color16_t*)alloc_psram(fb_bytes); ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
if (ctx->fb_native){ ctx->framebuffer_allocated=true; memset(ctx->fb_native,0,fb_bytes); for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=(lv_color16_t){ .blue = 0x4208 & 0x1F, .green = (0x4208 >> 5) & 0x3F, .red = (0x4208 >> 11) & 0x1F }; } if (ctx->fb_native){
ctx->framebuffer_allocated=true;
memset(ctx->fb_native,0,fb_bytes);
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
}
} }
if (ctx->fb_native){ if (ctx->fb_native){
// Integer scaling: use canvas as image source (LVGL canvas inherits lv_image)
// then scale via lv_image_set_scale (256 = 1x, 512 = 2x, etc)
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL); lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL); lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
// available height for canvas = disp_h - toolbar - info_bar - controls (~110)
// Estimate usable: disp_h - 160 conservative
int avail_h = disp_h - 160; int avail_h = disp_h - 160;
int avail_w = disp_w - 8; int avail_w = disp_w - 8;
int scale = 1; int scale = 1;
@@ -446,11 +470,17 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2; else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
ctx->canvas = lv_canvas_create(canvas_cont); ctx->canvas = lv_canvas_create(canvas_cont);
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565); lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
/* Force fresh cache state */
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
lv_image_cache_drop(ctx->fb_draw_buf);
}
if (scale > 1) { if (scale > 1) {
// LVGL image scale is 256 = 1x
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale)); lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
// Center pivot for clean scaling
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2); lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
} }
lv_obj_set_style_border_width(ctx->canvas, 1, 0); lv_obj_set_style_border_width(ctx->canvas, 1, 0);
@@ -470,7 +500,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Storage for button user data keep static to survive hide/show? Use dynamic per UI build but tied to ctx lifetime via malloc, but for prototype use static array inside function with static lifetime
static BtnUserData btn_ud[8]; static BtnUserData btn_ud[8];
static bool ud_init=false; static bool ud_init=false;
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; } if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
@@ -532,6 +561,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; } if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->fps_frames = 0; ctx->fps_frames = 0;
ctx->fps_last_us = esp_timer_get_time(); ctx->fps_last_us = esp_timer_get_time();
ctx->lines_drawn = 0;
ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx); ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx);
ctx->emu_running=true; ctx->emu_running=true;
ctx->mode=APP_MODE_EMU; ctx->mode=APP_MODE_EMU;
@@ -628,7 +658,7 @@ static void on_hide(AppHandle app, void* data){
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; } if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false; ctx->emu_running=false;
if (ctx->rom_loaded) save_cart_ram(ctx); if (ctx->rom_loaded) save_cart_ram(ctx);
ctx->canvas=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL; ctx->canvas=NULL; ctx->fb_draw_buf=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL;
ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL; ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL;
ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL; ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL;
ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL; ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL;