Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/menu/include/menu.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions components/menu/src/menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ void Menu::resume() {
paused_ = false;
}

void Menu::click(Action action) {
std::lock_guard<std::recursive_mutex> lk(mutex_);
lv_obj_t *const buttons[] = {btn_resume_, btn_reset_, btn_save_, btn_load_, btn_quit_};
if (auto *btn = buttons[static_cast<size_t>(action)]) {
lv_obj_send_event(btn, LV_EVENT_CLICKED, nullptr);
}
}

void Menu::update() {
if (!paused_) {
std::lock_guard<std::recursive_mutex> lk(mutex_);
Expand Down
9 changes: 7 additions & 2 deletions components/tab5-emu/idf_component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
49 changes: 18 additions & 31 deletions components/tab5-emu/src/tab5-emu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

#include <esp_cache.h>
#include <esp_heap_caps.h>
#include <esp_lcd_mipi_dsi.h>

#include "statistics.hpp"

Expand All @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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_;
}
Expand Down
134 changes: 108 additions & 26 deletions components/tpm/src/tpm_engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#include <string>
#include <vector>

#include <sys/stat.h>

#include <esp_app_desc.h>
#include <esp_heap_caps.h>
#include <esp_rom_sys.h>
Expand Down Expand Up @@ -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<size_t>(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<uint8_t *>(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<const uint8_t *>(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<uint8_t *>(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<long>(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
Expand Down Expand Up @@ -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();
Expand All @@ -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");
}
Expand Down Expand Up @@ -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<size_t>(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
Expand All @@ -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];
}
Expand Down
10 changes: 10 additions & 0 deletions main/Kconfig.projbuild
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions main/cart.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#include <string>
#include <vector>

#include "sdkconfig.h"

#include "logger.hpp"

#include "menu.hpp"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading