From da41f3da20165a172a64ae355c9945fc83378827 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 10 Nov 2026 21:04:39 -0600 Subject: [PATCH 1/6] launcher: games grouped by console, console badge, saved settings, resume - The game list is grouped under a header per console (SNES, Game Boy Advance, Genesis, PlayStation, PC, PICO-8), metadata.csv order within one. - The selected game's console is shown above its boxart, on the launcher only: boxart/consoles/.jpg from the SD card if present, else a badge drawn from LVGL objects (a generic icon and the name, no logos). - Settings survive a power cycle in /sdcard/settings.bin (espp persistent_data, versioned): volume, mute, brightness, video scaling, resume, the last game started (selected again at boot) and the save slot last used per game. Written once launcher changes stop, at each launch and on the way back from a game (pause-menu changes). - "Resume last save" (on by default): starting a game loads the slot it was last saved to / loaded from, for the emulator cores (Genesis, SNES, GBA, PICO-8 after its first second); the native ports start fresh. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/gui/CMakeLists.txt | 2 +- components/gui/include/console_art.hpp | 19 +++ components/gui/include/gui.hpp | 20 ++- components/gui/src/console_art.cpp | 135 ++++++++++++++++++++ components/gui/src/gui.cpp | 150 +++++++++++++++++++---- components/rom_info/include/rom_info.hpp | 22 ++++ components/rom_info/src/rom_info.cpp | 36 ++++++ components/settings/CMakeLists.txt | 4 + components/settings/idf_component.yml | 5 + components/settings/include/settings.hpp | 89 ++++++++++++++ components/settings/src/settings.cpp | 66 ++++++++++ main/cart.hpp | 43 +++++++ main/gba_cart.hpp | 2 + main/genesis_cart.hpp | 2 + main/main.cpp | 40 ++++++ main/pico8_cart.hpp | 4 + main/snes_cart.hpp | 2 + 17 files changed, 615 insertions(+), 26 deletions(-) create mode 100644 components/gui/include/console_art.hpp create mode 100644 components/gui/src/console_art.cpp create mode 100644 components/settings/CMakeLists.txt create mode 100644 components/settings/idf_component.yml create mode 100644 components/settings/include/settings.hpp create mode 100644 components/settings/src/settings.cpp diff --git a/components/gui/CMakeLists.txt b/components/gui/CMakeLists.txt index f66162c..cdfccb1 100644 --- a/components/gui/CMakeLists.txt +++ b/components/gui/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES lvgl timer logger rom_info tab5-emu) + REQUIRES lvgl timer logger rom_info settings tab5-emu) diff --git a/components/gui/include/console_art.hpp b/components/gui/include/console_art.hpp new file mode 100644 index 0000000..2cb664c --- /dev/null +++ b/components/gui/include/console_art.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "lvgl.h" + +#include "rom_info.hpp" + +/// Draw a small icon of `info`'s console (a gamepad, a handheld, a disc...) +/// out of plain LVGL objects into a new `h * 8 / 5` x `h` container in +/// `parent`. Generic shapes in the console's accent color; no logos. +lv_obj_t *console_icon_create(lv_obj_t *parent, const ConsoleInfo &info, int h); + +/// A rounded badge in the console's accent color: the icon and the console's +/// name, `h` pixels tall, sized to its content. +lv_obj_t *console_badge_create(lv_obj_t *parent, const ConsoleInfo &info, int h, const lv_font_t *font); + +/// The SD card art for `info`, if there is any: `/boxart/consoles/.jpg`. +std::string console_art_path(const std::string &root, const ConsoleInfo &info); diff --git a/components/gui/include/gui.hpp b/components/gui/include/gui.hpp index c36a2f3..ae4d2be 100644 --- a/components/gui/include/gui.hpp +++ b/components/gui/include/gui.hpp @@ -14,12 +14,16 @@ #include "high_resolution_timer.hpp" #include "logger.hpp" +#include "console_art.hpp" #include "rom_info.hpp" +#include "settings.hpp" #include "tab5-emu.hpp" -/// The launcher GUI: a list of games (from metadata.csv on the SD card) with -/// boxart, plus settings (volume, brightness, video scaling). Hand-written -/// LVGL, laid out for the Tab5's 1280x720 landscape screen. +/// The launcher GUI: a list of games (from metadata.csv on the SD card), +/// grouped under a header per console, with the selected game's boxart and +/// console, plus settings (volume, brightness, video scaling, resume), which +/// are kept in AppSettings. Hand-written LVGL, laid out for the Tab5's +/// 1280x720 landscape screen. class Gui { public: struct Config { @@ -72,6 +76,15 @@ class Gui { lv_obj_t *boxart_{nullptr}; lv_draw_buf_t *boxart_buf_{nullptr}; ///< decoded boxart the image widget shows void load_boxart(const std::string &path); + /// Decode a JPEG on the SD card into a new RGB565 draw buffer (nullptr if it cannot) + lv_draw_buf_t *decode_image(const std::string &path); + + // the console the selected game runs on: its SD card art, or a badge + lv_obj_t *console_area_{nullptr}; + lv_draw_buf_t *console_art_buf_{nullptr}; + int shown_console_{-1}; + void show_console(Console console); + void add_console_header(Console console); lv_obj_t *title_label_{nullptr}; lv_obj_t *play_button_{nullptr}; lv_obj_t *volume_slider_{nullptr}; @@ -81,6 +94,7 @@ class Gui { lv_obj_t *usb_label_{nullptr}; void set_usb_drive(bool on); lv_obj_t *video_dropdown_{nullptr}; + lv_obj_t *resume_switch_{nullptr}; lv_obj_t *battery_label_{nullptr}; std::vector rom_buttons_; diff --git a/components/gui/src/console_art.cpp b/components/gui/src/console_art.cpp new file mode 100644 index 0000000..771973c --- /dev/null +++ b/components/gui/src/console_art.cpp @@ -0,0 +1,135 @@ +#include "console_art.hpp" + +#include +#include + +namespace { + +lv_obj_t *shape(lv_obj_t *parent, int x, int y, int w, int h, int radius, lv_color_t color) { + auto *o = lv_obj_create(parent); + lv_obj_remove_style_all(o); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_pos(o, x, y); + lv_obj_set_size(o, w, h); + lv_obj_set_style_radius(o, radius, 0); + lv_obj_set_style_bg_color(o, color, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); + return o; +} + +// a + shaped d-pad centered at (cx, cy), `s` across +void dpad(lv_obj_t *parent, int cx, int cy, int s, lv_color_t color) { + const int t = std::max(2, s / 3); + shape(parent, cx - s / 2, cy - t / 2, s, t, 1, color); + shape(parent, cx - t / 2, cy - s / 2, t, s, 1, color); +} + +void dot(lv_obj_t *parent, int cx, int cy, int d, lv_color_t color) { + shape(parent, cx - d / 2, cy - d / 2, d, d, LV_RADIUS_CIRCLE, color); +} + +} // namespace + +lv_obj_t *console_icon_create(lv_obj_t *parent, const ConsoleInfo &info, int h) { + const int w = h * 8 / 5; + auto *box = lv_obj_create(parent); + lv_obj_remove_style_all(box); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(box, w, h); + + const lv_color_t body = lv_color_white(); + const lv_color_t accent = lv_color_hex(info.color); + const lv_color_t dark = lv_color_hex(0x202428); + + switch (info.shape) { + case ConsoleShape::GAMEPAD: { + // a wide rounded controller: d-pad left, two buttons right + const int bh = h * 6 / 10, by = (h - bh) / 2; + shape(box, 0, by, w, bh, bh / 2, body); + dpad(box, w * 27 / 100, h / 2, bh * 6 / 10, accent); + dot(box, w * 66 / 100, h / 2 + bh / 8, bh / 4, accent); + dot(box, w * 80 / 100, h / 2 - bh / 8, bh / 4, accent); + break; + } + case ConsoleShape::HANDHELD_TALL: { + // a portrait handheld: screen on top, d-pad and buttons below it + const int bw = h * 66 / 100, bx = (w - bw) / 2; + shape(box, bx, 0, bw, h, bw / 6, body); + shape(box, bx + bw / 8, h / 10, bw * 6 / 8, h * 4 / 10, 2, dark); + dpad(box, bx + bw * 30 / 100, h * 72 / 100, bw * 3 / 10, accent); + dot(box, bx + bw * 68 / 100, h * 76 / 100, bw / 6, accent); + dot(box, bx + bw * 84 / 100, h * 66 / 100, bw / 6, accent); + break; + } + case ConsoleShape::HANDHELD_WIDE: { + // a landscape handheld: screen in the middle, d-pad and buttons beside it + const int bh = h * 7 / 10, by = (h - bh) / 2; + shape(box, 0, by, w, bh, bh * 4 / 10, body); + shape(box, w * 30 / 100, by + bh / 6, w * 40 / 100, bh * 2 / 3, 2, dark); + dpad(box, w * 15 / 100, h / 2, bh * 4 / 10, accent); + dot(box, w * 82 / 100, h / 2 + bh / 10, bh / 5, accent); + dot(box, w * 90 / 100, h / 2 - bh / 10, bh / 5, accent); + break; + } + case ConsoleShape::DISC: { + // a CD: rim, label ring, hole + const int d = h, x = (w - d) / 2; + shape(box, x, 0, d, d, LV_RADIUS_CIRCLE, body); + dot(box, w / 2, h / 2, d * 45 / 100, accent); + dot(box, w / 2, h / 2, d * 15 / 100, body); + break; + } + case ConsoleShape::COMPUTER: { + // a monitor on a stand + const int mw = w * 70 / 100, mh = h * 66 / 100, mx = (w - mw) / 2; + shape(box, mx, 0, mw, mh, 3, body); + shape(box, mx + mw / 12, mh / 8, mw * 10 / 12, mh * 6 / 8, 1, accent); + shape(box, w / 2 - w / 14, mh, w / 7, h / 6, 0, body); + shape(box, w / 2 - w / 5, h - h / 8, w * 2 / 5, h / 8, 2, body); + break; + } + case ConsoleShape::FANTASY: { + // a tiny cartridge-console: a square with a screen of palette dots + const int s = h, x = (w - s) / 2; + shape(box, x, 0, s, s, s / 6, body); + const int sx = x + s / 8, sy = s / 8, ss = s * 6 / 8; + shape(box, sx, sy, ss, ss * 3 / 4, 2, dark); + const uint32_t palette[] = {0xFF004D, 0xFFA300, 0xFFEC27, 0x00E436}; + const int p = std::max(2, ss / 6); + for (int i = 0; i < 4; i++) { + shape(box, sx + ss / 10 + i * (p + p / 3), sy + ss * 3 / 8 - p / 2, p, p, 0, lv_color_hex(palette[i])); + } + break; + } + } + return box; +} + +lv_obj_t *console_badge_create(lv_obj_t *parent, const ConsoleInfo &info, int h, const lv_font_t *font) { + auto *badge = lv_obj_create(parent); + lv_obj_remove_style_all(badge); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(badge, LV_SIZE_CONTENT, h); + lv_obj_set_style_radius(badge, h / 2, 0); + lv_obj_set_style_bg_color(badge, lv_color_hex(info.color), 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0); + lv_obj_set_style_pad_hor(badge, h / 3, 0); + lv_obj_set_style_pad_column(badge, h / 4, 0); + lv_obj_set_flex_flow(badge, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(badge, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + console_icon_create(badge, info, h * 6 / 10); + auto *label = lv_label_create(badge); + lv_label_set_text(label, info.name); + lv_obj_set_style_text_color(label, lv_color_white(), 0); + if (font) lv_obj_set_style_text_font(label, font, 0); + return badge; +} + +std::string console_art_path(const std::string &root, const ConsoleInfo &info) { + const std::string path = root + "/boxart/consoles/" + info.key + ".jpg"; + std::error_code ec; + return std::filesystem::exists(path, ec) ? path : std::string{}; +} diff --git a/components/gui/src/gui.cpp b/components/gui/src/gui.cpp index 37e1235..dc97e1e 100644 --- a/components/gui/src/gui.cpp +++ b/components/gui/src/gui.cpp @@ -2,6 +2,8 @@ #include "lvgl_private.h" +#include + using namespace std::chrono_literals; @@ -79,6 +81,10 @@ void Gui::update_shared_state() { else lv_obj_remove_state(mute_switch_, LV_STATE_CHECKED); lv_dropdown_set_selected(video_dropdown_, (int)emu.video_setting()); + if (AppSettings::get().get_settings().resume_last_save) + lv_obj_add_state(resume_switch_, LV_STATE_CHECKED); + else + lv_obj_remove_state(resume_switch_, LV_STATE_CHECKED); } void Gui::on_volume(const std::vector &) { @@ -120,11 +126,20 @@ void Gui::init_ui() { lv_obj_align(rom_list_, LV_ALIGN_TOP_LEFT, 24, 76); lv_obj_set_style_text_font(rom_list_, &lv_font_montserrat_24, 0); + // console of the selected game (center top): its SD card art or a badge + console_area_ = lv_obj_create(screen_); + lv_obj_remove_style_all(console_area_); + lv_obj_remove_flag(console_area_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(console_area_, 400, 56); + lv_obj_align(console_area_, LV_ALIGN_TOP_MID, 40, 76); + lv_obj_set_flex_flow(console_area_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(console_area_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + // boxart + title (center) boxart_ = lv_image_create(screen_); - lv_obj_set_size(boxart_, 400, 400); + lv_obj_set_size(boxart_, 360, 360); lv_image_set_inner_align(boxart_, LV_IMAGE_ALIGN_CONTAIN); - lv_obj_align(boxart_, LV_ALIGN_TOP_MID, 40, 90); + lv_obj_align(boxart_, LV_ALIGN_TOP_MID, 40, 144); title_label_ = lv_label_create(screen_); lv_label_set_text(title_label_, ""); @@ -188,6 +203,10 @@ void Gui::init_ui() { lv_obj_set_width(video_dropdown_, lv_pct(90)); lv_obj_add_event_cb(video_dropdown_, event_callback, LV_EVENT_VALUE_CHANGED, this); + make_label(LV_SYMBOL_REFRESH " Resume last save"); + resume_switch_ = lv_switch_create(settings); + lv_obj_add_event_cb(resume_switch_, event_callback, LV_EVENT_VALUE_CHANGED, this); + // gamepad / keyboard focus order (see Tab5Emu::keypad_indev()): the ROM // buttons come first (added by update_rom_list), then the controls group_ = lv_group_create(); @@ -213,14 +232,37 @@ void Gui::update_rom_list() { return; } metadata_last_modified_ = mtime; + // keep the selected game selected across a reload (its index may change); + // at boot that is the last game played + std::string selected_path; + if (focused_rom_ >= 0 && focused_rom_ < (int)rom_infos_.size()) { + selected_path = rom_infos_[focused_rom_].rom_path; + } else { + selected_path = AppSettings::get().get_settings().last_rom; + } rom_infos_ = parse_metadata(metadata_filename_); + // grouped by console, in Console order; metadata.csv order within a console + std::stable_sort(rom_infos_.begin(), rom_infos_.end(), [](const RomInfo &a, const RomInfo &b) { + return console_of(a.platform) < console_of(b.platform); + }); + focused_rom_ = -1; + for (size_t i = 0; i < rom_infos_.size(); i++) { + if (rom_infos_[i].rom_path == selected_path) { + focused_rom_ = i; + break; + } + } lv_obj_clean(rom_list_); rom_buttons_.clear(); if (group_) { lv_group_remove_all_objs(group_); } for (size_t i = 0; i < rom_infos_.size(); i++) { - auto btn = lv_list_add_button(rom_list_, LV_SYMBOL_FILE, rom_infos_[i].name.c_str()); + const Console console = console_of(rom_infos_[i].platform); + if (i == 0 || console != console_of(rom_infos_[i - 1].platform)) { + add_console_header(console); + } + auto btn = lv_list_add_button(rom_list_, nullptr, rom_infos_[i].name.c_str()); lv_obj_set_user_data(btn, reinterpret_cast(i)); lv_obj_add_event_cb(btn, event_callback, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(btn, event_callback, LV_EVENT_FOCUSED, this); @@ -228,7 +270,8 @@ void Gui::update_rom_list() { if (group_) lv_group_add_obj(group_, btn); } if (group_) { - for (auto *o : {play_button_, volume_slider_, mute_switch_, brightness_slider_, usb_switch_, video_dropdown_}) { + for (auto *o : {play_button_, volume_slider_, mute_switch_, brightness_slider_, usb_switch_, video_dropdown_, + resume_switch_}) { if (o) lv_group_add_obj(group_, o); } } @@ -237,11 +280,62 @@ void Gui::update_rom_list() { focused_rom_ = -1; lv_label_set_text(title_label_, ""); lv_image_set_src(boxart_, nullptr); + lv_obj_clean(console_area_); + shown_console_ = -1; } else { select_rom(std::clamp(focused_rom_, 0, rom_infos_.size() - 1)); } } +// A non-selectable row in the game list starting a console's group: the +// console's icon and name on its accent color. +void Gui::add_console_header(Console console) { + const auto &info = console_info(console); + auto *hdr = lv_obj_create(rom_list_); + lv_obj_remove_style_all(hdr); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(hdr, lv_pct(100), 44); + lv_obj_set_style_bg_color(hdr, lv_color_hex(info.color), 0); + lv_obj_set_style_bg_opa(hdr, LV_OPA_COVER, 0); + lv_obj_set_style_pad_hor(hdr, 12, 0); + lv_obj_set_style_pad_column(hdr, 12, 0); + lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + console_icon_create(hdr, info, 26); + auto *label = lv_label_create(hdr); + lv_label_set_text(label, info.name); + lv_obj_set_style_text_color(label, lv_color_white(), 0); + lv_obj_set_style_text_font(label, &lv_font_montserrat_20, 0); +} + +// Above the boxart: the SD card art of the selected game's console +// (boxart/consoles/.jpg) if there is one, else the firmware's badge. +// Only on this screen: in a game the screen is the game's. +void Gui::show_console(Console console) { + if (shown_console_ == static_cast(console)) { + return; + } + shown_console_ = static_cast(console); + lv_obj_clean(console_area_); + if (console_art_buf_) { + lv_draw_buf_destroy(console_art_buf_); + console_art_buf_ = nullptr; + } + const auto &info = console_info(console); + if (const auto art = console_art_path(Tab5Emu::mount_point, info); !art.empty()) { + console_art_buf_ = decode_image(art); + if (console_art_buf_) { + auto *img = lv_image_create(console_area_); + lv_obj_set_size(img, lv_pct(100), lv_pct(100)); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_image_set_src(img, console_art_buf_); + return; + } + } + console_badge_create(console_area_, info, 48, &lv_font_montserrat_24); +} + bool Gui::select_rom_matching(std::string_view text) { std::lock_guard lk(mutex_); for (size_t i = 0; i < rom_infos_.size(); i++) { @@ -272,6 +366,7 @@ void Gui::select_rom(int index) { } const auto &rom = rom_infos_[index]; lv_label_set_text(title_label_, rom.name.c_str()); + show_console(console_of(rom.platform)); load_boxart(rom.boxart_path); } @@ -288,25 +383,24 @@ void Gui::load_boxart(const std::string &path) { if (path.empty()) { return; } + boxart_buf_ = decode_image(path); + if (boxart_buf_) { + lv_image_set_src(boxart_, boxart_buf_); + } +} + +lv_draw_buf_t *Gui::decode_image(const std::string &path) { const std::string src = "S:" + path; lv_image_decoder_dsc_t dsc{}; if (lv_image_decoder_open(&dsc, src.c_str(), nullptr) != LV_RESULT_OK) { - logger_.warn("boxart '{}': cannot decode", path); - return; + logger_.warn("image '{}': cannot decode", path); + return nullptr; } const uint32_t w = dsc.header.w, h = dsc.header.h; - if (dsc.decoded && dsc.decoded->data) { - // fully decoded formats: LVGL scales those itself, use the file directly - lv_image_decoder_close(&dsc); - static std::string file_src; - file_src = src; - lv_image_set_src(boxart_, file_src.c_str()); - return; - } auto *out = lv_draw_buf_create(w, h, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO); if (!out) { lv_image_decoder_close(&dsc); - return; + return nullptr; } lv_area_t full = {0, 0, static_cast(w) - 1, static_cast(h) - 1}; lv_area_t area{}; @@ -334,9 +428,8 @@ void Gui::load_boxart(const std::string &path) { } } lv_image_decoder_close(&dsc); - boxart_buf_ = out; - lv_image_set_src(boxart_, boxart_buf_); - logger_.info("boxart '{}': {}x{}", path, w, h); + logger_.info("image '{}': {}x{}", path, w, h); + return out; } void Gui::event_callback(lv_event_t *e) { @@ -376,16 +469,29 @@ void Gui::event_callback(lv_event_t *e) { } } } else if (code == LV_EVENT_VALUE_CHANGED) { + // (saved to the SD card by the main loop once the changes stop) + auto &settings = AppSettings::get(); if (target == gui->volume_slider_) { - emu.volume(lv_slider_get_value(gui->volume_slider_)); + const int v = lv_slider_get_value(gui->volume_slider_); + emu.volume(v); + settings.modify([v](Settings &s) { s.volume = v; }); } else if (target == gui->brightness_slider_) { - emu.brightness(lv_slider_get_value(gui->brightness_slider_)); + const int v = lv_slider_get_value(gui->brightness_slider_); + emu.brightness(v); + settings.modify([v](Settings &s) { s.brightness = v; }); } else if (target == gui->mute_switch_) { - emu.mute(lv_obj_has_state(gui->mute_switch_, LV_STATE_CHECKED)); + const bool v = lv_obj_has_state(gui->mute_switch_, LV_STATE_CHECKED); + emu.mute(v); + settings.modify([v](Settings &s) { s.muted = v; }); } else if (target == gui->usb_switch_) { gui->set_usb_drive(lv_obj_has_state(gui->usb_switch_, LV_STATE_CHECKED)); } else if (target == gui->video_dropdown_) { - emu.video_setting(static_cast(lv_dropdown_get_selected(gui->video_dropdown_))); + const int v = lv_dropdown_get_selected(gui->video_dropdown_); + emu.video_setting(static_cast(v)); + settings.modify([v](Settings &s) { s.video_setting = v; }); + } else if (target == gui->resume_switch_) { + const bool v = lv_obj_has_state(gui->resume_switch_, LV_STATE_CHECKED); + settings.modify([v](Settings &s) { s.resume_last_save = v; }); } } } diff --git a/components/rom_info/include/rom_info.hpp b/components/rom_info/include/rom_info.hpp index 5f787dc..8b74c86 100644 --- a/components/rom_info/include/rom_info.hpp +++ b/components/rom_info/include/rom_info.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -17,6 +18,27 @@ struct RomInfo { bool operator==(const RomInfo &) const = default; }; +/// The console a game is played as, in the order the game list shows them +/// (several emulators can share one, e.g. Genesis / Mega Drive; the PC and +/// PlayStation ports count as the console the game came out on). +enum class Console : uint8_t { SNES, GAME_BOY_ADVANCE, GENESIS, PLAYSTATION, PC, PICO8, UNKNOWN }; + +/// The shape the firmware draws for a console that has no art on the SD card. +enum class ConsoleShape : uint8_t { GAMEPAD, HANDHELD_TALL, HANDHELD_WIDE, DISC, COMPUTER, FANTASY }; + +struct ConsoleInfo { + const char *name; ///< shown in the list header and the badge + const char *short_name; ///< for a small screen's badge + const char *key; ///< art on the SD card: boxart/consoles/.jpg + uint32_t color; ///< badge / header accent (0xRRGGBB) + ConsoleShape shape; +}; + +/// The console `platform` games are grouped under. +Console console_of(Emulator platform); +/// Name, art key and colors of `console`. +const ConsoleInfo &console_info(Console console); + /// Parse the metadata csv (rom_path, boxart_path, name) relative to the SD /// card mount point. The platform is picked from the rom path's extension / /// file name: diff --git a/components/rom_info/src/rom_info.cpp b/components/rom_info/src/rom_info.cpp index fa92f16..67f6e12 100644 --- a/components/rom_info/src/rom_info.cpp +++ b/components/rom_info/src/rom_info.cpp @@ -92,3 +92,39 @@ std::vector parse_metadata(const std::string &metadata_path) { fmt::print("Loaded {} roms from metadata file {}\n", infos.size(), metadata_path); return infos; } + +Console console_of(Emulator platform) { + switch (platform) { + case Emulator::SNES: + return Console::SNES; + case Emulator::GBA: + return Console::GAME_BOY_ADVANCE; + case Emulator::SEGA_GENESIS: + case Emulator::SEGA_MEGA_DRIVE: + return Console::GENESIS; + case Emulator::MGS: + case Emulator::TPM: + return Console::PLAYSTATION; + case Emulator::JEDI_KNIGHT: + case Emulator::DARK_FORCES: + return Console::PC; + case Emulator::PICO8: + return Console::PICO8; + default: + return Console::UNKNOWN; + } +} + +const ConsoleInfo &console_info(Console console) { + static const ConsoleInfo infos[] = { + {"Super Nintendo", "SNES", "snes", 0x6A5ACD, ConsoleShape::GAMEPAD}, + {"Game Boy Advance", "GBA", "gba", 0x4B3FA8, ConsoleShape::HANDHELD_WIDE}, + {"Sega Genesis", "Genesis", "genesis", 0xC8102E, ConsoleShape::GAMEPAD}, + {"PlayStation", "PlayStation", "playstation", 0x2E6DB4, ConsoleShape::DISC}, + {"PC", "PC", "pc", 0x5A6B7B, ConsoleShape::COMPUTER}, + {"PICO-8", "PICO-8", "pico8", 0xFF004D, ConsoleShape::FANTASY}, + {"Other", "Other", "other", 0x606060, ConsoleShape::GAMEPAD}, + }; + const auto i = static_cast(console); + return infos[i < std::size(infos) ? i : std::size(infos) - 1]; +} diff --git a/components/settings/CMakeLists.txt b/components/settings/CMakeLists.txt new file mode 100644 index 0000000..fdde494 --- /dev/null +++ b/components/settings/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES format persistent_data) diff --git a/components/settings/idf_component.yml b/components/settings/idf_component.yml new file mode 100644 index 0000000..0f849a4 --- /dev/null +++ b/components/settings/idf_component.yml @@ -0,0 +1,5 @@ +## IDF Component Manager Manifest File +dependencies: + idf: '>=6.0' + # versioned settings file with forward migration + espp/persistent_data: '>=1.4.2' diff --git a/components/settings/include/settings.hpp b/components/settings/include/settings.hpp new file mode 100644 index 0000000..4d3b516 --- /dev/null +++ b/components/settings/include/settings.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "persistent_data.hpp" + +/// The launcher settings that survive a power cycle, version 1. Kept in +/// settings.bin at the root of the SD card by espp::PersistentData: a new +/// field means a new version struct (with a migrate() from this one) in +/// front of the type list in AppSettings, never a change to this one. +struct SettingsV1 { + static constexpr uint32_t VERSION = 1; + uint8_t volume{60}; ///< 0..100 + bool muted{false}; + uint8_t brightness{80}; ///< 0..100 + uint8_t video_setting{1}; ///< VideoSetting (0 original, 1 fit, 2 fill) + bool resume_last_save{true}; ///< starting a game loads the slot it was last saved to / loaded from + std::string last_rom; ///< rom_path of the last game started (selected again at boot) + std::map last_slot; ///< rom_path -> the save slot last saved to / loaded from +}; + +/// The current settings version. +using Settings = SettingsV1; + +/// The settings file and an in-RAM copy of it. Changes are made in RAM +/// (modify()) and written when save() is called; the launcher calls save() +/// a moment after the last change, so dragging a slider is one write. +/// Thread-safe. +class AppSettings { +public: + static AppSettings &get() { + static AppSettings instance; + return instance; + } + + /// Load the file, or create it from `defaults` when there is none. A file + /// that cannot be read (corrupt, or written by newer firmware) is reported + /// and replaced by `defaults` in RAM; the file itself is only overwritten by + /// the next save(). + bool init(const std::string &file_path, const Settings &defaults); + + /// A copy of the current settings. + Settings get_settings() const; + + /// Change the settings in RAM (written by the next save()). + template void modify(Fn &&fn) { + std::lock_guard lk(mutex_); + Settings s = data_ ? data_->get() : fallback_; + const Settings before = s; + fn(s); + if (!same(before, s)) { + if (data_) + data_->set(s); + else + fallback_ = s; + dirty_ = true; + last_change_ = std::chrono::steady_clock::now(); + } + } + + /// Write the settings if they changed since the last save. + bool save(); + + /// save(), but only once `quiet` has passed since the last change (call it + /// periodically: a burst of changes is written once, after it ends). + bool save_if_idle(std::chrono::milliseconds quiet = std::chrono::milliseconds(1500)); + + /// The save slot last used for `rom_path`, if any. + std::optional last_slot(const std::string &rom_path) const; + /// Remember `slot` as the last one used for `rom_path`. + void set_last_slot(const std::string &rom_path, int slot); + +protected: + AppSettings() = default; + static bool same(const Settings &a, const Settings &b); + + using Store = espp::PersistentData; + mutable std::mutex mutex_; + std::unique_ptr data_; + Settings fallback_{}; ///< used when init() was not called (no SD card) + bool dirty_{false}; + std::chrono::steady_clock::time_point last_change_{}; +}; diff --git a/components/settings/src/settings.cpp b/components/settings/src/settings.cpp new file mode 100644 index 0000000..dab3511 --- /dev/null +++ b/components/settings/src/settings.cpp @@ -0,0 +1,66 @@ +#include "settings.hpp" + +#include + +#include "format.hpp" + +bool AppSettings::same(const Settings &a, const Settings &b) { + return std::tie(a.volume, a.muted, a.brightness, a.video_setting, a.resume_last_save, a.last_rom, a.last_slot) == + std::tie(b.volume, b.muted, b.brightness, b.video_setting, b.resume_last_save, b.last_rom, b.last_slot); +} + +bool AppSettings::init(const std::string &file_path, const Settings &defaults) { + std::lock_guard lk(mutex_); + data_ = std::make_unique(Store::Config{.file_path = file_path, .auto_load = false}); + std::error_code ec; + if (data_->load_or_create(defaults, ec)) { + dirty_ = false; + return true; + } + fmt::print("settings: cannot load {} ({}); using defaults\n", file_path, ec.message()); + data_->set(defaults); + dirty_ = false; + return false; +} + +Settings AppSettings::get_settings() const { + std::lock_guard lk(mutex_); + return data_ ? data_->get() : fallback_; +} + +bool AppSettings::save() { + std::lock_guard lk(mutex_); + if (!dirty_ || !data_) { + return true; + } + std::error_code ec; + if (!data_->save(ec)) { + fmt::print("settings: save failed: {}\n", ec.message()); + return false; + } + dirty_ = false; + return true; +} + +bool AppSettings::save_if_idle(std::chrono::milliseconds quiet) { + { + std::lock_guard lk(mutex_); + if (!dirty_ || std::chrono::steady_clock::now() - last_change_ < quiet) { + return true; + } + } + return save(); +} + +std::optional AppSettings::last_slot(const std::string &rom_path) const { + std::lock_guard lk(mutex_); + const Settings s = data_ ? data_->get() : fallback_; + if (auto it = s.last_slot.find(rom_path); it != s.last_slot.end()) { + return it->second; + } + return std::nullopt; +} + +void AppSettings::set_last_slot(const std::string &rom_path, int slot) { + modify([&](Settings &s) { s.last_slot[rom_path] = static_cast(slot); }); +} diff --git a/main/cart.hpp b/main/cart.hpp index 10b97ad..e1c066d 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -16,6 +16,7 @@ #include "menu.hpp" #include "rom_info.hpp" +#include "settings.hpp" #include "tab5-emu.hpp" /// This class is the base class for all carts (games / emulator cores). @@ -108,6 +109,22 @@ class Cart { virtual bool is_running() const { return running_; } + /// Start from the save state in `slot` (the one this game was last saved + /// to / loaded from), if the cart supports that and the slot holds a + /// state. The load is done the way the pause menu does it, on the first + /// frame for which resume_after_frames() has passed. + void resume_from_slot(int slot) { + if (!supports_resume()) { + return; + } + menu_->select_slot(slot); + if (get_save_path().empty()) { + logger_.info("resume: no state in slot {}", slot); + return; + } + resume_pending_ = true; + } + /// Quit the way the pause menu's Quit does (the game is paused first, then /// the cart stops; the caller stops calling run()); for the automated /// launch tests. @@ -140,6 +157,13 @@ class Cart { virtual bool run() { running_ = true; auto &emu = Tab5Emu::get(); + if (resume_pending_ && ++frames_run_ > resume_after_frames()) { + resume_pending_ = false; + logger_.info("resume: loading slot {}", get_selected_save_slot()); + pre_menu(); + load(); + post_menu(); + } // the menu is requested by the boot button, by the touch "menu" hot corner // or by start+select on a gamepad auto state = emu.gamepad_state(); @@ -202,15 +226,32 @@ class Cart { break; case Menu::Action::SAVE: save(); + remember_slot(); break; case Menu::Action::LOAD: load(); + remember_slot(); break; default: break; } } + /// Whether resume_from_slot() may load a state right after the game + /// started. The emulator cores say yes; the native ports keep their own + /// saves and are started fresh. + virtual bool supports_resume() const { return false; } + /// Frames to run before the resume load (a core that needs its game to + /// get going first, e.g. a PICO-8 cart's _init, says how long). + virtual int resume_after_frames() const { return 0; } + + /// The slot just saved to / loaded from becomes the one resume uses. + void remember_slot() { + auto &settings = AppSettings::get(); + settings.set_last_slot(get_rom_filename(), get_selected_save_slot()); + settings.save(); + } + virtual std::string get_save_extension() const { return ".sav"; } std::string get_screenshot_extension() const { return ".bin"; } @@ -288,6 +329,8 @@ class Cart { } std::atomic running_{false}; + bool resume_pending_{false}; + int frames_run_{0}; size_t rom_size_bytes_{0}; uint8_t *romdata_{nullptr}; RomInfo info_; diff --git a/main/gba_cart.hpp b/main/gba_cart.hpp index 3654bed..00020f7 100644 --- a/main/gba_cart.hpp +++ b/main/gba_cart.hpp @@ -8,6 +8,8 @@ class GbaCart : public Cart { public: + // an emulator core: a state loads right after the game starts + virtual bool supports_resume() const override { return true; } explicit GbaCart(const Cart::Config &config) : Cart(config) { // the ROM cache takes its first blocks from the ROM arena (gba.cpp // gpsp_rom_block_alloc), so the arena stays where it is diff --git a/main/genesis_cart.hpp b/main/genesis_cart.hpp index 5899696..c30b490 100644 --- a/main/genesis_cart.hpp +++ b/main/genesis_cart.hpp @@ -7,6 +7,8 @@ class GenesisCart : public Cart { public: + // an emulator core: a state loads right after the game starts + virtual bool supports_resume() const override { return true; } explicit GenesisCart(const Cart::Config& config) : Cart(config) { diff --git a/main/main.cpp b/main/main.cpp index 2c469b7..86339b2 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -21,6 +21,7 @@ #include "gui.hpp" #include "heap_utils.hpp" #include "rom_info.hpp" +#include "settings.hpp" #include "statistics.hpp" #include "tab5-emu.hpp" @@ -129,6 +130,26 @@ extern "C" void app_main(void) { } internal_free_log("initialize_video"); + // the settings kept on the SD card (volume, brightness, video scaling, the + // last game and its save slots); the first boot writes the defaults + { + Settings defaults; + defaults.volume = static_cast(emu.volume()); + defaults.muted = emu.is_muted(); + defaults.brightness = static_cast(emu.brightness()); + defaults.video_setting = static_cast(emu.video_setting()); + auto &settings = AppSettings::get(); + settings.init(std::string(Tab5Emu::mount_point) + "/settings.bin", defaults); + const Settings s = settings.get_settings(); + emu.volume(s.volume); + emu.mute(s.muted); + emu.brightness(std::max(5, s.brightness)); + emu.video_setting(static_cast(std::min(s.video_setting, 2))); + logger.info("settings: volume {}{}, brightness {}, video {}, resume {}, last game '{}'", s.volume, + s.muted ? " (muted)" : "", s.brightness, s.video_setting, s.resume_last_save, s.last_rom); + } + internal_free_log("settings"); + logger.info("initializing gui..."); // initialize the gui @@ -198,6 +219,8 @@ extern "C" void app_main(void) { #endif while (!gui.ready_to_play()) { std::this_thread::sleep_for(50ms); + // settings changed in the GUI are written once the changes stop + AppSettings::get().save_if_idle(); } gui.pause(); @@ -206,6 +229,8 @@ extern "C" void app_main(void) { if (maybe_selected_rom.has_value()) { auto selected_rom = maybe_selected_rom.value(); logger.info("Selected rom:\n\t{}", selected_rom); + AppSettings::get().modify([&](Settings &s) { s.last_rom = selected_rom.rom_path; }); + AppSettings::get().save(); print_heap_state(); @@ -213,6 +238,12 @@ extern "C" void app_main(void) { { std::unique_ptr cart(make_cart(selected_rom)); if (cart) { + // start from the save state this game was last saved to / loaded from + if (AppSettings::get().get_settings().resume_last_save) { + if (const auto slot = AppSettings::get().last_slot(selected_rom.rom_path)) { + cart->resume_from_slot(*slot); + } + } #if CONFIG_TAB5_EMU_AUTOSTART_DELAY_MS > 0 && CONFIG_TAB5_EMU_AUTOQUIT_SECONDS > 0 const auto start = std::chrono::steady_clock::now(); const auto quit_at = start + std::chrono::seconds(CONFIG_TAB5_EMU_AUTOQUIT_SECONDS); @@ -286,6 +317,15 @@ extern "C" void app_main(void) { #endif } + // volume / brightness / video scaling may have changed in the pause menu + AppSettings::get().modify([&](Settings &s) { + s.volume = static_cast(emu.volume()); + s.muted = emu.is_muted(); + s.brightness = static_cast(emu.brightness()); + s.video_setting = static_cast(emu.video_setting()); + }); + AppSettings::get().save(); + // print the frame statistics from the previous run print_statistics(); diff --git a/main/pico8_cart.hpp b/main/pico8_cart.hpp index 2164e98..5a2d69c 100644 --- a/main/pico8_cart.hpp +++ b/main/pico8_cart.hpp @@ -8,6 +8,10 @@ // PICO-8 carts (.p8 / .p8.png) through femto8. class Pico8Cart : public Cart { public: + // an emulator core: a state loads right after the game starts + virtual bool supports_resume() const override { return true; } + // the cart's _init has to have run before its Lua state can be replaced + virtual int resume_after_frames() const override { return 60; } explicit Pico8Cart(const Cart::Config& config) : Cart(config) { handle_video_setting(); diff --git a/main/snes_cart.hpp b/main/snes_cart.hpp index 68151f0..13c8d5a 100644 --- a/main/snes_cart.hpp +++ b/main/snes_cart.hpp @@ -8,6 +8,8 @@ class SnesCart : public Cart { public: + // an emulator core: a state loads right after the game starts + virtual bool supports_resume() const override { return true; } explicit SnesCart(const Cart::Config &config) : Cart(config) { handle_video_setting(); init(); From 5512a932bc218ba8874559be06481021f2047ca0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 11 Nov 2026 16:06:41 -0600 Subject: [PATCH 2/6] USB link: composite device (console, test-control link, SD card drive), OTA updater, emulink The USB-C port becomes an espp::UsbDevice with three functions: - CDC console: the logs (teed to UART0) - a vendor interface carrying framed services: OTA (through a separate updater app in ota_1, so the 9 MB main image can be replaced), the core dump, system info / reboot, a log monitor, and the emulator's own test control (status, launch / quit, press / hold buttons, save / load states, screenshots, and SD card file list / push / pull / rm / mkdir) - MSC: the SD card as a USB drive, handed to the PC only on request (the launcher's switch or the link) and taken back once the PC ejected it tools/emulink is the Python client (python -m emulink ...), shared with esp-box-emu, for scripting tests and getting content onto the card. Also: JK stages files through cache-aligned PSRAM (internal DMA memory is scarce with USB up) and reads the card with stdio, so a failed read is an error instead of an abort; the menu loads screenshots with stdio too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- CMakeLists.txt | 2 +- components/devlink/CMakeLists.txt | 10 + components/devlink/idf_component.yml | 7 + components/devlink/include/devlink.hpp | 112 +++++ components/devlink/src/devlink.cpp | 163 +++++++ components/gui/include/gui.hpp | 12 + components/gui/src/gui.cpp | 15 + components/jk/src/jk.cpp | 9 +- components/menu/src/menu.cpp | 19 +- components/tab5-emu/CMakeLists.txt | 1 + components/tab5-emu/idf_component.yml | 7 +- components/tab5-emu/include/tab5-emu.hpp | 51 +- components/tab5-emu/linker.lf | 15 + components/tab5-emu/src/coredump.cpp | 33 +- components/tab5-emu/src/tab5-emu.cpp | 9 + components/tab5-emu/src/usb_device.cpp | 184 +++++++ components/tab5-emu/src/usb_hid.cpp | 3 + components/tab5-emu/src/usb_msc.cpp | 151 ------ main/cart.hpp | 59 ++- main/main.cpp | 28 +- main/mgs_cart.hpp | 5 + main/remote_control.hpp | 589 +++++++++++++++++++++++ partitions.csv | 17 +- sdkconfig.defaults | 14 +- tools/emulink/emulink/__init__.py | 12 + tools/emulink/emulink/__main__.py | 5 + tools/emulink/emulink/cli.py | 526 ++++++++++++++++++++ tools/emulink/emulink/control.py | 268 +++++++++++ tools/emulink/emulink/espp_tools.py | 38 ++ tools/emulink/emulink/frame.py | 86 ++++ tools/emulink/emulink/link.py | 175 +++++++ updater/CMakeLists.txt | 14 + updater/main/CMakeLists.txt | 3 + updater/main/idf_component.yml | 7 + updater/main/updater.cpp | 143 ++++++ updater/sdkconfig.defaults | 32 ++ 36 files changed, 2612 insertions(+), 212 deletions(-) create mode 100644 components/devlink/CMakeLists.txt create mode 100644 components/devlink/idf_component.yml create mode 100644 components/devlink/include/devlink.hpp create mode 100644 components/devlink/src/devlink.cpp create mode 100644 components/tab5-emu/linker.lf create mode 100644 components/tab5-emu/src/usb_device.cpp delete mode 100644 components/tab5-emu/src/usb_msc.cpp create mode 100644 main/remote_control.hpp create mode 100644 tools/emulink/emulink/__init__.py create mode 100644 tools/emulink/emulink/__main__.py create mode 100644 tools/emulink/emulink/cli.py create mode 100644 tools/emulink/emulink/control.py create mode 100644 tools/emulink/emulink/espp_tools.py create mode 100644 tools/emulink/emulink/frame.py create mode 100644 tools/emulink/emulink/link.py create mode 100644 updater/CMakeLists.txt create mode 100644 updater/main/CMakeLists.txt create mode 100644 updater/main/idf_component.yml create mode 100644 updater/main/updater.cpp create mode 100644 updater/sdkconfig.defaults diff --git a/CMakeLists.txt b/CMakeLists.txt index 55d3fbe..de24b0a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,7 +73,7 @@ set(GAME_COMPONENTS set( COMPONENTS main esptool_py esp_lcd esp_psram task format display display_drivers monitor timer - ${GAME_COMPONENTS} tab5-emu gui menu rom_info statistics + ${GAME_COMPONENTS} tab5-emu gui menu rom_info statistics settings devlink CACHE STRING "List of components to include" FORCE diff --git a/components/devlink/CMakeLists.txt b/components/devlink/CMakeLists.txt new file mode 100644 index 0000000..387def3 --- /dev/null +++ b/components/devlink/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + PRIV_REQUIRES esp_system + REQUIRES app_update coredump dispatcher monitor system tab5-emu) +# the two-slot OTA service: off on the Tab5, whose app does not fit twice (an +# update goes through the updater app in ota_1, see updater/) +target_compile_definitions(${COMPONENT_LIB} PUBLIC DEVLINK_HAS_OTA=0) +# keep the last panic's reason across the reset (DevLink::last_panic()) +target_link_options(${COMPONENT_LIB} INTERFACE "-Wl,--wrap=esp_panic_handler" "-Wl,--wrap=panic_abort") diff --git a/components/devlink/idf_component.yml b/components/devlink/idf_component.yml new file mode 100644 index 0000000..11aa1f5 --- /dev/null +++ b/components/devlink/idf_component.yml @@ -0,0 +1,7 @@ +## IDF Component Manager Manifest File +dependencies: + idf: '>=6.0' + espp/coredump: '>=1.4.2' + espp/dispatcher: '>=1.4.2' + espp/monitor: '>=1.4.2' + espp/system: '>=1.4.2' diff --git a/components/devlink/include/devlink.hpp b/components/devlink/include/devlink.hpp new file mode 100644 index 0000000..064542c --- /dev/null +++ b/components/devlink/include/devlink.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include + +#include "coredump.hpp" +#include "coredump_service.hpp" +#include "dispatcher_worker.hpp" +#include "monitor_service.hpp" +#include "system_service.hpp" + +#include +#include + +#include "box-emu.hpp" + +/// Check the heap around every flash-touching request (debugging aid). +#ifndef DEVLINK_HEAP_CHECK +#define DEVLINK_HEAP_CHECK 0 +#endif +#if DEVLINK_HAS_OTA +#include "ota.hpp" +#include "ota_service.hpp" +#endif + +/// A module whose requests touch the flash (erase / write / read), run under +/// the launcher's LVGL lock. While the flash is busy the cache is off and only +/// interrupt handlers placed in internal RAM run; the display's SPI "transfer +/// done" interrupt is one and calls into LVGL (flash / PSRAM): a screen update +/// finishing during an OTA erase panics (esp-box-emu). LVGL waits for its +/// transfers before it lets go of the lock, so none is in flight meanwhile. +template class FlashGuarded { +public: + explicit FlashGuarded(Module &module) : module_(module) {} + uint8_t module_id() const { return module_.module_id(); } + espp::DispatcherModuleInfo module_info() const { return module_.module_info(); } + void handle(const espp::stream_frame::Frame &frame) { + std::lock_guard lk(BoxEmu::lvgl_mutex()); +#if DEVLINK_HEAP_CHECK + if (!heap_caps_check_integrity_all(true)) abort_corrupt("before", frame); +#endif + module_.handle(frame); +#if DEVLINK_HEAP_CHECK + if (!heap_caps_check_integrity_all(true)) abort_corrupt("after", frame); +#endif + } + +protected: +#if DEVLINK_HEAP_CHECK + [[noreturn]] static void abort_corrupt(const char *when, const espp::stream_frame::Frame &frame) { + static char msg[80]; + const bool internal_ok = heap_caps_check_integrity(MALLOC_CAP_INTERNAL, false); + const bool psram_ok = heap_caps_check_integrity(MALLOC_CAP_SPIRAM, false); + snprintf(msg, sizeof(msg), "heap corrupt %s request: internal %s, psram %s (frame @%p)", when, + internal_ok ? "ok" : "BAD", psram_ok ? "ok" : "BAD", (const void *)&frame); + esp_system_abort(msg); + } +#endif + Module &module_; +}; + +/// The framed services on the composite USB device's vendor interface, one +/// espp::DispatcherWorker routing them (shared by esp-box-emu and tab5-emu): +/// +/// | module | service | host tool | +/// |--------|-------------------------------------------|--------------------------| +/// | 0 | OTA (DEVLINK_HAS_OTA: a two-slot layout) | espp_ota, emulink ota | +/// | 4 | core dump of the last crash | espp_coredump, emulink | +/// | 7 | system info, reboot, ROM download mode | emulink reboot | +/// | 8 | heap / task monitor | the espp Device Hub | +/// | 0x20 | test control (registered by the app) | emulink | +/// +/// The device answers discovery, so the hosted espp web consoles find them. +class DevLink { +public: + struct Config { + std::string device_name; ///< advertised through discovery + std::string firmware; ///< firmware version, advertised through discovery + }; + + explicit DevLink(const Config &config); + ~DevLink(); + + /// Register an application module (e.g. the test control service). + template void register_module(Module &module) { + worker_->register_module(module); + } + + /// Send one frame to the host (serialized; all or nothing). + static void send(std::span frame); + + /// The running image booted after an update and has not been confirmed + /// yet: confirm it (call once the app is up). No-op otherwise. + static void mark_app_valid(); + + /// After a panic reset: what it was ("reason (detail) at PC on core N"), + /// kept in RTC memory (no core dump needed); "" otherwise. + static std::string last_panic(); + +protected: + std::unique_ptr worker_; + espp::CoreDump core_dump_; + std::unique_ptr core_dump_service_; + std::unique_ptr> core_dump_guarded_; + std::unique_ptr system_service_; + std::unique_ptr monitor_service_; +#if DEVLINK_HAS_OTA + std::unique_ptr ota_; + std::unique_ptr ota_service_; + std::unique_ptr> ota_guarded_; +#endif +}; diff --git a/components/devlink/src/devlink.cpp b/components/devlink/src/devlink.cpp new file mode 100644 index 0000000..a7396f4 --- /dev/null +++ b/components/devlink/src/devlink.cpp @@ -0,0 +1,163 @@ +#include "devlink.hpp" + +#include + +#include +#include +#include +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#endif +#include +#include +#include +#include + +#include "box-emu.hpp" + +void DevLink::send(std::span frame) { BoxEmu::get().vendor_write(frame); } + +DevLink::DevLink(const Config &config) { + auto &emu = BoxEmu::get(); + worker_ = std::make_unique(espp::DispatcherWorker::Config{ + .send = &DevLink::send, + .on_overflow = + [this]() { +#if DEVLINK_HAS_OTA + if (ota_service_) ota_service_->on_rx_overflow(); +#endif + }, + // ~3.5 KB used at the high-water mark (file transfers, core dump reads); + // internal RAM, since the core dump service reads the flash + .task_config = {.name = "devlink", .stack_size_bytes = 6144}, + }); + core_dump_service_ = std::make_unique(core_dump_, espp::CoreDumpService::Config{ + .send = &DevLink::send, + }); + system_service_ = std::make_unique(espp::SystemService::Config{ + .send = &DevLink::send, + .on_reboot_request = + [](espp::SystemService::RebootKind kind) { + if (kind == espp::SystemService::RebootKind::Bootloader) { + BoxEmu::get().usb_prepare_for_rom_download(); + } + return true; + }, + }); + monitor_service_ = std::make_unique(espp::MonitorService::Config{ + .send = &DevLink::send, + }); + core_dump_guarded_ = std::make_unique>(*core_dump_service_); + worker_->register_module(*core_dump_guarded_); + worker_->register_module(*system_service_); + worker_->register_module(*monitor_service_); +#if DEVLINK_HAS_OTA + ota_ = std::make_unique(espp::Ota::Config{}); + ota_service_ = std::make_unique(*ota_, espp::OtaService::Config{ + .send = &DevLink::send, + }); + ota_guarded_ = std::make_unique>(*ota_service_); + worker_->register_module(*ota_guarded_); +#endif + worker_->serve_discovery(config.device_name, config.firmware); + emu.set_vendor_rx([this](std::span data) { worker_->push(data); }); +} + +DevLink::~DevLink() { BoxEmu::get().set_vendor_rx(nullptr); } + +void DevLink::mark_app_valid() { + esp_ota_img_states_t state; + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running && esp_ota_get_state_partition(running, &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) { + esp_ota_mark_app_valid_cancel_rollback(); + fmt::print("devlink: the updated firmware is up; marked valid\n"); + } +} + +// The last panic, kept across the reset in RTC memory: a crash in the middle of +// a flash operation (an OTA erase) cannot write its core dump, and the console +// is the USB CDC port, which dies with the chip. The wrappers run in the panic +// path (possibly with the cache off): IRAM, and only plain byte copies. +namespace { +struct LastPanic { + uint32_t magic; + uint32_t addr; + int32_t core; + int32_t exception; + char reason[64]; + char detail[96]; + char task[16]; + uint32_t backtrace[20]; // Xtensa: the abort()'s caller chain +}; +constexpr uint32_t kPanicMagic = 0x50414e43; // "PANC" +RTC_NOINIT_ATTR LastPanic s_last_panic; +DRAM_ATTR volatile bool s_aborting = false; +IRAM_ATTR void copy_str(char *dst, size_t n, const char *src) { + size_t i = 0; + if (src) { + for (; i + 1 < n && src[i]; ++i) dst[i] = src[i]; + } + dst[i] = 0; +} +} // namespace + +extern "C" void __real_esp_panic_handler(panic_info_t *info); +extern "C" IRAM_ATTR void __wrap_esp_panic_handler(panic_info_t *info) { + // an abort() gets here too (panic_abort() traps): keep its message + if (s_aborting) { + __real_esp_panic_handler(info); + return; + } + s_last_panic.magic = kPanicMagic; + s_last_panic.addr = (uint32_t)info->addr; + s_last_panic.core = info->core; + s_last_panic.exception = (int32_t)info->exception; + copy_str(s_last_panic.reason, sizeof(s_last_panic.reason), info->reason); + copy_str(s_last_panic.detail, sizeof(s_last_panic.detail), info->description); + TaskHandle_t t = xTaskGetCurrentTaskHandle(); + copy_str(s_last_panic.task, sizeof(s_last_panic.task), t ? pcTaskGetName(t) : "?"); + for (auto &pc : s_last_panic.backtrace) pc = 0; + __real_esp_panic_handler(info); +} + +extern "C" void __real_panic_abort(const char *details); +IRAM_ATTR void note_task_and_backtrace() { + TaskHandle_t t = xTaskGetCurrentTaskHandle(); + copy_str(s_last_panic.task, sizeof(s_last_panic.task), t ? pcTaskGetName(t) : "?"); + for (auto &pc : s_last_panic.backtrace) pc = 0; +#if CONFIG_IDF_TARGET_ARCH_XTENSA + esp_backtrace_frame_t frame{}; + esp_backtrace_get_start(&frame.pc, &frame.sp, &frame.next_pc); + for (int i = 0; i < 20; ++i) { + s_last_panic.backtrace[i] = esp_cpu_process_stack_pc(frame.pc); + if (!esp_backtrace_get_next_frame(&frame)) break; + } +#endif +} + +extern "C" IRAM_ATTR void __wrap_panic_abort(const char *details) { + s_aborting = true; + note_task_and_backtrace(); + s_last_panic.magic = kPanicMagic; + s_last_panic.addr = 0; + s_last_panic.core = -1; + s_last_panic.exception = -1; + copy_str(s_last_panic.reason, sizeof(s_last_panic.reason), "abort"); + copy_str(s_last_panic.detail, sizeof(s_last_panic.detail), details); + __real_panic_abort(details); +} + +std::string DevLink::last_panic() { + if (esp_reset_reason() != ESP_RST_PANIC || s_last_panic.magic != kPanicMagic) { + return ""; + } + s_last_panic.reason[sizeof(s_last_panic.reason) - 1] = 0; + s_last_panic.detail[sizeof(s_last_panic.detail) - 1] = 0; + s_last_panic.task[sizeof(s_last_panic.task) - 1] = 0; + std::string bt; + for (auto pc : s_last_panic.backtrace) { + if (pc) bt += fmt::format(" {:#010x}", pc); + } + return fmt::format("{} ({}) at {:#010x} on core {} in task '{}'{}{}", s_last_panic.reason, s_last_panic.detail, + s_last_panic.addr, s_last_panic.core, s_last_panic.task, bt.empty() ? "" : "; backtrace", bt); +} diff --git a/components/gui/include/gui.hpp b/components/gui/include/gui.hpp index ae4d2be..a76251f 100644 --- a/components/gui/include/gui.hpp +++ b/components/gui/include/gui.hpp @@ -51,6 +51,18 @@ class Gui { void resume(); void update_rom_list(); + /// Re-read metadata.csv even if its modification time did not change (FAT + /// times are coarse, and without a set clock they may not move at all). + void reload_rom_list() { + std::lock_guard lk(mutex_); + metadata_last_modified_ = std::filesystem::file_time_type::min(); + update_rom_list(); + } + /// The games in list order (grouped by console). + std::vector rom_infos() const { + std::lock_guard lk(mutex_); + return rom_infos_; + } /// Select the first game whose name or rom path contains `text` bool select_rom_matching(std::string_view text); diff --git a/components/gui/src/gui.cpp b/components/gui/src/gui.cpp index dc97e1e..c746be6 100644 --- a/components/gui/src/gui.cpp +++ b/components/gui/src/gui.cpp @@ -68,6 +68,21 @@ void Gui::resume() { void Gui::update() { if (!paused_) { std::lock_guard lk(mutex_); + // the PC can end the USB drive mode itself (ejecting the drive gives the + // card back): follow it with the switch, and pick up what it copied over + const bool drive_on = Tab5Emu::get().usb_msc_host_has_card(); + if (usb_switch_ && drive_on != lv_obj_has_state(usb_switch_, LV_STATE_CHECKED)) { + if (drive_on) { + lv_obj_add_state(usb_switch_, LV_STATE_CHECKED); + lv_obj_add_state(play_button_, LV_STATE_DISABLED); + lv_label_set_text(usb_label_, "The PC has the SD card. Eject it there to get it back."); + } else { + lv_obj_remove_state(usb_switch_, LV_STATE_CHECKED); + lv_obj_remove_state(play_button_, LV_STATE_DISABLED); + lv_label_set_text(usb_label_, "SD card as a USB drive on the USB-C port"); + reload_rom_list(); + } + } lv_timer_handler(); } } diff --git a/components/jk/src/jk.cpp b/components/jk/src/jk.cpp index b1a342e..73dd0a5 100644 --- a/components/jk/src/jk.cpp +++ b/components/jk/src/jk.cpp @@ -399,7 +399,14 @@ void jk_esp_audio_unlock(void) { g_audio_mutex.unlock(); } uint32_t jk_esp_time_ms(void) { return (uint32_t)(esp_timer_get_time() / 1000); } size_t jk_esp_psram_free(void) { return heap_caps_get_free_size(MALLOC_CAP_SPIRAM) + arena_free_size(); } -void *jk_esp_malloc_dma(size_t bytes) { return heap_caps_malloc(bytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); } +// The P4's SD host DMAs straight to and from cache-line aligned PSRAM, so the +// file staging buffer goes there: internal DMA memory is scarce once the USB +// device is up, and the SD driver needs what is left for its bounce buffers +// (FATFS's own reads into unaligned buffers, the pause menu's images, saves). +void *jk_esp_malloc_dma(size_t bytes) { + void *p = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA | MALLOC_CAP_CACHE_ALIGNED); + return p ? p : heap_caps_malloc(bytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); +} void jk_esp_free_dma(void *ptr) { heap_caps_free(ptr); } int jk_esp_park_point(void) { diff --git a/components/menu/src/menu.cpp b/components/menu/src/menu.cpp index 69965e4..89b7a99 100644 --- a/components/menu/src/menu.cpp +++ b/components/menu/src/menu.cpp @@ -1,5 +1,6 @@ #include "menu.hpp" +#include #include Menu::Menu(const Config &config) @@ -89,21 +90,29 @@ void Menu::update_shared_state() { bool Menu::load_screenshot(const std::string &path, lv_obj_t *image, std::vector &storage, lv_image_dsc_t &desc) { - std::ifstream file(path, std::ios::binary); - if (!file.is_open()) { + // stdio, not ifstream: a read error (EIO from the card) makes libstdc++'s + // filebuf throw, and exceptions are off, so that would abort + FILE *file = fopen(path.c_str(), "rb"); + if (!file) { lv_image_set_src(image, nullptr); return false; } uint8_t header[4]; - file.read((char *)header, 4); + const bool header_ok = fread(header, 1, sizeof(header), file) == sizeof(header); uint16_t w = (header[0] << 8) | header[1]; uint16_t h = (header[2] << 8) | header[3]; - if (w == 0 || h == 0 || w > 2048 || h > 2048 || (size_t)w * h * 2 > storage.capacity()) { + if (!header_ok || w == 0 || h == 0 || w > 2048 || h > 2048 || (size_t)w * h * 2 > storage.capacity()) { + fclose(file); lv_image_set_src(image, nullptr); return false; } storage.resize((size_t)w * h * 2); // within the reserved capacity: no allocation - file.read((char *)storage.data(), storage.size()); + const bool data_ok = fread(storage.data(), 1, storage.size(), file) == storage.size(); + fclose(file); + if (!data_ok) { + lv_image_set_src(image, nullptr); + return false; + } memset(&desc, 0, sizeof(desc)); desc.header.magic = LV_IMAGE_HEADER_MAGIC; desc.header.cf = LV_COLOR_FORMAT_RGB565; diff --git a/components/tab5-emu/CMakeLists.txt b/components/tab5-emu/CMakeLists.txt index 3f63ca3..167da43 100644 --- a/components/tab5-emu/CMakeLists.txt +++ b/components/tab5-emu/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" + LDFRAGMENTS "linker.lf" REQUIRES "driver" "heap" diff --git a/components/tab5-emu/idf_component.yml b/components/tab5-emu/idf_component.yml index e990c24..fd661fc 100644 --- a/components/tab5-emu/idf_component.yml +++ b/components/tab5-emu/idf_component.yml @@ -17,7 +17,12 @@ dependencies: espp/timer: '>=1.3' # USB mass storage (the SD card as a USB drive), brings esp_tinyusb; needs # UsbDevice::Config::port (the Tab5's USB-C is the P4's full-speed port) - espp/usb_device: '>=1.3.5' + espp/usb_device: + # TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898); back to the + # registry once it is released + git: https://github.com/esp-cpp/espp.git + path: components/usb_device + version: feat/usb-msc-host-eject espp/sdcard: '>=1.0' # crash reports + core dump download at boot espp/coredump: '>=1.3.5' diff --git a/components/tab5-emu/include/tab5-emu.hpp b/components/tab5-emu/include/tab5-emu.hpp index b3fb318..06ac095 100644 --- a/components/tab5-emu/include/tab5-emu.hpp +++ b/components/tab5-emu/include/tab5-emu.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -149,6 +150,9 @@ class Tab5Emu : public espp::BaseComponent { /// polls once per 30 fps frame). Presses are latched where touch and USB /// gamepad updates arrive; the USB keyboard is only sampled here. GamepadState gamepad_state_latched(); + /// Buttons held by the USB link's test control (scripted input), merged + /// into gamepad_state() like any other input (GamepadState::buttons bits). + void set_remote_buttons(uint16_t buttons); /// Latest touch data (in logical / landscape coordinates); a finger is /// down when num_touch_points > 0 (btn_state is the GT911's home button) TouchpadData touchpad_data() const { @@ -206,31 +210,46 @@ class Tab5Emu : public espp::BaseComponent { static std::recursive_mutex m; return m; } - /// Stop the USB host library (keyboard / mouse stop working). The USB - /// controller is shared with device mode (see initialize_usb_msc()). + /// Stop the USB host library (keyboard / mouse stop working). void deinitialize_usb_host(); bool is_usb_host_enabled() const { return usb_hid_ != nullptr; } ///////////////////////////////////////////////////////////////////////////// - // USB mass storage: expose the SD card to a PC as a USB drive + // The composite USB device on the USB-C port // - // The card's FAT volume is unmounted here while a PC has it, so nothing on - // the device may use /sdcard in that time (the GUI shows a "USB drive" mode - // and reloads the ROM list afterwards). The P4 has one USB OTG controller, - // so the USB host (keyboard / mouse) is stopped while the drive is on and - // restarted afterwards. + // - CDC: the console (stdout is routed there, teed to UART0). The + // USB-Serial-JTAG console is parked meanwhile (it shares the pins). + // - vendor (WebUSB): a framed byte stream for the link services (OTA, core + // dump, system, test control): set_vendor_rx() / vendor_write(). + // - MSC: the SD card as a USB drive. The device owns the card (mounted at + // mount_point) until it is handed to the PC with initialize_usb_msc(); + // while the PC has it nothing on the device may use /sdcard (the GUI shows + // a "USB drive" mode and reloads the ROM list afterwards). + // The USB host (USB-A jack) runs on the other (high-speed) controller. ///////////////////////////////////////////////////////////////////////////// + /// Start the composite device (after initialize_sdcard()). + bool initialize_usb_device(); + bool is_usb_device_enabled() const { return usb_device_ != nullptr; } + /// Bytes received on the vendor interface (TinyUSB task context: do not + /// block; hand them to a DispatcherWorker). Set before the host talks. + void set_vendor_rx(std::function)> fn) { vendor_rx_ = std::move(fn); } + /// Send one frame on the vendor interface (all or nothing; serialized). + bool vendor_write(std::span frame); + /// Before a reboot into the ROM download mode: hand the USB-C pins back to + /// the USB-Serial-JTAG controller (the pin mapping survives the reset). + void usb_prepare_for_rom_download(); + /// Hand the SD card to the PC as a USB drive bool initialize_usb_msc(); + /// Take the SD card back from the PC (mounted at mount_point again) void deinitialize_usb_msc(); /// Give the USB-C pins back to the serial console if a previous run left /// them on the OTG controller (called once at construction). void usb_msc_restore_console(); - /// A crash while the console is unavailable (e.g. during the USB drive - /// hand-over) leaves its core dump in flash: print it base64 to the console - /// (decode on the PC with esp-coredump info_corefile), then erase it. + /// Print the last crash's report (if any) to the console. The core dump + /// itself stays in flash for the link's core dump service. static void dump_core_dump_to_console(); - bool is_usb_msc_enabled() const { return usb_device_ != nullptr; } + bool is_usb_msc_enabled() const { return usb_msc_host_has_card(); } /// True while a PC holds the card (the volume is not mounted here) bool usb_msc_host_has_card() const; bool button_state() const { return Bsp::get().button_state(); } @@ -380,9 +399,10 @@ class Tab5Emu : public espp::BaseComponent { // usb hid (see usb_hid.cpp) struct UsbHid; UsbHid *usb_hid_{nullptr}; - // usb msc (see usb_msc.cpp) + // the composite usb device (see usb_device.cpp) std::unique_ptr usb_device_; - bool usb_host_was_enabled_{false}; + std::function)> vendor_rx_; + std::mutex vendor_tx_mutex_; mutable std::mutex hid_mutex_; KeyboardState keyboard_{}; MouseState mouse_{}; @@ -390,7 +410,8 @@ class Tab5Emu : public espp::BaseComponent { std::atomic usb_mice_{0}; std::atomic usb_gamepads_{0}; GamepadState usb_gamepad_{}; - std::atomic press_latch_{0}; // buttons pressed since gamepad_state_latched() + std::atomic press_latch_{0}; + std::atomic remote_buttons_{0}; // buttons pressed since gamepad_state_latched() GamepadAxes usb_gamepad_axes_{}; lv_indev_t *keypad_indev_{nullptr}; uint32_t keypad_last_key_{0}; diff --git a/components/tab5-emu/linker.lf b/components/tab5-emu/linker.lf new file mode 100644 index 0000000..d9f0a12 --- /dev/null +++ b/components/tab5-emu/linker.lf @@ -0,0 +1,15 @@ +# TinyUSB's per-interface software FIFOs (the vendor link's and the CDC +# console's tu_fifo storage, ~8.7 KB) live in PSRAM: the CPU copies through +# them, no DMA touches them. Their endpoint buffers (CFG_TUD_MEM_SECTION, +# .data) stay internal and DMA-capable. Internal RAM is what the games run +# short of (the SD card's DMA bounce buffers come from it). +[scheme:tab5_extram_bss] +entries: + bss -> extern_ram + common -> extern_ram + +[mapping:tab5_tinyusb_fifos] +archive: libespressif__esp_tinyusb.a +entries: + vendor_device (tab5_extram_bss) + cdc_device (tab5_extram_bss) diff --git a/components/tab5-emu/src/coredump.cpp b/components/tab5-emu/src/coredump.cpp index 11d47cf..bacb9fe 100644 --- a/components/tab5-emu/src/coredump.cpp +++ b/components/tab5-emu/src/coredump.cpp @@ -1,16 +1,13 @@ // Crash reporting at boot: espp::CoreDump reads the flash core dump left by a -// panic (and classifies abnormal resets that leave none, e.g. a brownout), -// prints its text report, then prints the whole image base64 to the console -// so it can be decoded on the PC (esp-coredump info_corefile -t raw) even when -// the crash happened while the console was unavailable (the USB drive -// hand-over), and erases it. +// panic (and classifies abnormal resets that leave none, e.g. a brownout) and +// prints its text report. The image stays in flash for the USB link's core +// dump service (espp_coredump / emulink coredump), which also erases it. #include "tab5-emu.hpp" #include #include #include -#include #include "coredump.hpp" @@ -20,26 +17,8 @@ void Tab5Emu::dump_core_dump_to_console() { if (!report.empty()) { printf("==== CRASH REPORT ====\n%s\n", report.c_str()); } - if (!core_dump.has_core_dump()) { - return; + if (core_dump.has_core_dump()) { + printf("==== CORE DUMP in flash (%u bytes): fetch it over USB with espp_coredump / emulink coredump ====\n", + (unsigned)core_dump.image_size()); } - const size_t size = core_dump.image_size(); - printf("==== CORE DUMP BEGIN (%u bytes, base64) ====\n", (unsigned)size); - std::vector raw(3 * 1024); - std::vector b64(4 * 1024 + 16); - std::error_code ec; - for (size_t off = 0; off < size; off += raw.size()) { - const size_t n = std::min(raw.size(), size - off); - if (!core_dump.read_image(off, std::span(raw.data(), n), ec)) { - printf("==== CORE DUMP READ FAILED at %u: %s ====\n", (unsigned)off, ec.message().c_str()); - break; - } - size_t olen = 0; - mbedtls_base64_encode(b64.data(), b64.size(), &olen, raw.data(), n); - fwrite(b64.data(), 1, olen, stdout); - fputc('\n', stdout); - } - printf("==== CORE DUMP END ====\n"); - fflush(stdout); - core_dump.erase(ec); } diff --git a/components/tab5-emu/src/tab5-emu.cpp b/components/tab5-emu/src/tab5-emu.cpp index 7819710..9b1c44d 100644 --- a/components/tab5-emu/src/tab5-emu.cpp +++ b/components/tab5-emu/src/tab5-emu.cpp @@ -298,6 +298,8 @@ void Tab5Emu::keypad_read(lv_indev_t *indev, lv_indev_data_t *data) { std::lock_guard lk(self->hid_mutex_); st.buttons = self->usb_gamepad_.buttons; } + // the USB link's test control (emulink press) + st.buttons |= self->remote_buttons_.load(std::memory_order_relaxed); if (self->usb_keyboard_present()) { const auto kb = self->keyboard_state(); auto key = [&](int usage) { return (kb.keys[usage >> 3] >> (usage & 7)) & 1; }; @@ -437,9 +439,16 @@ GamepadState Tab5Emu::gamepad_state() { std::lock_guard lk(hid_mutex_); state.buttons |= usb_gamepad_.buttons; } + // buttons held by the USB link's test control (scripted input) + state.buttons |= remote_buttons_.load(std::memory_order_relaxed); return state; } +void Tab5Emu::set_remote_buttons(uint16_t buttons) { + const uint16_t old = remote_buttons_.exchange(buttons, std::memory_order_relaxed); + press_latch_.fetch_or(static_cast(buttons & ~old), std::memory_order_relaxed); +} + GamepadState Tab5Emu::gamepad_state_latched() { GamepadState state = gamepad_state(); state.buttons |= press_latch_.exchange(0, std::memory_order_relaxed); diff --git a/components/tab5-emu/src/usb_device.cpp b/components/tab5-emu/src/usb_device.cpp new file mode 100644 index 0000000..598b3ac --- /dev/null +++ b/components/tab5-emu/src/usb_device.cpp @@ -0,0 +1,184 @@ +// The composite USB device on the USB-C port (espp::UsbDevice): the console +// on CDC, the framed link (OTA, core dump, system, test control) on a vendor +// interface, and the SD card as a USB drive (MSC over the BSP's espp::SdCard). +// See tab5-emu.hpp for the ownership rules. +#include "tab5-emu.hpp" + +#include +#include +#include +#include +#include +#include +#include + +// The ESP32-P4 has two full-speed PHYs: PHY 0 on GPIO24/25 and PHY 1 on +// GPIO26/27. By default the USB-Serial-JTAG console owns PHY 0 and the OTG 1.1 +// controller (TinyUSB port 0) owns PHY 1. On the Tab5 only PHY 0 reaches a +// connector (USB-C); GPIO26/27 carry I2S. LP_SYS.usb_ctrl can swap the two, so +// while the drive is on the OTG controller takes the USB-C pins and the +// console is parked on the (pad-disabled) other PHY. +static constexpr gpio_num_t usb_c_dm_io = GPIO_NUM_24; +static constexpr gpio_num_t usb_c_dp_io = GPIO_NUM_25; +static constexpr gpio_num_t phy1_dm_io = GPIO_NUM_26; +static constexpr gpio_num_t phy1_dp_io = GPIO_NUM_27; + +// A PC only re-enumerates after it has seen a disconnect, so the pull-up has +// to drop for a moment before the other controller takes the pins. +static constexpr int usb_disconnect_ms = 300; + +static void usb_otg11_take_usb_c(bool take) { + if (take) { + // the console drops off the bus (its pads are also the audio pins once + // it is moved to PHY 1, so they stay disabled while the drive is on) + usb_serial_jtag_ll_phy_enable_pad(false); + vTaskDelay(pdMS_TO_TICKS(usb_disconnect_ms)); + usb_wrap_ll_phy_select(&USB_WRAP, 0); + // the internal PHY driver only set the drive strength on PHY 1's pins + gpio_set_drive_capability(usb_c_dm_io, GPIO_DRIVE_CAP_3); + gpio_set_drive_capability(usb_c_dp_io, GPIO_DRIVE_CAP_3); + } else { + // the OTG controller is already torn down here (pull-up gone) + vTaskDelay(pdMS_TO_TICKS(usb_disconnect_ms)); + LP_SYS.usb_ctrl.sw_usb_phy_sel = 0; + LP_SYS.usb_ctrl.sw_hw_usb_phy_sel = 0; // hardware default: console on PHY 0 + usb_serial_jtag_ll_phy_enable_pad(true); + gpio_set_drive_capability(phy1_dm_io, GPIO_DRIVE_CAP_2); + gpio_set_drive_capability(phy1_dp_io, GPIO_DRIVE_CAP_2); + } +} + +// LP_SYS survives a software reset: make sure the console owns the USB-C +// pins whenever the app starts (e.g. after a crash while the drive was on). +void Tab5Emu::usb_msc_restore_console() { + if (LP_SYS.usb_ctrl.sw_hw_usb_phy_sel) { + usb_otg11_take_usb_c(false); + } +} + +bool Tab5Emu::initialize_usb_device() { + if (usb_device_) { + return true; + } + auto &bsp = Bsp::get(); + auto *sd = bsp.sdcard_component(); + const bool have_sd = sd && bsp.sdcard(); + std::error_code ec; + // the MSC function mounts the card for us while we own it: release our mount + if (have_sd && sd->is_mounted() && !sd->unmount(ec)) { + logger_.error("USB: could not unmount the SD card for the drive function: {}", ec.message()); + return false; + } + espp::UsbDevice::Config cfg; + cfg.manufacturer = "esp-cpp"; + cfg.product = "Tab5 Emu"; + cfg.serial_number = "tab5-emu"; + // The P4's high-speed OTG port is wired to the Tab5's USB-A host jack; the + // USB-C connector carries the full-speed port (shared with the + // USB-Serial-JTAG console, which is parked while TinyUSB owns the pins). + cfg.port = 0; // TINYUSB_PORT_FULL_SPEED_0 + // the console: logs on this CDC port (teed to UART0) + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "Tab5 Emu console"; + cdc.route_console = true; + cfg.cdc = cdc; + // the framed services (OTA, core dump, system, test control) + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "Tab5 Emu link"; + vendor.webusb = true; + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/device_hub.html"; + vendor.on_receive = [this](std::span data) { + if (vendor_rx_) vendor_rx_(data); + }; + cfg.vendor = vendor; + // the SD card as a USB drive: ours until handed over (initialize_usb_msc()) + if (have_sd) { + espp::UsbDevice::MscMedium medium; + medium.type = espp::UsbDevice::MscMedium::Type::SdCard; + medium.sd_card = bsp.sdcard(); + medium.base_path = mount_point; + medium.max_files = 16; + medium.initial_owner = espp::UsbDevice::MscOwner::App; + espp::UsbDevice::MscFunction msc; + msc.interface_name = "Tab5 Emu SD card"; + msc.media = {medium}; + msc.auto_handover = false; // only when asked (launcher switch / host request) + msc.on_event = [this](size_t lun, espp::UsbDevice::MscEvent event, espp::UsbDevice::MscOwner owner) { + using E = espp::UsbDevice::MscEvent; + if (event == E::OwnerChanged) { + logger_.info("USB drive: SD card now owned by the {}", owner == espp::UsbDevice::MscOwner::Host ? "PC" : "device"); + } else if (event == E::OwnerChangeFailed) { + logger_.error("USB drive: hand-over failed"); + } else if (event == E::FormatRequired) { + logger_.error("USB drive: the SD card has no FAT filesystem"); + } + }; + cfg.msc = msc; + } + logger_.info("USB: moving the USB-C port from the serial console to the composite device"); + usb_otg11_take_usb_c(true); + usb_device_ = std::make_unique(cfg); + if (!usb_device_->initialize(ec)) { + usb_device_.reset(); + usb_otg11_take_usb_c(false); + logger_.error("USB: could not start the composite device: {}", ec.message()); + if (have_sd) sd->mount(); + return false; + } + logger_.info("USB: composite device up (console, link{})", have_sd ? ", SD card drive" : ""); + return true; +} + +bool Tab5Emu::vendor_write(std::span frame) { + std::lock_guard lk(vendor_tx_mutex_); + return usb_device_ && usb_device_->write_vendor(frame); +} + +void Tab5Emu::usb_prepare_for_rom_download() { + // LP_SYS survives the reset: give the USB-C pins back to the USB-Serial-JTAG + // controller so the ROM's download mode shows up on the connector + LP_SYS.usb_ctrl.sw_usb_phy_sel = 0; + LP_SYS.usb_ctrl.sw_hw_usb_phy_sel = 0; + usb_serial_jtag_ll_phy_enable_pad(true); +} + +bool Tab5Emu::initialize_usb_msc() { + if (!usb_device_ || usb_device_->msc_lun_count() == 0) { + logger_.error("USB drive: no SD card drive function"); + return false; + } + // the launcher reads the card (metadata, boxart) under the LVGL lock: hold + // it so the volume is not unmounted under one of its reads + std::lock_guard lk(lvgl_mutex()); + std::error_code ec; + if (!usb_device_->set_msc_owner(0, espp::UsbDevice::MscOwner::Host, ec)) { + logger_.error("USB drive: could not hand the SD card to the PC: {}", ec.message()); + return false; + } + logger_.info("USB drive on: the PC has the SD card"); + return true; +} + +void Tab5Emu::deinitialize_usb_msc() { + if (!usb_device_ || usb_device_->msc_lun_count() == 0) { + return; + } + std::lock_guard lk(lvgl_mutex()); // see initialize_usb_msc() + std::error_code ec; + // The PC stays attached (console, link), so this is taken on trust that it + // ejected the drive first (the launcher says so; emulink drive off ejects + // it): one that did not may lose writes it had cached, as on an unplug. + if (!usb_device_->take_msc_from_ejected_host(0, ec)) { + logger_.error("USB drive: could not take the SD card back: {}", ec.message()); + return; + } + logger_.info("USB drive off: the SD card is ours again"); +} + +bool Tab5Emu::usb_msc_host_has_card() const { + if (!usb_device_ || usb_device_->msc_lun_count() == 0) { + return false; + } + const auto owner = usb_device_->msc_owner(0); + return owner.has_value() && *owner == espp::UsbDevice::MscOwner::Host; +} diff --git a/components/tab5-emu/src/usb_hid.cpp b/components/tab5-emu/src/usb_hid.cpp index ada7315..c8f8a94 100644 --- a/components/tab5-emu/src/usb_hid.cpp +++ b/components/tab5-emu/src/usb_hid.cpp @@ -214,6 +214,9 @@ bool Tab5Emu::initialize_usb_host() { .on_device_connected = [hid](const auto &device) { hid->on_connected(device); }, .on_device_disconnected = [hid](const auto &device) { hid->on_disconnected(device); }, .auto_start = true, + // controller 0: the high-speed OTG 2.0, wired to the USB-A jack (the + // full-speed one is the composite device on the USB-C port) + .port = 0, .task_priority = 5, .dispatch_task_stack_size = 8 * 1024, .full_speed_only = true, // devices behind a hub: IDF has no transaction translator diff --git a/components/tab5-emu/src/usb_msc.cpp b/components/tab5-emu/src/usb_msc.cpp deleted file mode 100644 index 307ffc9..0000000 --- a/components/tab5-emu/src/usb_msc.cpp +++ /dev/null @@ -1,151 +0,0 @@ -// USB mass storage: the SD card as a USB drive (espp::UsbDevice MSC function -// over the BSP's espp::SdCard). See tab5-emu.hpp for the ownership rules. -#include "tab5-emu.hpp" - -#include -#include -#include -#include -#include -#include -#include - -// The ESP32-P4 has two full-speed PHYs: PHY 0 on GPIO24/25 and PHY 1 on -// GPIO26/27. By default the USB-Serial-JTAG console owns PHY 0 and the OTG 1.1 -// controller (TinyUSB port 0) owns PHY 1. On the Tab5 only PHY 0 reaches a -// connector (USB-C); GPIO26/27 carry I2S. LP_SYS.usb_ctrl can swap the two, so -// while the drive is on the OTG controller takes the USB-C pins and the -// console is parked on the (pad-disabled) other PHY. -static constexpr gpio_num_t usb_c_dm_io = GPIO_NUM_24; -static constexpr gpio_num_t usb_c_dp_io = GPIO_NUM_25; -static constexpr gpio_num_t phy1_dm_io = GPIO_NUM_26; -static constexpr gpio_num_t phy1_dp_io = GPIO_NUM_27; - -// A PC only re-enumerates after it has seen a disconnect, so the pull-up has -// to drop for a moment before the other controller takes the pins. -static constexpr int usb_disconnect_ms = 300; - -static void usb_otg11_take_usb_c(bool take) { - if (take) { - // the console drops off the bus (its pads are also the audio pins once - // it is moved to PHY 1, so they stay disabled while the drive is on) - usb_serial_jtag_ll_phy_enable_pad(false); - vTaskDelay(pdMS_TO_TICKS(usb_disconnect_ms)); - usb_wrap_ll_phy_select(&USB_WRAP, 0); - // the internal PHY driver only set the drive strength on PHY 1's pins - gpio_set_drive_capability(usb_c_dm_io, GPIO_DRIVE_CAP_3); - gpio_set_drive_capability(usb_c_dp_io, GPIO_DRIVE_CAP_3); - } else { - // the OTG controller is already torn down here (pull-up gone) - vTaskDelay(pdMS_TO_TICKS(usb_disconnect_ms)); - LP_SYS.usb_ctrl.sw_usb_phy_sel = 0; - LP_SYS.usb_ctrl.sw_hw_usb_phy_sel = 0; // hardware default: console on PHY 0 - usb_serial_jtag_ll_phy_enable_pad(true); - gpio_set_drive_capability(phy1_dm_io, GPIO_DRIVE_CAP_2); - gpio_set_drive_capability(phy1_dp_io, GPIO_DRIVE_CAP_2); - } -} - -// LP_SYS survives a software reset: make sure the console owns the USB-C -// pins whenever the app starts (e.g. after a crash while the drive was on). -void Tab5Emu::usb_msc_restore_console() { - if (LP_SYS.usb_ctrl.sw_hw_usb_phy_sel) { - usb_otg11_take_usb_c(false); - } -} - -bool Tab5Emu::initialize_usb_msc() { - if (usb_device_) { - return true; - } - auto &bsp = Bsp::get(); - auto *sd = bsp.sdcard_component(); - if (!sd || !bsp.sdcard()) { - logger_.error("USB drive: no SD card"); - return false; - } - // one OTG controller: the host side (keyboard / mouse) has to go - usb_host_was_enabled_ = is_usb_host_enabled(); - if (usb_host_was_enabled_) { - deinitialize_usb_host(); - } - // release the volume; the MSC function mounts it for us while we own it - std::error_code ec; - if (sd->is_mounted() && !sd->unmount(ec)) { - logger_.error("USB drive: could not unmount the SD card: {}", ec.message()); - if (usb_host_was_enabled_) initialize_usb_host(); - return false; - } - espp::UsbDevice::Config cfg; - cfg.manufacturer = "esp-cpp"; - cfg.product = "Tab5 Emu"; - cfg.serial_number = "tab5-emu"; - // The P4's high-speed OTG port is wired to the Tab5's USB-A host jack; the - // USB-C connector carries the full-speed port (shared with the - // USB-Serial-JTAG console, which drops out while the drive is on). - cfg.port = 0; // TINYUSB_PORT_FULL_SPEED_0 - espp::UsbDevice::MscMedium medium; - medium.type = espp::UsbDevice::MscMedium::Type::SdCard; - medium.sd_card = bsp.sdcard(); - medium.base_path = mount_point; - medium.max_files = 8; - medium.initial_owner = espp::UsbDevice::MscOwner::App; - espp::UsbDevice::MscFunction msc; - msc.interface_name = "Tab5 Emu SD card"; - msc.media = {medium}; - msc.auto_handover = true; // the PC takes the card when it mounts the drive - msc.on_event = [this](size_t lun, espp::UsbDevice::MscEvent event, espp::UsbDevice::MscOwner owner) { - using E = espp::UsbDevice::MscEvent; - if (event == E::OwnerChanged) { - logger_.info("USB drive: SD card now owned by the {}", owner == espp::UsbDevice::MscOwner::Host ? "PC" : "device"); - } else if (event == E::OwnerChangeFailed) { - logger_.error("USB drive: hand-over failed"); - } else if (event == E::FormatRequired) { - logger_.error("USB drive: the SD card has no FAT filesystem"); - } - }; - cfg.msc = msc; - logger_.info("USB drive: moving the USB-C port from the console to the OTG controller"); - usb_otg11_take_usb_c(true); - usb_device_ = std::make_unique(cfg); - if (!usb_device_->initialize(ec)) { - usb_device_.reset(); - usb_otg11_take_usb_c(false); - logger_.error("USB drive: could not start the USB device: {}", ec.message()); - sd->mount(); - if (usb_host_was_enabled_) initialize_usb_host(); - return false; - } - logger_.info("USB drive on: plug the USB-C port into a PC (the serial console is off until the drive is)"); - return true; -} - -bool Tab5Emu::usb_msc_host_has_card() const { - if (!usb_device_) { - return false; - } - const auto owner = usb_device_->msc_owner(0); - return owner.has_value() && *owner == espp::UsbDevice::MscOwner::Host; -} - -void Tab5Emu::deinitialize_usb_msc() { - if (!usb_device_) { - return; - } - // destroying the device detaches from the PC and releases the card - // (initialized, unmounted); then it is ours again - usb_device_.reset(); - usb_otg11_take_usb_c(false); - // give the PC time to re-enumerate the console before anything that can - // fail loudly (host re-init, mount): a panic printed earlier is lost - vTaskDelay(pdMS_TO_TICKS(1500)); - auto *sd = Bsp::get().sdcard_component(); - std::error_code ec; - if (sd && !sd->is_mounted() && !sd->mount(ec)) { - logger_.error("USB drive: could not re-mount the SD card: {}", ec.message()); - } - if (usb_host_was_enabled_) { - initialize_usb_host(); - } - logger_.info("USB drive off"); -} diff --git a/main/cart.hpp b/main/cart.hpp index e1c066d..a50b231 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,39 @@ class Cart { running_ = false; } + /// Save the state to `slot` (with its screenshot) the way the pause menu's + /// Save does, without showing the menu; for the USB link's test control. + /// Call from the task that runs the cart (between run() calls). + void save_from_host(int slot) { + menu_->select_slot(slot); + pre_menu(); + screenshot(get_paused_image_path()); + save(); + remember_slot(); + if (running_) + post_menu(); + } + + /// Load the state in `slot` the way the pause menu's Load does; false if + /// the slot is empty. Same rules as save_from_host(). + bool load_from_host(int slot) { + menu_->select_slot(slot); + if (get_save_path().empty()) { + return false; + } + pre_menu(); + load(); + remember_slot(); + if (running_) + post_menu(); + return true; + } + + const RomInfo &rom_info() const { return info_; } + /// Frames the game has drawn so far (for the USB link's status: frame-rate + /// tests); 0 where the cart does not count them. + virtual uint32_t game_frames() const { return 0; } + /// Open the pause menu over the game, click the button of `action` there /// and resume, as a player would; for the automated launch tests. void menu_action_as_from_menu(Menu::Action action) { @@ -154,7 +188,16 @@ class Cart { } /// Run one iteration of the game loop; returns false once the user quits. + /// Quit the game at the next run() call, as the pause menu's Quit does + /// (for the USB link's test control; any task). + void request_quit() { quit_requested_ = true; } + virtual bool run() { + if (quit_requested_) { + pre_menu(); + running_ = false; + return false; + } running_ = true; auto &emu = Tab5Emu::get(); if (resume_pending_ && ++frames_run_ > resume_after_frames()) { @@ -206,9 +249,18 @@ class Cart { if (std::filesystem::exists(to, ec)) { std::filesystem::remove(to, ec); } - std::ifstream src(from, std::ios::binary); - std::ofstream dst(to, std::ios::binary); - dst << src.rdbuf(); + // stdio: an ifstream read error (EIO) throws, which aborts here + FILE *src = fopen(from.c_str(), "rb"); + if (!src) return; + FILE *dst = fopen(to.c_str(), "wb"); + if (dst) { + char buf[1024]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), src)) > 0 && fwrite(buf, 1, n, dst) == n) { + } + fclose(dst); + } + fclose(src); } virtual void on_menu_action(Menu::Action action) { @@ -330,6 +382,7 @@ class Cart { std::atomic running_{false}; bool resume_pending_{false}; + std::atomic quit_requested_{false}; int frames_run_{0}; size_t rom_size_bytes_{0}; uint8_t *romdata_{nullptr}; diff --git a/main/main.cpp b/main/main.cpp index 86339b2..2d5f643 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -18,8 +18,10 @@ #include "task_monitor.hpp" #include "carts.hpp" +#include "devlink.hpp" #include "gui.hpp" #include "heap_utils.hpp" +#include "remote_control.hpp" #include "rom_info.hpp" #include "settings.hpp" #include "statistics.hpp" @@ -156,6 +158,27 @@ extern "C" void app_main(void) { Gui gui({.log_level = espp::Logger::Verbosity::WARN}); internal_free_log("gui"); + // the USB link's services (core dump, system, monitor) and the test control + // (see tools/emulink); its requests for the launcher / the game run here, on + // the main loop, through main_queue + // the composite USB device on the USB-C port: the console moves to its CDC + // port, the link services get its vendor interface, the SD card its drive + // function (the card stays ours until handed over). Started last, as the + // launcher's USB drive switch used to. + if (!emu.initialize_usb_device()) { + logger.warn("USB composite device not available (console stays on USB-Serial-JTAG)"); + } + internal_free_log("initialize_usb_device"); + + MainQueue main_queue; + RemoteControl control(gui, main_queue); + DevLink devlink({.device_name = "Tab5 Emu", .firmware = esp_app_get_description()->version}); + devlink.register_module(control); + internal_free_log("devlink"); + // up and running: an update that got this far is good (else the bootloader + // rolls back to the updater on the next reset) + DevLink::mark_app_valid(); + print_heap_state(); #if CONFIG_TAB5_EMU_USB_MSC_AT_BOOT @@ -221,6 +244,7 @@ extern "C" void app_main(void) { std::this_thread::sleep_for(50ms); // settings changed in the GUI are written once the changes stop AppSettings::get().save_if_idle(); + main_queue.poll(); } gui.pause(); @@ -238,6 +262,7 @@ extern "C" void app_main(void) { { std::unique_ptr cart(make_cart(selected_rom)); if (cart) { + control.set_cart(cart.get()); // start from the save state this game was last saved to / loaded from if (AppSettings::get().get_settings().resume_last_save) { if (const auto slot = AppSettings::get().last_slot(selected_rom.rom_path)) { @@ -274,8 +299,9 @@ extern "C" void app_main(void) { } #else while (cart->run()) - ; + main_queue.poll(); #endif + control.set_cart(nullptr); } else { logger.error("Failed to create cart!"); } diff --git a/main/mgs_cart.hpp b/main/mgs_cart.hpp index b7ed54d..4e22828 100644 --- a/main/mgs_cart.hpp +++ b/main/mgs_cart.hpp @@ -3,6 +3,7 @@ #include "cart.hpp" #if defined(ENABLE_MGS) #include "mgs.hpp" +extern "C" unsigned mgs_dbg_drawotag; // libdg: one DrawOTag per frame drawn #endif /// Cart for Metal Gear Solid, running natively (the decompilation on psyz). @@ -76,6 +77,10 @@ class MgsCart : public Cart { return Cart::run(); } +#if defined(ENABLE_MGS) + uint32_t game_frames() const override { return mgs_dbg_drawotag; } +#endif + protected: void pre_menu() override { Cart::pre_menu(); diff --git a/main/remote_control.hpp b/main/remote_control.hpp new file mode 100644 index 0000000..47e6711 --- /dev/null +++ b/main/remote_control.hpp @@ -0,0 +1,589 @@ +#pragma once + +// Test control over the USB link (dispatcher module 0x20, protocol +// "emu.control"): what a test script needs to drive the emulator -- list and +// launch games, quit, hold buttons, save / load states, take screenshots, move +// files to and from the SD card (with the device running), hand the card to +// the PC as a USB drive and reboot into the updater. tools/emulink is the +// host side. +// +// Every request is answered with a reply frame of the same type whose +// payload is one status byte (0 = ok, 1 = error) and then the body: JSON text +// (an error's message as {"error": ...}), or the raw bytes for FILE_READ. +// Requests that touch the launcher or the running game are handed to the main +// loop (MainQueue) and answered once it ran them. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "bootloader_common.h" +#include "esp_flash_partitions.h" +#if CONFIG_IDF_TARGET_ESP32S3 +#include "soc/rtc_cntl_reg.h" +#endif + +#include "dispatcher.hpp" +#include "format.hpp" +#include "stream_frame.hpp" + +#include "cart.hpp" +#include "devlink.hpp" +#include "gui.hpp" +#include "settings.hpp" + +/// Work for the main loop (the task that owns the launcher and the running +/// cart), posted from other tasks; run() waits for it to be done. +class MainQueue { +public: + /// Run `fn` on the main loop and wait for it. If the main loop does not + /// get to it within `timeout` it is dropped (never run later) and run() + /// returns false; once started it is always waited for. + bool run(std::function fn, std::chrono::milliseconds timeout) { + auto item = std::make_shared(); + item->fn = std::move(fn); + std::unique_lock lk(mutex_); + queue_.push_back(item); + if (!cv_.wait_for(lk, timeout, [&] { return item->state != Item::QUEUED; })) { + item->state = Item::CANCELLED; + return false; + } + cv_.wait(lk, [&] { return item->state == Item::DONE; }); + return true; + } + /// Called by the main loop: run what was posted. + void poll() { + std::unique_lock lk(mutex_); + while (!queue_.empty()) { + auto item = queue_.front(); + queue_.pop_front(); + if (item->state == Item::CANCELLED) continue; + item->state = Item::RUNNING; + cv_.notify_all(); + lk.unlock(); + item->fn(); + lk.lock(); + item->state = Item::DONE; + cv_.notify_all(); + } + } + +protected: + struct Item { + enum State { QUEUED, RUNNING, DONE, CANCELLED } state{QUEUED}; + std::function fn; + }; + std::mutex mutex_; + std::condition_variable cv_; + std::deque> queue_; +}; + +class RemoteControl { +public: + static constexpr uint8_t kModule = 0x20; + static constexpr const char *kProtocol = "emu.control"; + static constexpr uint16_t kProtocolVersion = 1; + + enum class Type : uint8_t { + STATUS = 0x01, ///< () -> {state, rom, platform, console, heap, uptime, ...} + LIST_ROMS = 0x02, ///< (offset: text) -> {total, roms: [{name, path, platform, console}]} + LAUNCH = 0x03, ///< (rom path or part of a name / path) -> {rom} + QUIT = 0x04, ///< () + BUTTONS = 0x05, ///< (u16 buttons, u16 hold ms; 0 = until the next BUTTONS) + SAVE = 0x06, ///< (u8 slot) + LOAD = 0x07, ///< (u8 slot) + SCREENSHOT = 0x08, ///< (path: text, optional) -> {path} [u16be w][u16be h][RGB565] + USB_DRIVE = 0x09, ///< (u8 1 = the PC gets the SD card, 0 = the device takes it back) + RELOAD_ROMS = 0x0A, ///< () re-read metadata.csv + ENTER_UPDATER = 0x0B, ///< () reboot into the updater app (ota_1) for an update + FILE_LIST = 0x10, ///< (path: text) -> {entries: [{name, dir, size}]} + FILE_READ = 0x11, ///< (u32 offset, u16 length, path) -> raw bytes (short at the end) + FILE_WRITE = 0x12, ///< (u32 offset, path_len u16, path, data); offset 0 truncates + FILE_DELETE = 0x13, ///< (path: text) + MKDIR = 0x14, ///< (path: text) (with parents) + FILE_STAT = 0x15, ///< (path: text) -> {size, dir} + FILE_CLOSE = 0x16, ///< () flush and close the file being written + }; + + /// Where the main loop is, for STATUS. + enum class State { MENU, GAME }; + + RemoteControl(Gui &gui, MainQueue &main_queue) + : gui_(gui) + , main_(main_queue) {} + + uint8_t module_id() const { return kModule; } + espp::DispatcherModuleInfo module_info() const { + return {.name = "Emulator control", + .app = "", + .description = "Launch games, input, save states, screenshots, SD card files", + .protocol = kProtocol, + .protocol_version = kProtocolVersion}; + } + + /// The main loop tells us what is running (nullptr: the launcher). + void set_cart(Cart *cart) { + std::lock_guard lk(state_mutex_); + cart_ = cart; + } + + void handle(const espp::stream_frame::Frame &frame) { + if (frame.is_reply() || frame.module != kModule) { + return; + } + std::vector body; + bool ok = true; + const std::string_view text(reinterpret_cast(frame.payload.data()), frame.payload.size()); + // the first error is the one reported + auto fail = [&](std::string_view message) { + if (!ok) return; + ok = false; + const auto j = fmt::format("{{\"error\":{}}}", quote(message)); + body.assign(j.begin(), j.end()); + }; + auto json = [&](const std::string &j) { + if (ok) body.assign(j.begin(), j.end()); + }; + // the main loop does not take work while it waits in the pause menu + auto on_main = [&](std::function fn, std::chrono::milliseconds timeout) { + if (main_.run(std::move(fn), timeout)) return true; + fail("the device is busy (is the pause menu open?)"); + return false; + }; + const auto &p = frame.payload; + switch (static_cast(frame.type)) { + case Type::STATUS: + json(status()); + break; + case Type::LIST_ROMS: { + const auto roms = gui_.rom_infos(); + size_t offset = 0; + if (!text.empty()) offset = std::strtoul(std::string(text).c_str(), nullptr, 10); + std::string j = fmt::format("{{\"total\":{},\"offset\":{},\"roms\":[", roms.size(), offset); + size_t i = offset; + for (; i < roms.size(); i++) { + const auto &r = roms[i]; + auto e = fmt::format("{}{{\"name\":{},\"path\":{},\"platform\":\"{}\",\"console\":{}}}", + i > offset ? "," : "", quote(r.name), quote(r.rom_path), r.platform, + quote(console_info(console_of(r.platform)).name)); + if (j.size() + e.size() + 8 > espp::stream_frame::kMaxPayloadSize) break; // the host asks for the rest + j += e; + } + j += fmt::format("],\"next\":{}}}", i); + json(j); + break; + } + case Type::LAUNCH: { + if (current_cart()) { + fail("a game is running; QUIT first"); + break; + } + const std::string match(text); + bool found = false; + on_main([&] { found = select(match) && (gui_.ready_to_play(true), true); }, std::chrono::seconds(2)); + if (!found) fail(fmt::format("no game matches '{}'", match)); + else json(fmt::format("{{\"rom\":{}}}", quote(gui_.get_selected_rom()->rom_path))); + break; + } + case Type::QUIT: { + bool quit = false; + on_main([&] { + if (auto *cart = current_cart()) { + cart->request_quit(); + quit = true; + } + }, std::chrono::seconds(5)); + if (!quit) fail("no game is running"); + break; + } + case Type::BUTTONS: { + if (p.size() < 4) { + fail("payload: u16 buttons, u16 hold_ms"); + break; + } + const uint16_t buttons = p[0] | (p[1] << 8); + const uint16_t hold_ms = p[2] | (p[3] << 8); + BoxEmu::get().set_remote_buttons(buttons); + if (hold_ms) { + // held for hold_ms, then released (this task answers afterwards) + std::this_thread::sleep_for(std::chrono::milliseconds(hold_ms)); + BoxEmu::get().set_remote_buttons(0); + } + break; + } + case Type::SAVE: + case Type::LOAD: { + if (p.empty()) { + fail("payload: u8 slot"); + break; + } + const int slot = p[0]; + const bool save = static_cast(frame.type) == Type::SAVE; + bool done = false, has_cart = false; + on_main([&] { + if (auto *cart = current_cart()) { + has_cart = true; + if (save) { + cart->save_from_host(slot); + done = true; + } else { + done = cart->load_from_host(slot); + } + } + }, std::chrono::seconds(30)); + if (!has_cart) fail("no game is running"); + else if (!done) fail(fmt::format("slot {} has no state", slot)); + break; + } + case Type::SCREENSHOT: { + std::string path(text); + if (path.empty()) path = std::string(BoxEmu::mount_point) + "/screenshots/screenshot.bin"; + bool done = false, has_cart = false; + on_main([&] { + if (auto *cart = current_cart()) { + has_cart = true; + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + done = cart->screenshot(path); + } + }, std::chrono::seconds(10)); + if (!has_cart) fail("no game is running"); + else if (!done) fail("screenshot failed"); + else json(fmt::format("{{\"path\":{}}}", quote(path))); + break; + } + case Type::USB_DRIVE: { + const bool host = !p.empty() && p[0]; + close_file(); + bool done = false; + on_main([&] { + if (current_cart() && host) return; // a running game uses the card + if (host) { + done = BoxEmu::get().initialize_usb_msc(); + } else { + BoxEmu::get().deinitialize_usb_msc(); + done = !BoxEmu::get().usb_msc_host_has_card(); + if (done) gui_.reload_rom_list(); + } + }, std::chrono::seconds(10)); + if (!done) fail(host ? "could not hand the SD card over (a game running?)" : "could not take the SD card back"); + break; + } + case Type::RELOAD_ROMS: + close_file(); + on_main([&] { gui_.reload_rom_list(); }, std::chrono::seconds(5)); + json(fmt::format("{{\"total\":{}}}", gui_.rom_infos().size())); + break; + case Type::ENTER_UPDATER: { +#if DEVLINK_HAS_OTA + fail("this device updates in place (the OTA service, module 0)"); + break; +#endif + if (!boot_updater_next()) { + fail("no updater app (ota_1), or otadata could not be written"); + break; + } + close_file(); + // answer first, then restart + reply(frame, true, body); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + esp_restart(); + return; + } + case Type::FILE_LIST: { + std::error_code ec; + std::string j = "{\"entries\":["; + bool first = true; + for (const auto &e : std::filesystem::directory_iterator(std::string(text), ec)) { + std::error_code ec2; + const bool dir = e.is_directory(ec2); + const uintmax_t size = dir ? 0 : e.file_size(ec2); + auto item = fmt::format("{}{{\"name\":{},\"dir\":{},\"size\":{}}}", first ? "" : ",", + quote(e.path().filename().string()), dir, size); + if (j.size() + item.size() + 32 > espp::stream_frame::kMaxPayloadSize) { + j += "],\"truncated\":true}"; + json(j); + goto done; + } + j += item; + first = false; + } + if (ec) { + fail(fmt::format("{}: {}", text, ec.message())); + break; + } + j += "]}"; + json(j); + done: + break; + } + case Type::FILE_READ: { + if (p.size() < 7) { + fail("payload: u32 offset, u16 length, path"); + break; + } + const uint32_t offset = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); + const size_t length = std::min(p[4] | (p[5] << 8), espp::stream_frame::kMaxPayloadSize - 1); + const std::string path(reinterpret_cast(p.data() + 6), p.size() - 6); + close_file(path); + FILE *f = fopen(path.c_str(), "rb"); + if (!f) { + fail(fmt::format("cannot open {}", path)); + break; + } + body.resize(length); + fseek(f, offset, SEEK_SET); + body.resize(fread(body.data(), 1, length, f)); + fclose(f); + break; + } + case Type::FILE_WRITE: { + if (p.size() < 6) { + fail("payload: u32 offset, u16 path_len, path, data"); + break; + } + const uint32_t offset = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); + const size_t path_len = p[4] | (p[5] << 8); + if (p.size() < 6 + path_len) { + fail("path_len past the end"); + break; + } + const std::string path(reinterpret_cast(p.data() + 6), path_len); + const uint8_t *data = p.data() + 6 + path_len; + const size_t n = p.size() - 6 - path_len; + if (!open_for_write(path, offset)) { + fail(fmt::format("cannot write {} at {}", path, offset)); + break; + } + if (fwrite(data, 1, n, write_file_) != n) { + close_file(); + fail(fmt::format("write to {} failed (card full?)", path)); + break; + } + write_pos_ = offset + n; + break; + } + case Type::FILE_CLOSE: + close_file(); + break; + case Type::FILE_DELETE: { + const std::string path(text); + close_file(path); + struct stat st; + if (stat(path.c_str(), &st) != 0) { + fail(fmt::format("{} does not exist", path)); + } else if (S_ISDIR(st.st_mode)) { + std::error_code ec; + std::filesystem::remove_all(path, ec); + if (ec) fail(fmt::format("cannot delete {}: {}", path, ec.message())); + } else if (unlink(path.c_str()) != 0) { + fail(fmt::format("cannot delete {}: {}", path, strerror(errno))); + } + break; + } + case Type::MKDIR: { + std::error_code ec; + std::filesystem::create_directories(std::string(text), ec); + if (ec) fail(fmt::format("{}: {}", text, ec.message())); + break; + } + case Type::FILE_STAT: { + struct stat st; + if (stat(std::string(text).c_str(), &st) != 0) { + fail(fmt::format("{} does not exist", text)); + break; + } + json(fmt::format("{{\"size\":{},\"dir\":{}}}", (uintmax_t)st.st_size, S_ISDIR(st.st_mode))); + break; + } + default: + fail(fmt::format("unknown request type {:#x}", frame.type)); + break; + } + reply(frame, ok, body); + } + +protected: + static std::string quote(std::string_view s) { + std::string out = "\""; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char)c < 0x20) out += fmt::format("\\u{:04x}", (unsigned char)c); + else out += c; + } + } + return out + "\""; + } + + static void reply(const espp::stream_frame::Frame &request, bool ok, const std::vector &body) { + std::vector payload; + payload.reserve(body.size() + 1); + payload.push_back(ok ? 0 : 1); + payload.insert(payload.end(), body.begin(), body.end()); + const auto frame = espp::stream_frame::build_frame(true, kModule, request.type, payload, request.correlation); + DevLink::send(frame); + } + + Cart *current_cart() { + std::lock_guard lk(state_mutex_); + return cart_; + } + + bool select(const std::string &match) { + // an exact path first, then a part of a name / path + const auto roms = gui_.rom_infos(); + for (const auto &r : roms) { + if (r.rom_path == match || r.rom_path == std::string(BoxEmu::mount_point) + "/" + match) { + return gui_.select_rom_matching(r.rom_path); + } + } + return gui_.select_rom_matching(match); + } + + std::string status() { + std::string rom, platform, console; + bool in_game = false; + uint32_t frames = 0; + { + std::lock_guard lk(state_mutex_); + if (cart_) { + in_game = true; + const auto &info = cart_->rom_info(); + rom = info.rom_path; + platform = fmt::format("{}", info.platform); + console = console_info(console_of(info.platform)).name; + frames = cart_->game_frames(); + } + } + const auto *app = esp_app_get_description(); + const auto *running = esp_ota_get_running_partition(); + return fmt::format("{{\"state\":\"{}\",\"rom\":{},\"platform\":\"{}\",\"console\":{},\"game_frames\":{},\"usb_drive\":{}," + "\"uptime_ms\":{},\"internal_free\":{},\"internal_largest\":{},\"psram_free\":{}," + "\"psram_largest\":{},\"dma_free\":{},\"dma_largest\":{},\"firmware\":{},\"built\":{}," + "\"partition\":{},\"elf_sha256\":{},\"reset_reason\":{},\"last_panic\":{}}}", + in_game ? "game" : "menu", quote(rom), platform, quote(console), frames, + BoxEmu::get().usb_msc_host_has_card(), esp_timer_get_time() / 1000, + heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), + heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM), + heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL), + heap_caps_get_largest_free_block(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL), quote(app->version), + quote(fmt::format("{} {}", app->date, app->time)), quote(running ? running->label : ""), + quote(elf_sha256()), (int)esp_reset_reason(), quote(DevLink::last_panic())); + } + + /// Make the bootloader start the updater (ota_1) next. esp_ota_set_boot_partition() + /// first verifies the image through a flash mapping, which is unreliable in an + /// app that runs its code from PSRAM (esp-box-emu: the mapping can land on the + /// PSRAM heap's addresses), so the otadata entry is written here directly, with + /// plain SPI flash reads / writes: the same entry esp_ota_ops would write + /// (boot slot = (seq - 1) % slots). The updater, which has no PSRAM, verifies + /// and sets the boot partition the usual way. + static bool boot_updater_next() { + const esp_partition_t *updater = + esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, nullptr); + const esp_partition_t *otadata = + esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_OTA, nullptr); + if (!updater || !otadata) return false; + const uint32_t slots = esp_ota_get_app_partition_count(); + const uint32_t sector = otadata->erase_size; + esp_ota_select_entry_t e[2]; + for (int i = 0; i < 2; i++) { + if (esp_partition_read(otadata, i * sector, &e[i], sizeof(e[i])) != ESP_OK) return false; + } + const bool v0 = bootloader_common_ota_select_valid(&e[0]), v1 = bootloader_common_ota_select_valid(&e[1]); + const uint32_t seq = std::max(v0 ? e[0].ota_seq : 0, v1 ? e[1].ota_seq : 0); + uint32_t next = seq + 1; + while ((next - 1) % slots != 1) next++; // slot 1: the updater + // overwrite the older (or an invalid) entry, so the current one survives a failure + const int target = !v0 ? 0 : !v1 ? 1 : (e[0].ota_seq <= e[1].ota_seq ? 0 : 1); + esp_ota_select_entry_t entry; + memset(&entry, 0xFF, sizeof(entry)); + entry.ota_seq = next; + entry.ota_state = ESP_OTA_IMG_VALID; + entry.crc = bootloader_common_ota_select_crc(&entry); + esp_ota_select_entry_t check; + if (esp_partition_erase_range(otadata, target * sector, sector) == ESP_OK && + esp_partition_write(otadata, target * sector, &entry, sizeof(entry)) == ESP_OK && + esp_partition_read(otadata, target * sector, &check, sizeof(check)) == ESP_OK && + memcmp(&check, &entry, sizeof(entry)) == 0) { + return true; + } +#if CONFIG_IDF_TARGET_ESP32S3 + // esp-box-emu: with the flash and PSRAM at 120 MHz the app's flash writes + // do not land; the bootloader (bootloader_components/updater_switch) + // writes the entry when it finds this, at the next reset + REG_WRITE(RTC_CNTL_STORE0_REG, 0x55504454u); // "UPDT" + return true; +#else + return false; +#endif + } + + static std::string elf_sha256() { + char sha[17] = {}; + esp_app_get_elf_sha256(sha, sizeof(sha)); + return sha; + } + + // FILE_WRITE keeps the file open between chunks + bool open_for_write(const std::string &path, uint32_t offset) { + if (write_file_ && path == write_path_ && offset == write_pos_) { + return true; + } + close_file(); + if (offset == 0) { + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + write_file_ = fopen(path.c_str(), "wb"); + } else { + write_file_ = fopen(path.c_str(), "r+b"); + if (write_file_ && fseek(write_file_, offset, SEEK_SET) != 0) { + close_file(); + } + } + if (!write_file_) return false; + write_path_ = path; + write_pos_ = offset; + return true; + } + void close_file(const std::string &only_if_path = "") { + if (write_file_ && (only_if_path.empty() || only_if_path == write_path_)) { + fclose(write_file_); + write_file_ = nullptr; + write_path_.clear(); + } + } + + Gui &gui_; + MainQueue &main_; + std::mutex state_mutex_; + Cart *cart_{nullptr}; + FILE *write_file_{nullptr}; + std::string write_path_; + uint32_t write_pos_{0}; +}; diff --git a/partitions.csv b/partitions.csv index 51d6f56..d3be2ae 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,5 +1,12 @@ -# Name, Type, SubType, Offset, Size -nvs, data, nvs, 0xa000, 0x6000 -phy_init, data, phy, , 0x1000 -factory, app, factory, , 12M -coredump, data, coredump, , 64K +# Name, Type, SubType, Offset, Size +# ota_0: the emulator (at the old factory offset, so `idf.py flash` writes it +# there). ota_1: the small updater app (updater/), which an update reboots +# into: it takes the new emulator image over USB, writes ota_0 and boots it. +# With rollback on, an emulator image that resets before marking itself valid +# falls back to the updater. +nvs, data, nvs, 0xa000, 0x6000 +otadata, data, ota, , 0x2000 +phy_init, data, phy, , 0x1000 +ota_0, app, ota_0, 0x20000, 13M +ota_1, app, ota_1, , 2M +coredump, data, coredump, , 64K diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 19df53f..d477104 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -115,8 +115,18 @@ CONFIG_LV_CACHE_DEF_SIZE=65536 # USB mass storage (espp::UsbDevice MSC over the SD card) CONFIG_TINYUSB_MSC_ENABLED=y -CONFIG_TINYUSB_MSC_BUFSIZE=8192 -CONFIG_TINYUSB_CDC_ENABLED=n +CONFIG_TINYUSB_MSC_BUFSIZE=4096 +# the composite USB device: the console on CDC, the link services on a +# vendor interface (TX holds a whole reply frame: 4 KiB + header; RX is a +# stream the dispatcher reassembles). These FIFOs are internal RAM. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_RX_BUFSIZE=512 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=2048 +CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=1024 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4608 +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y CONFIG_FATFS_USE_LABEL=y # core dumps to flash (printed base64 on the next boot, see Tab5Emu::dump_core_dump_to_console) diff --git a/tools/emulink/emulink/__init__.py b/tools/emulink/emulink/__init__.py new file mode 100644 index 0000000..c12cea3 --- /dev/null +++ b/tools/emulink/emulink/__init__.py @@ -0,0 +1,12 @@ +"""emulink: drive esp-box-emu / tab5-emu over their composite USB device. + +The device's vendor (WebUSB) interface carries espp `stream_frame` frames +routed by module: OTA (0, esp-box-emu), core dump (4), system (7), monitor (8) +and the emulator's test control (0x20). The CDC interface carries the console +and the MSC interface is the SD card (when handed to the PC). +""" + +from .link import Link, LinkError # noqa: F401 +from .control import Control, ControlError, Buttons # noqa: F401 + +__version__ = "0.1.0" diff --git a/tools/emulink/emulink/__main__.py b/tools/emulink/emulink/__main__.py new file mode 100644 index 0000000..dd8a8c9 --- /dev/null +++ b/tools/emulink/emulink/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import main + +sys.exit(main()) diff --git a/tools/emulink/emulink/cli.py b/tools/emulink/emulink/cli.py new file mode 100644 index 0000000..2ff6c1f --- /dev/null +++ b/tools/emulink/emulink/cli.py @@ -0,0 +1,526 @@ +"""emulink command line: python -m emulink ...""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import sys +import time +import zlib +from typing import Optional + +from . import espp_tools +from .control import Buttons, Control, ControlError +from .link import Link, LinkError, find_console_port, find_devices + + +def _progress(label: str): + start = time.monotonic() + + def cb(done: int, total: int) -> None: + if not sys.stderr.isatty(): + return + pct = 100 * done // total if total else 100 + rate = done / max(time.monotonic() - start, 1e-3) / 1024 + sys.stderr.write(f"\r{label}: {pct:3d}% {done}/{total} bytes ({rate:.0f} KiB/s)") + if done >= total: + sys.stderr.write("\n") + sys.stderr.flush() + + return cb + + +def _png(width: int, height: int, rgb: bytes) -> bytes: + raw = b"".join(b"\x00" + rgb[y * width * 3:(y + 1) * width * 3] for y in range(height)) + + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) + + +def screenshot_to_png(data: bytes, byteswap: bool) -> bytes: + """A Cart::screenshot() file ([u16be w][u16be h][RGB565]) as a PNG.""" + w, h = struct.unpack(">HH", data[:4]) + # a core may report more lines than its frame buffer holds (the box's NES: + # 240 for a 224-line frame): trust the data + h = min(h, (len(data) - 4) // (w * 2)) + px = data[4:4 + w * h * 2] + rgb = bytearray(w * h * 3) + for i in range(w * h): + v = (px[2 * i] << 8 | px[2 * i + 1]) if byteswap else (px[2 * i] | px[2 * i + 1] << 8) + r, g, b = (v >> 11) & 0x1F, (v >> 5) & 0x3F, v & 0x1F + rgb[3 * i:3 * i + 3] = bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2))) + return _png(w, h, bytes(rgb)) + + +def _open(args, timeout_s: float = 0.0) -> Link: + return Link(args.serial).open(timeout_s) + + +def cmd_devices(args) -> int: + for d in find_devices(): + print(f"{d['serial']:12s} {d['product']:20s} bus {d['bus']} address {d['address']} " + f"console {find_console_port(d['serial']) or '-'}") + return 0 + + +def cmd_status(args) -> int: + with _open(args) as link: + print(json.dumps(Control(link).status(), indent=2)) + return 0 + + +def cmd_info(args) -> int: + with _open(args) as link: + print(json.dumps(Control(link).info(), indent=2)) + return 0 + + +def cmd_roms(args) -> int: + with _open(args) as link: + roms = Control(link).roms() + if args.json: + print(json.dumps(roms, indent=2)) + return 0 + console = None + for r in roms: + if r["console"] != console: + console = r["console"] + print(f"== {console}") + print(f" {r['name']} ({r['path']})") + return 0 + + +def cmd_launch(args) -> int: + with _open(args) as link: + rom = Control(link).launch(args.match, wait=not args.no_wait, timeout_s=args.timeout) + print(rom) + return 0 + + +def cmd_quit(args) -> int: + with _open(args) as link: + Control(link).quit(wait=not args.no_wait) + return 0 + + +def cmd_press(args) -> int: + with _open(args) as link: + c = Control(link) + for _ in range(args.repeat): + c.press(args.buttons, hold_ms=args.hold, gap_ms=args.gap) + return 0 + + +def cmd_hold(args) -> int: + with _open(args) as link: + Control(link).buttons(Buttons.parse(args.buttons), 0) + return 0 + + +def cmd_save(args) -> int: + with _open(args) as link: + Control(link).save(args.slot) + return 0 + + +def cmd_load(args) -> int: + with _open(args) as link: + Control(link).load(args.slot) + return 0 + + +def cmd_screenshot(args) -> int: + with _open(args) as link: + c = Control(link) + remote = c.screenshot() + data = c.read_file(remote) + byteswap = args.byteswap if args.byteswap is not None else link.serial == "esp-box-emu" + out = args.output or time.strftime("screenshot-%Y%m%d-%H%M%S.png") + with open(out, "wb") as f: + f.write(screenshot_to_png(data, byteswap) if out.endswith(".png") else data) + print(out) + return 0 + + +def cmd_ls(args) -> int: + with _open(args) as link: + for e in Control(link).ls(args.path): + print(f"{'d' if e['dir'] else '-'} {e['size']:>10} {e['name']}") + return 0 + + +def cmd_push(args) -> int: + with _open(args) as link: + for f in Control(link).push(args.local, args.remote, _progress(os.path.basename(args.local))): + print(f) + return 0 + + +def cmd_pull(args) -> int: + with _open(args) as link: + data = Control(link).read_file(args.remote, _progress(os.path.basename(args.remote))) + out = args.local or os.path.basename(args.remote) + with open(out, "wb") as f: + f.write(data) + print(out) + return 0 + + +def cmd_rm(args) -> int: + with _open(args) as link: + Control(link).rm(args.path) + return 0 + + +def cmd_mkdir(args) -> int: + with _open(args) as link: + Control(link).mkdir(args.path) + return 0 + + +def cmd_add_rom(args) -> int: + """Copy a game (and its boxart) to the card, list it in metadata.csv, reload.""" + with _open(args) as link: + c = Control(link) + rom_remote = f"{args.dir.strip('/')}/{os.path.basename(args.rom)}" + c.push(args.rom, rom_remote, _progress(os.path.basename(args.rom))) + boxart = "" + if args.boxart: + boxart = f"boxart/{os.path.basename(args.boxart)}" + c.push(args.boxart, boxart, _progress(os.path.basename(args.boxart))) + name = args.name or os.path.splitext(os.path.basename(args.rom))[0] + total = c.add_rom(rom_remote, name, boxart) + print(f"{rom_remote}: listed as {name!r} ({total} games)") + return 0 + + +def cmd_remove_rom(args) -> int: + with _open(args) as link: + c = Control(link) + total = c.add_rom(args.rom, "", remove=True) + if args.delete: + c.rm(args.rom) + print(f"{total} games") + return 0 + + +def cmd_reload(args) -> int: + with _open(args) as link: + print(Control(link).reload_roms()) + return 0 + + +def _msc_disk(product: str) -> Optional[str]: + """macOS: the BSD disk (e.g. disk20) of the emulator's SD card drive.""" + import re + import subprocess + + try: + out = subprocess.run(["ioreg", "-p", "IOService", "-l", "-w0", "-r", "-n", product], + capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + return None + m = re.search(r'"BSD Name" = "(disk\d+)"', out) + return m.group(1) if m else None + + +def _msc_mount(product: str) -> Optional[str]: + """macOS: where the emulator's SD card is mounted, once it is.""" + import plistlib + import subprocess + + disk = _msc_disk(product) + if not disk: + return None + try: + out = subprocess.run(["diskutil", "info", "-plist", f"{disk}s1"], capture_output=True, timeout=10).stdout + mount = plistlib.loads(out).get("MountPoint") if out else None + except (OSError, subprocess.TimeoutExpired, plistlib.InvalidFileException): + return None + return mount or None + + +def cmd_drive(args) -> int: + """Hand the SD card to the PC (it mounts it), or take it back: on macOS the + volume is ejected first, which the device needs before it can have it.""" + with _open(args) as link: + c = Control(link) + if args.state == "on": + c.usb_drive(True) + if sys.platform == "darwin": + deadline = time.monotonic() + 60 + mount = None + while time.monotonic() < deadline and not mount: + time.sleep(0.5) + mount = _msc_mount(link.product) + print(f"the PC has the SD card ({mount or 'not mounted yet'})") + return 0 + if sys.platform == "darwin": + disk = _msc_disk(link.product) + if disk: + import subprocess + + # Spotlight (mdsync) indexing a freshly mounted volume refuses + # the unmount for a while: retry + deadline = time.monotonic() + 60 + while True: + r = subprocess.run(["diskutil", "eject", disk], capture_output=True, text=True, timeout=60) + if r.returncode == 0 or time.monotonic() >= deadline: + break + time.sleep(2) + if r.returncode != 0: + print(f"emulink: could not eject {disk}: {r.stderr.strip() or r.stdout.strip()}", + file=sys.stderr) + return 1 + try: + c.usb_drive(False) + except LinkError: + pass # the device may drop off the bus for a moment to take the card back + # (re)connect and check + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + try: + with Link(args.serial).open(5) as link: + if not Control(link).status()["usb_drive"]: + print("the device has the SD card again") + return 0 + except (LinkError, ControlError): + pass + time.sleep(0.5) + print("emulink: the device did not take the SD card back", file=sys.stderr) + return 1 + + +def cmd_reboot(args) -> int: + with _open(args) as link: + try: + Control(link).reboot(bootloader=args.bootloader) + except LinkError: + pass # the device may drop off the bus before its reply gets out + if args.bootloader: + # the ROM's download mode shows up as the chip's USB-Serial-JTAG port + from serial.tools import list_ports + + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + for p in list_ports.comports(): + if p.vid == 0x303A and p.pid == 0x1001: + print(p.device) + return 0 + time.sleep(0.5) + print("emulink: no download-mode port showed up", file=sys.stderr) + return 1 + return 0 + + +def cmd_log(args) -> int: + """Follow the console; across a reboot (the port goes away and comes back) + it reconnects, so a log can span a reset.""" + import serial + + out = open(args.output, "ab") if args.output else None + write = out.write if out else sys.stdout.buffer.write + end = time.monotonic() + args.duration if args.duration else None + first_wait = time.monotonic() + args.wait + while end is None or time.monotonic() < end: + port = find_console_port(args.serial) + if not port: + if time.monotonic() > first_wait and out is None and args.duration == 0 and not args.follow: + print("no emulator console port found", file=sys.stderr) + return 1 + time.sleep(0.2) + continue + try: + with serial.Serial(port, 115200, timeout=0.2) as s: + while end is None or time.monotonic() < end: + data = s.read(4096) + if data: + write(data) + (out or sys.stdout.buffer).flush() + if args.until and args.until.encode() in data: + return 0 + except (serial.SerialException, OSError): + time.sleep(0.2) # the device went away (reset): wait for it to come back + return 0 + + +def cmd_coredump(args) -> int: + cd = espp_tools.load("espp_coredump", "coredump") + from espp_coredump.client import CoreDumpClient # type: ignore + + with _open(args) as link: + client = CoreDumpClient(link) + size = client.size() + if not size: + print("no core dump on the device") + return 0 + print(client.summary()) + if args.output: + image = client.read_image() + with open(args.output, "wb") as f: + f.write(image) + print(f"{args.output}: {len(image)} bytes (decode: esp-coredump info_corefile -t raw -c {args.output} " + f"build/.elf)") + if args.erase: + client.erase() + print("erased") + return 0 + + +def cmd_ota(args) -> int: + """Flash a new app over USB. On the Tab5 (no second emulator slot) the + emulator reboots into the updater app first, which takes the image.""" + espp_tools.load("espp_ota", "ota") + from espp_ota.client import OtaClient # type: ignore + + image = open(args.binary, "rb").read() + link = _open(args) + try: + if "updater" not in link.product.lower(): + print("rebooting into the updater ...") + Control(link).enter_updater() + link.close() + time.sleep(2) + link = Link(args.serial) + deadline = time.monotonic() + 30 + while True: + try: + link.open(5) + if "updater" in link.product.lower(): + break + link.close() + except LinkError: + pass + if time.monotonic() > deadline: + raise LinkError("the updater did not show up") + time.sleep(1) + print(f"flashing {args.binary} ({len(image)} bytes) via {link.product} ...") + OtaClient(link, progress=_progress("ota")).flash(image, image_size=len(image)) + print("done: the device restarts into the new firmware") + finally: + link.close() + if args.wait: + link = Link(args.serial) + last_error = None + deadline = time.monotonic() + args.wait + while time.monotonic() < deadline: + try: + link.open(5) + if "updater" not in link.product.lower(): + st = Control(link).status() + print(f"up: {st['firmware']} ({st['built']}, elf {st.get('elf_sha256', '?')}) on {st['partition']}") + link.close() + return 0 + link.close() + except (LinkError, ControlError) as e: + last_error = e + link.close() + time.sleep(1) + print(f"the new firmware did not come up ({last_error})", file=sys.stderr) + return 1 + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="emulink", description=__doc__) + p.add_argument("--serial", help="USB serial number (tab5-emu / esp-box-emu); default: the first one found") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("devices", help="list the emulators on USB").set_defaults(fn=cmd_devices) + sub.add_parser("status", help="state, running game, memory, firmware").set_defaults(fn=cmd_status) + sub.add_parser("info", help="chip, firmware, partitions, last reset reason (system service)").set_defaults(fn=cmd_info) + s = sub.add_parser("roms", help="the game list") + s.add_argument("--json", action="store_true") + s.set_defaults(fn=cmd_roms) + s = sub.add_parser("launch", help="start a game (rom path, or part of a name / path)") + s.add_argument("match") + s.add_argument("--no-wait", action="store_true") + s.add_argument("--timeout", type=float, default=60) + s.set_defaults(fn=cmd_launch) + s = sub.add_parser("quit", help="quit the running game") + s.add_argument("--no-wait", action="store_true") + s.set_defaults(fn=cmd_quit) + s = sub.add_parser("press", help="press buttons (a b x y select start up down left right l r l2 r2; a+b = together)") + s.add_argument("buttons", nargs="+") + s.add_argument("--hold", type=int, default=100, help="ms each press is held") + s.add_argument("--gap", type=int, default=100, help="ms after each press") + s.add_argument("--repeat", type=int, default=1) + s.set_defaults(fn=cmd_press) + s = sub.add_parser("hold", help="hold buttons until the next press/hold (hold with none releases)") + s.add_argument("buttons", nargs="*") + s.set_defaults(fn=cmd_hold) + for name, fn in (("save", cmd_save), ("load", cmd_load)): + s = sub.add_parser(name, help=f"{name} a save state") + s.add_argument("slot", type=int) + s.set_defaults(fn=fn) + s = sub.add_parser("screenshot", help="the running game's frame as a PNG") + s.add_argument("-o", "--output") + s.add_argument("--byteswap", action=argparse.BooleanOptionalAction, default=None, + help="RGB565 byte order (default: swapped on esp-box-emu)") + s.set_defaults(fn=cmd_screenshot) + s = sub.add_parser("ls", help="list a directory on the SD card") + s.add_argument("path", nargs="?", default="") + s.set_defaults(fn=cmd_ls) + s = sub.add_parser("push", help="copy a file / directory to the SD card (relative paths are from its root)") + s.add_argument("local") + s.add_argument("remote") + s.set_defaults(fn=cmd_push) + s = sub.add_parser("pull", help="copy a file from the SD card") + s.add_argument("remote") + s.add_argument("local", nargs="?") + s.set_defaults(fn=cmd_pull) + s = sub.add_parser("rm", help="delete a file / directory on the SD card") + s.add_argument("path") + s.set_defaults(fn=cmd_rm) + s = sub.add_parser("mkdir") + s.add_argument("path") + s.set_defaults(fn=cmd_mkdir) + s = sub.add_parser("add-rom", help="copy a game to the card and list it in metadata.csv") + s.add_argument("rom") + s.add_argument("--dir", required=True, help="directory on the card, e.g. genesis") + s.add_argument("--name") + s.add_argument("--boxart", help="a JPEG, copied to boxart/") + s.set_defaults(fn=cmd_add_rom) + s = sub.add_parser("remove-rom", help="take a game out of metadata.csv") + s.add_argument("rom", help="its path on the card, as in metadata.csv") + s.add_argument("--delete", action="store_true", help="also delete the file") + s.set_defaults(fn=cmd_remove_rom) + sub.add_parser("reload", help="re-read metadata.csv").set_defaults(fn=cmd_reload) + s = sub.add_parser("drive", help="hand the SD card to the PC as a USB drive (on) or take it back (off)") + s.add_argument("state", choices=("on", "off")) + s.set_defaults(fn=cmd_drive) + s = sub.add_parser("reboot") + s.add_argument("--bootloader", action="store_true", help="into the ROM download mode (for idf.py flash)") + s.set_defaults(fn=cmd_reboot) + s = sub.add_parser("log", help="print (or save) the console") + s.add_argument("-o", "--output") + s.add_argument("--duration", type=float, default=0, help="seconds (0 = until ^C)") + s.add_argument("--until", help="stop once this text is seen") + s.add_argument("--follow", action="store_true", help="keep waiting for the port to (re)appear") + s.add_argument("--wait", type=float, default=10, help="seconds to wait for the port") + s.set_defaults(fn=cmd_log) + s = sub.add_parser("coredump", help="the last crash: summary, image, erase") + s.add_argument("-o", "--output", help="save the raw core dump") + s.add_argument("--erase", action="store_true") + s.set_defaults(fn=cmd_coredump) + s = sub.add_parser("ota", help="flash a new app (build/.bin) over USB") + s.add_argument("binary") + s.add_argument("--wait", type=float, default=90, help="seconds to wait for it to come up (0 = do not)") + s.set_defaults(fn=cmd_ota) + return p + + +def main(argv: Optional[list] = None) -> int: + args = build_parser().parse_args(argv) + try: + return args.fn(args) + except (LinkError, ControlError, ValueError) as e: + print(f"emulink: {e}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 diff --git a/tools/emulink/emulink/control.py b/tools/emulink/emulink/control.py new file mode 100644 index 0000000..1c201f7 --- /dev/null +++ b/tools/emulink/emulink/control.py @@ -0,0 +1,268 @@ +"""The emulator's test control (dispatcher module 0x20, "emu.control") and the +espp system service (module 7), on top of a Link.""" + +from __future__ import annotations + +import json +import os +import struct +import time +from typing import Iterable, List, Optional + +from .link import Link, LinkError + +MODULE = 0x20 +SYSTEM_MODULE = 7 + +STATUS, LIST_ROMS, LAUNCH, QUIT, BUTTONS, SAVE, LOAD, SCREENSHOT, USB_DRIVE, RELOAD_ROMS, ENTER_UPDATER = range(1, 12) +FILE_LIST, FILE_READ, FILE_WRITE, FILE_DELETE, MKDIR, FILE_STAT, FILE_CLOSE = range(0x10, 0x17) + +#: one FILE_WRITE: the frame payload is at most 4096 bytes, of which 6 + the path are the header +CHUNK = 3584 + + +class ControlError(RuntimeError): + pass + + +class Buttons: + """GamepadState::buttons bits.""" + + A, B, X, Y, SELECT, START, UP, DOWN, LEFT, RIGHT, L, R, L2, R2 = (1 << i for i in range(14)) + NAMES = {"a": A, "b": B, "x": X, "y": Y, "select": SELECT, "start": START, "up": UP, "down": DOWN, + "left": LEFT, "right": RIGHT, "l": L, "r": R, "l2": L2, "r2": R2} + + @classmethod + def parse(cls, names: Iterable[str]) -> int: + mask = 0 + for n in names: + for part in n.replace("+", ",").split(","): + part = part.strip().lower() + if not part: + continue + if part not in cls.NAMES: + raise ValueError(f"unknown button {part!r} (one of {', '.join(cls.NAMES)})") + mask |= cls.NAMES[part] + return mask + + +class Control: + def __init__(self, link: Link) -> None: + self.link = link + + # -- plumbing -------------------------------------------------------------- + def _call(self, type_: int, payload: bytes = b"", timeout_s: float = 15.0, raw: bool = False): + fr = self.link.request(MODULE, type_, payload, timeout_s=timeout_s) + if not fr.payload: + raise ControlError("empty reply") + status, body = fr.payload[0], fr.payload[1:] + if status != 0: + try: + msg = json.loads(body.decode()).get("error", body.decode()) + except Exception: + msg = body.decode(errors="replace") + raise ControlError(msg) + if raw: + return body + return json.loads(body.decode()) if body else {} + + @property + def root(self) -> str: + return "/sdcard" + + def remote(self, path: str) -> str: + """A path on the SD card: absolute, or relative to the card's root.""" + if path in ("", "/", "."): + return self.root + if path.startswith(self.root): + return path + return f"{self.root}/{path.lstrip('/')}" + + # -- the emulator ---------------------------------------------------------- + def status(self) -> dict: + return self._call(STATUS) + + def roms(self) -> List[dict]: + out: List[dict] = [] + offset = 0 + while True: + r = self._call(LIST_ROMS, str(offset).encode()) + out += r["roms"] + offset = r["next"] + if offset >= r["total"] or not r["roms"]: + return out + + def launch(self, match: str, wait: bool = True, timeout_s: float = 60.0) -> str: + rom = self._call(LAUNCH, match.encode())["rom"] + if wait: + self.wait_state("game", timeout_s) + return rom + + def quit(self, wait: bool = True, timeout_s: float = 30.0) -> None: + self._call(QUIT) + if wait: + self.wait_state("menu", timeout_s) + + def wait_state(self, state: str, timeout_s: float = 60.0) -> dict: + deadline = time.monotonic() + timeout_s + while True: + try: + st = self.status() + if st["state"] == state: + return st + except LinkError: + pass # busy (e.g. a game loading) + if time.monotonic() > deadline: + raise ControlError(f"not in state {state!r} after {timeout_s:.0f} s") + time.sleep(0.25) + + def buttons(self, mask: int, hold_ms: int = 0) -> None: + """Hold `mask` for hold_ms (then release), or until the next call (0).""" + self._call(BUTTONS, struct.pack(" None: + self.buttons(Buttons.parse(names), hold_ms) + if gap_ms: + time.sleep(gap_ms / 1000) + + def save(self, slot: int) -> None: + self._call(SAVE, bytes([slot]), timeout_s=60) + + def load(self, slot: int) -> None: + self._call(LOAD, bytes([slot]), timeout_s=60) + + def screenshot(self, remote_path: str = "") -> str: + return self._call(SCREENSHOT, self.remote(remote_path).encode() if remote_path else b"")["path"] + + def usb_drive(self, host: bool) -> None: + self._call(USB_DRIVE, bytes([1 if host else 0]), timeout_s=20) + + def reload_roms(self) -> int: + return self._call(RELOAD_ROMS)["total"] + + def enter_updater(self) -> None: + self._call(ENTER_UPDATER) + + # -- files on the SD card -------------------------------------------------- + def ls(self, path: str = "") -> List[dict]: + return self._call(FILE_LIST, self.remote(path).encode())["entries"] + + def stat(self, path: str) -> Optional[dict]: + try: + return self._call(FILE_STAT, self.remote(path).encode()) + except ControlError: + return None + + def mkdir(self, path: str) -> None: + self._call(MKDIR, self.remote(path).encode()) + + def rm(self, path: str) -> None: + self._call(FILE_DELETE, self.remote(path).encode(), timeout_s=60) + + def read_file(self, path: str, progress=None) -> bytes: + path = self.remote(path) + st = self._call(FILE_STAT, path.encode()) + size = st["size"] + out = bytearray() + while len(out) < size: + n = min(4000, size - len(out)) + chunk = self._call(FILE_READ, struct.pack(" None: + path = self.remote(path) + p = path.encode() + chunk = CHUNK - len(p) + if chunk < 256: + raise ControlError("path too long") + off = 0 + while True: + part = data[off: off + chunk] + self._call(FILE_WRITE, struct.pack("= len(data): + break + self._call(FILE_CLOSE) + + def push(self, local: str, remote: str, progress=None) -> List[str]: + """Copy a file or a directory tree to the SD card; returns the remote files.""" + written = [] + if os.path.isdir(local): + for root, _dirs, files in os.walk(local): + for f in files: + src = os.path.join(root, f) + rel = os.path.relpath(src, local) + dst = f"{remote.rstrip('/')}/{rel}" + self.write_file(dst, open(src, "rb").read(), progress) + written.append(self.remote(dst)) + else: + if remote.endswith("/"): + remote += os.path.basename(local) + self.write_file(remote, open(local, "rb").read(), progress) + written.append(self.remote(remote)) + return written + + def add_rom(self, rom_path: str, name: str, boxart: str = "", remove: bool = False) -> int: + """Add (or with remove=True take out) a metadata.csv line; returns the + number of games after reloading the list.""" + rom_rel = rom_path[len(self.root) + 1:] if rom_path.startswith(self.root + "/") else rom_path + meta = self.remote("metadata.csv") + try: + text = self.read_file(meta).decode() + except ControlError: + text = "" + lines = [l for l in text.splitlines() if l.strip() and l.split(",")[0].strip() != rom_rel] + if not remove: + lines.append(f"{rom_rel}, {boxart}, {name}") + self.write_file(meta, ("\n".join(lines) + "\n").encode()) + return self.reload_roms() + + # -- the espp system service ------------------------------------------------ + RESET_REASONS = {0: "unknown", 1: "power-on", 2: "external", 3: "software", 4: "panic", 5: "interrupt WDT", + 6: "task WDT", 7: "other WDT", 8: "deep sleep", 9: "brownout", 10: "SDIO", 11: "USB", + 12: "JTAG", 13: "eFuse", 14: "power glitch", 15: "CPU lockup"} + + def info(self) -> dict: + """The espp system service's GET_INFO: chip, firmware, partitions, reset reason, uptime...""" + fr = self.link.request(SYSTEM_MODULE, 0x01, b"", timeout_s=5, reply_types=(0x81, 0x84)) + if fr.type != 0x81: + raise ControlError("GET_INFO failed") + p, i, out = fr.payload, 0, {} + strs = {1: "chip", 6: "project", 7: "firmware", 5: "idf", 8: "build_date", 9: "build_time", + 11: "running_partition", 12: "boot_partition"} + while i + 2 <= len(p): + tag, n = p[i], p[i + 1] + v = p[i + 2:i + 2 + n] + i += 2 + n + if tag in strs: + out[strs[tag]] = v.decode(errors="replace") + elif tag == 2: + out["chip_revision"] = int.from_bytes(v, "little") + elif tag == 3: + out["cores"] = v[0] + elif tag == 10: + out["elf_sha256"] = v.hex() + elif tag == 13: + out["ota_state"] = {0: "new", 1: "pending verify", 2: "valid", 3: "invalid", 4: "aborted", + 0xFE: "undefined", 0xFF: "n/a"}.get(v[0], v[0]) + elif tag == 14: + out["reset_reason"] = self.RESET_REASONS.get(v[0], v[0]) + elif tag == 15: + out["uptime_ms"] = int.from_bytes(v, "little") + elif tag == 16: + out["mac"] = ":".join(f"{b:02x}" for b in v) + elif tag in (17, 18, 19, 20, 21): + out[{17: "flash_size", 18: "psram_size", 19: "cpu_mhz", 20: "free_heap", 21: "min_free_heap"}[tag]] = \ + int.from_bytes(v, "little") + return out + + def reboot(self, bootloader: bool = False, delay_ms: int = 250) -> None: + self.link.request(SYSTEM_MODULE, 0x03 if bootloader else 0x02, struct.pack("/python``) or in a project's +``managed_components/espp__/python`` after a build. +""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve() +_REPO = _HERE.parents[3] # tools/emulink/emulink/espp_tools.py -> the project + + +def _candidates(component: str): + if os.environ.get("ESPP_DIR"): + yield Path(os.environ["ESPP_DIR"]) / "components" / component / "python" + for base in (_REPO, _REPO / "updater"): + yield base / "managed_components" / f"espp__{component}" / "python" + yield Path.home() / "esp-cpp" / "espp" / "components" / component / "python" + + +def load(package: str, component: str): + """Import `package` (e.g. espp_ota) from wherever the espp component is.""" + try: + return importlib.import_module(package) + except ImportError: + pass + for c in _candidates(component): + if (c / package).is_dir(): + sys.path.insert(0, str(c)) + return importlib.import_module(package) + raise ImportError(f"{package} not found: set ESPP_DIR to an espp checkout, or build the project " + f"so managed_components/espp__{component} exists") diff --git a/tools/emulink/emulink/frame.py b/tools/emulink/emulink/frame.py new file mode 100644 index 0000000..6949550 --- /dev/null +++ b/tools/emulink/emulink/frame.py @@ -0,0 +1,86 @@ +"""The espp stream_frame v2 codec (see espp/components/stream_frame). + +[magic u16 = 0x4F54]["flags u8"][module u8][type u8]{[correlation u16]}[len u32][payload][crc32 u32] +little-endian; flags bit0 = reply, bit1 = correlation present, bits 4..7 = version (1). +""" + +from __future__ import annotations + +import struct +import zlib +from dataclasses import dataclass +from typing import List, Optional + +MAGIC = 0x4F54 +MAGIC_BYTES = struct.pack(" bool: + return bool(self.flags & FLAG_REPLY) + + +def build(module: int, type_: int, payload: bytes = b"", reply: bool = False, + correlation: Optional[int] = None) -> bytes: + if len(payload) > MAX_PAYLOAD: + raise ValueError(f"payload too large ({len(payload)} > {MAX_PAYLOAD})") + flags = (VERSION << 4) | (FLAG_REPLY if reply else 0) | (FLAG_CORRELATION if correlation is not None else 0) + out = bytearray(MAGIC_BYTES) + out += bytes([flags, module & 0xFF, type_ & 0xFF]) + if correlation is not None: + out += struct.pack(" None: + self._buf = bytearray() + + def feed(self, data: bytes) -> List[Frame]: + self._buf += data + frames: List[Frame] = [] + while True: + i = self._buf.find(MAGIC_BYTES) + if i < 0: + # keep a trailing half magic + del self._buf[:-1] + return frames + if i: + del self._buf[:i] + if len(self._buf) < 5: + return frames + flags = self._buf[2] + hdr = 5 + (2 if flags & FLAG_CORRELATION else 0) + 4 + if len(self._buf) < hdr: + return frames + corr = struct.unpack_from(" MAX_PAYLOAD: + del self._buf[:2] # not a frame: resync past this magic + continue + total = hdr + length + 4 + if len(self._buf) < total: + return frames + crc = struct.unpack_from(" List[dict]: + """The emulators on the bus: [{serial, product, bus, address}].""" + core, util = _usb() + out = [] + for dev in core.find(find_all=True, idVendor=VID, idProduct=PID) or []: + try: + serial = util.get_string(dev, dev.iSerialNumber) + product = util.get_string(dev, dev.iProduct) + except Exception: + continue + if serial in SERIALS: + out.append({"serial": serial, "product": product, "bus": dev.bus, "address": dev.address, "dev": dev}) + return out + + +class Link: + """One open vendor interface. Also a transport for the espp host clients + (``write(bytes, timeout_ms)`` / ``read(max_len, timeout_ms)``).""" + + def __init__(self, serial: Optional[str] = None) -> None: + self.serial = serial or os.environ.get("EMULINK_SERIAL") + self._dev = None + self._itf = None + self._ep_in = None + self._ep_out = None + self._parser = _f.Parser() + self._pending: Deque[_f.Frame] = deque() + self._corr = itertools.count(1) + self.product = "" + + # -- lifecycle ------------------------------------------------------------- + def open(self, timeout_s: float = 0.0) -> "Link": + core, util = _usb() + deadline = time.monotonic() + timeout_s + while True: + devs = [d for d in find_devices() if self.serial in (None, d["serial"])] + if devs: + break + if time.monotonic() >= deadline: + raise LinkError("no emulator found on USB" + (f" with serial {self.serial!r}" if self.serial else "")) + time.sleep(0.5) + d = devs[0] + self._dev, self.serial, self.product = d["dev"], d["serial"], d["product"] + try: + return self._claim(core, util) + except core.USBError as e: + # e.g. still enumerating after a reset: the caller may retry + self._dev = None + raise LinkError(f"cannot open the device's vendor interface: {e}") from e + + def _claim(self, core, util) -> "Link": + cfg = self._dev.get_active_configuration() + for itf in cfg: + if itf.bInterfaceClass != 0xFF: + continue + eps = list(itf) + ep_in = next((e for e in eps if util.endpoint_direction(e.bEndpointAddress) == util.ENDPOINT_IN), None) + ep_out = next((e for e in eps if util.endpoint_direction(e.bEndpointAddress) == util.ENDPOINT_OUT), None) + if ep_in is not None and ep_out is not None: + self._itf, self._ep_in, self._ep_out = itf.bInterfaceNumber, ep_in, ep_out + break + if self._itf is None: + raise LinkError("the device has no vendor interface") + try: + if self._dev.is_kernel_driver_active(self._itf): + self._dev.detach_kernel_driver(self._itf) + except (NotImplementedError, core.USBError): + pass + util.claim_interface(self._dev, self._itf) + return self + + def close(self) -> None: + if self._dev is not None: + _, util = _usb() + try: + util.release_interface(self._dev, self._itf) + util.dispose_resources(self._dev) + except Exception: + pass + self._dev = None + self._itf = None + + def __enter__(self) -> "Link": + return self.open() if self._dev is None else self + + def __exit__(self, *exc) -> None: + self.close() + + # -- raw transport (espp clients use these) -------------------------------- + def write(self, data: bytes, timeout_ms: int = 5000) -> int: + try: + return self._ep_out.write(data, timeout=timeout_ms) + except Exception as e: + raise LinkError(f"USB write failed: {e}") from e + + def read(self, max_len: int = 16384, timeout_ms: int = 1000) -> bytes: + core, _ = _usb() + try: + return bytes(self._ep_in.read(max(max_len, 64), timeout=timeout_ms)) + except core.USBTimeoutError: + return b"" + except core.USBError as e: + if getattr(e, "errno", None) in (60, 110) or "timed out" in str(e).lower(): + return b"" + raise LinkError(f"USB read failed: {e}") from e + + # -- request / reply ------------------------------------------------------- + def request(self, module: int, type_: int, payload: bytes = b"", timeout_s: float = 10.0, + reply_types: Optional[tuple] = None) -> _f.Frame: + """Send a request and return its reply (matched by module and + correlation id; frames for anything else are dropped).""" + corr = next(self._corr) & 0xFFFF + try: + self.write(_f.build(module, type_, payload, correlation=corr)) + except LinkError: + # the host reset the interface (e.g. macOS after ejecting the + # device's drive): open it again once + self.close() + self.open(timeout_s=10) + self.write(_f.build(module, type_, payload, correlation=corr)) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + while self._pending: + fr = self._pending.popleft() + if fr.is_reply and fr.module == module and (fr.correlation in (None, corr)) and \ + (reply_types is None or fr.type in reply_types): + return fr + data = self.read(16384, timeout_ms=200) + if data: + self._pending.extend(self._parser.feed(data)) + raise LinkError(f"no reply from module {module:#x} to request {type_:#x} in {timeout_s:.0f} s") + + +def find_console_port(serial: Optional[str] = None) -> Optional[str]: + """The CDC console's serial port (e.g. /dev/cu.usbmodem...) of an emulator.""" + from serial.tools import list_ports + + for p in list_ports.comports(): + if p.vid == VID and p.pid == PID and (p.serial_number in SERIALS) and serial in (None, p.serial_number): + return p.device + return None diff --git a/updater/CMakeLists.txt b/updater/CMakeLists.txt new file mode 100644 index 0000000..487d5c5 --- /dev/null +++ b/updater/CMakeLists.txt @@ -0,0 +1,14 @@ +# The Tab5 Emu updater: a small app in the ota_1 slot (the emulator does not +# fit twice in the Tab5's flash). An update reboots into it (the emulator's +# test control ENTER_UPDATER request); it takes the new emulator image over +# the USB link (espp OTA service), writes ota_0 and boots it. With nothing to +# do it boots the emulator again after a while. See tools/emulink. +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(COMPONENTS main esptool_py CACHE STRING "List of components to include" FORCE) + +project(tab5-emu-updater) + +set(CMAKE_CXX_STANDARD 23) diff --git a/updater/main/CMakeLists.txt b/updater/main/CMakeLists.txt new file mode 100644 index 0000000..8e34f60 --- /dev/null +++ b/updater/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "updater.cpp" + INCLUDE_DIRS "." + REQUIRES app_update esp_hal_usb hal driver ota dispatcher system usb_device) diff --git a/updater/main/idf_component.yml b/updater/main/idf_component.yml new file mode 100644 index 0000000..220d451 --- /dev/null +++ b/updater/main/idf_component.yml @@ -0,0 +1,7 @@ +## IDF Component Manager Manifest File +dependencies: + idf: '>=6.0' + espp/usb_device: '>=1.4.2' + espp/ota: '>=1.4.2' + espp/dispatcher: '>=1.4.2' + espp/system: '>=1.4.2' diff --git a/updater/main/updater.cpp b/updater/main/updater.cpp new file mode 100644 index 0000000..6bc70df --- /dev/null +++ b/updater/main/updater.cpp @@ -0,0 +1,143 @@ +// The Tab5 Emu updater (the ota_1 slot). The emulator app (ota_0) is 9+ MB +// and the Tab5 has 16 MB of flash, so the usual two-slot OTA cannot hold two +// emulators; instead an update reboots into this small app, which serves the +// espp OTA protocol on the USB-C port's vendor interface, writes the new +// emulator into ota_0 and boots it (the OTA service restarts after FINISH). +// +// Booted with no update to take (e.g. ENTER_UPDATER and then nothing), it +// boots the emulator again after kIdleTimeout. If the emulator image is not +// usable (a failed update, or one that rolled back here) it waits for one. +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dispatcher_worker.hpp" +#include "logger.hpp" +#include "ota.hpp" +#include "ota_service.hpp" +#include "system_service.hpp" +#include "usb_device.hpp" + +using namespace std::chrono_literals; + +static constexpr auto kIdleTimeout = 90s; + +// The USB-C pins: by default the USB-Serial-JTAG console has them (PHY 0); the +// OTG 1.1 controller takes them while this app runs (see the emulator's +// components/tab5-emu/src/usb_device.cpp for the details). +static void usb_otg11_take_usb_c(bool take) { + if (take) { + usb_serial_jtag_ll_phy_enable_pad(false); + vTaskDelay(pdMS_TO_TICKS(300)); // let the PC see the disconnect + usb_wrap_ll_phy_select(&USB_WRAP, 0); + gpio_set_drive_capability(GPIO_NUM_24, GPIO_DRIVE_CAP_3); + gpio_set_drive_capability(GPIO_NUM_25, GPIO_DRIVE_CAP_3); + } else { + LP_SYS.usb_ctrl.sw_usb_phy_sel = 0; + LP_SYS.usb_ctrl.sw_hw_usb_phy_sel = 0; + usb_serial_jtag_ll_phy_enable_pad(true); + } +} + +static const esp_partition_t *emulator_partition() { + return esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, nullptr); +} + +// Whether ota_0 holds an app that can boot (a valid image that did not fail +// its verification after an update). +static bool emulator_bootable() { + const auto *p = emulator_partition(); + if (!p) return false; + esp_app_desc_t desc; + if (esp_ota_get_partition_description(p, &desc) != ESP_OK) return false; + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(p, &state) == ESP_OK && + (state == ESP_OTA_IMG_INVALID || state == ESP_OTA_IMG_ABORTED)) { + return false; + } + return true; +} + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "updater", .level = espp::Logger::Verbosity::INFO}); + logger.info("Tab5 Emu updater {}", esp_app_get_description()->version); + // this app is flashed once, not through an OTA; never leave it pending + esp_ota_mark_app_valid_cancel_rollback(); + + espp::UsbDevice::Config cfg; + cfg.manufacturer = "esp-cpp"; + cfg.product = "Tab5 Emu updater"; + cfg.serial_number = "tab5-emu"; + cfg.max_power_ma = 500; + cfg.port = 0; // the full-speed controller, on the USB-C pins + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "Tab5 Emu updater console"; + cdc.route_console = true; + cfg.cdc = cdc; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "Tab5 Emu link"; + vendor.webusb = true; + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/ota_console.html"; + cfg.vendor = vendor; + + usb_otg11_take_usb_c(true); + espp::UsbDevice usb(cfg); + + std::mutex tx_mutex; + auto send = [&](std::span frame) { + std::lock_guard lk(tx_mutex); + usb.write_vendor(frame); + }; + + std::atomic busy{false}; // an update has started: do not time out + espp::Ota ota({.progress_callback = [&](size_t written, size_t total) { busy = true; }}); + espp::OtaService ota_service(ota, {.send = send, .log_level = espp::Logger::Verbosity::INFO}); + espp::SystemService system_service({.send = send, + .on_reboot_request = + [](espp::SystemService::RebootKind kind) { + if (kind == espp::SystemService::RebootKind::Bootloader) { + usb_otg11_take_usb_c(false); + } + return true; + }}); + espp::DispatcherWorker link({.send = send, + .on_overflow = [&]() { ota_service.on_rx_overflow(); }, + .task_config = {.name = "link", .stack_size_bytes = 8192}}); + link.register_module(ota_service); + link.register_module(system_service); + link.serve_discovery("Tab5 Emu updater", esp_app_get_description()->version); + usb.set_vendor_receive_callback([&](std::span data) { + busy = true; + link.push(data); + }); + + std::error_code ec; + if (!usb.initialize(ec)) { + usb_otg11_take_usb_c(false); + logger.error("USB device: {}", ec.message()); + } + logger.info("waiting for an emulator image (espp OTA on the vendor interface)"); + + const auto start = std::chrono::steady_clock::now(); + while (true) { + std::this_thread::sleep_for(1s); + // nothing asked of us: back to the emulator, if it can boot + if (!busy && std::chrono::steady_clock::now() - start > kIdleTimeout && emulator_bootable()) { + logger.info("no update; booting the emulator"); + esp_ota_set_boot_partition(emulator_partition()); + usb_otg11_take_usb_c(false); + esp_restart(); + } + } +} diff --git a/updater/sdkconfig.defaults b/updater/sdkconfig.defaults new file mode 100644 index 0000000..e5a4cf4 --- /dev/null +++ b/updater/sdkconfig.defaults @@ -0,0 +1,32 @@ +CONFIG_IDF_TARGET="esp32p4" + +# the Tab5's ESP32-P4 silicon revision (< v3), as in the emulator +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y +CONFIG_ESP32P4_REV_MIN_0=y + +CONFIG_COMPILER_OPTIMIZATION_SIZE=y + +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" + +# the emulator's partition table (this app is built for its ota_1 slot) +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_OFFSET=0x9000 +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="../partitions.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=921600 + +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_ESP_TASK_WDT_EN=n + +# the composite USB device: console on CDC, the OTA link on a vendor interface +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_CDC_TX_BUFSIZE=2048 +CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=1024 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4608 From 5e1d040e709293776b3cde0b6b9bf4ac52f8cd6a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 11 Nov 2026 16:06:53 -0600 Subject: [PATCH 3/6] mgs: full speed again with the USB device up The USB drive function's buffers take ~8 KB of main SRAM. With that gone, the rasterizer's per-core CLUT caches landed in the P4's LP SRAM (slow to read): sprites took 4.7x longer and busy scenes ran ~40% slower. - psyz: allocate the caches only from main SRAM or TCM, and take them when the game starts (Draw_ClutCacheInit()), after the statics reset - SD card: a permanent 16 KB PSRAM bounce buffer for reads into unaligned buffers. The driver otherwise mallocs one from DMA-capable internal RAM per call, which failed once the caches had the main SRAM, and moved one sector per command MGS intro, frames drawn 91 s after launch: 1017-1042 before, 1238-1260 now (1231 with no drive function at all). Stress tested: five MGS launch / quit cycles (no leak, same speed each time), save / load, JK, TPM, GBA, SNES, Genesis and Doom with 1 MB SD round trips while playing, and three drive hand-overs with Mac writes verified on the device. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/mgs/psyz | 2 +- components/mgs/src/mgs.cpp | 4 ++++ components/tab5-emu/src/tab5-emu.cpp | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 3245d54..f3aebd9 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 3245d546f1a2becce2e17333d2e65e8dc9d8c612 +Subproject commit f3aebd96646cd0a6d93aabcc37c06179620672d3 diff --git a/components/mgs/src/mgs.cpp b/components/mgs/src/mgs.cpp index 61f30a9..0f61b9b 100644 --- a/components/mgs/src/mgs.cpp +++ b/components/mgs/src/mgs.cpp @@ -61,6 +61,7 @@ void Mgs_PauseVblank(void); void Mgs_ResumeVblank(void); void Mgs_StopVblank(void); void Psyz_GpuSync(void); // psyz libgpu.c: wait for the raster worker +void Draw_ClutCacheInit(void); // psyz soft_raster: the palette caches // The game's arena (port/esp32_arena.c): one PSRAM block for the PSX's RAM // image, the codec faces, VRAM, the SPU, the GPU queues and the thread // stacks. Defined here, outside the statics reset between launches, so the @@ -311,6 +312,9 @@ bool init(const Config &config) { } Mgs_ArenaBind(); Draw_Reset(); + // the rasterizer's palette caches, while the main SRAM still has room (after + // the reset, which would drop the pointers; the GPU shutdown frees them) + Draw_ClutCacheInit(); g_preserve.clear(); Mgs_ThreadsSnapshotSetup(); Mgs_CdSnapshotSetup(); diff --git a/components/tab5-emu/src/tab5-emu.cpp b/components/tab5-emu/src/tab5-emu.cpp index 9b1c44d..d50243a 100644 --- a/components/tab5-emu/src/tab5-emu.cpp +++ b/components/tab5-emu/src/tab5-emu.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include "statistics.hpp" @@ -81,6 +82,33 @@ bool Tab5Emu::initialize_sdcard() { return false; } sdcard_ = bsp.sdcard(); + // A read into a buffer that is not cache-line aligned (most of the games' + // own) goes through a bounce buffer, which the driver otherwise mallocs + // from DMA-capable internal RAM on every call, one sector at a time. That + // RAM is the scarcest there is with the USB device up (a failed bounce + // allocation is a failed read), and the P4's SDMMC can DMA to PSRAM: give + // it a permanent PSRAM one, used 16 KB per command. + if (sdcard_) { + constexpr size_t bounce_bytes = 16 * 1024; + void *bounce = heap_caps_calloc(1, bounce_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA | MALLOC_CAP_CACHE_ALIGNED); + // the driver sizes its transfers (and cache syncs) by the allocated size, + // which has to be whole cache lines too + size_t line = 0; + esp_cache_get_alignment(MALLOC_CAP_SPIRAM, &line); + if (bounce && line && (heap_caps_get_allocated_size(bounce) % line || (uintptr_t)bounce % line)) { + logger_.warn("The SD card's bounce buffer at {} ({} B) is not cache-line aligned", bounce, + heap_caps_get_allocated_size(bounce)); + heap_caps_free(bounce); + bounce = nullptr; + } + if (bounce) { + sdcard_->host.dma_aligned_buffer = bounce; + sdcard_->host.unaligned_multi_block_rw_max_chunk_size = bounce_bytes / 512; + logger_.info("SD card: {} KB bounce buffer in PSRAM", bounce_bytes / 1024); + } else { + logger_.warn("No PSRAM for the SD card's {} byte bounce buffer", bounce_bytes); + } + } uint32_t size_mb = 0, free_mb = 0; if (bsp.get_sd_card_info(&size_mb, &free_mb)) { logger_.info("SD card: {} MB total, {} MB free", size_mb, free_mb); From 1e94d1b01cf1fbeef0a09d1d5123ab6a0abbcf12 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 11 Nov 2026 17:30:54 -0600 Subject: [PATCH 4/6] Screenshots straight from the game's frame buffer; stderr on the USB console - The link's SCREENSHOT no longer goes through the SD card: the main loop takes the game's frame between two frames and holds the game still (Cart::capture_hold() pauses the cores with their own tasks: MGS, JK, TPM, PICO-8) while SCREENSHOT_READ reads the pixels, so nothing is allocated or copied. emulink converts them with a lookup table: a screenshot takes 0.3-1.2 s (JK's 640x480 the longest). - espp routes only stdout to the CDC console: stderr goes there too now. - espp/usb_device pinned to the #898 merge commit (the branch is gone). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/tab5-emu/idf_component.yml | 6 +- components/tab5-emu/src/usb_device.cpp | 6 + main/cart.hpp | 10 ++ main/jk_cart.hpp | 10 ++ main/mgs_cart.hpp | 10 ++ main/pico8_cart.hpp | 10 ++ main/remote_control.hpp | 150 ++++++++++++++++++++++--- main/tpm_cart.hpp | 10 ++ tools/emulink/emulink/cli.py | 31 ++--- tools/emulink/emulink/control.py | 65 ++++++++++- 10 files changed, 270 insertions(+), 38 deletions(-) diff --git a/components/tab5-emu/idf_component.yml b/components/tab5-emu/idf_component.yml index fd661fc..af30d2e 100644 --- a/components/tab5-emu/idf_component.yml +++ b/components/tab5-emu/idf_component.yml @@ -18,11 +18,11 @@ dependencies: # USB mass storage (the SD card as a USB drive), brings esp_tinyusb; needs # UsbDevice::Config::port (the Tab5's USB-C is the P4's full-speed port) espp/usb_device: - # TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898); back to the - # registry once it is released + # TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898, merged as + # 08295c0); back to the registry once it is released git: https://github.com/esp-cpp/espp.git path: components/usb_device - version: feat/usb-msc-host-eject + version: 08295c0904224f25a42a518093021ee68c755ff2 espp/sdcard: '>=1.0' # crash reports + core dump download at boot espp/coredump: '>=1.3.5' diff --git a/components/tab5-emu/src/usb_device.cpp b/components/tab5-emu/src/usb_device.cpp index 598b3ac..bd91099 100644 --- a/components/tab5-emu/src/usb_device.cpp +++ b/components/tab5-emu/src/usb_device.cpp @@ -4,6 +4,7 @@ // See tab5-emu.hpp for the ownership rules. #include "tab5-emu.hpp" +#include #include #include #include @@ -126,6 +127,11 @@ bool Tab5Emu::initialize_usb_device() { return false; } logger_.info("USB: composite device up (console, link{})", have_sd ? ", SD card drive" : ""); + // espp routes only stdout to the CDC console; send stderr (the cores' error + // messages) there too. /dev/usbcons is the console device espp registers. + if (usb_device_->is_console_routed_to_cdc() && freopen("/dev/usbcons", "w", stderr)) { + setvbuf(stderr, nullptr, _IONBF, 0); + } return true; } diff --git a/main/cart.hpp b/main/cart.hpp index a50b231..b3fd600 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -167,6 +167,16 @@ class Cart { /// tests); 0 where the cart does not count them. virtual uint32_t game_frames() const { return 0; } + /// The frame a screenshot captures: its size and its RGB565 pixels (for + /// the USB link's screenshot, which copies it without going through a file) + std::pair frame_size() const { return get_video_size(); } + std::span frame_pixels() const { return get_video_buffer(); } + /// Hold the game still (true) while the USB link reads a screenshot + /// straight from its frame buffer, and let it go on (false). The main loop + /// is held meanwhile, which is enough for a core that runs in run(); a core + /// with its own tasks pauses them, as for the pause menu. + virtual void capture_hold(bool hold) {} + /// Open the pause menu over the game, click the button of `action` there /// and resume, as a player would; for the automated launch tests. void menu_action_as_from_menu(Menu::Action action) { diff --git a/main/jk_cart.hpp b/main/jk_cart.hpp index c4baed0..4428b2e 100644 --- a/main/jk_cart.hpp +++ b/main/jk_cart.hpp @@ -78,6 +78,16 @@ class JkCart : public Cart { } protected: + void capture_hold(bool hold) override { +#if defined(ENABLE_JK) + if (hold) { + jk::pause(); + } else { + jk::resume(); + } +#endif + } + void pre_menu() override { Cart::pre_menu(); #if defined(ENABLE_JK) diff --git a/main/mgs_cart.hpp b/main/mgs_cart.hpp index 4e22828..f302f61 100644 --- a/main/mgs_cart.hpp +++ b/main/mgs_cart.hpp @@ -82,6 +82,16 @@ class MgsCart : public Cart { #endif protected: + void capture_hold(bool hold) override { +#if defined(ENABLE_MGS) + if (hold) { + mgs::pause(); + } else { + mgs::resume(); + } +#endif + } + void pre_menu() override { Cart::pre_menu(); #if defined(ENABLE_MGS) diff --git a/main/pico8_cart.hpp b/main/pico8_cart.hpp index 5a2d69c..1f23d1e 100644 --- a/main/pico8_cart.hpp +++ b/main/pico8_cart.hpp @@ -74,6 +74,16 @@ class Pico8Cart : public Cart { static constexpr size_t P8_WIDTH = 128; static constexpr size_t P8_HEIGHT = 128; + void capture_hold(bool hold) override { +#if defined(ENABLE_PICO8) + if (hold) { + pause_pico8_tasks(); + } else { + resume_pico8_tasks(); + } +#endif + } + // cppcheck-suppress uselessOverride virtual void pre_menu() override { Cart::pre_menu(); diff --git a/main/remote_control.hpp b/main/remote_control.hpp index 47e6711..6f603e9 100644 --- a/main/remote_control.hpp +++ b/main/remote_control.hpp @@ -9,7 +9,8 @@ // // Every request is answered with a reply frame of the same type whose // payload is one status byte (0 = ok, 1 = error) and then the body: JSON text -// (an error's message as {"error": ...}), or the raw bytes for FILE_READ. +// (an error's message as {"error": ...}), or the raw bytes for FILE_READ and +// SCREENSHOT_READ. // Requests that touch the launcher or the running game are handed to the main // loop (MainQueue) and answered once it ran them. @@ -21,11 +22,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -69,6 +72,13 @@ class MainQueue { cv_.wait(lk, [&] { return item->state == Item::DONE; }); return true; } + /// Run `fn` on the main loop without waiting for it. + void post(std::function fn) { + auto item = std::make_shared(); + item->fn = std::move(fn); + std::lock_guard lk(mutex_); + queue_.push_back(item); + } /// Called by the main loop: run what was posted. void poll() { std::unique_lock lk(mutex_); @@ -100,7 +110,7 @@ class RemoteControl { public: static constexpr uint8_t kModule = 0x20; static constexpr const char *kProtocol = "emu.control"; - static constexpr uint16_t kProtocolVersion = 1; + static constexpr uint16_t kProtocolVersion = 2; // 2: in-memory screenshots enum class Type : uint8_t { STATUS = 0x01, ///< () -> {state, rom, platform, console, heap, uptime, ...} @@ -110,10 +120,15 @@ class RemoteControl { BUTTONS = 0x05, ///< (u16 buttons, u16 hold ms; 0 = until the next BUTTONS) SAVE = 0x06, ///< (u8 slot) LOAD = 0x07, ///< (u8 slot) - SCREENSHOT = 0x08, ///< (path: text, optional) -> {path} [u16be w][u16be h][RGB565] + /// (path: text, optional). With a path: saved there on the card as + /// [u16be w][u16be h][RGB565] -> {path}. Without: -> {width, height, size, + /// format: rgb565le}, and the game holds still (its frame buffer with it) + /// while SCREENSHOT_READ reads the pixels, until the last of them is read. + SCREENSHOT = 0x08, USB_DRIVE = 0x09, ///< (u8 1 = the PC gets the SD card, 0 = the device takes it back) RELOAD_ROMS = 0x0A, ///< () re-read metadata.csv ENTER_UPDATER = 0x0B, ///< () reboot into the updater app (ota_1) for an update + SCREENSHOT_READ = 0x0C, ///< (u32 offset, u16 length) -> raw bytes of the held SCREENSHOT (short at the end) FILE_LIST = 0x10, ///< (path: text) -> {entries: [{name, dir, size}]} FILE_READ = 0x11, ///< (u32 offset, u16 length, path) -> raw bytes (short at the end) FILE_WRITE = 0x12, ///< (u32 offset, path_len u16, path, data); offset 0 truncates @@ -255,19 +270,104 @@ class RemoteControl { } case Type::SCREENSHOT: { std::string path(text); - if (path.empty()) path = std::string(BoxEmu::mount_point) + "/screenshots/screenshot.bin"; bool done = false, has_cart = false; - on_main([&] { - if (auto *cart = current_cart()) { - has_cart = true; - std::error_code ec; - std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); - done = cart->screenshot(path); + if (!path.empty()) { + // the old way: a file on the card + on_main([&] { + if (auto *cart = current_cart()) { + has_cart = true; + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + done = cart->screenshot(path); + } + }, std::chrono::seconds(10)); + if (!has_cart) fail("no game is running"); + else if (!done) fail("screenshot failed"); + else json(fmt::format("{{\"path\":{}}}", quote(path))); + break; + } + // The frame is read where the game drew it (for most cores one of the + // BSP's two frame buffers): the main loop takes the pointer between two + // frames and then holds, so the game draws nothing over it, until the + // last SCREENSHOT_READ (or a pause in the reading) lets it go on. + release_shot(); + size_t width = 0, height = 0; + { + std::unique_lock lk(shot_mutex_); + shot_state_ = ShotState::CAPTURING; + } + main_.post([this] { + std::unique_lock lk(shot_mutex_); + if (shot_state_ != ShotState::CAPTURING) return; // the request gave up + auto *cart = current_cart(); + if (cart) { + cart->capture_hold(true); + auto [w, h] = cart->frame_size(); + const auto pixels = cart->frame_pixels(); + // a core may report more lines than its frame buffer holds (the + // box's NES: 240 for a 224-line frame): keep what is there + if (w) h = std::min(h, pixels.size() / (w * 2)); + shot_width_ = w; + shot_height_ = h; + shot_pixels_ = pixels.data(); + shot_size_ = w * h * 2; } - }, std::chrono::seconds(10)); - if (!has_cart) fail("no game is running"); - else if (!done) fail("screenshot failed"); - else json(fmt::format("{{\"path\":{}}}", quote(path))); + shot_state_ = (cart && shot_size_) ? ShotState::HELD : ShotState::IDLE; + has_shot_cart_ = cart != nullptr; + shot_cv_.notify_all(); + // hold the game while the frame is read + shot_deadline_ = std::chrono::steady_clock::now() + kShotIdle; + while (shot_state_ == ShotState::HELD && std::chrono::steady_clock::now() < shot_deadline_) { + shot_cv_.wait_until(lk, shot_deadline_); + } + shot_state_ = ShotState::IDLE; + shot_pixels_ = nullptr; + shot_cv_.notify_all(); + lk.unlock(); + if (cart) cart->capture_hold(false); + }); + std::unique_lock lk(shot_mutex_); + if (!shot_cv_.wait_for(lk, std::chrono::seconds(10), [&] { return shot_state_ != ShotState::CAPTURING; })) { + shot_state_ = ShotState::IDLE; // the posted job skips it + fail("the device is busy (is the pause menu open?)"); + break; + } + if (!has_shot_cart_) { + fail("no game is running"); + break; + } + if (shot_state_ != ShotState::HELD) { + fail("this game has no frame to capture"); + break; + } + width = shot_width_; + height = shot_height_; + json(fmt::format("{{\"width\":{},\"height\":{},\"size\":{},\"format\":\"{}\"}}", width, height, + shot_size_, kFrameFormat)); + break; + } + case Type::SCREENSHOT_READ: { + if (p.size() < 6) { + fail("payload: u32 offset, u16 length"); + break; + } + const uint32_t offset = p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); + const size_t length = std::min(p[4] | (p[5] << 8), espp::stream_frame::kMaxPayloadSize - 1); + std::lock_guard lk(shot_mutex_); + if (shot_state_ != ShotState::HELD || !shot_pixels_) { + fail("no screenshot is being read (SCREENSHOT first)"); + break; + } + if (offset < shot_size_) { + const size_t n = std::min(length, shot_size_ - offset); + body.assign(shot_pixels_ + offset, shot_pixels_ + offset + n); + } + if (offset + length >= shot_size_) { + shot_state_ = ShotState::RELEASED; // all read: the game goes on + } else { + shot_deadline_ = std::chrono::steady_clock::now() + kShotIdle; + } + shot_cv_.notify_all(); break; } case Type::USB_DRIVE: { @@ -579,10 +679,32 @@ class RemoteControl { } } + // the pixels' byte order in the cores' frame buffers (both devices) + static constexpr const char *kFrameFormat = "rgb565le"; + // how long the game is held for a screenshot without a SCREENSHOT_READ + static constexpr std::chrono::milliseconds kShotIdle{2000}; + enum class ShotState { IDLE, CAPTURING, HELD, RELEASED }; + + /// Let the game go on if a screenshot is held (a new one replaces it). + void release_shot() { + std::unique_lock lk(shot_mutex_); + if (shot_state_ == ShotState::HELD) shot_state_ = ShotState::RELEASED; + shot_cv_.notify_all(); + shot_cv_.wait_for(lk, std::chrono::seconds(1), [&] { return shot_state_ == ShotState::IDLE; }); + } + Gui &gui_; MainQueue &main_; std::mutex state_mutex_; Cart *cart_{nullptr}; + // the screenshot being read: the held game's frame buffer + std::mutex shot_mutex_; + std::condition_variable shot_cv_; + ShotState shot_state_{ShotState::IDLE}; + std::chrono::steady_clock::time_point shot_deadline_; + const uint8_t *shot_pixels_{nullptr}; + size_t shot_width_{0}, shot_height_{0}, shot_size_{0}; + bool has_shot_cart_{false}; FILE *write_file_{nullptr}; std::string write_path_; uint32_t write_pos_{0}; diff --git a/main/tpm_cart.hpp b/main/tpm_cart.hpp index 19f3ef3..a1ccc52 100644 --- a/main/tpm_cart.hpp +++ b/main/tpm_cart.hpp @@ -75,6 +75,16 @@ class TpmCart : public Cart { } protected: + void capture_hold(bool hold) override { +#if defined(ENABLE_TPM) + if (hold) { + tpm::pause(); + } else { + tpm::resume(); + } +#endif + } + void pre_menu() override { Cart::pre_menu(); #if defined(ENABLE_TPM) diff --git a/tools/emulink/emulink/cli.py b/tools/emulink/emulink/cli.py index 2ff6c1f..882038c 100644 --- a/tools/emulink/emulink/cli.py +++ b/tools/emulink/emulink/cli.py @@ -42,19 +42,8 @@ def chunk(kind: bytes, data: bytes) -> bytes: chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) -def screenshot_to_png(data: bytes, byteswap: bool) -> bytes: - """A Cart::screenshot() file ([u16be w][u16be h][RGB565]) as a PNG.""" - w, h = struct.unpack(">HH", data[:4]) - # a core may report more lines than its frame buffer holds (the box's NES: - # 240 for a 224-line frame): trust the data - h = min(h, (len(data) - 4) // (w * 2)) - px = data[4:4 + w * h * 2] - rgb = bytearray(w * h * 3) - for i in range(w * h): - v = (px[2 * i] << 8 | px[2 * i + 1]) if byteswap else (px[2 * i] | px[2 * i + 1] << 8) - r, g, b = (v >> 11) & 0x1F, (v >> 5) & 0x3F, v & 0x1F - rgb[3 * i:3 * i + 3] = bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2))) - return _png(w, h, bytes(rgb)) +def frame_to_png(frame, big_endian: bool) -> bytes: + return _png(frame.width, frame.height, frame.rgb(big_endian)) def _open(args, timeout_s: float = 0.0) -> Link: @@ -136,13 +125,17 @@ def cmd_load(args) -> int: def cmd_screenshot(args) -> int: with _open(args) as link: - c = Control(link) - remote = c.screenshot() - data = c.read_file(remote) - byteswap = args.byteswap if args.byteswap is not None else link.serial == "esp-box-emu" + frame = Control(link).frame(progress=_progress("screenshot")) + if args.byteswap is not None: + big_endian = args.byteswap + else: # every core so far renders little-endian RGB565 + big_endian = frame.format == "rgb565be" out = args.output or time.strftime("screenshot-%Y%m%d-%H%M%S.png") with open(out, "wb") as f: - f.write(screenshot_to_png(data, byteswap) if out.endswith(".png") else data) + if out.endswith(".png"): + f.write(frame_to_png(frame, big_endian)) + else: # the device's own screenshot file format + f.write(struct.pack(">HH", frame.width, frame.height) + frame.data) print(out) return 0 @@ -461,7 +454,7 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("screenshot", help="the running game's frame as a PNG") s.add_argument("-o", "--output") s.add_argument("--byteswap", action=argparse.BooleanOptionalAction, default=None, - help="RGB565 byte order (default: swapped on esp-box-emu)") + help="RGB565 big-endian (default: the byte order the device reports)") s.set_defaults(fn=cmd_screenshot) s = sub.add_parser("ls", help="list a directory on the SD card") s.add_argument("path", nargs="?", default="") diff --git a/tools/emulink/emulink/control.py b/tools/emulink/emulink/control.py index 1c201f7..d1dd511 100644 --- a/tools/emulink/emulink/control.py +++ b/tools/emulink/emulink/control.py @@ -6,6 +6,7 @@ import json import os import struct +import sys import time from typing import Iterable, List, Optional @@ -15,6 +16,7 @@ SYSTEM_MODULE = 7 STATUS, LIST_ROMS, LAUNCH, QUIT, BUTTONS, SAVE, LOAD, SCREENSHOT, USB_DRIVE, RELOAD_ROMS, ENTER_UPDATER = range(1, 12) +SCREENSHOT_READ = 0x0C FILE_LIST, FILE_READ, FILE_WRITE, FILE_DELETE, MKDIR, FILE_STAT, FILE_CLOSE = range(0x10, 0x17) #: one FILE_WRITE: the frame payload is at most 4096 bytes, of which 6 + the path are the header @@ -25,6 +27,36 @@ class ControlError(RuntimeError): pass +class Frame: + """A game's frame: RGB565 pixels, row by row. ``format`` is "rgb565le" / + "rgb565be" (None: not reported by the device).""" + + def __init__(self, width: int, height: int, format: Optional[str], data: bytes) -> None: + self.width, self.height, self.format, self.data = width, height, format, data + + def rgb(self, big_endian: bool) -> bytes: + """The pixels as 8-bit RGB.""" + from array import array + px = array("H") + px.frombytes(self.data) + if big_endian != (sys.byteorder == "big"): + px.byteswap() + return b"".join(map(_rgb565_table().__getitem__, px)) + + +_RGB565 = None + + +def _rgb565_table(): + global _RGB565 + if _RGB565 is None: + def entry(v): + r, g, b = (v >> 11) & 0x1F, (v >> 5) & 0x3F, v & 0x1F + return bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2))) + _RGB565 = [entry(v) for v in range(65536)] + return _RGB565 + + class Buttons: """GamepadState::buttons bits.""" @@ -131,8 +163,37 @@ def save(self, slot: int) -> None: def load(self, slot: int) -> None: self._call(LOAD, bytes([slot]), timeout_s=60) - def screenshot(self, remote_path: str = "") -> str: - return self._call(SCREENSHOT, self.remote(remote_path).encode() if remote_path else b"")["path"] + def screenshot(self, remote_path: str = "screenshots/screenshot.bin") -> str: + """Save the running game's frame on the SD card ([u16be w][u16be h][RGB565]); returns its path.""" + return self._call(SCREENSHOT, self.remote(remote_path).encode())["path"] + + def frame(self, progress=None) -> "Frame": + """The running game's frame, copied straight from its frame buffer + (firmware before protocol 2 saves it on the card and it is read from + there).""" + info = self._call(SCREENSHOT, timeout_s=15) + if "path" in info: # older firmware + data = self.read_file(info["path"]) + if len(data) < 4: + raise ControlError(f"the screenshot file {info['path']} is {len(data)} bytes") + w, h = struct.unpack(">HH", data[:4]) + px = data[4:] + if w: + h = min(h, len(px) // (w * 2)) + return Frame(w, h, None, px[:w * h * 2]) + size = info["size"] + out = bytearray() + while len(out) < size: + n = min(4000, size - len(out)) + chunk = self._call(SCREENSHOT_READ, struct.pack(" None: self._call(USB_DRIVE, bytes([1 if host else 0]), timeout_s=20) From b365e19149d5ed66bed97dda363632f5d4e5d55c Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 11 Nov 2026 18:06:15 -0600 Subject: [PATCH 5/6] Link status: frames presented / dropped and the heaps' low-water marks For performance and memory tests of any core: frames_presented and frames_dropped (the BSP's counters: displayed fps for every emulator, not just the ones that count their own frames), internal_min_free and psram_min_free. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- main/remote_control.hpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/main/remote_control.hpp b/main/remote_control.hpp index 6f603e9..3667ecd 100644 --- a/main/remote_control.hpp +++ b/main/remote_control.hpp @@ -581,15 +581,20 @@ class RemoteControl { } const auto *app = esp_app_get_description(); const auto *running = esp_ota_get_running_partition(); - return fmt::format("{{\"state\":\"{}\",\"rom\":{},\"platform\":\"{}\",\"console\":{},\"game_frames\":{},\"usb_drive\":{}," - "\"uptime_ms\":{},\"internal_free\":{},\"internal_largest\":{},\"psram_free\":{}," - "\"psram_largest\":{},\"dma_free\":{},\"dma_largest\":{},\"firmware\":{},\"built\":{}," + return fmt::format("{{\"state\":\"{}\",\"rom\":{},\"platform\":\"{}\",\"console\":{},\"game_frames\":{}," + "\"frames_presented\":{},\"frames_dropped\":{},\"usb_drive\":{}," + "\"uptime_ms\":{},\"internal_free\":{},\"internal_largest\":{},\"internal_min_free\":{}," + "\"psram_free\":{},\"psram_largest\":{},\"psram_min_free\":{},\"dma_free\":{}," + "\"dma_largest\":{},\"firmware\":{},\"built\":{}," "\"partition\":{},\"elf_sha256\":{},\"reset_reason\":{},\"last_panic\":{}}}", in_game ? "game" : "menu", quote(rom), platform, quote(console), frames, + BoxEmu::get().frames_blitted(), BoxEmu::get().frames_dropped(), BoxEmu::get().usb_msc_host_has_card(), esp_timer_get_time() / 1000, heap_caps_get_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), + heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM), + heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL), heap_caps_get_largest_free_block(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL), quote(app->version), quote(fmt::format("{} {}", app->date, app->time)), quote(running ? running->label : ""), From 87bf56a26ef68a66897cc3e8d8d3f8b532aeff8d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 11 Nov 2026 19:47:30 -0600 Subject: [PATCH 6/6] Paged ls; femto8 at the merge of esp-cpp/femto8#1 - FILE_LIST pages like LIST_ROMS (path\0offset, reply "next"), so emulink ls lists a whole directory instead of stopping at ~69 entries. - femto8: cart buffers only while they are needed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/pico8/femto8 | 2 +- main/remote_control.hpp | 28 ++++++++++++++++++++-------- tools/emulink/emulink/control.py | 9 ++++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/components/pico8/femto8 b/components/pico8/femto8 index 3c5fc45..5773213 160000 --- a/components/pico8/femto8 +++ b/components/pico8/femto8 @@ -1 +1 @@ -Subproject commit 3c5fc452ee542a99de80b3962927a665f886cc69 +Subproject commit 5773213d597ba047bb2fec1ae2b64727b34ef720 diff --git a/main/remote_control.hpp b/main/remote_control.hpp index 3667ecd..26d9618 100644 --- a/main/remote_control.hpp +++ b/main/remote_control.hpp @@ -129,7 +129,7 @@ class RemoteControl { RELOAD_ROMS = 0x0A, ///< () re-read metadata.csv ENTER_UPDATER = 0x0B, ///< () reboot into the updater app (ota_1) for an update SCREENSHOT_READ = 0x0C, ///< (u32 offset, u16 length) -> raw bytes of the held SCREENSHOT (short at the end) - FILE_LIST = 0x10, ///< (path: text) -> {entries: [{name, dir, size}]} + FILE_LIST = 0x10, ///< (path[\0offset]) -> {entries: [{name, dir, size}], truncated?, next?} FILE_READ = 0x11, ///< (u32 offset, u16 length, path) -> raw bytes (short at the end) FILE_WRITE = 0x12, ///< (u32 offset, path_len u16, path, data); offset 0 truncates FILE_DELETE = 0x13, ///< (path: text) @@ -409,25 +409,37 @@ class RemoteControl { return; } case Type::FILE_LIST: { + // path, optionally followed by '\0' and the index to start at (the "next" of a truncated + // reply) + std::string_view path = text; + size_t offset = 0; + if (const auto nul = text.find('\0'); nul != std::string_view::npos) { + path = text.substr(0, nul); + offset = std::strtoul(std::string(text.substr(nul + 1)).c_str(), nullptr, 10); + } std::error_code ec; std::string j = "{\"entries\":["; - bool first = true; - for (const auto &e : std::filesystem::directory_iterator(std::string(text), ec)) { + size_t i = 0; + for (const auto &e : std::filesystem::directory_iterator(std::string(path), ec)) { + if (i < offset) { + i++; + continue; + } std::error_code ec2; const bool dir = e.is_directory(ec2); const uintmax_t size = dir ? 0 : e.file_size(ec2); - auto item = fmt::format("{}{{\"name\":{},\"dir\":{},\"size\":{}}}", first ? "" : ",", + auto item = fmt::format("{}{{\"name\":{},\"dir\":{},\"size\":{}}}", i > offset ? "," : "", quote(e.path().filename().string()), dir, size); - if (j.size() + item.size() + 32 > espp::stream_frame::kMaxPayloadSize) { - j += "],\"truncated\":true}"; + if (j.size() + item.size() + 48 > espp::stream_frame::kMaxPayloadSize) { + j += fmt::format("],\"truncated\":true,\"next\":{}}}", i); json(j); goto done; } j += item; - first = false; + i++; } if (ec) { - fail(fmt::format("{}: {}", text, ec.message())); + fail(fmt::format("{}: {}", path, ec.message())); break; } j += "]}"; diff --git a/tools/emulink/emulink/control.py b/tools/emulink/emulink/control.py index d1dd511..421863f 100644 --- a/tools/emulink/emulink/control.py +++ b/tools/emulink/emulink/control.py @@ -206,7 +206,14 @@ def enter_updater(self) -> None: # -- files on the SD card -------------------------------------------------- def ls(self, path: str = "") -> List[dict]: - return self._call(FILE_LIST, self.remote(path).encode())["entries"] + path = self.remote(path).encode() + r = self._call(FILE_LIST, path) + out = r["entries"] + # a full page says where the next starts; older firmware only says "truncated" + while r.get("truncated") and r.get("next") is not None and r["entries"]: + r = self._call(FILE_LIST, path + b"\0" + str(r["next"]).encode()) + out += r["entries"] + return out def stat(self, path: str) -> Optional[dict]: try: