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/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..a76251f 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 { @@ -47,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); @@ -72,6 +88,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 +106,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..c746be6 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; @@ -66,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(); } } @@ -79,6 +96,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 +141,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 +218,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 +247,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 +285,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 +295,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 +381,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 +398,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 +443,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 +484,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/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/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/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/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/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..af30d2e 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, merged as + # 08295c0); back to the registry once it is released + git: https://github.com/esp-cpp/espp.git + path: components/usb_device + version: 08295c0904224f25a42a518093021ee68c755ff2 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..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); @@ -298,6 +326,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 +467,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..bd91099 --- /dev/null +++ b/components/tab5-emu/src/usb_device.cpp @@ -0,0 +1,190 @@ +// 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 +#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" : ""); + // 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; +} + +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 10b97ad..b3fd600 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,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 +110,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. @@ -116,6 +134,49 @@ 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; } + + /// 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) { @@ -137,9 +198,25 @@ 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()) { + 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(); @@ -182,9 +259,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) { @@ -202,15 +288,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 +391,9 @@ 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}; 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/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/main.cpp b/main/main.cpp index 2c469b7..2d5f643 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -18,9 +18,12 @@ #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" #include "tab5-emu.hpp" @@ -129,12 +132,53 @@ 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 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 @@ -198,6 +242,9 @@ 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(); + main_queue.poll(); } gui.pause(); @@ -206,6 +253,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 +262,13 @@ 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)) { + 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); @@ -243,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!"); } @@ -286,6 +343,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/mgs_cart.hpp b/main/mgs_cart.hpp index b7ed54d..f302f61 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,7 +77,21 @@ class MgsCart : public Cart { return Cart::run(); } +#if defined(ENABLE_MGS) + uint32_t game_frames() const override { return mgs_dbg_drawotag; } +#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 2164e98..1f23d1e 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(); @@ -70,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 new file mode 100644 index 0000000..26d9618 --- /dev/null +++ b/main/remote_control.hpp @@ -0,0 +1,728 @@ +#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 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. + +#include +#include +#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; + } + /// 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_); + 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 = 2; // 2: in-memory screenshots + + 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) + /// (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[\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) + 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); + bool done = false, has_cart = false; + 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; + } + 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: { + 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: { + // 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\":["; + 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\":{}}}", i > offset ? "," : "", + quote(e.path().filename().string()), dir, size); + if (j.size() + item.size() + 48 > espp::stream_frame::kMaxPayloadSize) { + j += fmt::format("],\"truncated\":true,\"next\":{}}}", i); + json(j); + goto done; + } + j += item; + i++; + } + if (ec) { + fail(fmt::format("{}: {}", path, 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\":{}," + "\"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 : ""), + 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(); + } + } + + // 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/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(); 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/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..882038c --- /dev/null +++ b/tools/emulink/emulink/cli.py @@ -0,0 +1,519 @@ +"""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 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: + 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: + 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: + 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 + + +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 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="") + 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..421863f --- /dev/null +++ b/tools/emulink/emulink/control.py @@ -0,0 +1,336 @@ +"""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 sys +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) +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 +CHUNK = 3584 + + +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.""" + + 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 = "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) + + 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]: + 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: + 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