From c459e7a9bc6ba9e70eecaa24632fe48d43bd36fa Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 12 Nov 2026 23:42:08 -0600 Subject: [PATCH 1/3] Launcher: console filter and quick console navigation - Left / right on the game list jump to the previous / next console. - The console in the header opens a console picker (tap it, or X): all consoles, or only one console's games. The console is outlined while the list is filtered, and left / right then move the filter. Launching a game the filter hides lifts it. - USB link (protocol 3): CONSOLE_FILTER (emulink filter ), the filter in STATUS, and SCREENSHOT of the launcher's screen when no game runs (lv_snapshot, now enabled). - Graphics: a filtered roller does not wrap (a short list showed twice), and the Game Gear badge is lighter (it vanished on the dark header). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- README.md | 4 + components/gui/include/gui.hpp | 40 +++- components/gui/src/gui.cpp | 285 +++++++++++++++++++++++++-- components/rom_info/src/rom_info.cpp | 2 +- main/remote_control.hpp | 77 ++++++-- sdkconfig.defaults | 2 + tools/emulink/emulink/cli.py | 12 +- tools/emulink/emulink/control.py | 15 +- 8 files changed, 403 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f36a088e..8d225148 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,10 @@ A checklist of what's implemented and what's still in progress. **User interface** - [x] LVGL ROM-select menu with boxart and title, navigable by controller +- [x] Games grouped by console: left / right jump to the previous / next + console, and the console in the header (tap it, or press X) opens a picker + that shows only one console's games (outlined while filtered; `emulink + filter ` over USB) - [x] LVGL settings menu (volume, video, haptics) and in-game pause menu - [x] GUI data (titles + boxart) loaded from the [metadata file](./metadata.csv) - [x] Squareline Studio source for the LVGL boilerplate ([SLS files](./squareline), [generated files](./components/gui/generated)) diff --git a/components/gui/include/gui.hpp b/components/gui/include/gui.hpp index 681e90d6..d4848537 100644 --- a/components/gui/include/gui.hpp +++ b/components/gui/include/gui.hpp @@ -145,9 +145,25 @@ class Gui { std::lock_guard lk(mutex_); return rom_infos_; } - /// Select the first game whose name or rom path contains `text` + /// Select the first game whose name or rom path contains `text` (a game + /// the console filter hides lifts the filter) bool select_rom_matching(std::string_view text); + /// The consoles that have games, in list order. + std::vector consoles() const; + /// The console whose games the list shows, if it is filtered. + std::optional console_filter() const; + /// Show only `console`'s games (nullopt: every console's); false if it has none. + bool set_console_filter(std::optional console); + + /// The launcher screen (RGB565, little endian, rows packed), for the USB + /// link's screenshot when no game runs; empty if it cannot be drawn. + struct Snapshot { + size_t width{0}, height{0}; + std::vector pixels; + }; + Snapshot snapshot(); + protected: void init_ui(); void deinit_ui(); @@ -173,6 +189,13 @@ class Gui { VideoSetting get_video_setting(); void on_rom_focused(int index); + void build_roller(const std::string &selected_path); + void step_console(int dir); + void update_filter_marker(); + void open_console_picker(); + void close_console_picker(); + static void picker_event(lv_event_t *e); + static void roller_key(lv_event_t *e); /// The roller moved to `row`: select its game, or step past a console /// header in the direction the roller moved. void on_roller_row(int row); @@ -205,6 +228,14 @@ class Gui { if (!paused_) { std::lock_guard lk(mutex_); sync_usb_drive(); + // X opens (or closes) the console picker (the keypad input has no key + // for it); not the latched state, the keypad reads that + const bool x = BoxEmu::get().gamepad_state().x; + if (x && !x_down_) { + if (picker_) close_console_picker(); + else open_console_picker(); + } + x_down_ = x; lv_task_handler(); } } @@ -264,6 +295,13 @@ class Gui { uint8_t *console_art_data_{nullptr}; lv_image_dsc_t console_art_{}; + // the console filter: the Console whose games the roller shows, -1 all + int filter_{-1}; + lv_obj_t *picker_{nullptr}; ///< the console picker, while it is open + lv_group_t *picker_group_{nullptr}; + std::vector picker_consoles_; ///< the picker's rows: Console, -1 all + bool x_down_{false}; + lv_obj_t *resume_button_{nullptr}; lv_obj_t *resume_label_{nullptr}; diff --git a/components/gui/src/gui.cpp b/components/gui/src/gui.cpp index 64653a86..68d40355 100644 --- a/components/gui/src/gui.cpp +++ b/components/gui/src/gui.cpp @@ -97,26 +97,49 @@ void Gui::update_rom_list() { std::stable_sort(rom_infos_.begin(), rom_infos_.end(), [](const RomInfo &a, const RomInfo &b) { return console_of(a.platform) < console_of(b.platform); }); - if (rom_infos_.empty()) { - return; + build_roller(selected_path); +} + +// The roller: the games that pass the console filter, a header row before +// each console's games; `selected_path` stays selected if it is shown. +void Gui::build_roller(const std::string &selected_path) { + std::lock_guard lk(mutex_); + // a filter for a console that is no longer on the card shows them all + if (filter_ >= 0 && std::none_of(rom_infos_.begin(), rom_infos_.end(), [this](const RomInfo &r) { + return static_cast(console_of(r.platform)) == filter_; + })) { + filter_ = -1; } - // the roller: a header row before each console's games + roller_rows_.clear(); std::string options; int selected_row = -1; + int last_console = -1; for (size_t i = 0; i < rom_infos_.size(); i++) { const Console console = console_of(rom_infos_[i].platform); - if (i == 0 || console != console_of(rom_infos_[i - 1].platform)) { - options += std::string(LV_SYMBOL_LIST " ") + console_info(console).name + "\n"; + if (filter_ >= 0 && static_cast(console) != filter_) { + continue; + } + if (static_cast(console) != last_console) { + last_console = static_cast(console); + options += std::string(LV_SYMBOL_LIST " ") + console_info(console).name + (filter_ >= 0 ? " only" : "") + "\n"; roller_rows_.push_back(-1); } - if (rom_infos_[i].rom_path == selected_path) { + if (selected_row < 0 && rom_infos_[i].rom_path == selected_path) { selected_row = roller_rows_.size(); } options += rom_infos_[i].name + "\n"; roller_rows_.push_back(i); } + update_filter_marker(); + if (roller_rows_.empty()) { + lv_roller_set_options(ui_roms, "", LV_ROLLER_MODE_INFINITE); + focused_rom_ = -1; + return; + } if (!options.empty()) options.pop_back(); // (the last '\n') - lv_roller_set_options(ui_roms, options.c_str(), LV_ROLLER_MODE_INFINITE); + // (one console's games can be too few to fill an infinite roller, which + // then shows them twice) + lv_roller_set_options(ui_roms, options.c_str(), filter_ >= 0 ? LV_ROLLER_MODE_NORMAL : LV_ROLLER_MODE_INFINITE); if (selected_row < 0) { selected_row = 1; // the first game, under the first header } @@ -125,21 +148,243 @@ void Gui::update_rom_list() { on_rom_focused(roller_rows_[selected_row]); } +std::vector Gui::consoles() const { + std::lock_guard lk(mutex_); + std::vector out; + for (const auto &r : rom_infos_) { + const Console c = console_of(r.platform); + if (out.empty() || out.back() != c) out.push_back(c); // (grouped by console) + } + return out; +} + +std::optional Gui::console_filter() const { + std::lock_guard lk(mutex_); + if (filter_ < 0) return std::nullopt; + return static_cast(filter_); +} + +bool Gui::set_console_filter(std::optional console) { + std::lock_guard lk(mutex_); + const auto present = consoles(); + if (console && std::find(present.begin(), present.end(), *console) == present.end()) { + return false; // no games for it + } + // keep the selected game if the filter shows it, else its first game + std::string keep; + if (focused_rom_ >= 0 && focused_rom_ < (int)rom_infos_.size() && + (!console || console_of(rom_infos_[focused_rom_].platform) == *console)) { + keep = rom_infos_[focused_rom_].rom_path; + } + filter_ = console ? static_cast(*console) : -1; + build_roller(keep); + return true; +} + +// Left / right: the previous / next console's first game; with a filter on, +// the filter moves to that console. +void Gui::step_console(int dir) { + std::lock_guard lk(mutex_); + const auto present = consoles(); + if (present.size() < 2 || focused_rom_ < 0 || focused_rom_ >= (int)rom_infos_.size()) { + return; + } + const Console current = console_of(rom_infos_[focused_rom_].platform); + const int n = present.size(); + const int at = std::find(present.begin(), present.end(), current) - present.begin(); + const Console next = present[(at + (dir < 0 ? n - 1 : 1)) % n]; + if (filter_ >= 0) { + set_console_filter(next); + return; + } + for (size_t row = 0; row < roller_rows_.size(); row++) { + const int i = roller_rows_[row]; + if (i >= 0 && console_of(rom_infos_[i].platform) == next) { + roller_row_ = row; + lv_roller_set_selected(ui_roms, row, LV_ANIM_ON); + on_rom_focused(i); + return; + } + } +} + +// With a filter on, the console in the header is outlined (the area is also the +// button that opens the console picker). +void Gui::update_filter_marker() { + if (!console_area_) return; + // (on the badge / art itself, which is narrower than the area) + if (auto *shown = lv_obj_get_child(console_area_, 0)) { + lv_obj_set_style_outline_color(shown, lv_color_white(), 0); + lv_obj_set_style_outline_pad(shown, 2, 0); + lv_obj_set_style_outline_width(shown, filter_ >= 0 ? 2 : 0, 0); + } +} + +// The console picker: "All consoles" and each console on the card with its +// number of games, over the game list. Tap / A picks one, B or a tap beside +// the list closes it. +void Gui::open_console_picker() { + std::lock_guard lk(mutex_); + if (picker_ || lv_scr_act() != ui_romscreen) { + return; + } + const auto present = consoles(); + picker_ = lv_obj_create(ui_romscreen); + lv_obj_remove_style_all(picker_); + lv_obj_set_size(picker_, lv_pct(100), lv_pct(100)); + lv_obj_set_style_bg_color(picker_, lv_color_black(), 0); + lv_obj_set_style_bg_opa(picker_, LV_OPA_60, 0); + lv_obj_add_flag(picker_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(picker_, &Gui::picker_event, LV_EVENT_CLICKED, this); + + auto *list = lv_list_create(picker_); + lv_obj_set_size(list, 230, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(list, 220, 0); + lv_obj_center(list); + lv_obj_set_style_text_font(list, &lv_font_montserrat_14, 0); + lv_list_add_text(list, "Show games for"); + + picker_group_ = lv_group_create(); + picker_consoles_.clear(); + lv_obj_t *focus = nullptr; + auto add = [&](int console, const std::string &text) { + auto *btn = lv_list_add_button(list, console < 0 ? LV_SYMBOL_LIST : nullptr, text.c_str()); + if (console >= 0) { + auto *icon = console_icon_create(btn, console_info(static_cast(console)), 16); + lv_obj_move_to_index(icon, 0); + } + lv_obj_add_style(btn, &button_style_, LV_STATE_FOCUSED); + if (console == filter_) lv_obj_add_state(btn, LV_STATE_CHECKED); + lv_obj_set_user_data(btn, reinterpret_cast(static_cast(picker_consoles_.size()))); + lv_obj_add_event_cb(btn, &Gui::picker_event, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(btn, &Gui::picker_event, LV_EVENT_KEY, this); + lv_group_add_obj(picker_group_, btn); + picker_consoles_.push_back(console); + if (console == filter_) focus = btn; + }; + add(-1, fmt::format("All consoles ({})", rom_infos_.size())); + for (const Console c : present) { + const auto n = std::count_if(rom_infos_.begin(), rom_infos_.end(), + [c](const RomInfo &r) { return console_of(r.platform) == c; }); + add(static_cast(c), fmt::format("{} ({})", console_info(c).name, n)); + } + if (focus) lv_group_focus_obj(focus); + freeze_focus(); + if (auto keypad = BoxEmu::get().keypad()) { + if (auto input = keypad->get_input_device()) lv_indev_set_group(input, picker_group_); + } +} + +void Gui::close_console_picker() { + std::lock_guard lk(mutex_); + if (!picker_) { + return; + } + // (out of the group before the group goes; the objects go after this event) + lv_group_remove_all_objs(picker_group_); + lv_group_delete(picker_group_); + picker_group_ = nullptr; + lv_obj_delete_async(picker_); + picker_ = nullptr; + focus_rommenu(); +} + +void Gui::picker_event(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui || !gui->picker_) { + return; + } + auto *target = static_cast(lv_event_get_target(e)); + const auto code = lv_event_get_code(e); + if (code == LV_EVENT_KEY) { + // (the keypad's arrows come as keys, not focus moves, as on the settings screen) + const auto key = lv_indev_get_key(lv_indev_active()); + if (key == LV_KEY_ESC) { + gui->close_console_picker(); + } else if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT) { + lv_group_focus_next(gui->picker_group_); + } else if (key == LV_KEY_UP || key == LV_KEY_LEFT) { + lv_group_focus_prev(gui->picker_group_); + } + return; + } + if (target == gui->picker_) { + gui->close_console_picker(); // a tap beside the list + return; + } + const auto index = reinterpret_cast(lv_obj_get_user_data(target)); + if (index >= 0 && index < (intptr_t)gui->picker_consoles_.size()) { + const int console = gui->picker_consoles_[index]; + gui->close_console_picker(); + gui->set_console_filter(console < 0 ? std::nullopt : std::optional(static_cast(console))); + } +} + +void Gui::roller_key(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + // left / right go to the previous / next console, not one row + const auto key = lv_indev_get_key(lv_indev_active()); + if (key == LV_KEY_LEFT || key == LV_KEY_RIGHT) { + gui->step_console(key == LV_KEY_LEFT ? -1 : 1); + lv_event_stop_processing(e); + } +} + +Gui::Snapshot Gui::snapshot() { + std::lock_guard lk(mutex_); + Snapshot shot; + auto *screen = lv_scr_act(); + if (!screen) { + return shot; + } + lv_obj_update_layout(screen); + const int32_t w = lv_obj_get_width(screen), h = lv_obj_get_height(screen); + const uint32_t stride = lv_draw_buf_width_to_stride(w, LV_COLOR_FORMAT_RGB565); + shot.pixels.resize(stride * h); + lv_draw_buf_t buf; + if (lv_draw_buf_init(&buf, w, h, LV_COLOR_FORMAT_RGB565, stride, shot.pixels.data(), shot.pixels.size()) != + LV_RESULT_OK || + lv_snapshot_take_to_draw_buf(screen, LV_COLOR_FORMAT_RGB565, &buf) != LV_RESULT_OK) { + shot.pixels.clear(); + return shot; + } + // (rows packed: the reader expects w * 2 bytes per row) + if (stride != static_cast(w) * 2) { + for (int32_t y = 1; y < h; y++) { + memmove(shot.pixels.data() + y * w * 2, shot.pixels.data() + y * stride, w * 2); + } + shot.pixels.resize(w * h * 2); + } + shot.width = w; + shot.height = h; + return shot; +} + bool Gui::select_rom_matching(std::string_view text) { std::lock_guard lk(mutex_); // an exact path or name first (one ROM's path can contain another's), then // the first game containing the text for (const bool exact : {true, false}) { - for (size_t row = 0; row < roller_rows_.size(); row++) { - const int i = roller_rows_[row]; - if (i < 0) continue; + for (size_t i = 0; i < rom_infos_.size(); i++) { const auto &rom = rom_infos_[i]; if (exact ? (rom.rom_path == text || rom.name == text) : (rom.name.find(text) != std::string::npos || rom.rom_path.find(text) != std::string::npos)) { - roller_row_ = row; - lv_roller_set_selected(ui_roms, row, LV_ANIM_OFF); - on_rom_focused(i); - return true; + // a game the console filter hides: show them all + if (filter_ >= 0 && static_cast(console_of(rom.platform)) != filter_) { + filter_ = -1; + build_roller(rom.rom_path); + } + for (size_t row = 0; row < roller_rows_.size(); row++) { + if (roller_rows_[row] == (int)i) { + roller_row_ = row; + lv_roller_set_selected(ui_roms, row, LV_ANIM_OFF); + on_rom_focused(i); + return true; + } + } } } } @@ -213,12 +458,14 @@ void Gui::show_console(Console console) { console_art_.data = console_art_data_; auto *img = lv_image_create(console_area_); lv_image_set_src(img, &console_art_); + update_filter_marker(); return; } } } console_badge_create(console_area_, {info.short_name, info.short_name, info.key, info.color, info.shape}, 36, &lv_font_montserrat_14); + update_filter_marker(); } void Gui::update_haptic_waveform_label() { @@ -266,6 +513,9 @@ void Gui::init_ui() { lv_obj_add_event_cb(ui_roms, &Gui::event_callback, LV_EVENT_VALUE_CHANGED, static_cast(this)); lv_obj_add_event_cb(ui_roms, &Gui::event_callback, LV_EVENT_SCROLL, static_cast(this)); lv_obj_add_event_cb(ui_roms, &Gui::event_callback, LV_EVENT_KEY, static_cast(this)); + // (before the roller's own handling: left / right change the console) + lv_obj_add_event_cb(ui_roms, &Gui::roller_key, static_cast(LV_EVENT_KEY | LV_EVENT_PREPROCESS), + static_cast(this)); // video settings lv_obj_add_event_cb(ui_videosettingdropdown, &Gui::event_callback, LV_EVENT_VALUE_CHANGED, static_cast(this)); @@ -292,7 +542,12 @@ void Gui::init_ui() { console_area_ = lv_obj_create(ui_header); lv_obj_remove_style_all(console_area_); lv_obj_remove_flag(console_area_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_remove_flag(console_area_, LV_OBJ_FLAG_CLICKABLE); + // a tap on it opens the console picker (so does X) + lv_obj_add_flag(console_area_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(console_area_, LV_OBJ_FLAG_OVERFLOW_VISIBLE); // (the filter outline) + lv_obj_add_event_cb(console_area_, [](lv_event_t *e) { + static_cast(lv_event_get_user_data(e))->open_console_picker(); + }, LV_EVENT_CLICKED, this); lv_obj_set_size(console_area_, 160, 48); lv_obj_set_align(console_area_, LV_ALIGN_CENTER); lv_obj_set_x(console_area_, 12); diff --git a/components/rom_info/src/rom_info.cpp b/components/rom_info/src/rom_info.cpp index 462b9911..a808f3e2 100644 --- a/components/rom_info/src/rom_info.cpp +++ b/components/rom_info/src/rom_info.cpp @@ -126,7 +126,7 @@ const ConsoleInfo &console_info(Console console) { {"Game Boy", "Game Boy", "gb", 0x5B7F3A, ConsoleShape::HANDHELD_TALL}, {"Game Boy Color", "GB Color", "gbc", 0x8E44AD, ConsoleShape::HANDHELD_TALL}, {"Master System", "SMS", "sms", 0x1F5FAF, ConsoleShape::GAMEPAD}, - {"Game Gear", "Game Gear", "gg", 0x2F3B4C, ConsoleShape::HANDHELD_WIDE}, + {"Game Gear", "Game Gear", "gg", 0x4F6D8F, ConsoleShape::HANDHELD_WIDE}, {"Sega Genesis", "Genesis", "genesis", 0xC8102E, ConsoleShape::GAMEPAD}, {"MSX", "MSX", "msx", 0x3A7D7D, ConsoleShape::COMPUTER}, {"PC", "PC", "pc", 0x5A6B7B, ConsoleShape::COMPUTER}, diff --git a/main/remote_control.hpp b/main/remote_control.hpp index 00c3bf66..fb80e28b 100644 --- a/main/remote_control.hpp +++ b/main/remote_control.hpp @@ -111,7 +111,7 @@ 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 + static constexpr uint16_t kProtocolVersion = 3; // 2: in-memory screenshots, 3: console filter, launcher screenshots enum class Type : uint8_t { STATUS = 0x01, ///< () -> {state, rom, platform, console, heap, uptime, ...} @@ -123,13 +123,18 @@ class RemoteControl { 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. + /// format: rgb565le, source: game | launcher}, and the game holds still + /// (its frame buffer with it) while SCREENSHOT_READ reads the pixels, + /// until the last of them is read; with no game running, the launcher's + /// screen is drawn into a copy instead. 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) + /// (console key: text, e.g. "snes"; empty or "all" for every console) -> + /// {filter, consoles: [keys]}: the launcher's list shows only that console's games + CONSOLE_FILTER = 0x0D, 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 @@ -342,6 +347,7 @@ class RemoteControl { std::unique_lock lk(shot_mutex_); if (shot_state_ != ShotState::CAPTURING) return; // the request gave up auto *cart = current_cart(); + shot_size_ = 0; if (cart) { cart->capture_hold(true); auto [w, h] = cart->frame_size(); @@ -354,7 +360,15 @@ class RemoteControl { shot_pixels_ = pixels.data(); shot_size_ = w * h * 2; } - shot_state_ = (cart && shot_size_) ? ShotState::HELD : ShotState::IDLE; + else { + // no game: the launcher's screen, drawn into a copy + launcher_shot_ = gui_.snapshot(); + shot_width_ = launcher_shot_.width; + shot_height_ = launcher_shot_.height; + shot_pixels_ = launcher_shot_.pixels.data(); + shot_size_ = launcher_shot_.pixels.size(); + } + shot_state_ = shot_size_ ? ShotState::HELD : ShotState::IDLE; has_shot_cart_ = cart != nullptr; shot_cv_.notify_all(); // hold the game while the frame is read @@ -364,6 +378,7 @@ class RemoteControl { } shot_state_ = ShotState::IDLE; shot_pixels_ = nullptr; + launcher_shot_ = {}; shot_cv_.notify_all(); lk.unlock(); if (cart) cart->capture_hold(false); @@ -374,18 +389,14 @@ class RemoteControl { 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"); + fail(has_shot_cart_ ? "this game has no frame to capture" : "the launcher could not be drawn"); break; } width = shot_width_; height = shot_height_; - json(fmt::format("{{\"width\":{},\"height\":{},\"size\":{},\"format\":\"{}\"}}", width, height, - shot_size_, kFrameFormat)); + json(fmt::format("{{\"width\":{},\"height\":{},\"size\":{},\"format\":\"{}\",\"source\":\"{}\"}}", width, + height, shot_size_, kFrameFormat, has_shot_cart_ ? "game" : "launcher")); break; } case Type::SCREENSHOT_READ: { @@ -452,6 +463,30 @@ class RemoteControl { return; #endif } + case Type::CONSOLE_FILTER: { + std::string key(text); + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + const auto present = gui_.consoles(); + bool done = false; + if (key.empty() || key == "all") { + done = gui_.set_console_filter(std::nullopt); + } else { + const auto it = std::find_if(present.begin(), present.end(), + [&](Console c) { return key == console_info(c).key; }); + if (it == present.end()) { + fail(fmt::format("no games for console '{}'", key)); + break; + } + done = gui_.set_console_filter(*it); + } + if (!done) { + fail("the filter could not be set"); + break; + } + json(filter_json()); + break; + } case Type::FILE_LIST: { // path, optionally followed by '\0' and the index to start at (the "next" of a truncated // reply) @@ -679,7 +714,7 @@ class RemoteControl { "\"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\":{}}}", + "\"partition\":{},\"elf_sha256\":{},\"reset_reason\":{},\"last_panic\":{},\"filter\":{}}}", 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, @@ -691,7 +726,22 @@ class RemoteControl { 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())); + quote(elf_sha256()), (int)esp_reset_reason(), quote(DevLink::last_panic()), + quote(filter_key())); + } + + /// The launcher's console filter: its console's key, empty for all. + std::string filter_key() const { + const auto f = gui_.console_filter(); + return f ? console_info(*f).key : ""; + } + + std::string filter_json() const { + std::string consoles; + for (const Console c : gui_.consoles()) { + consoles += fmt::format("{}{}", consoles.empty() ? "" : ",", quote(console_info(c).key)); + } + return fmt::format("{{\"filter\":{},\"consoles\":[{}]}}", quote(filter_key()), consoles); } /// Make the bootloader start the updater (ota_1) next. esp_ota_set_boot_partition() @@ -810,6 +860,7 @@ class RemoteControl { const uint8_t *shot_pixels_{nullptr}; size_t shot_width_{0}, shot_height_{0}, shot_size_{0}; bool has_shot_cart_{false}; + Gui::Snapshot launcher_shot_; ///< the launcher's screen, while it is read FILE *write_file_{nullptr}; std::string write_path_; uint32_t write_pos_{0}; diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 8ab3c7f6..563ff9aa 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -127,6 +127,8 @@ CONFIG_LV_USE_THEME_DEFAULT=y CONFIG_LV_THEME_DEFAULT_DARK=y CONFIG_LV_THEME_DEFAULT_GROW=y CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=30 +# the USB link's screenshot of the launcher (lv_snapshot) +CONFIG_LV_USE_SNAPSHOT=y CONFIG_I2S_ISR_IRAM_SAFE=y CONFIG_I2C_ISR_IRAM_SAFE=y diff --git a/tools/emulink/emulink/cli.py b/tools/emulink/emulink/cli.py index de264287..e48350a7 100644 --- a/tools/emulink/emulink/cli.py +++ b/tools/emulink/emulink/cli.py @@ -140,6 +140,13 @@ def cmd_screenshot(args) -> int: return 0 +def cmd_filter(args) -> int: + with _open(args) as link: + r = Control(link).console_filter(args.console) + print(f"filter: {r['filter'] or 'all'} (consoles: {', '.join(r['consoles'])})") + return 0 + + def cmd_ls(args) -> int: with _open(args) as link: for e in Control(link).ls(args.path): @@ -457,7 +464,7 @@ def build_parser() -> argparse.ArgumentParser: 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 = sub.add_parser("screenshot", help="the running game's frame (or the launcher's screen) 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)") @@ -490,6 +497,9 @@ def build_parser() -> argparse.ArgumentParser: 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("filter", help="show only one console's games in the launcher (a key such as snes, or all)") + s.add_argument("console", nargs="?", help="console key, or all (default: show the current filter)") + s.set_defaults(fn=cmd_filter) 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) diff --git a/tools/emulink/emulink/control.py b/tools/emulink/emulink/control.py index abe2c6b8..dc3140f5 100644 --- a/tools/emulink/emulink/control.py +++ b/tools/emulink/emulink/control.py @@ -17,6 +17,7 @@ STATUS, LIST_ROMS, LAUNCH, QUIT, BUTTONS, SAVE, LOAD, SCREENSHOT, USB_DRIVE, RELOAD_ROMS, ENTER_UPDATER = range(1, 12) SCREENSHOT_READ = 0x0C +CONSOLE_FILTER = 0x0D 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 @@ -168,9 +169,9 @@ def screenshot(self, remote_path: str = "screenshots/screenshot.bin") -> str: 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).""" + """The running game's frame, copied straight from its frame buffer, + or with no game running (protocol 3) the launcher's screen (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"]) @@ -200,6 +201,14 @@ def frame(self, progress=None) -> "Frame": def usb_drive(self, host: bool) -> None: self._call(USB_DRIVE, bytes([1 if host else 0]), timeout_s=20) + def console_filter(self, console: str | None = None) -> dict: + """Set the launcher's console filter (a console key such as "snes", or + "all"); with None, only report it. -> {filter, consoles}""" + if console is None: + console = self.status().get("filter", "") + # (an empty filter would clear it: ask for the same one again) + return self._call(CONSOLE_FILTER, (console or "all").encode()) + def reload_roms(self) -> int: return self._call(RELOAD_ROMS)["total"] From 12fdd3273cd842b7dbedee11b528d3842061a6d4 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 13 Nov 2026 09:00:33 -0600 Subject: [PATCH 2/3] Pin esp_tinyusb to 3.0.0 esp_tinyusb 3.0.1 (TinyUSB 0.21.0~6, the DWC2 SETUP-DMA erratum workaround) corrupts the PSRAM heap on esp-box-emu while a Genesis game runs: the link task's next allocation hits a tlsf assert or an MMU entry fault. With 3.0.0 the same firmware is fine (checked on the box with the same commit built both ways). A fresh build resolves '>=3.0.0' to 3.0.1, so CI would have shipped it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/box-emu/idf_component.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/components/box-emu/idf_component.yml b/components/box-emu/idf_component.yml index 6123c337..4e5c0b19 100644 --- a/components/box-emu/idf_component.yml +++ b/components/box-emu/idf_component.yml @@ -16,6 +16,11 @@ dependencies: espp/serialization: '>=1.3.4' espp/task: '>=1.3.4' espp/timer: '>=1.3.4' + # Pinned: esp_tinyusb 3.0.1 (TinyUSB 0.21.0~6: the DWC2 SETUP-DMA erratum + # workaround) corrupts the PSRAM heap on esp-box-emu while a Genesis game + # runs (tlsf asserts / MMU faults on the next allocation in the link task); + # 3.0.0 does not. Lift once that is understood upstream. + espressif/esp_tinyusb: '==3.0.0' espp/usb_device: # TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898, merged as # 08295c0); back to the registry once it is released From 1ccf084a124f71868a5a41ab6ee3300eae283123 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 13 Nov 2026 10:11:38 -0600 Subject: [PATCH 3/3] Revert "Pin esp_tinyusb to 3.0.0" The heap corruption seen with Sonic 2 on the box turned out to be intermittent: the same builds that failed also pass repeated trials, so the runs that blamed esp_tinyusb 3.0.1 were not evidence of a cause. The corruption is tracked separately; no pin until it is understood. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/box-emu/idf_component.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/components/box-emu/idf_component.yml b/components/box-emu/idf_component.yml index 4e5c0b19..6123c337 100644 --- a/components/box-emu/idf_component.yml +++ b/components/box-emu/idf_component.yml @@ -16,11 +16,6 @@ dependencies: espp/serialization: '>=1.3.4' espp/task: '>=1.3.4' espp/timer: '>=1.3.4' - # Pinned: esp_tinyusb 3.0.1 (TinyUSB 0.21.0~6: the DWC2 SETUP-DMA erratum - # workaround) corrupts the PSRAM heap on esp-box-emu while a Genesis game - # runs (tlsf asserts / MMU faults on the next allocation in the link task); - # 3.0.0 does not. Lift once that is understood upstream. - espressif/esp_tinyusb: '==3.0.0' espp/usb_device: # TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898, merged as # 08295c0); back to the registry once it is released