diff --git a/components/menu/include/menu.hpp b/components/menu/include/menu.hpp index 2585cc3..0b58601 100644 --- a/components/menu/include/menu.hpp +++ b/components/menu/include/menu.hpp @@ -44,6 +44,10 @@ class Menu { void pause(); void resume(); + /// Click the button of an action as a tap on it would (the automated + /// launch tests) + void click(Action action); + protected: void init_ui(); void deinit_ui(); diff --git a/components/menu/src/menu.cpp b/components/menu/src/menu.cpp index 048b868..69965e4 100644 --- a/components/menu/src/menu.cpp +++ b/components/menu/src/menu.cpp @@ -56,6 +56,14 @@ void Menu::resume() { paused_ = false; } +void Menu::click(Action action) { + std::lock_guard lk(mutex_); + lv_obj_t *const buttons[] = {btn_resume_, btn_reset_, btn_save_, btn_load_, btn_quit_}; + if (auto *btn = buttons[static_cast(action)]) { + lv_obj_send_event(btn, LV_EVENT_CLICKED, nullptr); + } +} + void Menu::update() { if (!paused_) { std::lock_guard lk(mutex_); diff --git a/components/tab5-emu/idf_component.yml b/components/tab5-emu/idf_component.yml index 378a8e4..e990c24 100644 --- a/components/tab5-emu/idf_component.yml +++ b/components/tab5-emu/idf_component.yml @@ -4,8 +4,13 @@ dependencies: lvgl/lvgl: '>=9.2.2' # >= 1.3.4: powers the SD card IO rail through the P4's LDO (SDMMC works); # >= 1.3.7: the multi-touch state (every finger, for the virtual gamepad) - # through the touch interface and the ST7123 driver - espp/m5stack-tab5: '>=1.3.7' + # through the touch interface and the ST7123 driver; + # >= 1.4.0: frame_buffers() (the DPI panel's frame buffers, which the PPA + # draws the games into) and the serialized, asynchronous LVGL flush; + # >= 1.4.2: audio through espp::Audio (initialize_audio(AudioConfig) with + # recording off, initialize_lcd(LcdConfig) with DMA2D off) and the logger + # without a mutex per tag + espp/m5stack-tab5: '>=1.4.2' espp/touch: '>=1.3.7' espp/st7123touch: '>=1.3.7' espp/event_manager: '>=1.3' diff --git a/components/tab5-emu/src/tab5-emu.cpp b/components/tab5-emu/src/tab5-emu.cpp index 91d5714..5443cbf 100644 --- a/components/tab5-emu/src/tab5-emu.cpp +++ b/components/tab5-emu/src/tab5-emu.cpp @@ -7,7 +7,6 @@ #include #include -#include #include "statistics.hpp" @@ -18,25 +17,6 @@ using namespace std::chrono_literals; // way you hold the Tab5. static constexpr lv_display_rotation_t LANDSCAPE_ROTATION = LV_DISPLAY_ROTATION_90; -// --------------------------------------------------------------------------- -// Access to protected BSP state. -// -// The espp Tab5 BSP keeps the esp_lcd panel handle protected. We need it to -// get the MIPI-DSI panel's frame buffer so the PPA can scale + rotate game -// frames straight into it (no intermediate copy through -// esp_lcd_panel_draw_bitmap). Forming a pointer-to-member from a derived -// class is the standard-conforming way to reach a protected member of a base -// object; replace with a BSP accessor once espp exposes one. -// --------------------------------------------------------------------------- -namespace { -struct BspAccess : espp::M5StackTab5 { - static esp_lcd_panel_handle_t panel(espp::M5StackTab5 &bsp) { - auto pm = &BspAccess::lcd_handles_; - return (bsp.*pm).panel; - } -}; -} // namespace - Tab5Emu::Tab5Emu() : espp::BaseComponent("Tab5Emu", espp::Logger::Verbosity::INFO) { usb_msc_restore_console(); @@ -55,7 +35,10 @@ bool Tab5Emu::initialize_tab5() { logger_.error("Failed to initialize IO expanders"); return false; } - if (!bsp.initialize_lcd()) { + // every LVGL flush is rotated (landscape), so it goes through the PPA and + // the DPI panel's DMA2D copies would never run: leave them off (~3 KB of + // internal DMA-capable RAM) + if (!bsp.initialize_lcd(Bsp::LcdConfig{.dma2d_enabled = false})) { logger_.error("Failed to initialize LCD"); return false; } @@ -156,7 +139,12 @@ void Tab5Emu::free_romdata() { bool Tab5Emu::initialize_audio() { auto &bsp = Bsp::get(); - if (!bsp.initialize_audio(48000)) { + // playback only, at the DAC's fixed rate (play_audio() resamples): no + // microphones, so no RX DMA ring or recording task in internal RAM + auto config = Bsp::default_audio_config(); + config.sample_rate = 48000; + config.recording_enabled = false; + if (!bsp.initialize_audio(config)) { return false; } bsp.mute(false); @@ -499,18 +487,17 @@ void *Tab5Emu::dpi_frame_buffer() { if (dpi_fb_) { return dpi_fb_; } - auto panel = BspAccess::panel(Bsp::get()); - if (!panel) { - logger_.error("No LCD panel handle; was initialize_tab5() called?"); - return nullptr; - } - void *fb0 = nullptr; - if (esp_lcd_dpi_panel_get_frame_buffer(panel, 1, &fb0) != ESP_OK || !fb0) { - logger_.error("Could not get the DPI panel frame buffer"); + // the panel's frame buffer (the one it scans out: the BSP keeps a single + // buffer, CONFIG_M5STACK_TAB5_DPI_NUM_FBS = 1), so the PPA can scale and + // rotate game frames straight into it + const auto fbs = Bsp::get().frame_buffers(); + if (fbs.empty() || !fbs[0]) { + logger_.error("No DPI panel frame buffer; was initialize_tab5() called?"); return nullptr; } + void *fb0 = fbs[0]; dpi_fb_ = fb0; - dpi_fb_bytes_ = Bsp::display_width() * Bsp::display_height() * sizeof(Pixel); + dpi_fb_bytes_ = Bsp::frame_buffer_size(); logger_.info("DPI frame buffer at {} ({} bytes)", fb0, dpi_fb_bytes_); return dpi_fb_; } diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 7713d93..dcf4cff 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -25,6 +25,8 @@ #include #include +#include + #include #include #include @@ -1083,17 +1085,104 @@ void snap_skips(SnapHeader &h) { } } -bool io_chunked(FILE *f, uint8_t *p, size_t n, bool writing) { - while (n > 0) { - const size_t chunk = std::min(n, 64 * 1024); - if ((writing ? fwrite(p, 1, chunk, f) : fread(p, 1, chunk, f)) != chunk) { - return false; +// A save state's file, read and written a 64 KB block at a time through one +// 128-byte-aligned PSRAM buffer, at file offsets that are multiples of the +// block, as the disc's read-ahead does. The pieces of a state (the header, +// the game's memory regions) sit at any address and length: handed to FATFS +// as they are, the SD driver bounces each through internal DMA memory, and +// with the pause menu up there was too little of it (a write failed). +class StateFile { +public: + static constexpr size_t BLOCK = 64 * 1024; + + StateFile(const std::string &path, bool writing) : writing_(writing) { + buf_ = static_cast(heap_caps_aligned_alloc(128, BLOCK, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA)); + if (!buf_) { + logger.error("state file: no memory for its {} KB buffer", BLOCK / 1024); + return; + } + f_ = fopen(path.c_str(), writing ? "wb" : "rb"); + if (f_) { + setvbuf(f_, nullptr, _IONBF, 0); // the block buffer instead } - p += chunk; - n -= chunk; } - return true; -} + ~StateFile() { + close(); + heap_caps_free(buf_); + } + StateFile(const StateFile &) = delete; + StateFile &operator=(const StateFile &) = delete; + + bool is_open() const { return f_ != nullptr; } + + bool write(const void *p, size_t n) { + const auto *src = static_cast(p); + while (n > 0 && ok()) { + const size_t k = std::min(n, BLOCK - len_); + memcpy(buf_ + len_, src, k); + len_ += k; + src += k; + n -= k; + if (len_ == BLOCK) { + flush(); + } + } + return ok(); + } + + bool read(void *p, size_t n) { + auto *dst = static_cast(p); + while (n > 0 && ok()) { + if (pos_ == len_) { + len_ = fread(buf_, 1, BLOCK, f_); + pos_ = 0; + if (len_ == 0) { + failed_ = true; + break; + } + } + const size_t k = std::min(n, len_ - pos_); + memcpy(dst, buf_ + pos_, k); + pos_ += k; + dst += k; + n -= k; + } + return ok(); + } + + /// the file's size in bytes, or -1 + long size() { + struct stat st; + return f_ && fstat(fileno(f_), &st) == 0 ? static_cast(st.st_size) : -1; + } + + /// writes what is left and closes the file: true if all of it went well + bool close() { + if (!f_) { + return !failed_; + } + if (writing_ && len_ > 0) { + flush(); + } + failed_ = fclose(f_) != 0 || failed_; + f_ = nullptr; + return !failed_; + } + +private: + bool ok() const { return f_ && !failed_; } + void flush() { + failed_ = fwrite(buf_, 1, len_, f_) != len_ || failed_; + len_ = 0; + } + + FILE *f_{nullptr}; + uint8_t *buf_{nullptr}; + size_t len_{0}; // bytes in the buffer + size_t pos_{0}; // reading: bytes of the buffer already handed out + bool writing_; + bool failed_{false}; +}; // ask the game's thread (waiting in plat_pause_wait) for a checkpoint or to // park; true once it has @@ -1164,15 +1253,13 @@ bool save_state(const std::string &path) { size_t total = sizeof h; if (ok) { snap_skips(h); - FILE *f = fopen(path.c_str(), "wb"); - ok = f && fwrite(&h, 1, sizeof h, f) == sizeof h; + StateFile f(path, true); + ok = f.is_open() && f.write(&h, sizeof h); for (const auto &r : snap_regions(stack_lo, h)) { - ok = ok && io_chunked(f, r.p, r.n, true); + ok = ok && f.write(r.p, r.n); total += r.n; } - if (f) { - ok = fclose(f) == 0 && ok; - } + ok = f.close() && ok; } gpu_start_workers(); snap_release(); @@ -1187,18 +1274,17 @@ bool load_state(const std::string &path) { return false; } const int64_t t0 = esp_timer_get_time(); - FILE *f = fopen(path.c_str(), "rb"); - if (!f) { + StateFile f(path, false); + if (!f.is_open()) { logger.error("load state: cannot open {}", path); return false; } SnapHeader h{}; const auto refuse = [&](const char *why) { logger.error("load state: {}: {}", path, why); - fclose(f); return false; }; - if (fread(&h, 1, sizeof h, f) != sizeof h || memcmp(h.magic, "TPMS", 4) != 0 || h.version != kSnapVersion || + if (!f.read(&h, sizeof h) || memcmp(h.magic, "TPMS", 4) != 0 || h.version != kSnapVersion || h.jb_size != sizeof(jmp_buf)) { return refuse("not a save state of this format"); } @@ -1228,21 +1314,17 @@ bool load_state(const std::string &path) { for (const auto &r : regions) { expected += r.n; } - fseek(f, 0, SEEK_END); - const long actual = ftell(f); - fseek(f, sizeof h, SEEK_SET); + const long actual = f.size(); if (actual < 0 || static_cast(actual) != expected) { return refuse("truncated or corrupt"); } } if (!gpu_stop_workers()) { gpu_start_workers(); - fclose(f); return false; } if (!snap_request(SNAP_LOAD)) { gpu_start_workers(); - fclose(f); return false; } // the game's thread waits on the spare stack: everything else may go @@ -1254,9 +1336,9 @@ bool load_state(const std::string &path) { } bool ok = true; for (const auto &r : regions) { - ok = ok && io_chunked(f, r.p, r.n, false); + ok = ok && f.read(r.p, r.n); } - fclose(f); + f.close(); for (int i = 0; i < n_slots; i++) { *slots[i] = kept[i]; } diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index a459610..09bda3e 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -24,6 +24,16 @@ menu "tab5-emu" next game of TAB5_EMU_AUTOSTART_MATCH, round and round: a soak test of launching and quitting the carts one after another. + config TAB5_EMU_AUTOQUIT_STATES + bool "Test: save and load a state through the pause menu before each quit" + default n + depends on TAB5_EMU_AUTOQUIT_SECONDS > 0 + help + A third of the way into each auto-started game, open the pause + menu and press Save; two thirds in, open it again and press Load + (the same slot). Exercises the menu over a running game and each + cart's save states. + config TAB5_EMU_USB_MSC_AT_BOOT bool "Test: turn the USB drive on right after boot" default n diff --git a/main/cart.hpp b/main/cart.hpp index f15ef91..10b97ad 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -10,6 +10,8 @@ #include #include +#include "sdkconfig.h" + #include "logger.hpp" #include "menu.hpp" @@ -114,6 +116,26 @@ class Cart { running_ = false; } + /// 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) { + using namespace std::chrono_literals; + auto &emu = Tab5Emu::get(); + pre_menu(); + screenshot(get_paused_image_path()); + menu_->resume(); + std::this_thread::sleep_for(1s); + menu_->click(action); + std::this_thread::sleep_for(1s); + if (!menu_->is_paused()) { + menu_->click(Menu::Action::RESUME); + } + (void)emu.menu_requested(); + emu.clear_screen(); + if (running_) + post_menu(); + } + /// Run one iteration of the game loop; returns false once the user quits. virtual bool run() { running_ = true; @@ -145,7 +167,12 @@ class Cart { static constexpr size_t SCREEN_WIDTH = Tab5Emu::lcd_width(); static constexpr size_t SCREEN_HEIGHT = Tab5Emu::lcd_height(); static inline const std::string FS_PREFIX = Tab5Emu::mount_point; +#if CONFIG_TAB5_EMU_AUTOQUIT_STATES + // the automated save / load test writes its own saves, not the player's + static inline const std::string SAVE_DIR = "saves-soak"; +#else static inline const std::string SAVE_DIR = "saves"; +#endif static void copy_file(const std::string &from, const std::string &to) { std::error_code ec; diff --git a/main/main.cpp b/main/main.cpp index d902a9f..2c469b7 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -214,8 +214,27 @@ extern "C" void app_main(void) { std::unique_ptr cart(make_cart(selected_rom)); if (cart) { #if CONFIG_TAB5_EMU_AUTOSTART_DELAY_MS > 0 && CONFIG_TAB5_EMU_AUTOQUIT_SECONDS > 0 - const auto quit_at = std::chrono::steady_clock::now() + std::chrono::seconds(CONFIG_TAB5_EMU_AUTOQUIT_SECONDS); + const auto start = std::chrono::steady_clock::now(); + const auto quit_at = start + std::chrono::seconds(CONFIG_TAB5_EMU_AUTOQUIT_SECONDS); +#if CONFIG_TAB5_EMU_AUTOQUIT_STATES + // a third of the way in the state is saved, two thirds in loaded + // back, both through the pause menu + int state_step = 0; +#endif while (cart->run()) { +#if CONFIG_TAB5_EMU_AUTOQUIT_STATES + const auto now = std::chrono::steady_clock::now(); + if (state_step < 2 && now >= start + (state_step + 1) * (quit_at - start) / 3) { + const auto action = state_step == 0 ? Menu::Action::SAVE : Menu::Action::LOAD; + logger.warn("AUTOQUIT: {} state from the menu", state_step == 0 ? "save" : "load"); + cart->menu_action_as_from_menu(action); + logger.warn("AUTOQUIT: {} done; free: internal {} (largest {}), psram {} (largest {})", + state_step == 0 ? "save" : "load", heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT), + heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT), + heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); + state_step++; + } +#endif if (std::chrono::steady_clock::now() >= quit_at) { logger.warn("AUTOQUIT after {} s", CONFIG_TAB5_EMU_AUTOQUIT_SECONDS); cart->quit_as_from_menu();