From 5a26494586044c8f8fd029eb69f52a171548fdb6 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 26 Oct 2026 21:59:00 -0500 Subject: [PATCH 1/5] tpm, mgs: memory cards, the games' own saves, on card image files The Phantom Menace and Metal Gear Solid save to a memory card in port 1: saves/tpm_card1.mcd and saves/mgs_card1.mcd, raw 128 KB card images as PC emulators use, made the first time the game looks. Unlike save states, which hold raw memory and load only into the build that wrote them, these survive firmware updates. - tpm-decomp fea7648..d51af21: libmcrd natively on the card image (tested on the desktop and the board: a save from one build loaded into another); recomp.py treats a jr to the function's own return address as a return (the save screen's bit packer returns through $a3) - psyz 3245d54: mcd, the card image and the BIOS's buXX: device - mgs_reversing 6d58e9f: the port's bu00: device and memcard.c on it (the game finds the card at boot) - Cart::get_memory_card_path names the image after the game's directory Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- README.md | 4 +++- components/mgs/README.md | 6 +++++- components/mgs/include/mgs.hpp | 4 ++++ components/mgs/mgs_reversing | 2 +- components/mgs/psyz | 2 +- components/mgs/src/mgs.cpp | 2 ++ components/tpm/CMakeLists.txt | 3 ++- components/tpm/README.md | 13 +++++++++---- components/tpm/include/tpm.hpp | 4 ++++ components/tpm/src/tpm.cpp | 2 +- components/tpm/src/tpm_engine.cpp | 10 +++++++++- components/tpm/src/tpm_engine.hpp | 6 ++++-- components/tpm/tpm-decomp | 2 +- main/cart.hpp | 10 ++++++++++ main/mgs_cart.hpp | 2 +- main/tpm_cart.hpp | 2 +- 16 files changed, 58 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6950175..d23e71a 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,9 @@ docs/PLAN.md the Jedi Knight plan and history # (components/mgs/mgs_reversing/port/extract_disc.py) tpm/ # The Phantom Menace, USA Rev 1: the .bin of a bin/cue rip Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin - saves/ # save states and their screenshots + saves/ # save states and their screenshots; the PlayStation + # games' memory cards (tpm_card1.mcd, mgs_card1.mcd: + # raw 128 KB images, as PC emulators use) ``` Example `metadata.csv` (one line per game: rom path, boxart, name; the diff --git a/components/mgs/README.md b/components/mgs/README.md index 14e11d9..466c50b 100644 --- a/components/mgs/README.md +++ b/components/mgs/README.md @@ -64,7 +64,11 @@ mgs/STAGE.DIR,boxart/mgs.jpg,Metal Gear Solid every thread's stack and context), keyed on the memory layout so a rebuild that leaves the game code in place keeps old states loadable. - Pause, reset, relaunch and quit in place. -- Not done: memory-card game saves (use states), full-motion video (MDEC, +- Memory card: the game's own saves (Mei Ling's codec frequency, 140.85; + the title's load) go to `/sdcard/saves/mgs_card1.mcd`, a raw 128 KB card + image as PC emulators use, made the first time the game looks. Unlike + save states they survive any rebuild. +- Not done: full-motion video (MDEC, movies are skipped). 56 of the 96 stages have decompiled actors upstream. ## Measuring diff --git a/components/mgs/include/mgs.hpp b/components/mgs/include/mgs.hpp index 366bb7f..13107c1 100644 --- a/components/mgs/include/mgs.hpp +++ b/components/mgs/include/mgs.hpp @@ -16,6 +16,10 @@ struct Config { /// Directory holding the disc files: STAGE.DIR, RADIO.DAT, FACE.DAT, ... /// (what port/extract_disc.py produces), e.g. "/sdcard/mgs". std::string data_dir; + /// The memory card in port 1, a 128 KB card image file (the format PC + /// emulators use): the game's own saves go there. Made, formatted, the + /// first time the game looks. Empty: no card. + std::string memory_card; }; /// Start the game. Returns false if the data is missing or the engine could diff --git a/components/mgs/mgs_reversing b/components/mgs/mgs_reversing index 0ac4d5d..6d58e9f 160000 --- a/components/mgs/mgs_reversing +++ b/components/mgs/mgs_reversing @@ -1 +1 @@ -Subproject commit 0ac4d5d167a0ff0b2013005844e08fd74d3428be +Subproject commit 6d58e9fe56d7fbdeafe93b59f7d17f62a9f3a90c diff --git a/components/mgs/psyz b/components/mgs/psyz index 0c2cfcb..3245d54 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 0c2cfcb60ab0e9638e8c7ab7f0e7d1abce766f65 +Subproject commit 3245d546f1a2becce2e17333d2e65e8dc9d8c612 diff --git a/components/mgs/src/mgs.cpp b/components/mgs/src/mgs.cpp index 2921674..61f30a9 100644 --- a/components/mgs/src/mgs.cpp +++ b/components/mgs/src/mgs.cpp @@ -15,6 +15,7 @@ #include #include "../mgs_reversing/port/mgs_snapshot.h" +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -323,6 +324,7 @@ bool init(const Config &config) { // the directory must be called MGS (FAT is case-insensitive about it). const auto root = std::filesystem::path(config.data_dir).parent_path().string(); Mgs_SetDataRoot(root.c_str()); + Mcd_SetPath(0, config.memory_card.c_str()); // the "bu00:" device (psyz/mcd.h) mgs_platform_init(config.data_dir.c_str()); // the frame buffers the scanout converts VRAM into (the S3 port's app_main // does this; here it is the cart's job) diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 96148d2..bd8a5ae 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -119,7 +119,8 @@ if(TPM_ENGINE_READY) set(rt ${TPM_DECOMP}/recomp/runtime) file(GLOB rt_srcs ${rt}/*.c) file(GLOB gen_srcs ${TPM_GEN}/recomp_*.c) - set(psyz_srcs ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.c) # (the GTE is the runtime's own: rt_gte.c) + # (the GTE is the runtime's own: rt_gte.c); psyz_mcd.c: the memory card's image file + set(psyz_srcs ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.c ${TPM_PSYZ}/psyz/src/psyz/psyz_mcd.c) add_library(tpm_rt STATIC ${gen_srcs} ${rt_srcs} ${psyz_srcs}) idf_build_get_property(config_dir CONFIG_DIR) target_include_directories(tpm_rt PRIVATE diff --git a/components/tpm/README.md b/components/tpm/README.md index 0449290..a2e3e91 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -120,15 +120,20 @@ menu (3.6 MB each; save 1.5 s, load 0.6 s). - Pads: the HAL's merged gamepad, face buttons by position (the HAL's B is south): B = cross, A = circle, Y = square, X = triangle, L/R = L1/R1. - Movies play at full speed (15 fps, the game thread ~80% busy) with their - XA audio when the overlay was recompiled (`CONFIG_TPM_DISC_PATH`). There is - no memory card (the game says so and carries on). + XA audio when the overlay was recompiled (`CONFIG_TPM_DISC_PATH`). +- Memory card: the game's own saves (pause menu: Save Game; title: Load + Game) go to `/sdcard/saves/tpm_card1.mcd`, a raw 128 KB card image as PC + emulators use (DuckStation, ePSXe, ...), made the first time the game + looks. Unlike save states they load into any firmware build. - Frameskip (`CONFIG_TPM_FRAMESKIP`, default 2): when the game is ahead of the GPU, the worker leaves up to that many frames in a row undrawn, so the game keeps its pace instead of running in slow motion. - The pause menu pauses the game at its next loop back-edge. Save states hold guest RAM, VRAM, the SPU, the CPU / GTE / hardware state and the - runtime's live statics; without enough free PSRAM for the game's arena - (after another big cart) the game still runs, without save states. + runtime's live statics, and load only into the firmware build that wrote + them (the memory card is for keeping progress across updates); without + enough free PSRAM for the game's arena (after another big cart) the game + still runs, without save states. Where the time goes (in the level): both the game thread and the GPU worker are ~95% busy. The game thread is the recompiled code (with g_ram pinned in diff --git a/components/tpm/include/tpm.hpp b/components/tpm/include/tpm.hpp index 5c3b263..4809383 100644 --- a/components/tpm/include/tpm.hpp +++ b/components/tpm/include/tpm.hpp @@ -21,6 +21,10 @@ struct Config { /// Directory holding the disc's files: SLUS_008.84 and FILE.000 (FILE.001 /// .. FILE.003 later, for streams and movies), e.g. "/sdcard/tpm". std::string data_dir; + /// The memory card in port 1, a 128 KB card image file (the format PC + /// emulators use): the game's own saves go there. Made, formatted, the + /// first time the game looks. Empty: no card. + std::string memory_card; }; /// Open the data. Returns false if it is missing or not the USA Rev 1 disc. diff --git a/components/tpm/src/tpm.cpp b/components/tpm/src/tpm.cpp index ddf7f60..6b2c2e0 100644 --- a/components/tpm/src/tpm.cpp +++ b/components/tpm/src/tpm.cpp @@ -73,7 +73,7 @@ bool show(size_t index) { bool init(const Config &config) { config_ = config; #if defined(TPM_HAVE_ENGINE) - if (engine::init(config.data_dir)) { + if (engine::init(config.data_dir, config.memory_card)) { engine_ = true; running_ = true; return true; diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 3518128..7713d93 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -63,6 +63,7 @@ constexpr uint32_t AUDIO_RATE = 44100; FILE *g_disc{nullptr}; uint32_t g_disc_sectors{0}; +std::string g_memory_card; // the card image in port 1 (plat_memcard_path) std::vector g_exe; TaskHandle_t g_game_task{nullptr}; @@ -810,7 +811,8 @@ std::string find_disc(const std::string &dir) { } // namespace -bool init(const std::string &data_dir) { +bool init(const std::string &data_dir, const std::string &memory_card) { + g_memory_card = memory_card; const auto disc = find_disc(data_dir); if (disc.empty()) { logger.error("no disc image (.bin, raw 2352-byte sectors) in {}", data_dir); @@ -1482,6 +1484,12 @@ static void __attribute__((noinline)) snap_saved_wait(void) { } } +// The game's own saves (libmcrd, tpm-decomp's rt_hle.c): a card image file. +const char *plat_memcard_path(int port) { + using namespace tpm::engine; + return port == 0 && !g_memory_card.empty() ? g_memory_card.c_str() : nullptr; +} + void plat_pause_wait(void) { using namespace tpm::engine; const int req = g_snap_req.load(); diff --git a/components/tpm/src/tpm_engine.hpp b/components/tpm/src/tpm_engine.hpp index f628e1a..f6f7f75 100644 --- a/components/tpm/src/tpm_engine.hpp +++ b/components/tpm/src/tpm_engine.hpp @@ -8,8 +8,10 @@ namespace tpm::engine { /// Open the disc image in data_dir (a .bin of raw 2352-byte sectors) and -/// start the game. Returns false if there is no usable image. -bool init(const std::string &data_dir); +/// start the game. Returns false if there is no usable image. memory_card: +/// the image file of the card in port 1 (made the first time the game looks; +/// empty: no card). +bool init(const std::string &data_dir, const std::string &memory_card); void deinit(); bool running(); void pause(); diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 00fcb3b..d51af21 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 00fcb3bd8f23f925644eab30d1c1e54d3ef922fb +Subproject commit d51af21f74cccaf1a5d492c7ef39a48c3468bb98 diff --git a/main/cart.hpp b/main/cart.hpp index 7a39f5e..f15ef91 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -240,6 +240,16 @@ class Cart { return ""; } + /// The image file of a PlayStation game's memory card (the raw 128 KB + /// format PC emulators read), named after the game's data directory + /// (saves/tpm_card1.mcd): the game's own saves, which unlike save states + /// do not depend on the firmware build. + std::string get_memory_card_path(int card = 1) const { + namespace fs = std::filesystem; + const auto game = fs::path(get_rom_filename()).parent_path().filename().string(); + return savedir_ + "/" + game + fmt::format("_card{}.mcd", card); + } + std::string get_paused_image_path() const { return savedir_ + "/paused" + get_screenshot_extension(); } std::string get_screenshot_path(bool bypass_exist_check = false) const { diff --git a/main/mgs_cart.hpp b/main/mgs_cart.hpp index 623bbed..b7ed54d 100644 --- a/main/mgs_cart.hpp +++ b/main/mgs_cart.hpp @@ -18,7 +18,7 @@ class MgsCart : public Cart { handle_video_setting(); #if defined(ENABLE_MGS) const auto data_dir = std::filesystem::path(get_rom_filename()).parent_path().string(); - if (!mgs::init({.data_dir = data_dir})) { + if (!mgs::init({.data_dir = data_dir, .memory_card = get_memory_card_path()})) { logger_.error("could not start Metal Gear Solid"); running_ = false; } diff --git a/main/tpm_cart.hpp b/main/tpm_cart.hpp index ca917f2..19f3ef3 100644 --- a/main/tpm_cart.hpp +++ b/main/tpm_cart.hpp @@ -18,7 +18,7 @@ class TpmCart : public Cart { handle_video_setting(); #if defined(ENABLE_TPM) const auto data_dir = std::filesystem::path(get_rom_filename()).parent_path().string(); - if (!tpm::init({.data_dir = data_dir})) { + if (!tpm::init({.data_dir = data_dir, .memory_card = get_memory_card_path()})) { logger_.error("could not start The Phantom Menace"); running_ = false; } From ba5a40b75b5641fad16456c53d461fa53e9f562a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 3 Nov 2026 10:45:35 -0600 Subject: [PATCH 2/5] tab5-emu: espp 1.4.0; the panel's frame buffer through frame_buffers() The BSP now exposes the DPI panel's frame buffers, so the PPA's target no longer comes from its protected panel handle. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/tab5-emu/idf_component.yml | 6 +++-- components/tab5-emu/src/tab5-emu.cpp | 37 ++++++--------------------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/components/tab5-emu/idf_component.yml b/components/tab5-emu/idf_component.yml index 378a8e4..6321744 100644 --- a/components/tab5-emu/idf_component.yml +++ b/components/tab5-emu/idf_component.yml @@ -4,8 +4,10 @@ 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 + espp/m5stack-tab5: '>=1.4.0' 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..5abc19b 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(); @@ -499,18 +479,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_; } From 79639af317d3847161297119b7b02e92d63bb19e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 3 Nov 2026 10:45:35 -0600 Subject: [PATCH 3/5] tpm: save states through an aligned 64 KB block buffer The header and the game's memory regions went to FATFS at any address and file offset, so the SD driver bounced every write through internal DMA memory. With espp 1.4.0 (deeper I2S DMA rings, DMA2D for the panel) about 16 KB less of it is free, and a save from the pause menu failed outright (allocate_dma_buf: not enough mem). Through one 128-byte-aligned PSRAM block at block-multiple file offsets, as the disc's read-ahead does, nothing is bounced: saves 707-969 ms (1295 ms before), loads 330 ms (489 ms). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/tpm/src/tpm_engine.cpp | 134 ++++++++++++++++++++++++------ 1 file changed, 108 insertions(+), 26 deletions(-) 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]; } From a1b52d9ea089c8e2881ac49174b534bc10feff7b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 3 Nov 2026 10:45:35 -0600 Subject: [PATCH 4/5] test: save and load a state through the pause menu in the launch soak TAB5_EMU_AUTOQUIT_STATES: a third of the way into each auto-started game the pause menu opens over it and Save is clicked, two thirds in Load, the same way taps do (Menu::click). The soak's saves go to saves-soak/ so the player's own are left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/menu/include/menu.hpp | 4 ++++ components/menu/src/menu.cpp | 8 ++++++++ main/Kconfig.projbuild | 10 ++++++++++ main/cart.hpp | 27 +++++++++++++++++++++++++++ main/main.cpp | 21 ++++++++++++++++++++- 5 files changed, 69 insertions(+), 1 deletion(-) 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/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(); From c9590b1e2d9e9d715e3ea961b3d782172dd7e1a0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 7 Nov 2026 21:50:48 -0600 Subject: [PATCH 5/5] tab5-emu: espp 1.4.2; audio through espp::Audio, playback only, no DPI DMA2D initialize_audio() takes the BSP's AudioConfig (espp::Audio::AppConfig) with recording off: no RX DMA ring or microphone task in internal RAM. initialize_lcd() turns the panel's DMA2D copies off: every LVGL flush is rotated, so it goes through the PPA and DMA2D never ran (~3 KB of internal DMA-capable RAM). 1.4.2 also brings the logger without a mutex per tag. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY --- components/tab5-emu/idf_component.yml | 7 +++++-- components/tab5-emu/src/tab5-emu.cpp | 12 ++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/components/tab5-emu/idf_component.yml b/components/tab5-emu/idf_component.yml index 6321744..e990c24 100644 --- a/components/tab5-emu/idf_component.yml +++ b/components/tab5-emu/idf_component.yml @@ -6,8 +6,11 @@ dependencies: # >= 1.3.7: the multi-touch state (every finger, for the virtual gamepad) # 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 - espp/m5stack-tab5: '>=1.4.0' + # 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 5abc19b..5443cbf 100644 --- a/components/tab5-emu/src/tab5-emu.cpp +++ b/components/tab5-emu/src/tab5-emu.cpp @@ -35,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; } @@ -136,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);