Merge personal/main into main - preserve GameBoy fixes and RobotArm mDNS
Main / Build (AudioTest) (push) Has been cancelled
Main / Build (BibleVerse) (push) Has been cancelled
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (EspNowBridge) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GameBoy) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (McpScreen) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (Mp3Player) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (PocketDungeon) (push) Has been cancelled
Main / Build (ReynaBot) (push) Has been cancelled
Main / Build (RobotArm) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Build (VoiceRecorder) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
Main / PublishApps (push) Has been cancelled
Main / Build (AudioTest) (push) Has been cancelled
Main / Build (BibleVerse) (push) Has been cancelled
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (EspNowBridge) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GameBoy) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (McpScreen) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (Mp3Player) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (PocketDungeon) (push) Has been cancelled
Main / Build (ReynaBot) (push) Has been cancelled
Main / Build (RobotArm) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Build (VoiceRecorder) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
Main / PublishApps (push) Has been cancelled
- Keep GameBoy emulator with canvas fixes from personal/main (4ff787fetc) - Keep RobotArm mDNS auto-connect (6d9001f) over local cute version - Keep BibleVerse stack overflow fix (4a15309) - Keep manifest V2 conversions (c258aeb)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||
else()
|
||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||
endif()
|
||||
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(GameBoy)
|
||||
tactility_project(GameBoy)
|
||||
@@ -0,0 +1,96 @@
|
||||
# GameBoy Emulator (Tactility Prototype, No Audio)
|
||||
|
||||
DMG Game Boy emulator for Tactility side-loaded apps, using **Peanut-GB** (MIT) as CPU/LCD core.
|
||||
|
||||
## Status
|
||||
|
||||
- Prototype v0.1.0-dev – compiling draft focused on architecture, not yet production-hardened.
|
||||
- **No audio** (ENABLE_SOUND 0). Audio path stubbed for future MiniGB APU or i2s-driven implementation.
|
||||
- Supports MBC1/MBC2/MBC3/MBC5 via Peanut-GB.
|
||||
- ROM loader from SD card.
|
||||
- Save RAM persistence via `.sav` file next to ROM.
|
||||
- LVGL framebuffer: native 160x144 RGB565, integer-scaled if display large enough, centered on black background.
|
||||
- Input: on-screen D-pad + A/B + Start/Select + hardware keyboard arrows + LVGL key events (z= A, x= B).
|
||||
- Timer-driven at ~16ms (~60Hz) calling `gb_run_frame()`.
|
||||
|
||||
## ROM Location
|
||||
|
||||
- Scanned directory: `/sdcard/roms/gb/` for `*.gb`, `*.gbc`, `*.bin` (up to 64 entries).
|
||||
- Default quick-load: `/sdcard/roms/gb/default.gb` – if present on app show, autoloads and jumps directly to emulation.
|
||||
- Place your legally dumped ROMs there; no ROMs are bundled.
|
||||
|
||||
## Save RAM Path Design (stubbed + implemented minimal)
|
||||
|
||||
- Save file = ROM path with extension replaced by `.sav` (e.g. `/sdcard/roms/gb/tetris.gb` -> `/sdcard/roms/gb/tetris.sav`).
|
||||
- Loaded on ROM load, saved on:
|
||||
- switching back to menu
|
||||
- app hide
|
||||
- error recovery path
|
||||
- Size queried via `gb_get_save_size_s()` / `gb_get_save_size()`.
|
||||
- Future improvement: also mirror to app user-data dir (`tt_app_get_user_data_child_path`) if SD is read-only.
|
||||
|
||||
## Memory Considerations (ESP32-S3 / PSRAM)
|
||||
|
||||
- Large buffers allocated via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)` with fallback to internal.
|
||||
- ROM buffer: up to 2MB (MBC5 max-ish) – PSRAM preferred.
|
||||
- Cart RAM: variable, often 8KB-32KB, PSRAM.
|
||||
- Framebuffer: 160*144*2 = 46,080 bytes (~45KB) native RGB565. PSRAM preferred. No double buffering needed (line callback writes directly).
|
||||
- No huge heap allocations.
|
||||
- Emulator context `struct gb_s` is static inside AppCtx (~few KB).
|
||||
|
||||
## Controls / Input Mapping
|
||||
|
||||
| GB | On-screen | Keyboard | Remarks |
|
||||
|----|-----------|----------|---------|
|
||||
| D-pad | 4 arrow buttons | LV_KEY_ arrows | press/release tracked |
|
||||
| A | A button (right cluster) | z | |
|
||||
| B | B button (right cluster) | x | |
|
||||
| Start | Sta | Enter / Space | |
|
||||
| Select | Sel | Esc / Backspace | |
|
||||
| Touch | Quadrants not yet separated – buttons cover |
|
||||
|
||||
Future: touch quadrants mapping via `pointToQuadrant` like GameKitInput.
|
||||
|
||||
## LCD Rendering
|
||||
|
||||
- Peanut-GB calls `lcd_draw_line(gb, pixels[160], line)` per scanline.
|
||||
- `pixels` low 2 bits = shade 0-3.
|
||||
- Mapped to olive/gray palette RGB565 (editable).
|
||||
- Canvas buffer is the framebuffer itself.
|
||||
- Scale: if display resolution >= 320x432 => 2x, >=480x576 => 3x via LVGL transform scale (keeps native buffer).
|
||||
|
||||
## No-Audio Limitation
|
||||
|
||||
- `ENABLE_SOUND 0` – audio callbacks not compiled.
|
||||
- To add audio:
|
||||
1. Vendor MiniGB APU (`minigb_apu`) or similar.
|
||||
2. Implement `audio_read` / `audio_write` forwarding to APU.
|
||||
3. Define ENABLE_SOUND 1, include APU, create audio task similar to BookPlayer (i2s_controller).
|
||||
4. Feed APU samples in timer / separate task.
|
||||
|
||||
## Build
|
||||
|
||||
Same as other Tactility apps:
|
||||
|
||||
```
|
||||
. $IDF_PATH/export.sh
|
||||
export TACTILITY_SDK_PATH=...
|
||||
python3 tactility.py Apps/GameBoy build esp32s3 --local-sdk
|
||||
```
|
||||
|
||||
## Licensing
|
||||
|
||||
- App code: GPLv3 (same as Tactility Apps).
|
||||
- Peanut-GB vendored lib: MIT (Copyright (c) 2018-2023 Mahyar Koshkouei). License preserved in `Libraries/PeanutGB/peanut_gb.h`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Improve input: add touch quadrant → D-pad, repeat timers for held buttons.
|
||||
- [ ] Add pause/resume UI, FPS display.
|
||||
- [ ] Add palette selector (auto_assign_palette logic from peanut_sdl).
|
||||
- [ ] Add file picker dialog (`tt_app_selectiondialog_start`) improvement + recursive folder browsing.
|
||||
- [ ] Audio: vendoring `minigb_apu` and creating I2S task.
|
||||
- [ ] Save state beyond cart RAM (full emu snapshot).
|
||||
- [ ] RTC persistence for MBC3 RTC games.
|
||||
- [ ] Error dialog via `tt_app_alertdialog_start`.
|
||||
- [ ] Validate with Cppcheck / clang-format and ESP-IDF build.
|
||||
@@ -0,0 +1,7 @@
|
||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
INCLUDE_DIRS Source ../../../Libraries/PeanutGB
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.gameboy
|
||||
versionName=0.1.0-dev
|
||||
versionCode=1
|
||||
name=GameBoy
|
||||
description=DMG Game Boy emulator (no audio prototype, Peanut-GB)
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <tt_mdns.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -8,10 +9,8 @@
|
||||
#include <unistd.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/inet.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "RobotArm"
|
||||
#define ARM_HOST "192.168.68.103"
|
||||
#define ARM_PORT 80
|
||||
#define ARM_PATH "/api/mcp"
|
||||
#define NUM_JOINTS 6
|
||||
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
|
||||
static bool seq_playing = false;
|
||||
static int seq_play_pos = 0;
|
||||
|
||||
static char arm_host[64] = "";
|
||||
static bool arm_connected = false;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
lv_obj_t* root;
|
||||
lv_obj_t* content; // scrollable column container
|
||||
lv_obj_t* status;
|
||||
lv_obj_t* sliders[6];
|
||||
lv_obj_t* vals[6];
|
||||
lv_obj_t* seq_label;
|
||||
lv_obj_t* play_label;
|
||||
lv_obj_t* blocks[SEQ_MAX];
|
||||
lv_obj_t* connect_box;
|
||||
lv_obj_t* main_box;
|
||||
lv_obj_t* connect_label;
|
||||
lv_obj_t* connect_spinner;
|
||||
int pend[6];
|
||||
bool has[6];
|
||||
int ticks[6];
|
||||
lv_timer_t* poll;
|
||||
lv_timer_t* seq_timer;
|
||||
lv_timer_t* connect_timer;
|
||||
int connect_attempts;
|
||||
} Ctx;
|
||||
static Ctx* g = NULL;
|
||||
|
||||
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||
|
||||
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||
uint32_t ip=ipaddr_addr(host);
|
||||
if(ip==0 || ip==0xFFFFFFFF) return -2;
|
||||
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||
if(fd<0) return -1;
|
||||
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
|
||||
struct timeval tv={5,0};
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
|
||||
struct timeval tv={2,0};
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||
char hdr[256];
|
||||
int bl=strlen(body);
|
||||
char hdr[256]; int bl=strlen(body);
|
||||
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||
int tot=0;
|
||||
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
int tot=0; while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
out[tot]='\0'; close(fd);
|
||||
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||
return tot>0?0:-4;
|
||||
}
|
||||
|
||||
static bool jget(const char* js,const char* key,int* out){
|
||||
const char* p=strstr(js,key);
|
||||
if(!p) return false;
|
||||
p+=strlen(key);
|
||||
while(*p && *p!=':'){
|
||||
p++;
|
||||
if(!*p) return false;
|
||||
}
|
||||
p++;
|
||||
const char* p=strstr(js,key); if(!p) return false;
|
||||
p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
|
||||
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||
int sign=1;
|
||||
if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f;
|
||||
bool dot=false,got=false;
|
||||
int sign=1; if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f; bool dot=false,got=false;
|
||||
while(*p){
|
||||
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||
else if(*p=='.'&&!dot) dot=true;
|
||||
else break;
|
||||
p++;
|
||||
else if(*p=='.'&&!dot) dot=true; else break; p++;
|
||||
}
|
||||
if(!got) return false;
|
||||
*out=(int)(v*sign+0.5f);
|
||||
return true;
|
||||
*out=(int)(v*sign+0.5f); return true;
|
||||
}
|
||||
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
int v; bool ok=true;
|
||||
@@ -117,30 +116,24 @@ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* g
|
||||
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
if(http_post(arm_host,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
return parse_state(resp,b,s,e,p,ro,gr);
|
||||
}
|
||||
static bool rpc_move(const char* j,int a){
|
||||
char body[300]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
|
||||
j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||
char body[420]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||
b,s,e,p,ro,gr,dur);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
|
||||
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
|
||||
return rc==0;
|
||||
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_home(void){
|
||||
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
}
|
||||
|
||||
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||
static void apply_ui(void){
|
||||
if(!g) return;
|
||||
@@ -150,7 +143,7 @@ static void apply_ui(void){
|
||||
}
|
||||
}
|
||||
static void refresh_arm(void){
|
||||
set_status("Reading .103...");
|
||||
set_status("Reading...");
|
||||
int b,s,e,p,ro,gr;
|
||||
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||
@@ -158,21 +151,21 @@ static void refresh_arm(void){
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||
apply_ui();
|
||||
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||
} else set_status("No .103");
|
||||
} else set_status("No arm");
|
||||
}
|
||||
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void poll_cb(lv_timer_t* t){
|
||||
(void)t; if(!g) return;
|
||||
(void)t; if(!g || !arm_connected) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(!g->has[i]) continue;
|
||||
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||
g->has[i]=false; g->ticks[i]=0;
|
||||
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
char buf[24]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
bool ok=rpc_move(joints[i].name,tgt);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
|
||||
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── sequencer ── */
|
||||
static void update_seq_ui(void){
|
||||
if(!g) return;
|
||||
if(g->seq_label){
|
||||
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
|
||||
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||
}
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_rem_cb(void){
|
||||
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||
int rem=seq_idx;
|
||||
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
int rem=seq_idx; for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1;
|
||||
load_frame(seq_idx);
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1; load_frame(seq_idx);
|
||||
}
|
||||
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||
|
||||
static void seq_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!seq_playing||seq_len==0) return;
|
||||
int f=seq_play_pos%seq_len;
|
||||
int* v=seq[f].v;
|
||||
int f=seq_play_pos%seq_len; int* v=seq[f].v;
|
||||
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||
set_status("Seq fail"); seq_playing=false;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
|
||||
return;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); return;
|
||||
}
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=f; update_seq_ui();
|
||||
@@ -253,42 +239,155 @@ static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
||||
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||
|
||||
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||
|
||||
static bool try_host(const char* host){
|
||||
char resp[1024]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
|
||||
if(strstr(resp,"base")){
|
||||
strncpy(arm_host,host,sizeof(arm_host)-1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static void show_main_ui(void){
|
||||
if(!g) return;
|
||||
arm_connected=true;
|
||||
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
|
||||
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
|
||||
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,500,NULL);
|
||||
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
|
||||
}
|
||||
static void connect_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!g || arm_connected) return;
|
||||
g->connect_attempts++;
|
||||
char lbl[64];
|
||||
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
|
||||
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
|
||||
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
|
||||
|
||||
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
|
||||
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
|
||||
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
|
||||
for(int i=0;i<res.count;i++){
|
||||
if(res.services[i].primaryAddress[0]){
|
||||
if(try_host(res.services[i].primaryAddress)){
|
||||
show_main_ui(); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── 2) mDNS resolve robotarm.local hostname ──
|
||||
char ipbuf[64]={0};
|
||||
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
|
||||
if(try_host(ipbuf)){ show_main_ui(); return; }
|
||||
}
|
||||
// ── 3) fallback hardcoded (old behavior) ──
|
||||
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
|
||||
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
|
||||
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
|
||||
|
||||
if(g->connect_attempts>15){
|
||||
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
|
||||
}
|
||||
}
|
||||
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
|
||||
|
||||
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
(void)data;
|
||||
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
Ctx* c=calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
c->app=app; g=c;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
|
||||
|
||||
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||
|
||||
// Root fills screen below toolbar - NOT scrollable itself, content will be
|
||||
lv_obj_t* root=lv_obj_create(parent);
|
||||
c->root=root;
|
||||
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,0);
|
||||
lv_obj_set_style_border_width(root,0,0);
|
||||
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||
lv_obj_set_scroll_dir(root, LV_DIR_VER);
|
||||
lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
// ── connecting screen (initial visible) ──
|
||||
c->connect_box=lv_obj_create(root);
|
||||
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_pos(c->connect_box,0,0);
|
||||
lv_obj_set_style_border_width(c->connect_box,0,0);
|
||||
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->connect_box,4,0);
|
||||
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* tl=lv_label_create(c->connect_box);
|
||||
lv_label_set_text(tl,"Finding robotarm...");
|
||||
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
|
||||
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
|
||||
lv_obj_set_pos(tl,4,4);
|
||||
|
||||
c->connect_label=lv_label_create(c->connect_box);
|
||||
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
|
||||
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
|
||||
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
|
||||
|
||||
c->connect_spinner=lv_spinner_create(c->connect_box);
|
||||
lv_obj_set_size(c->connect_spinner,40,40);
|
||||
lv_obj_set_pos(c->connect_spinner,120,110);
|
||||
|
||||
lv_obj_t* rb=lv_btn_create(c->connect_box);
|
||||
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
|
||||
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
|
||||
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
|
||||
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
|
||||
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
|
||||
|
||||
// ── main scrollable content (hidden until connected) ──
|
||||
// content is the ONLY scrollable container - FIX overall app scroller lost
|
||||
c->content=lv_obj_create(root);
|
||||
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_pos(c->content,0,0);
|
||||
lv_obj_set_style_border_width(c->content,0,0);
|
||||
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->content,0,0);
|
||||
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
|
||||
|
||||
c->main_box=lv_obj_create(c->content);
|
||||
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(c->main_box,0,0);
|
||||
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->main_box,0,0);
|
||||
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(c->main_box); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0);
|
||||
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
|
||||
lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn...");
|
||||
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0);
|
||||
lv_obj_set_pos(c->status,90,0);
|
||||
|
||||
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_t* brow=lv_obj_create(c->main_box); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
|
||||
lv_obj_set_style_pad_all(brow,0,0);
|
||||
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||
@@ -302,12 +401,12 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
|
||||
lv_obj_t* grid=lv_obj_create(root);
|
||||
lv_obj_t* grid=lv_obj_create(c->main_box);
|
||||
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
|
||||
lv_obj_set_style_pad_all(grid,2,0);
|
||||
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
int col=i%3, row=i/3;
|
||||
@@ -320,6 +419,7 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_set_style_border_width(card,0,0);
|
||||
lv_obj_set_style_pad_all(card,3,0);
|
||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
lv_obj_t* name=lv_label_create(card);
|
||||
lv_label_set_text(name,joints[i].cute);
|
||||
@@ -357,27 +457,22 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||
}
|
||||
|
||||
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
|
||||
visual: colored blocks, no info, current highlighted, larger buttons */
|
||||
lv_obj_t* seqbar=lv_obj_create(root);
|
||||
lv_obj_t* seqbar=lv_obj_create(c->main_box);
|
||||
lv_obj_set_size(seqbar,316,96);
|
||||
lv_obj_set_pos(seqbar,2,232);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(seqbar,12,0);
|
||||
lv_obj_set_style_border_width(seqbar,1,0);
|
||||
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||
|
||||
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||
@@ -394,7 +489,6 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
uint32_t blk_cols[16]={
|
||||
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||
@@ -412,9 +506,8 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
}
|
||||
update_seq_ui();
|
||||
|
||||
c->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,600,NULL);
|
||||
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
|
||||
connect_timer_cb(NULL);
|
||||
}
|
||||
|
||||
static void onHide(AppHandle app,void* data){
|
||||
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
|
||||
if(g){
|
||||
if(g->poll) lv_timer_delete(g->poll);
|
||||
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||
if(g->connect_timer) lv_timer_delete(g->connect_timer);
|
||||
free(g); g=NULL;
|
||||
}
|
||||
seq_playing=false;
|
||||
seq_playing=false; arm_connected=false;
|
||||
}
|
||||
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
manifest.version=0.2
|
||||
target.sdk=0.8.0-dev
|
||||
target.platforms=esp32s3
|
||||
app.id=one.tactility.robotarm
|
||||
app.version.name=0.1.0
|
||||
app.version.code=1
|
||||
app.name=Robot Arm
|
||||
app.description=Cute controller for MCP robot arm
|
||||
[manifest]
|
||||
version=0.1
|
||||
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
|
||||
[app]
|
||||
id=one.tactility.robotarm
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Robot Arm
|
||||
description=Cute controller for MCP robot arm
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Peanut-GB vendored library
|
||||
|
||||
Source: https://github.com/deltabeard/Peanut-GB
|
||||
File: peanut_gb.h (single-header emulator)
|
||||
License: MIT License - Copyright (c) 2018-2023 Mahyar Koshkouei
|
||||
Date Vendored: 2026-07-17 UTC
|
||||
Upstream: master branch latest as fetched 2026-07-17
|
||||
Commit URL: https://github.com/deltabeard/Peanut-GB/tree/master
|
||||
Size: ~4044 lines
|
||||
|
||||
MIT license text is preserved intact at top of peanut_gb.h header.
|
||||
SameBoy-derived portions: Copyright (c) 2015-2019 Lior Halphon, also MIT.
|
||||
|
||||
Usage:
|
||||
```c
|
||||
#define ENABLE_SOUND 0
|
||||
#define ENABLE_LCD 1
|
||||
#include "peanut_gb.h"
|
||||
```
|
||||
|
||||
No modifications applied — used as-is.
|
||||
|
||||
For Tactility GameBoy app, audio is disabled (ENABLE_SOUND 0) per prototype spec.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user