Files
tactility_apps/Apps/GameBoy/main/Source/main.c
T
Adolfo 653ad8902f 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.
2026-07-20 12:36:11 -04:00

680 lines
30 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file main.c
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
*
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
* See app README for notes.
*/
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h>
#include <tt_lvgl_keyboard.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>
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
#undef ESP_LOGI
#undef ESP_LOGW
#undef ESP_LOGE
#define ESP_LOGI(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGW(tag, fmt, ...) do { (void)(tag); } while (0)
#define ESP_LOGE(tag, fmt, ...) do { (void)(tag); } while (0)
#include <esp_heap_caps.h>
#include <esp_timer.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#define ENABLE_SOUND 0
#define ENABLE_LCD 1
#include "peanut_gb.h"
#define TAG "GameBoy"
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
#define ROMS_DIR "/data/roms/gb"
#define MAX_ROMS 64
#define MAX_PATH 512
#define MAX_ROM_SIZE (2 * 1024 * 1024)
#define FRAME_W 160
#define FRAME_H 144
#define TICK_MS 16
/* RGB565 direct no bitfield endian ambiguity */
static const uint16_t GB_PALETTE[4] = {
0xFFFF, // white
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
0x4A49, // dark gray
0x0000 // black
};
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
typedef struct {
char filename[MAX_PATH];
char fullpath[MAX_PATH];
} RomEntry;
typedef struct {
struct gb_s gb;
uint8_t* rom_data;
size_t rom_size;
uint8_t* cart_ram;
size_t cart_ram_size;
char rom_path[MAX_PATH];
char rom_title[32];
uint8_t joypad_state;
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* root_wrapper;
lv_obj_t* browser_wrapper;
lv_obj_t* emu_wrapper;
lv_obj_t* toolbar;
lv_obj_t* status_label;
lv_obj_t* rom_list;
lv_obj_t* controls_cont;
lv_timer_t* emu_timer;
RomEntry roms[MAX_ROMS];
int rom_count;
int selected_rom_idx;
lv_obj_t* btn_up;
lv_obj_t* btn_down;
lv_obj_t* btn_left;
lv_obj_t* btn_right;
lv_obj_t* btn_a;
lv_obj_t* btn_b;
lv_obj_t* btn_start;
lv_obj_t* btn_select;
AppHandle app_handle;
AppMode mode;
bool emu_running;
bool framebuffer_allocated;
bool rom_loaded;
uint32_t fps_frames;
int64_t fps_last_us;
uint32_t lines_drawn; /* debug: should be 144 per frame */
} AppCtx;
typedef struct {
AppCtx* ctx;
uint8_t joypad_bit;
} BtnUserData;
/* PSRAM helpers */
static void* alloc_psram(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
if (!p) p = malloc(size);
return p;
}
/* Peanut-GB callbacks */
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->rom_data) return 0xFF;
if (addr < ctx->rom_size) return ctx->rom_data[addr];
return 0xFF;
}
static uint8_t gb_cart_ram_read(struct gb_s* gb, const uint_fast32_t addr) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return 0xFF;
if (addr < ctx->cart_ram_size) return ctx->cart_ram[addr];
return 0xFF;
}
static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const uint8_t val) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->cart_ram) return;
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
}
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
const char* err_str = "UNKNOWN";
switch (err) {
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
case GB_INVALID_READ: err_str = "INVALID READ"; break;
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
default: break;
}
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (ctx && ctx->cart_ram_size) {
char sp[MAX_PATH];
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
}
}
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
AppCtx* ctx = (AppCtx*)gb->direct.priv;
if (!ctx || !ctx->fb_native) return;
if (line >= FRAME_H) return;
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
for (int x=0;x<FRAME_W;x++) {
uint8_t shade = pixels[x] & 0x03;
dst[x] = GB_PALETTE[shade];
}
ctx->lines_drawn++;
}
/* Save path */
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
if (!rom_path || !out) return;
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
char* dot=strrchr(out,'.'); char* sl=strrchr(out,'/');
if (dot && (!sl || dot>sl)) { snprintf(dot, out_len-(dot-out), ".sav"); }
else { strncat(out,".sav",out_len-strlen(out)-1); }
}
static void load_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
size_t save_sz=0;
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
ctx->cart_ram_size=save_sz;
ctx->cart_ram=alloc_psram(save_sz);
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
memset(ctx->cart_ram,0,save_sz);
FILE* f=fopen(save_path,"rb");
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
}
static void save_cart_ram(AppCtx* ctx) {
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
if (!ctx->rom_path[0]) return;
char save_path[MAX_PATH];
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
FILE* f=fopen(save_path,"wb");
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
}
/* ROM loading */
static bool load_rom_file(AppCtx* ctx, const char* path) {
if (!ctx || !path) return false;
ESP_LOGI(TAG,"Loading ROM %s",path);
FILE* f=fopen(path,"rb");
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
uint8_t* buf=alloc_psram((size_t)sz);
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
ctx->rom_data=buf; ctx->rom_size=(size_t)sz;
strncpy(ctx->rom_path,path,sizeof(ctx->rom_path)-1); ctx->rom_path[sizeof(ctx->rom_path)-1]='\0';
memset(&ctx->gb,0,sizeof(ctx->gb));
ctx->joypad_state=0xFF;
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
gb_init_lcd(&ctx->gb, lcd_draw_line);
load_cart_ram(ctx);
gb_reset(&ctx->gb);
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
ctx->rom_loaded=true;
return true;
}
static void scan_rom_dir(AppCtx* ctx) {
ctx->rom_count=0;
DIR* dir=opendir(ROMS_DIR);
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
struct dirent* ent;
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
if (ent->d_name[0]=='.') continue;
size_t len=strlen(ent->d_name);
if (len<3) continue;
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
if (!is_gb) continue;
RomEntry* e=&ctx->roms[ctx->rom_count++];
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
}
closedir(dir);
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
}
/* Emu timer THIS IS WHERE "FPS but no image" WAS: missing cache drop */
static void emu_timer_cb(lv_timer_t* timer) {
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
ctx->lines_drawn = 0;
ctx->gb.direct.joypad = ctx->joypad_state;
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);
ctx->fps_frames++;
int64_t now = esp_timer_get_time();
if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
int64_t elapsed = now - ctx->fps_last_us;
if (elapsed >= 1000000) {
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
if (ctx->status_label) {
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 LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
ctx->fps_frames = 0;
ctx->fps_last_us = now;
}
}
/* Input helpers */
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
if (!ctx) return;
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
else ctx->joypad_state |= bit;
}
static void input_down_cb(lv_event_t* e){
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
if (!ud) return;
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
}
static void input_up_cb(lv_event_t* e){
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
if (!ud) return;
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
}
static void key_press_cb(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
uint32_t key=lv_event_get_key(e);
switch(key){
case LV_KEY_UP: set_joypad_bit(ctx, JOYPAD_UP, true); break;
case LV_KEY_DOWN: set_joypad_bit(ctx, JOYPAD_DOWN, true); break;
case LV_KEY_LEFT: set_joypad_bit(ctx, JOYPAD_LEFT, true); break;
case LV_KEY_RIGHT: set_joypad_bit(ctx, JOYPAD_RIGHT, 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;
default: {
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);
break;
}
}
}
/* Forward declarations for UI switching */
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
static void switch_to_browser(AppCtx* ctx);
static void switch_to_emu(AppCtx* ctx);
/* ROM selection events */
static void rom_button_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
lv_obj_t* btn=lv_event_get_target_obj(e);
int* pIdx=(int*)lv_obj_get_user_data(btn);
if (!pIdx) return;
ctx->selected_rom_idx=*pIdx;
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
if (load_rom_file(ctx, path)){
switch_to_emu(ctx);
} else {
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
}
}
static void default_rom_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
switch_to_emu(ctx);
} else {
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
}
}
static void back_to_menu_event(lv_event_t* e){
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
switch_to_browser(ctx);
}
/* UI builders */
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
ctx->rom_list=NULL;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 4, 0);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_t* info=lv_label_create(parent);
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info, LV_PCT(100));
lv_obj_set_style_text_color(info, lv_color_white(), 0);
ctx->status_label=lv_label_create(parent);
lv_label_set_text(ctx->status_label, "Select a ROM to start");
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
lv_obj_t* list=lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
ctx->rom_list=list;
if (ctx->rom_count==0){
lv_obj_t* lbl=lv_label_create(parent);
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
} else {
for (int i=0;i<ctx->rom_count;i++){
lv_obj_t* btn=lv_list_add_btn(list, LV_SYMBOL_FILE, ctx->roms[i].filename);
int* idx=(int*)malloc(sizeof(int)); *idx=i;
lv_obj_set_user_data(btn, idx);
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
}
}
lv_obj_t* default_btn=lv_btn_create(parent);
lv_obj_set_width(default_btn, LV_PCT(100));
lv_obj_t* dlbl=lv_label_create(default_btn);
lv_label_set_text(dlbl, "Try default.gb");
lv_obj_center(dlbl);
lv_obj_add_event_cb(default_btn, default_rom_event, LV_EVENT_CLICKED, ctx);
}
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
lv_obj_clean(parent);
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent, 0, 0);
lv_obj_set_style_pad_row(parent, 0, 0);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* info_bar=lv_obj_create(parent);
lv_obj_set_width(info_bar, LV_PCT(100));
lv_obj_set_height(info_bar, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(info_bar, 2, 0);
lv_obj_set_style_border_width(info_bar, 0, 0);
lv_obj_set_flex_flow(info_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x222222), 0);
lv_obj_t* title_lbl=lv_label_create(info_bar);
ctx->status_label = title_lbl;
lv_label_set_text_fmt(title_lbl, "GB: %s FPS:--", ctx->rom_title[0]?ctx->rom_title:"GameBoy");
lv_obj_set_style_text_color(title_lbl, lv_color_white(), 0);
lv_obj_t* spacer=lv_obj_create(info_bar);
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(spacer,0,0);
lv_obj_set_flex_grow(spacer,1);
lv_obj_t* back_btn=lv_btn_create(info_bar);
lv_obj_set_size(back_btn, 60, 28);
lv_obj_t* bl=lv_label_create(back_btn); lv_label_set_text(bl,"Menu"); lv_obj_center(bl);
lv_obj_add_event_cb(back_btn, back_to_menu_event, LV_EVENT_CLICKED, ctx);
lv_obj_t* canvas_cont=lv_obj_create(parent);
lv_obj_set_width(canvas_cont, LV_PCT(100));
lv_obj_set_flex_grow(canvas_cont,1);
lv_obj_set_style_bg_color(canvas_cont, lv_color_black(),0);
lv_obj_set_style_border_width(canvas_cont,0,0);
lv_obj_set_style_pad_all(canvas_cont,2,0);
lv_obj_set_flex_flow(canvas_cont, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
if (!ctx->fb_native){
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
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){
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
int avail_h = disp_h - 160;
int avail_w = disp_w - 8;
int scale = 1;
if (avail_w >= FRAME_W * 3 && avail_h >= FRAME_H * 3) scale = 3;
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
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);
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) {
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
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_color(ctx->canvas, lv_color_hex(0x444444), 0);
lv_obj_center(ctx->canvas);
} else {
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
}
lv_obj_t* ctrl=lv_obj_create(parent);
ctx->controls_cont=ctrl;
lv_obj_set_width(ctrl, LV_PCT(100));
lv_obj_set_height(ctrl, 110);
lv_obj_set_style_pad_all(ctrl,2,0);
lv_obj_set_style_bg_color(ctrl, lv_color_hex(0x111111),0);
lv_obj_set_style_border_width(ctrl,0,0);
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);
static BtnUserData btn_ud[8];
static bool ud_init=false;
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
lv_obj_t* dpad=lv_obj_create(ctrl);
lv_obj_set_size(dpad,96,96);
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(dpad,0,0);
lv_obj_set_style_pad_all(dpad,0,0);
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
lv_obj_t* down=lv_btn_create(dpad); lv_obj_set_size(down,32,32); lv_obj_set_pos(down,32,64);
lv_obj_t* lbl; lbl=lv_label_create(up); lv_label_set_text(lbl, LV_SYMBOL_UP); lv_obj_center(lbl);
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
btn_ud[2].ctx=ctx; btn_ud[2].joypad_bit=JOYPAD_LEFT; lv_obj_add_event_cb(left, input_down_cb, LV_EVENT_PRESSED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_RELEASED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[2]);
btn_ud[3].ctx=ctx; btn_ud[3].joypad_bit=JOYPAD_RIGHT;lv_obj_add_event_cb(right, input_down_cb, LV_EVENT_PRESSED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_RELEASED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[3]);
lv_obj_t* center_col=lv_obj_create(ctrl);
lv_obj_set_size(center_col,64,96);
lv_obj_set_style_bg_opa(center_col,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(center_col,0,0);
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(center_col,4,0);
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
btn_ud[4].ctx=ctx; btn_ud[4].joypad_bit=JOYPAD_SELECT; lv_obj_add_event_cb(sel_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[4]);
btn_ud[5].ctx=ctx; btn_ud[5].joypad_bit=JOYPAD_START; lv_obj_add_event_cb(sta_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[5]);
lv_obj_t* ab=lv_obj_create(ctrl);
lv_obj_set_size(ab,96,96);
lv_obj_set_style_bg_opa(ab,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(ab,0,0);
lv_obj_set_style_pad_all(ab,0,0);
lv_obj_t* b_btn=lv_btn_create(ab); lv_obj_set_size(b_btn,40,40); lv_obj_set_pos(b_btn,0,24); lv_obj_set_style_radius(b_btn,20,0);
lv_obj_t* a_btn=lv_btn_create(ab); lv_obj_set_size(a_btn,40,40); lv_obj_set_pos(a_btn,48,8); lv_obj_set_style_radius(a_btn,20,0);
lbl=lv_label_create(b_btn); lv_label_set_text(lbl,"B"); lv_obj_center(lbl);
lbl=lv_label_create(a_btn); lv_label_set_text(lbl,"A"); lv_obj_center(lbl);
ctx->btn_a=a_btn; ctx->btn_b=b_btn;
btn_ud[6].ctx=ctx; btn_ud[6].joypad_bit=JOYPAD_B; lv_obj_add_event_cb(b_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[6]);
btn_ud[7].ctx=ctx; btn_ud[7].joypad_bit=JOYPAD_A; lv_obj_add_event_cb(a_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[7]);
lv_obj_add_event_cb(parent, key_press_cb, LV_EVENT_KEY, ctx);
if (tt_lvgl_hardware_keyboard_is_available()){
lv_group_t* g=lv_group_get_default();
if (g){ lv_group_add_obj(g, parent); lv_group_focus_obj(parent); lv_group_set_editing(g, true); }
}
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->fps_frames = 0;
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_running=true;
ctx->mode=APP_MODE_EMU;
}
static void switch_to_browser(AppCtx* ctx){
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
save_cart_ram(ctx);
ctx->mode=APP_MODE_BROWSER;
if (!ctx->browser_wrapper || !lv_obj_is_valid(ctx->browser_wrapper)) return;
if (ctx->emu_wrapper) lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
scan_rom_dir(ctx);
build_browser_ui(ctx, ctx->browser_wrapper);
}
static void switch_to_emu(AppCtx* ctx){
if (!ctx->rom_loaded) return;
if (ctx->browser_wrapper) lv_obj_add_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
if (!ctx->emu_wrapper) return;
lv_obj_remove_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
build_emu_ui(ctx, ctx->emu_wrapper);
}
/* Lifecycle */
static void* create_data(void){
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
return ctx;
}
static void destroy_data(void* data){
AppCtx* ctx=(AppCtx*)data;
if (!ctx) return;
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
free(ctx);
}
static void on_create(AppHandle app, void* data){
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
}
static void on_destroy(AppHandle app, void* data){ (void)app; (void)data; }
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
ctx->app_handle=app;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(parent,0,0);
lv_obj_set_style_pad_row(parent,0,0);
lv_obj_set_style_bg_color(parent, lv_color_black(),0);
ctx->toolbar=tt_lvgl_toolbar_create_for_app(parent, app);
lv_obj_set_style_bg_color(ctx->toolbar, lv_color_hex(0x111111),0);
ctx->root_wrapper=lv_obj_create(parent);
lv_obj_set_width(ctx->root_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->root_wrapper,1);
lv_obj_set_style_pad_all(ctx->root_wrapper,0,0);
lv_obj_set_style_border_width(ctx->root_wrapper,0,0);
lv_obj_set_style_bg_color(ctx->root_wrapper, lv_color_black(),0);
lv_obj_set_flex_flow(ctx->root_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(ctx->root_wrapper, LV_OBJ_FLAG_SCROLLABLE);
ctx->browser_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->browser_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->browser_wrapper,1);
lv_obj_set_style_pad_all(ctx->browser_wrapper,0,0);
lv_obj_set_style_border_width(ctx->browser_wrapper,0,0);
ctx->emu_wrapper=lv_obj_create(ctx->root_wrapper);
lv_obj_set_width(ctx->emu_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->emu_wrapper,1);
lv_obj_set_style_pad_all(ctx->emu_wrapper,0,0);
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
scan_rom_dir(ctx);
struct stat st;
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
if (default_exists){
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
build_browser_ui(ctx, ctx->browser_wrapper);
switch_to_emu(ctx);
return;
}
}
build_browser_ui(ctx, ctx->browser_wrapper);
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
ctx->mode=APP_MODE_BROWSER;
}
static void on_hide(AppHandle app, void* data){
(void)app;
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
ctx->emu_running=false;
if (ctx->rom_loaded) save_cart_ram(ctx);
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->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->app_handle=NULL;
}
int main(int argc, char* argv[]){
(void)argc; (void)argv;
tt_app_register((AppRegistration){
.createData=create_data,
.destroyData=destroy_data,
.onCreate=on_create,
.onDestroy=on_destroy,
.onShow=on_show,
.onHide=on_hide
});
return 0;
}