From effbd3b5336b701e335dd3cf18d3eb5551fb0383 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 11 Oct 2026 12:20:08 -0500 Subject: [PATCH 01/29] tpm: Star Wars Episode I: The Phantom Menace (PSX) cart, data path first New native-port target alongside MGS. No decompilation of the game existed; it is being built in tpm-decomp (SLUS_008.84, USA Rev 1). Until its C runs, components/tpm proves the platform half: it opens SLUS_008.84 + FILE.000 from the card, resolves names through the game's own file table (read out of the executable at 0x800715A0, as the game does) and shows the 44 load screens (320x256 24-bit, cropped to 240 rows; left/right to step). Metadata entries point at /SLUS_008.84. CONFIG_TPM_ENGINE is reserved for the game C. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 6 + components/rom_info/include/rom_info.hpp | 4 +- components/rom_info/src/rom_info.cpp | 4 + components/tpm/CMakeLists.txt | 16 +++ components/tpm/Kconfig | 16 +++ components/tpm/README.md | 39 ++++++ components/tpm/include/tpm.hpp | 41 ++++++ components/tpm/src/tpm.cpp | 164 +++++++++++++++++++++++ components/tpm/src/tpm_files.cpp | 119 ++++++++++++++++ components/tpm/src/tpm_files.hpp | 45 +++++++ docs/PLAN.md | 28 ++++ main/carts.hpp | 4 + main/tpm_cart.hpp | 86 ++++++++++++ 13 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 components/tpm/CMakeLists.txt create mode 100644 components/tpm/Kconfig create mode 100644 components/tpm/README.md create mode 100644 components/tpm/include/tpm.hpp create mode 100644 components/tpm/src/tpm.cpp create mode 100644 components/tpm/src/tpm_files.cpp create mode 100644 components/tpm/src/tpm_files.hpp create mode 100644 main/tpm_cart.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a0ee4ce..8bad883 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,8 @@ set(SNES_COMPONENTS snes) set(GBA_COMPONENTS gba) ### Metal Gear Solid (mgs_reversing decompilation on psyz, native) ### set(MGS_COMPONENTS mgs) +### Star Wars Episode I: The Phantom Menace (tpm-decomp on psyz, native) ### +set(TPM_COMPONENTS tpm) ### Dark Forces (The Force Engine port, shared with esp-box-emu) ### # set(DARK_FORCES_COMPONENTS "dark_forces") @@ -44,6 +46,9 @@ endif() if(MGS_COMPONENTS) add_compile_definitions(ENABLE_MGS) endif() +if(TPM_COMPONENTS) + add_compile_definitions(ENABLE_TPM) +endif() if(DARK_FORCES_COMPONENTS) add_compile_definitions(ENABLE_DARK_FORCES) @@ -55,6 +60,7 @@ set(GAME_COMPONENTS ${SNES_COMPONENTS} ${GBA_COMPONENTS} ${MGS_COMPONENTS} + ${TPM_COMPONENTS} ${DARK_FORCES_COMPONENTS} ) diff --git a/components/rom_info/include/rom_info.hpp b/components/rom_info/include/rom_info.hpp index bcc3901..4caad70 100644 --- a/components/rom_info/include/rom_info.hpp +++ b/components/rom_info/include/rom_info.hpp @@ -7,7 +7,7 @@ #include "format.hpp" -enum class Emulator { UNKNOWN, JEDI_KNIGHT, DARK_FORCES, SEGA_GENESIS, SEGA_MEGA_DRIVE, SNES, GBA, MGS }; +enum class Emulator { UNKNOWN, JEDI_KNIGHT, DARK_FORCES, SEGA_GENESIS, SEGA_MEGA_DRIVE, SNES, GBA, MGS, TPM }; struct RomInfo { std::string name; @@ -45,6 +45,8 @@ template <> struct fmt::formatter { return fmt::format_to(ctx.out(), "DARK_FORCES"); case Emulator::MGS: return fmt::format_to(ctx.out(), "MGS"); + case Emulator::TPM: + return fmt::format_to(ctx.out(), "TPM"); default: return fmt::format_to(ctx.out(), "UNKNOWN"); } diff --git a/components/rom_info/src/rom_info.cpp b/components/rom_info/src/rom_info.cpp index 5902e91..80230e2 100644 --- a/components/rom_info/src/rom_info.cpp +++ b/components/rom_info/src/rom_info.cpp @@ -74,6 +74,10 @@ std::vector parse_metadata(const std::string &metadata_path) { } else if (endsWith(lp, "stage.dir") || endsWith(lp, ".mgs")) { // metal gear solid #if defined(ENABLE_MGS) platform = Emulator::MGS; +#endif + } else if (endsWith(lp, "slus_008.84")) { // star wars episode i: the phantom menace +#if defined(ENABLE_TPM) + platform = Emulator::TPM; #endif } if (platform != Emulator::UNKNOWN) { diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt new file mode 100644 index 0000000..313035e --- /dev/null +++ b/components/tpm/CMakeLists.txt @@ -0,0 +1,16 @@ +# Star Wars Episode I: The Phantom Menace (PSX), natively: the tpm-decomp +# decompilation (https://github.com/esp-cpp/tpm-decomp, not public yet) on +# psyz, as components/mgs does for Metal Gear Solid. The game is not +# decompiled yet: CONFIG_TPM_ENGINE is off by default and the component then +# builds only the platform half (src/), which reads the disc files and shows +# the load screens. + +set(TPM_SRCS "src/tpm.cpp" "src/tpm_files.cpp") + +idf_component_register( + SRCS ${TPM_SRCS} + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" + REQUIRES tab5-emu + PRIV_REQUIRES esp_timer +) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig new file mode 100644 index 0000000..dff3af8 --- /dev/null +++ b/components/tpm/Kconfig @@ -0,0 +1,16 @@ +menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" + + config TPM_ENGINE + bool "Build the decompiled game (not runnable yet)" + default n + help + Compile the decompiled C from tpm-decomp against psyz. Off until + enough of the game is decompiled to run; with it off the cart + shows the game's load screens from the SD card (the data and + display path). + + config TPM_SLIDE_SECONDS + int "Seconds per load screen in the data viewer" + default 4 + +endmenu diff --git a/components/tpm/README.md b/components/tpm/README.md new file mode 100644 index 0000000..f861f57 --- /dev/null +++ b/components/tpm/README.md @@ -0,0 +1,39 @@ +# Star Wars Episode I: The Phantom Menace, natively (in progress) + +The PlayStation game (Big Ape Productions / LucasArts, 1999), USA Rev 1 +(`SLUS_008.84`), being decompiled in **tpm-decomp** so that it can run here +the way Metal Gear Solid does in `components/mgs`: the game's C compiled for +the ESP32-P4 against psyz. No decompilation existed; that repo is building +one (see its `docs/PLAN.md`). + +Until the game's code runs, this component is the platform half on its own: +it opens the disc files on the SD card, resolves names through the game's +own file table (read out of the executable, as the game does), and shows the +game's load screens. That is the data path the engine will use, end to end. + +## Game data + +Nothing derived from the game is in this repository. Pull the files out of +**your own** disc image with tpm-decomp's script and copy them to the card: + +```sh +python3 tpm-decomp/tools/extract_disc.py "Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin" tpm/ +``` + +which gives `SLUS_008.84`, `FILE.000` (35 MB, all the game's data), +`FILE.001`-`FILE.003` (streamed audio and movies, ~500 MB; not used yet) and +`ZANZIBAR.000` (padding; leave it out). Put them in `/sdcard/tpm/` and add a +metadata entry pointing at the executable: + +``` +tpm/SLUS_008.84,boxart/tpm.jpg,Star Wars Episode I: The Phantom Menace +``` + +Only the USA Rev 1 executable is accepted: the file table is read from a +fixed address in it, and the original USA release is a different link. + +## Status + +- The load-screen viewer: `LOGO.RGB` first, then every 4 s + (`CONFIG_TPM_SLIDE_SECONDS`) or on left / right. +- `CONFIG_TPM_ENGINE` (off) is reserved for the decompiled game. diff --git a/components/tpm/include/tpm.hpp b/components/tpm/include/tpm.hpp new file mode 100644 index 0000000..2143636 --- /dev/null +++ b/components/tpm/include/tpm.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +/// C++ interface of the Star Wars Episode I: The Phantom Menace core used by +/// TpmCart. +/// +/// The game is being decompiled in tpm-decomp (SLUS_008.84, USA Rev 1); until +/// enough of it is C to run, this core proves the platform half on its own: +/// it opens the disc files from the SD card, resolves names through the +/// game's own file table (read out of the executable, as the game does), and +/// shows the load screens. The API is shaped like mgs.hpp so the engine can +/// slot in behind it. +namespace tpm { + +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; +}; + +/// Open the data. Returns false if it is missing or not the USA Rev 1 disc. +bool init(const Config &config); +void deinit(); +bool reset(); +bool running(); +/// One step of the core; called from the cart's run loop. Returns false when +/// the core has stopped. +bool update(); +void pause(); +void resume(); +/// Size of the presented frame. +std::pair video_size(); +/// The last presented frame as RGB565, for screenshots. +std::span video_buffer_rgb565(); + +} // namespace tpm diff --git a/components/tpm/src/tpm.cpp b/components/tpm/src/tpm.cpp new file mode 100644 index 0000000..8cacabb --- /dev/null +++ b/components/tpm/src/tpm.cpp @@ -0,0 +1,164 @@ +#include "tpm.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "logger.hpp" +#include "tab5-emu.hpp" +#include "tpm_files.hpp" + +// Until the decompiled game runs, the core is a viewer for the game's load +// screens: the 44 .RGB files in FILE.000, 320x256 in the GPU's 24-bit mode. +// Every one is found through the executable's own file table, so this +// exercises the same name -> sector path the engine will use. + +namespace tpm { + +namespace { + +constexpr size_t SCREEN_W = 320; +constexpr size_t SCREEN_H = 256; // the .RGB images +constexpr size_t FRAME_H = 240; // what the HAL frame buffers hold +constexpr size_t RGB_BYTES = SCREEN_W * SCREEN_H * 3; + +espp::Logger logger_({.tag = "tpm", .level = espp::Logger::Verbosity::INFO}); + +GameFiles files_; +std::vector screens_; +uint8_t *rgb_{nullptr}; // one load screen, PSRAM +uint8_t *frames_[2]{}; // the HAL's RGB565 frame buffers +int frame_index_{0}; +std::atomic last_frame_{nullptr}; +size_t current_{0}; +int64_t shown_at_us_{0}; +bool running_{false}; +bool paused_{false}; +bool prev_left_{false}, prev_right_{false}; +Config config_; + +bool show(size_t index) { + const auto &entry = *screens_[index]; + if (files_.read(entry, 0, rgb_, RGB_BYTES) != RGB_BYTES) { + logger_.error("short read of {}", entry.name); + return false; + } + auto *dst = reinterpret_cast(frames_[frame_index_]); + // 256 lines into 240: drop 8 at the top and 8 at the bottom (the PSX shows + // ~240 of them on an NTSC set anyway) + const uint8_t *src = rgb_ + (SCREEN_H - FRAME_H) / 2 * SCREEN_W * 3; + for (size_t i = 0; i < SCREEN_W * FRAME_H; i++, src += 3) { + dst[i] = static_cast(((src[0] & 0xF8) << 8) | ((src[1] & 0xFC) << 3) | (src[2] >> 3)); + } + last_frame_ = frames_[frame_index_]; + Tab5Emu::get().push_frame(dst); + frame_index_ ^= 1; + shown_at_us_ = esp_timer_get_time(); + logger_.info("{} ({}/{})", entry.name, index + 1, screens_.size()); + return true; +} + +} // namespace + +bool init(const Config &config) { + config_ = config; + std::string error; + if (!files_.open(config.data_dir, error)) { + logger_.error("{}", error); + return false; + } + screens_.clear(); + for (const auto &e : files_.entries()) { + if (e.size == RGB_BYTES && e.name.size() > 4 && e.name.ends_with(".RGB")) { + screens_.push_back(&e); + } + } + logger_.info("{} files in the table, {} load screens", files_.entries().size(), screens_.size()); + if (screens_.empty()) { + files_.close(); + return false; + } + rgb_ = static_cast(heap_caps_malloc(RGB_BYTES, MALLOC_CAP_SPIRAM)); + auto &emu = Tab5Emu::get(); + frames_[0] = emu.frame_buffer0(); + frames_[1] = emu.frame_buffer1(); + if (!rgb_ || !frames_[0] || !frames_[1]) { + logger_.error("out of memory"); + deinit(); + return false; + } + emu.palette(nullptr, 0); // RGB565 frames + emu.native_size(SCREEN_W, FRAME_H); + // start on the logo the game itself opens with, when it is there + current_ = 0; + for (size_t i = 0; i < screens_.size(); i++) { + if (screens_[i]->name == "LOGO.RGB") { + current_ = i; + } + } + running_ = show(current_); + return running_; +} + +void deinit() { + running_ = false; + files_.close(); + screens_.clear(); + heap_caps_free(rgb_); + rgb_ = nullptr; + last_frame_ = nullptr; +} + +bool reset() { + deinit(); + return init(config_); +} + +bool running() { return running_; } + +bool update() { + if (!running_ || paused_) { + return running_; + } + const auto pad = Tab5Emu::get().gamepad_state(); + const bool left = pad.left, right = pad.right; + size_t next = current_; + if (right && !prev_right_) { + next = (current_ + 1) % screens_.size(); + } else if (left && !prev_left_) { + next = (current_ + screens_.size() - 1) % screens_.size(); + } else if (esp_timer_get_time() - shown_at_us_ > CONFIG_TPM_SLIDE_SECONDS * 1000000LL) { + next = (current_ + 1) % screens_.size(); + } + prev_left_ = left; + prev_right_ = right; + if (next != current_) { + current_ = next; + show(current_); + } + return running_; +} + +void pause() { paused_ = true; } + +void resume() { + paused_ = false; + shown_at_us_ = esp_timer_get_time(); + if (running_) { + Tab5Emu::get().native_size(SCREEN_W, FRAME_H); + show(current_); // the menu drew over the panel + } +} + +std::pair video_size() { return {SCREEN_W, FRAME_H}; } + +std::span video_buffer_rgb565() { + auto *f = last_frame_.load(); + return f ? std::span{f, SCREEN_W * FRAME_H * 2} : std::span{}; +} + +} // namespace tpm diff --git a/components/tpm/src/tpm_files.cpp b/components/tpm/src/tpm_files.cpp new file mode 100644 index 0000000..fcbc9bb --- /dev/null +++ b/components/tpm/src/tpm_files.cpp @@ -0,0 +1,119 @@ +#include "tpm_files.hpp" + +#include +#include +#include + +namespace tpm { + +namespace { + +constexpr size_t EXE_SIZE = 487424; // SLUS_008.84, USA Rev 1 +constexpr uint32_t EXE_TEXT = 0x80010000; +constexpr uint32_t EXE_HEADER = 0x800; +constexpr uint32_t FILE_TABLE = 0x800715A0; +constexpr size_t ENTRY_SIZE = 24; +constexpr size_t MAX_ENTRIES = 128; // 107 on the disc +constexpr uint32_t SECTOR = 2048; + +int bcd(uint8_t b) { return (b >> 4) * 10 + (b & 15); } + +bool iequals(std::string_view a, std::string_view b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { + return std::tolower(static_cast(x)) == + std::tolower(static_cast(y)); + }); +} + +} // namespace + +GameFiles::~GameFiles() { close(); } + +bool GameFiles::open(const std::string &dir, std::string &error) { + close(); + const std::string exe_path = dir + "/SLUS_008.84"; + FILE *exe = fopen(exe_path.c_str(), "rb"); + if (!exe) { + error = "missing " + exe_path; + return false; + } + fseek(exe, 0, SEEK_END); + const long exe_size = ftell(exe); + uint8_t magic[8] = {}; + fseek(exe, 0, SEEK_SET); + fread(magic, 1, sizeof(magic), exe); + if (exe_size != static_cast(EXE_SIZE) || memcmp(magic, "PS-X EXE", 8) != 0) { + fclose(exe); + error = exe_path + " is not the USA Rev 1 executable"; + return false; + } + + uint8_t table[MAX_ENTRIES * ENTRY_SIZE]; + fseek(exe, FILE_TABLE - EXE_TEXT + EXE_HEADER, SEEK_SET); + const size_t got = fread(table, 1, sizeof(table), exe); + fclose(exe); + + for (size_t i = 0; i + ENTRY_SIZE <= got; i += ENTRY_SIZE) { + const uint8_t *e = table + i; + const char *name = reinterpret_cast(e + 8); + const size_t len = strnlen(name, 16); + if (len == 0 || !std::all_of(name, name + len, [](char c) { return c > 32 && c < 127; })) { + break; + } + const int sector = (bcd(e[0]) * 60 + bcd(e[1])) * 75 + bcd(e[2]) - 150; + uint32_t size; + memcpy(&size, e + 4, sizeof(size)); + entries_.push_back({std::string(name, len), static_cast(sector), size}); + } + // the first record is the Assault level's sound bank on this disc + if (entries_.size() < 100 || entries_.front().name != "ASAULT.PCV") { + entries_.clear(); + error = "no file table in " + exe_path + " (not USA Rev 1?)"; + return false; + } + + const std::string data_path = dir + "/FILE.000"; + data_ = fopen(data_path.c_str(), "rb"); + if (!data_) { + entries_.clear(); + error = "missing " + data_path; + return false; + } + return true; +} + +void GameFiles::close() { + if (data_) { + fclose(data_); + data_ = nullptr; + } + entries_.clear(); +} + +const GameFiles::Entry *GameFiles::find(std::string_view name) const { + // accept a full game path: keep what follows the last separator + const auto slash = name.find_last_of("\\/"); + if (slash != std::string_view::npos) { + name.remove_prefix(slash + 1); + } + for (const auto &e : entries_) { + if (iequals(e.name, name)) { + return &e; + } + } + return nullptr; +} + +size_t GameFiles::read(const Entry &entry, uint32_t offset, void *dst, size_t size) { + if (!data_ || offset >= entry.size) { + return 0; + } + size = std::min(size, entry.size - offset); + if (fseek(data_, static_cast(entry.sector) * SECTOR + offset, SEEK_SET) != 0) { + return 0; + } + return fread(dst, 1, size, data_); +} + +} // namespace tpm diff --git a/components/tpm/src/tpm_files.hpp b/components/tpm/src/tpm_files.hpp new file mode 100644 index 0000000..d2356fd --- /dev/null +++ b/components/tpm/src/tpm_files.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace tpm { + +/// The game's file system: a name table inside SLUS_008.84 pointing into +/// FILE.000. The executable asks for PC-style paths +/// ("c:\zanzibar\data\levels\%s.LVL"); on the disc only the base name is +/// kept, so lookups here are by base name, case-insensitive. +/// +/// struct FileEntry { u8 msf[3]; u8 pad; u32 size; char name[16]; }; +/// +/// at 0x800715A0 in the USA Rev 1 executable. msf is a BCD position inside +/// FILE.000 with the 2-second lead-in (00:02:00 = its first sector). +class GameFiles { +public: + struct Entry { + std::string name; + uint32_t sector; + uint32_t size; + }; + + ~GameFiles(); + + /// Read the table out of `/SLUS_008.84` and open `/FILE.000`. + /// Fails (with a reason) on anything but the USA Rev 1 executable. + bool open(const std::string &dir, std::string &error); + void close(); + + const std::vector &entries() const { return entries_; } + const Entry *find(std::string_view name) const; + /// Read `size` bytes at `offset` of a file into `dst`. Returns bytes read. + size_t read(const Entry &entry, uint32_t offset, void *dst, size_t size); + +private: + std::vector entries_; + FILE *data_{nullptr}; +}; + +} // namespace tpm diff --git a/docs/PLAN.md b/docs/PLAN.md index bc333b0..9291f49 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -263,3 +263,31 @@ Phases: game saves, full-motion video (MDEC). The two submodules are esp-cpp forks (`esp32p4` branches). + +## Star Wars Episode I: The Phantom Menace (2026-10-11) + +Same shape as the MGS port (decompiled C on psyz, natively), but there is no +decompilation to start from: making one is the job. It lives in its own repo, +`tpm-decomp` (local: `~/esp-cpp/tpm-decomp`; not on GitHub yet), target +`SLUS_008.84`, the USA **Rev 1** disc. Its `docs/LAYOUT.md` has what is known +about the executable and the disc, `docs/PLAN.md` the road map. + +Where it stands: + +1. **Decomp ground** (done): splat split, byte-identical rebuild from + `INCLUDE_ASM` stubs through PSY-Q 4.3's GCC 2.8.1 + maspsx; ~105 PSY-Q + library objects named by signature; 823 game functions waiting. +2. **Data path on the Tab5** (written, built, not run on hardware): + `components/tpm` reads `SLUS_008.84` + `FILE.000` from the card, resolves + names through the game's own file table, and shows the 44 load screens + (320x256 24-bit, cropped to 240 rows). Left / right steps through them. +3. **Next**: name the engine's top level (main loop, task manager, file + layer, level loader), decompile the file layer first (its bottom half + becomes SD reads), then the renderer and the 16 hand-written GTE routines + (to C on psyz's GTE), then gameplay level by level. + +The game C will join this component behind `CONFIG_TPM_ENGINE` (off), as a +submodule like `components/mgs/mgs_reversing`, once the repo is published. +Everything the MGS port learned applies: no hard-coded PSX / S3 address +ranges, game statics in PSRAM via a linker fragment, the game task at the +mts priority, the two-core rasteriser. diff --git a/main/carts.hpp b/main/carts.hpp index 40c26f7..9980b77 100644 --- a/main/carts.hpp +++ b/main/carts.hpp @@ -7,6 +7,7 @@ #include "jk_cart.hpp" #include "mgs_cart.hpp" #include "snes_cart.hpp" +#include "tpm_cart.hpp" inline std::unique_ptr make_cart(const RomInfo &info) { #if defined(ENABLE_MGS) @@ -21,6 +22,9 @@ inline std::unique_ptr make_cart(const RomInfo &info) { case Emulator::MGS: return std::make_unique(Cart::Config{ .info = info, .copy_romdata = false, .verbosity = espp::Logger::Verbosity::INFO}); + case Emulator::TPM: + return std::make_unique(Cart::Config{ + .info = info, .copy_romdata = false, .verbosity = espp::Logger::Verbosity::INFO}); case Emulator::GBA: return std::make_unique(Cart::Config{ .info = info, .copy_romdata = false, .verbosity = espp::Logger::Verbosity::INFO}); diff --git a/main/tpm_cart.hpp b/main/tpm_cart.hpp new file mode 100644 index 0000000..0040491 --- /dev/null +++ b/main/tpm_cart.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "cart.hpp" +#if defined(ENABLE_TPM) +#include "tpm.hpp" +#endif + +/// Cart for Star Wars Episode I: The Phantom Menace (PSX), native. +/// +/// The "rom" is the directory of disc files from tpm-decomp's +/// tools/extract_disc.py (SLUS_008.84, FILE.000 .. FILE.003): the metadata +/// entry points at `/SLUS_008.84`. Until the decompilation runs, the core +/// shows the game's load screens from that data (left / right to step). +class TpmCart : public Cart { +public: + explicit TpmCart(const Cart::Config &config) + : Cart(config) { + 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})) { + logger_.error("could not start The Phantom Menace"); + running_ = false; + } +#endif + } + + ~TpmCart() override { +#if defined(ENABLE_TPM) + tpm::deinit(); +#endif + } + + void reset() override { + Cart::reset(); +#if defined(ENABLE_TPM) + if (!tpm::reset()) { + running_ = false; + } +#endif + } + + bool run() override { +#if defined(ENABLE_TPM) + if (!tpm::update()) { + running_ = false; + return false; + } +#endif + using namespace std::chrono_literals; + std::this_thread::sleep_for(16ms); + return Cart::run(); + } + +protected: + void pre_menu() override { + Cart::pre_menu(); +#if defined(ENABLE_TPM) + tpm::pause(); +#endif + } + + void post_menu() override { + Cart::post_menu(); +#if defined(ENABLE_TPM) + tpm::resume(); +#endif + } + + static constexpr size_t TPM_WIDTH = 320; + static constexpr size_t TPM_HEIGHT = 240; + + void set_original_video_setting() override { display_original(TPM_WIDTH, TPM_HEIGHT); } + void set_fit_video_setting() override { display_fit(TPM_WIDTH, TPM_HEIGHT); } + void set_fill_video_setting() override { display_fill(); } + std::pair get_video_size() const override { return {TPM_WIDTH, TPM_HEIGHT}; } + std::span get_video_buffer() const override { +#if defined(ENABLE_TPM) + return tpm::video_buffer_rgb565(); +#else + return {}; +#endif + } + + std::string get_save_extension() const override { return "_tpm.sav"; } +}; From fa97516e44c29908651d191f3760dac1e1a05779 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 11 Oct 2026 14:47:49 -0500 Subject: [PATCH 02/29] tpm: add tpm-decomp as a submodule The decompilation is public now (esp-cpp/tpm-decomp, tracking main), so point the component's docs at it and vendor it as components/mgs does with mgs_reversing. Nothing compiles from it yet (CONFIG_TPM_ENGINE stays off). Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitmodules | 4 ++++ components/tpm/CMakeLists.txt | 2 +- components/tpm/README.md | 5 +++-- components/tpm/tpm-decomp | 1 + docs/PLAN.md | 3 ++- 5 files changed, 11 insertions(+), 4 deletions(-) create mode 160000 components/tpm/tpm-decomp diff --git a/.gitmodules b/.gitmodules index 6e9e01b..5c4118c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,3 +9,7 @@ path = components/mgs/psyz url = https://github.com/esp-cpp/psyz.git branch = esp32p4 +[submodule "components/tpm/tpm-decomp"] + path = components/tpm/tpm-decomp + url = https://github.com/esp-cpp/tpm-decomp.git + branch = main diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 313035e..9c7f001 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -1,5 +1,5 @@ # Star Wars Episode I: The Phantom Menace (PSX), natively: the tpm-decomp -# decompilation (https://github.com/esp-cpp/tpm-decomp, not public yet) on +# decompilation (https://github.com/esp-cpp/tpm-decomp, submodule tpm-decomp/) on # psyz, as components/mgs does for Metal Gear Solid. The game is not # decompiled yet: CONFIG_TPM_ENGINE is off by default and the component then # builds only the platform half (src/), which reads the disc files and shows diff --git a/components/tpm/README.md b/components/tpm/README.md index f861f57..16be645 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -1,7 +1,8 @@ # Star Wars Episode I: The Phantom Menace, natively (in progress) The PlayStation game (Big Ape Productions / LucasArts, 1999), USA Rev 1 -(`SLUS_008.84`), being decompiled in **tpm-decomp** so that it can run here +(`SLUS_008.84`), being decompiled in [tpm-decomp](https://github.com/esp-cpp/tpm-decomp) +(submodule `tpm-decomp/`) so that it can run here the way Metal Gear Solid does in `components/mgs`: the game's C compiled for the ESP32-P4 against psyz. No decompilation existed; that repo is building one (see its `docs/PLAN.md`). @@ -17,7 +18,7 @@ Nothing derived from the game is in this repository. Pull the files out of **your own** disc image with tpm-decomp's script and copy them to the card: ```sh -python3 tpm-decomp/tools/extract_disc.py "Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin" tpm/ +python3 components/tpm/tpm-decomp/tools/extract_disc.py "Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin" tpm/ ``` which gives `SLUS_008.84`, `FILE.000` (35 MB, all the game's data), diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp new file mode 160000 index 0000000..106e3b2 --- /dev/null +++ b/components/tpm/tpm-decomp @@ -0,0 +1 @@ +Subproject commit 106e3b2aba80ae1bd19f827a7a64a49307ee0bfd diff --git a/docs/PLAN.md b/docs/PLAN.md index 9291f49..c253cc3 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -268,7 +268,8 @@ The two submodules are esp-cpp forks (`esp32p4` branches). Same shape as the MGS port (decompiled C on psyz, natively), but there is no decompilation to start from: making one is the job. It lives in its own repo, -`tpm-decomp` (local: `~/esp-cpp/tpm-decomp`; not on GitHub yet), target +[tpm-decomp](https://github.com/esp-cpp/tpm-decomp) (submodule +`components/tpm/tpm-decomp`), target `SLUS_008.84`, the USA **Rev 1** disc. Its `docs/LAYOUT.md` has what is known about the executable and the disc, `docs/PLAN.md` the road map. From 87339dc2462101064092ce5e7858108d799b5d95 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sun, 11 Oct 2026 17:50:07 -0500 Subject: [PATCH 03/29] tpm: run the game, statically recompiled CONFIG_TPM_ENGINE (now on by default) recompiles the user's SLUS_008.84 to C at configure time with tpm-decomp's tools/recomp.py and builds it with the tpm-decomp runtime on psyz's rasterizer, GTE and SPU (the copy in components/mgs). The whole lot is linked into one relocatable object with only the runtime's API left global, since components/mgs links psyz under the same names. src/tpm_engine.cpp is the platform: the disc is the .bin on the card (the executable is read out of its ISO9660 directory), the game runs on core 0 in real time, vblanks hand the display area to a core-1 conversion task, the SPU's samples go to the HAL through a ring and a core-1 task, the HAL's gamepad fills the pad buffers. Without the executable at build time or a .bin on the card, the cart falls back to the load-screen viewer. The app partition grows to 12 MB (image: 7.9 MB). Built; not yet run on the Tab5. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 105 +++++++- components/tpm/Kconfig | 22 +- components/tpm/README.md | 74 +++-- components/tpm/src/tpm.cpp | 54 +++- components/tpm/src/tpm_engine.cpp | 434 ++++++++++++++++++++++++++++++ components/tpm/src/tpm_engine.hpp | 20 ++ components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 7 + docs/PLAN.md | 31 ++- main/tpm_cart.hpp | 8 +- partitions.csv | 2 +- 11 files changed, 695 insertions(+), 64 deletions(-) create mode 100644 components/tpm/src/tpm_engine.cpp create mode 100644 components/tpm/src/tpm_engine.hpp create mode 100644 components/tpm/tpm_rt.keep diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 9c7f001..6d0dad3 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -1,16 +1,105 @@ -# Star Wars Episode I: The Phantom Menace (PSX), natively: the tpm-decomp -# decompilation (https://github.com/esp-cpp/tpm-decomp, submodule tpm-decomp/) on -# psyz, as components/mgs does for Metal Gear Solid. The game is not -# decompiled yet: CONFIG_TPM_ENGINE is off by default and the component then -# builds only the platform half (src/), which reads the disc files and shows -# the load screens. +# Star Wars Episode I: The Phantom Menace (PSX), natively: tpm-decomp +# (https://github.com/esp-cpp/tpm-decomp, submodule tpm-decomp/). +# +# With CONFIG_TPM_ENGINE, the game's executable is statically recompiled to C +# at configure time (tpm-decomp/tools/recomp.py) and built with tpm-decomp's +# runtime (BIOS, interrupts, DMA, CD-ROM, GPU ports) on psyz's software GPU, +# GTE and SPU. The executable is the user's own: CONFIG_TPM_EXE_PATH. Without +# it the component builds only the platform half (src/), which shows the +# game's load screens from the SD card. + +set(TPM_DECOMP ${CMAKE_CURRENT_LIST_DIR}/tpm-decomp) +# psyz: the copy components/mgs already carries (same revision tpm-decomp pins) +set(TPM_PSYZ ${CMAKE_CURRENT_LIST_DIR}/../mgs/psyz) set(TPM_SRCS "src/tpm.cpp" "src/tpm_files.cpp") +set(TPM_ENGINE_READY OFF) +set(TPM_GEN ${CMAKE_CURRENT_BINARY_DIR}/recomp) + +if(CONFIG_TPM_ENGINE) + set(exe "${CONFIG_TPM_EXE_PATH}") + if(NOT IS_ABSOLUTE "${exe}") + set(exe "${CMAKE_SOURCE_DIR}/${exe}") + endif() + if(NOT EXISTS "${exe}") + message(WARNING "tpm: ${exe} not found (CONFIG_TPM_EXE_PATH): building without the game; " + "the cart will show the load screens only") + elseif(NOT EXISTS "${TPM_PSYZ}/psyz/src/platform/soft_raster.inc.c") + message(WARNING "tpm: psyz missing at ${TPM_PSYZ} (git submodule update --init components/mgs/psyz)") + else() + idf_build_get_property(python PYTHON) + if(NOT python) + set(python python3) + endif() + # Re-run when the executable or the recompiler changes + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${exe}" "${TPM_DECOMP}/tools/recomp.py" "${TPM_DECOMP}/config/recomp_entries.us.txt" + "${TPM_DECOMP}/recomp/hle.us.txt") + execute_process( + COMMAND ${python} ${TPM_DECOMP}/tools/recomp.py "${exe}" ${TPM_GEN} --files 16 + RESULT_VARIABLE rc OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(rc EQUAL 0) + set(TPM_ENGINE_READY ON) + string(REGEX MATCH "recomp: [0-9]+ functions[^\n]*" summary "${err}") + message(STATUS "tpm: ${summary}") + else() + message(WARNING "tpm: recompiling ${exe} failed:\n${err}") + endif() + endif() +endif() + +if(TPM_ENGINE_READY) + list(APPEND TPM_SRCS "src/tpm_engine.cpp") +endif() idf_component_register( SRCS ${TPM_SRCS} INCLUDE_DIRS "include" - PRIV_INCLUDE_DIRS "src" + PRIV_INCLUDE_DIRS "src" "${TPM_DECOMP}/recomp/runtime" REQUIRES tab5-emu - PRIV_REQUIRES esp_timer + PRIV_REQUIRES esp_timer heap statistics ) + +if(TPM_ENGINE_READY) + target_compile_definitions(${COMPONENT_LIB} PRIVATE TPM_HAVE_ENGINE) + + # ---- the game + runtime + the psyz pieces it uses, as one library ---- + 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/libgte.c ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.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 + ${rt} ${TPM_GEN} ${TPM_PSYZ}/psyz/include ${TPM_PSYZ}/psyz/src + ${TPM_PSYZ}/decomp/src/libspu ${config_dir}) + # IDF's own flags (the arch flags come with the toolchain file) + idf_build_get_property(idf_opts COMPILE_OPTIONS GENERATOR_EXPRESSION) + idf_build_get_property(idf_c_opts C_COMPILE_OPTIONS GENERATOR_EXPRESSION) + idf_build_get_property(idf_defs COMPILE_DEFINITIONS GENERATOR_EXPRESSION) + target_compile_options(tpm_rt PRIVATE ${idf_opts} ${idf_c_opts}) + target_compile_definitions(tpm_rt PRIVATE ${idf_defs} ESP_PLATFORM) + target_link_libraries(tpm_rt PRIVATE idf::heap idf::esp_hw_support idf::esp_common idf::esp_rom + idf::freertos idf::log) + # -O2 the game's code: speed over the IDF's size setting + # -fno-strict-aliasing guest memory is read through any type + # -msmall-data-limit=0 nothing in PSRAM may be addressed gp-relative + target_compile_options(tpm_rt PRIVATE -O2 -w -fno-strict-aliasing -msmall-data-limit=0 + -Wno-error) + # the rasterizer's inner loops decide the frame rate + set_property(SOURCE ${rt}/rt_gpu.c APPEND PROPERTY COMPILE_OPTIONS "-O3;-fomit-frame-pointer") + + # ---- hide it: components/mgs links its own psyz, with the same names ---- + # One relocatable object with only the runtime's API left global. + set(bundle ${CMAKE_CURRENT_BINARY_DIR}/tpm_rt_bundle.o) + add_custom_command(OUTPUT ${bundle} + COMMAND ${CMAKE_C_COMPILER} -nostdlib -r -Wl,--whole-archive $ + -Wl,--no-whole-archive -o ${bundle}.r.o + COMMAND ${CMAKE_OBJCOPY} --keep-global-symbols=${CMAKE_CURRENT_LIST_DIR}/tpm_rt.keep + ${bundle}.r.o ${bundle} + DEPENDS tpm_rt ${CMAKE_CURRENT_LIST_DIR}/tpm_rt.keep + VERBATIM) + add_custom_target(tpm_rt_bundle DEPENDS ${bundle}) + add_dependencies(${COMPONENT_LIB} tpm_rt_bundle) + target_link_libraries(${COMPONENT_LIB} PRIVATE ${bundle}) +endif() diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index dff3af8..2d45124 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -1,13 +1,23 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" config TPM_ENGINE - bool "Build the decompiled game (not runnable yet)" - default n + bool "Build the game (static recompilation of your SLUS_008.84)" + default y help - Compile the decompiled C from tpm-decomp against psyz. Off until - enough of the game is decompiled to run; with it off the cart - shows the game's load screens from the SD card (the data and - display path). + Recompile the game's executable to C at configure time + (tpm-decomp/tools/recomp.py) and build it with tpm-decomp's + runtime on psyz. Needs the USA Rev 1 executable at + TPM_EXE_PATH; without it the build carries on and the cart shows + the game's load screens (the data and display path) instead. + + config TPM_EXE_PATH + string "Path of SLUS_008.84 (USA Rev 1), relative to the project" + default "components/tpm/tpm-decomp/disks/us/SLUS_008.84" + depends on TPM_ENGINE + help + Your own copy of the game's executable, e.g. extracted with + tpm-decomp/tools/extract_disc.py. It is only read at build time; + nothing derived from it is committed. config TPM_SLIDE_SECONDS int "Seconds per load screen in the data viewer" diff --git a/components/tpm/README.md b/components/tpm/README.md index 16be645..45740d6 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -1,40 +1,64 @@ -# Star Wars Episode I: The Phantom Menace, natively (in progress) +# Star Wars Episode I: The Phantom Menace, natively The PlayStation game (Big Ape Productions / LucasArts, 1999), USA Rev 1 -(`SLUS_008.84`), being decompiled in [tpm-decomp](https://github.com/esp-cpp/tpm-decomp) -(submodule `tpm-decomp/`) so that it can run here -the way Metal Gear Solid does in `components/mgs`: the game's C compiled for -the ESP32-P4 against psyz. No decompilation existed; that repo is building -one (see its `docs/PLAN.md`). +(`SLUS_008.84`), running natively on the ESP32-P4 through +[tpm-decomp](https://github.com/esp-cpp/tpm-decomp) (submodule `tpm-decomp/`): +its static recompiler turns the game's executable into C at build time, and +its runtime is the PlayStation around that C (BIOS, interrupts, DMA, CD-ROM, +GPU ports) on psyz's software rasterizer, GTE and SPU, the same pieces the +Metal Gear Solid port in `components/mgs` uses. The decompilation in that +repo continues alongside. -Until the game's code runs, this component is the platform half on its own: -it opens the disc files on the SD card, resolves names through the game's -own file table (read out of the executable, as the game does), and shows the -game's load screens. That is the data path the engine will use, end to end. +## Building -## Game data - -Nothing derived from the game is in this repository. Pull the files out of -**your own** disc image with tpm-decomp's script and copy them to the card: +The recompiler needs **your own** copy of the executable at build time +(nothing derived from it is committed). Extract it from your disc image and +put it where `CONFIG_TPM_EXE_PATH` points (the default): ```sh -python3 components/tpm/tpm-decomp/tools/extract_disc.py "Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin" tpm/ +python3 components/tpm/tpm-decomp/tools/extract_disc.py ".bin" /tmp/tpm +cp /tmp/tpm/SLUS_008.84 components/tpm/tpm-decomp/disks/us/ +idf.py build ``` -which gives `SLUS_008.84`, `FILE.000` (35 MB, all the game's data), -`FILE.001`-`FILE.003` (streamed audio and movies, ~500 MB; not used yet) and -`ZANZIBAR.000` (padding; leave it out). Put them in `/sdcard/tpm/` and add a -metadata entry pointing at the executable: +CMake runs `tools/recomp.py` when it configures (and again whenever the +executable or the recompiler changes); the C lands in the build directory. +Without the executable the build still succeeds, with a warning, and the +cart falls back to the load-screen viewer below. + +`components/mgs` links its own psyz under the same names, so the game, the +runtime and their psyz pieces are linked into one relocatable object and +everything but the runtime's API (`tpm_rt.keep`) is made local. + +## On the SD card + +The disc image: the `.bin` of a bin/cue rip (raw 2352-byte sectors, one +track). The executable is read out of it, so nothing else is needed: + +``` +/sdcard/tpm/Star Wars - Episode I - The Phantom Menace (USA) (Rev 1).bin +``` + +and a metadata entry (the path names the cart; the file itself may be absent): ``` tpm/SLUS_008.84,boxart/tpm.jpg,Star Wars Episode I: The Phantom Menace ``` -Only the USA Rev 1 executable is accepted: the file table is read from a -fixed address in it, and the original USA release is a different link. +Only USA Rev 1 works: the recompiled code is that executable. + +## Running -## Status +- Threads: the game on core 0 (32 KB internal stack), VRAM -> RGB565 + conversion and the SPU's audio on core 1. Time is real time: vblanks, CD + sectors and audio samples follow the clock. +- Pads: the HAL's merged gamepad; A = cross, B = circle, X = square, + Y = triangle, L/R = L1/R1. +- Movies are skipped (no MDEC yet), XA speech / music is silent (not decoded + yet), there is no memory card (the game says so and carries on). +- The pause menu pauses the game at its next loop back-edge. -- The load-screen viewer: `LOGO.RGB` first, then every 4 s - (`CONFIG_TPM_SLIDE_SECONDS`) or on left / right. -- `CONFIG_TPM_ENGINE` (off) is reserved for the decompiled game. +Without the engine (or without a `.bin` on the card) the cart shows the +game's 44 load screens from extracted disc files (`FILE.000` and +`SLUS_008.84` in `/sdcard/tpm/`), every `CONFIG_TPM_SLIDE_SECONDS` or on +left / right. diff --git a/components/tpm/src/tpm.cpp b/components/tpm/src/tpm.cpp index 8cacabb..a49ec5d 100644 --- a/components/tpm/src/tpm.cpp +++ b/components/tpm/src/tpm.cpp @@ -11,11 +11,15 @@ #include "logger.hpp" #include "tab5-emu.hpp" #include "tpm_files.hpp" +#if defined(TPM_HAVE_ENGINE) +#include "tpm_engine.hpp" +#endif -// Until the decompiled game runs, the core is a viewer for the game's load -// screens: the 44 .RGB files in FILE.000, 320x256 in the GPU's 24-bit mode. -// Every one is found through the executable's own file table, so this -// exercises the same name -> sector path the engine will use. +// With the engine built (CONFIG_TPM_ENGINE and an executable to recompile at +// configure time) and a disc image on the card, the game runs (tpm_engine). +// Otherwise the core is a viewer for the game's load screens: the 44 .RGB +// files in FILE.000, 320x256 in the GPU's 24-bit mode, each found through the +// executable's own file table. namespace tpm { @@ -41,6 +45,8 @@ bool paused_{false}; bool prev_left_{false}, prev_right_{false}; Config config_; +bool engine_{false}; // the game is running instead of the viewer + bool show(size_t index) { const auto &entry = *screens_[index]; if (files_.read(entry, 0, rgb_, RGB_BYTES) != RGB_BYTES) { @@ -66,6 +72,14 @@ bool show(size_t index) { bool init(const Config &config) { config_ = config; +#if defined(TPM_HAVE_ENGINE) + if (engine::init(config.data_dir)) { + engine_ = true; + running_ = true; + return true; + } + logger_.warn("the game cannot start; showing the load screens instead"); +#endif std::string error; if (!files_.open(config.data_dir, error)) { logger_.error("{}", error); @@ -105,6 +119,12 @@ bool init(const Config &config) { } void deinit() { +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + engine::deinit(); + engine_ = false; + } +#endif running_ = false; files_.close(); screens_.clear(); @@ -121,6 +141,12 @@ bool reset() { bool running() { return running_; } bool update() { +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + running_ = engine::running(); + return running_; + } +#endif if (!running_ || paused_) { return running_; } @@ -143,10 +169,23 @@ bool update() { return running_; } -void pause() { paused_ = true; } +void pause() { + paused_ = true; +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + engine::pause(); + } +#endif +} void resume() { paused_ = false; +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + engine::resume(); + return; + } +#endif shown_at_us_ = esp_timer_get_time(); if (running_) { Tab5Emu::get().native_size(SCREEN_W, FRAME_H); @@ -157,6 +196,11 @@ void resume() { std::pair video_size() { return {SCREEN_W, FRAME_H}; } std::span video_buffer_rgb565() { +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + return engine::last_frame(); + } +#endif auto *f = last_frame_.load(); return f ? std::span{f, SCREEN_W * FRAME_H * 2} : std::span{}; } diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp new file mode 100644 index 0000000..2c51e20 --- /dev/null +++ b/components/tpm/src/tpm_engine.cpp @@ -0,0 +1,434 @@ +// The game itself: tpm-decomp's static recompilation of SLUS_008.84 (built +// from the user's own executable at configure time, see CMakeLists.txt) and +// its runtime, on the Tab5. +// +// Threads: +// tpm_game (core 0) the recompiled code; the runtime's hardware model runs +// inline at its loop back-edges (vblank, CD, SPU, interrupts) +// tpm_video (core 1) converts the PSX display area of VRAM to RGB565 and +// hands it to the HAL, woken by the game's vblanks +// tpm_audio (core 1) moves the SPU's samples to the HAL's audio queue +// +// The disc is a raw 2352-byte-sector image on the card (the .bin of a +// bin/cue rip); the executable comes from the card if it is there as a file, +// otherwise out of the image's ISO9660 directory. + +#include "tpm_engine.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "logger.hpp" +#include "statistics.hpp" +#include "tab5-emu.hpp" + +extern "C" { +#include "rt_platform.h" +} + +namespace tpm::engine { + +namespace { + +espp::Logger logger({.tag = "tpm", .level = espp::Logger::Verbosity::INFO}); + +constexpr size_t FRAME_W = 320; +constexpr size_t FRAME_H = 240; +constexpr uint32_t AUDIO_RATE = 44100; + +FILE *g_disc{nullptr}; +uint32_t g_disc_sectors{0}; +std::vector g_exe; + +TaskHandle_t g_game_task{nullptr}; +TaskHandle_t g_video_task{nullptr}; +TaskHandle_t g_audio_task{nullptr}; +std::atomic g_game_done{false}; +std::atomic g_stop{false}; +std::atomic g_paused{false}; +SemaphoreHandle_t g_resume{nullptr}; + +uint8_t *g_frames[2]{}; +int g_frame_index{0}; +std::atomic g_last_frame{nullptr}; +std::atomic g_video_busy{false}; +RtDisplay g_display{}; + +// SPU output: the game's thread writes, the audio task reads +constexpr size_t RING_FRAMES = 8192; // ~186 ms +int16_t *g_ring{nullptr}; +std::atomic g_ring_w{0}, g_ring_r{0}; + +// ---- the disc ---- + +bool read_sector(uint32_t lba, uint8_t *out) { + if (!g_disc || lba >= g_disc_sectors) { + return false; + } + if (fseek(g_disc, static_cast(lba) * 2352, SEEK_SET) != 0) { + return false; + } + return fread(out, 1, 2352, g_disc) == 2352; +} + +// SLUS_008.84 out of the image's root directory (ISO9660, Mode 2 Form 1) +bool exe_from_disc(std::vector &exe) { + std::vector sec(2352); + if (!read_sector(16, sec.data()) || memcmp(sec.data() + 24 + 1, "CD001", 5) != 0) { + return false; + } + const uint8_t *root = sec.data() + 24 + 156; + uint32_t lba = root[2] | (root[3] << 8) | (root[4] << 16) | (root[5] << 24); + uint32_t size = root[10] | (root[11] << 8) | (root[12] << 16) | (root[13] << 24); + for (uint32_t off = 0; off < size; off += 2048) { + if (!read_sector(lba + off / 2048, sec.data())) { + return false; + } + const uint8_t *d = sec.data() + 24; + for (size_t p = 0; p < 2048 && d[p];) { + const uint8_t len = d[p]; + const uint8_t name_len = d[p + 32]; + const std::string name(reinterpret_cast(d + p + 33), name_len); + if (name.rfind("SLUS_008.84", 0) == 0) { + const uint32_t f_lba = d[p + 2] | (d[p + 3] << 8) | (d[p + 4] << 16) | (d[p + 5] << 24); + const uint32_t f_size = d[p + 10] | (d[p + 11] << 8) | (d[p + 12] << 16) | (d[p + 13] << 24); + exe.resize(f_size); + for (uint32_t o = 0; o < f_size; o += 2048) { + if (!read_sector(f_lba + o / 2048, sec.data())) { + return false; + } + memcpy(exe.data() + o, sec.data() + 24, std::min(2048, f_size - o)); + } + return true; + } + p += len; + } + } + return false; +} + +// ---- tasks ---- + +void video_task(void *) { + while (!g_stop.load()) { + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)) == 0) { + continue; + } + const RtDisplay d = g_display; + auto *dst = reinterpret_cast(g_frames[g_frame_index]); + if (!d.enabled) { + memset(dst, 0, FRAME_W * FRAME_H * 2); + } else { + // centre narrower modes, sample wider ones; 480-line modes show every + // other line + const int w = d.w, h = d.h; + const int step_y = h > static_cast(FRAME_H) ? 2 : 1; + const int x_pad = w < static_cast(FRAME_W) ? (FRAME_W - w) / 2 : 0; + for (size_t y = 0; y < FRAME_H; y++) { + uint16_t *out = dst + y * FRAME_W; + const int vy = (d.y + static_cast(y) * step_y) & 511; + if (static_cast(y) * step_y >= h) { + memset(out, 0, FRAME_W * 2); + continue; + } + if (x_pad) { + memset(out, 0, x_pad * 2); + memset(out + FRAME_W - x_pad, 0, x_pad * 2); + } + const size_t n = std::min(w, FRAME_W); + if (d.rgb24) { + const auto *row = reinterpret_cast(&g_rt_vram[vy * 1024]); + for (size_t x = 0; x < n; x++) { + const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; + const uint8_t *p = row + ((d.x * 2 + sx * 3) & 2047); + out[x_pad + x] = static_cast(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); + } + } else { + const uint16_t *row = &g_rt_vram[vy * 1024]; + for (size_t x = 0; x < n; x++) { + const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; + const uint16_t v = row[(d.x + sx) & 1023]; + const uint16_t r = v & 0x1F, g = (v >> 5) & 0x1F, b = (v >> 10) & 0x1F; + out[x_pad + x] = static_cast((r << 11) | (((g << 1) | (g >> 4)) << 5) | b); + } + } + } + } + g_last_frame = g_frames[g_frame_index]; + Tab5Emu::get().push_frame(dst); + g_frame_index ^= 1; + static int64_t last_us = 0; + const int64_t now = esp_timer_get_time(); + if (last_us) { + update_frame_time(static_cast(now - last_us)); + } + last_us = now; + g_video_busy = false; + } + vTaskDelete(nullptr); +} + +void audio_task(void *) { + constexpr size_t CHUNK = 512; + static int16_t buf[CHUNK * 2]; + auto &emu = Tab5Emu::get(); + while (!g_stop.load()) { + const uint32_t r = g_ring_r.load(), w = g_ring_w.load(std::memory_order_acquire); + const uint32_t avail = w - r; + if (avail < CHUNK || g_paused.load()) { + vTaskDelay(pdMS_TO_TICKS(4)); + continue; + } + for (size_t i = 0; i < CHUNK; i++) { + const size_t k = (r + i) % RING_FRAMES; + buf[2 * i] = g_ring[2 * k]; + buf[2 * i + 1] = g_ring[2 * k + 1]; + } + g_ring_r.store(r + CHUNK, std::memory_order_release); + emu.play_audio(reinterpret_cast(buf), sizeof buf); + } + vTaskDelete(nullptr); +} + +void game_task(void *) { + logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); + rt_set_realtime(1); + rt_boot(g_exe.data(), static_cast(g_exe.size())); + logger.info("game stopped"); + g_game_done = true; + vTaskSuspend(nullptr); +} + +std::string find_disc(const std::string &dir) { + std::error_code ec; + std::string best; + uintmax_t best_size = 0; + for (const auto &e : std::filesystem::directory_iterator(dir, ec)) { + auto ext = e.path().extension().string(); + for (auto &c : ext) { + c = static_cast(tolower(c)); + } + if (ext != ".bin") { + continue; + } + const auto size = e.file_size(ec); + if (!ec && size > best_size) { + best = e.path().string(); + best_size = size; + } + } + return best; +} + +} // namespace + +bool init(const std::string &data_dir) { + const auto disc = find_disc(data_dir); + if (disc.empty()) { + logger.error("no disc image (.bin, raw 2352-byte sectors) in {}", data_dir); + return false; + } + g_disc = fopen(disc.c_str(), "rb"); + if (!g_disc) { + logger.error("cannot open {}", disc); + return false; + } + setvbuf(g_disc, nullptr, _IOFBF, 4096); + fseek(g_disc, 0, SEEK_END); + g_disc_sectors = static_cast(ftell(g_disc) / 2352); + logger.info("disc {}: {} sectors", disc, g_disc_sectors); + + g_exe.clear(); + if (FILE *f = fopen((data_dir + "/SLUS_008.84").c_str(), "rb")) { + fseek(f, 0, SEEK_END); + g_exe.resize(ftell(f)); + fseek(f, 0, SEEK_SET); + if (fread(g_exe.data(), 1, g_exe.size(), f) != g_exe.size()) { + g_exe.clear(); + } + fclose(f); + } + if (g_exe.empty() && !exe_from_disc(g_exe)) { + logger.error("SLUS_008.84 not found (neither in {} nor on the disc)", data_dir); + deinit(); + return false; + } + + auto &emu = Tab5Emu::get(); + g_frames[0] = emu.frame_buffer0(); + g_frames[1] = emu.frame_buffer1(); + if (!g_frames[0] || !g_frames[1]) { + logger.error("no frame buffers"); + deinit(); + return false; + } + emu.palette(nullptr, 0); + emu.native_size(FRAME_W, FRAME_H); + emu.audio_sample_rate(AUDIO_RATE); + emu.audio_max_wait_ms(30); + + g_ring = static_cast(heap_caps_calloc(RING_FRAMES * 2, sizeof(int16_t), MALLOC_CAP_SPIRAM)); + g_resume = xSemaphoreCreateBinary(); + g_stop = false; + g_paused = false; + g_game_done = false; + g_ring_w = g_ring_r = 0; + + xTaskCreatePinnedToCore(video_task, "tpm_video", 4096, nullptr, 6, &g_video_task, 1); + xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); + // The recompiled code nests a C frame per guest call (<= 304 bytes on + // RV32, guest chains seen up to 25 deep including interrupts): 32 KB, in + // internal RAM since those frames are the guest's registers; PSRAM if the + // internal heap cannot spare it. + constexpr uint32_t STACK = 32 * 1024; + if (xTaskCreatePinnedToCore(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0) != pdPASS && + xTaskCreatePinnedToCoreWithCaps(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0, + MALLOC_CAP_SPIRAM) != pdPASS) { + logger.error("could not create the game task"); + deinit(); + return false; + } + return true; +} + +void deinit() { + if (g_game_task) { + rt_request_exit(); + rt_pause(0); + if (g_resume) { + xSemaphoreGive(g_resume); + } + for (int i = 0; i < 200 && !g_game_done.load(); i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + vTaskDelete(g_game_task); + g_game_task = nullptr; + } + g_stop = true; + if (g_video_task) { + xTaskNotifyGive(g_video_task); + } + vTaskDelay(pdMS_TO_TICKS(150)); // the helpers notice g_stop and delete themselves + g_video_task = g_audio_task = nullptr; + if (g_disc) { + fclose(g_disc); + g_disc = nullptr; + } + heap_caps_free(g_ring); + g_ring = nullptr; + if (g_resume) { + vSemaphoreDelete(g_resume); + g_resume = nullptr; + } + g_last_frame = nullptr; + g_exe.clear(); + g_exe.shrink_to_fit(); +} + +bool running() { return g_game_task && !g_game_done.load(); } + +void pause() { + g_paused = true; + rt_pause(1); +} + +void resume() { + Tab5Emu::get().native_size(FRAME_W, FRAME_H); + g_paused = false; + rt_pause(0); + if (g_resume) { + xSemaphoreGive(g_resume); + } +} + +std::span last_frame() { + auto *f = g_last_frame.load(); + return f ? std::span{f, FRAME_W * FRAME_H * 2} : std::span{}; +} + +} // namespace tpm::engine + +// ---- what the runtime needs from the board (rt_platform.h) ---- + +extern "C" { + +int plat_read_sector(uint32_t lba, uint8_t *out) { return tpm::engine::read_sector(lba, out) ? 0 : -1; } +uint32_t plat_disc_sectors(void) { return tpm::engine::g_disc_sectors; } + +void plat_vblank(void) { + using namespace tpm::engine; + // present every other vblank at most (the game runs at 30 or less), and + // never queue behind a conversion that is still running + static uint32_t n; + if ((++n & 1) || g_video_busy.load() || !g_video_task) { + return; + } + rt_gpu_display(&g_display); + g_video_busy = true; + xTaskNotifyGive(g_video_task); +} + +uint16_t plat_pad_buttons(int port) { + if (port != 0) { + return 0; + } + const auto s = Tab5Emu::get().gamepad_state(); + uint16_t b = 0; + if (s.select) b |= 1u << 0; + if (s.start) b |= 1u << 3; + if (s.up) b |= 1u << 4; + if (s.right) b |= 1u << 5; + if (s.down) b |= 1u << 6; + if (s.left) b |= 1u << 7; + if (s.l) b |= 1u << 10; // L1 + if (s.r) b |= 1u << 11; // R1 + if (s.y) b |= 1u << 12; // triangle (north) + if (s.b) b |= 1u << 13; // circle (east) + if (s.a) b |= 1u << 14; // cross (south) + if (s.x) b |= 1u << 15; // square (west) + return b; +} + +void plat_audio(const int16_t *samples, int frames) { + using namespace tpm::engine; + if (!g_ring) { + return; + } + uint32_t w = g_ring_w.load(); + const uint32_t r = g_ring_r.load(std::memory_order_acquire); + for (int i = 0; i < frames; i++) { + if (w - r >= RING_FRAMES) { + break; // the audio task is behind: drop + } + const size_t k = w % RING_FRAMES; + g_ring[2 * k] = samples[2 * i]; + g_ring[2 * k + 1] = samples[2 * i + 1]; + w++; + } + g_ring_w.store(w, std::memory_order_release); +} + +uint64_t plat_time_us(void) { return static_cast(esp_timer_get_time()); } + +void *plat_alloc(uint32_t size) { return heap_caps_calloc(1, size, MALLOC_CAP_SPIRAM); } + +void plat_pause_wait(void) { + if (tpm::engine::g_resume) { + xSemaphoreTake(tpm::engine::g_resume, pdMS_TO_TICKS(100)); + } else { + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +} // extern "C" diff --git a/components/tpm/src/tpm_engine.hpp b/components/tpm/src/tpm_engine.hpp new file mode 100644 index 0000000..45cbb32 --- /dev/null +++ b/components/tpm/src/tpm_engine.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +/// The recompiled game (tpm-decomp's recomp runtime) behind tpm.hpp. +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); +void deinit(); +bool running(); +void pause(); +void resume(); +/// The last presented frame (320x240 RGB565), or empty. +std::span last_frame(); + +} // namespace tpm::engine diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 106e3b2..8bba6ea 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 106e3b2aba80ae1bd19f827a7a64a49307ee0bfd +Subproject commit 8bba6ea19b3196b3d975016c6212a6e8df32d5d6 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep new file mode 100644 index 0000000..9bdb2f1 --- /dev/null +++ b/components/tpm/tpm_rt.keep @@ -0,0 +1,7 @@ +rt_boot +rt_request_exit +rt_pause +rt_set_realtime +rt_gpu_display +rt_frame_count +g_rt_vram diff --git a/docs/PLAN.md b/docs/PLAN.md index c253cc3..5a8ac96 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -278,17 +278,20 @@ Where it stands: 1. **Decomp ground** (done): splat split, byte-identical rebuild from `INCLUDE_ASM` stubs through PSY-Q 4.3's GCC 2.8.1 + maspsx; ~105 PSY-Q library objects named by signature; 823 game functions waiting. -2. **Data path on the Tab5** (written, built, not run on hardware): - `components/tpm` reads `SLUS_008.84` + `FILE.000` from the card, resolves - names through the game's own file table, and shows the 44 load screens - (320x256 24-bit, cropped to 240 rows). Left / right steps through them. -3. **Next**: name the engine's top level (main loop, task manager, file - layer, level loader), decompile the file layer first (its bottom half - becomes SD reads), then the renderer and the 16 hand-written GTE routines - (to C on psyz's GTE), then gameplay level by level. - -The game C will join this component behind `CONFIG_TPM_ENGINE` (off), as a -submodule like `components/mgs/mgs_reversing`, once the repo is published. -Everything the MGS port learned applies: no hard-coded PSX / S3 address -ranges, game statics in PSRAM via a linker fragment, the game task at the -mts priority, the two-core rasteriser. +2. **The game runs, recompiled** (2026-10-11): tpm-decomp's static + recompiler translates the whole executable to C at build time and its + runtime emulates the machine around it (BIOS calls, interrupts, DMA, + CD-ROM over the `.bin`, GPU ports on psyz's rasterizer, psyz's GTE and + SPU). On the desktop it boots into the first level in 3D. Here it is + `CONFIG_TPM_ENGINE` (default on): CMake recompiles the user's + `SLUS_008.84` at configure time; the game, runtime and psyz pieces are + one relocatable object with only the runtime's API global (MGS links the + same psyz names). Built (7.9 MB image; app partition grown to 12 MB); + **not run on the Tab5 yet**. +3. **Next**: the first run on the board, then performance (the MGS lessons: + the two-core rasterizer), MDEC movies, XA audio, memory card saves. The + decompilation continues in tpm-decomp; decompiled functions can replace + recompiled ones one at a time. + +The load-screen viewer remains the fallback (no executable at build time, +or no `.bin` on the card). diff --git a/main/tpm_cart.hpp b/main/tpm_cart.hpp index 0040491..6744c87 100644 --- a/main/tpm_cart.hpp +++ b/main/tpm_cart.hpp @@ -7,10 +7,10 @@ /// Cart for Star Wars Episode I: The Phantom Menace (PSX), native. /// -/// The "rom" is the directory of disc files from tpm-decomp's -/// tools/extract_disc.py (SLUS_008.84, FILE.000 .. FILE.003): the metadata -/// entry points at `/SLUS_008.84`. Until the decompilation runs, the core -/// shows the game's load screens from that data (left / right to step). +/// The metadata entry points at `/SLUS_008.84`; `` holds the disc +/// image (.bin, raw sectors). The game itself is tpm-decomp's static +/// recompilation of the executable (components/tpm). Without it, the core +/// shows the game's load screens from extracted disc files. class TpmCart : public Cart { public: explicit TpmCart(const Cart::Config &config) diff --git a/partitions.csv b/partitions.csv index 1075aca..51d6f56 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,5 +1,5 @@ # Name, Type, SubType, Offset, Size nvs, data, nvs, 0xa000, 0x6000 phy_init, data, phy, , 0x1000 -factory, app, factory, , 8M +factory, app, factory, , 12M coredump, data, coredump, , 64K From 30c1a21b261715624332931850a63b871de2e432 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 21:58:05 -0500 Subject: [PATCH 04/29] tpm: bring-up aids; autostart by name; build on main's psyz - CONFIG_TAB5_EMU_AUTOSTART_MATCH: auto-start the first game whose name or path contains the text, not just the first entry - CONFIG_TPM_AUTOPRESS: scripted pad presses at given vblanks - CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS: the presented frame as base64 on the console; tools/serial_screenshots.py turns a log into PNGs - a status line every 5 s (vblanks, frames presented, display mode) - tpm-decomp f238b94: VRAM alias follows psyz's pointer on ESP32 Co-Authored-By: Claude Opus 5.5 (1M context) --- components/gui/include/gui.hpp | 4 ++ components/gui/src/gui.cpp | 12 ++++ components/tpm/Kconfig | 19 +++++++ components/tpm/src/tpm_engine.cpp | 93 ++++++++++++++++++++++++++++++- components/tpm/tpm-decomp | 2 +- main/Kconfig.projbuild | 7 +++ main/main.cpp | 3 + tools/serial_screenshots.py | 67 ++++++++++++++++++++++ 8 files changed, 205 insertions(+), 2 deletions(-) create mode 100755 tools/serial_screenshots.py diff --git a/components/gui/include/gui.hpp b/components/gui/include/gui.hpp index f167cd6..e6a53ee 100644 --- a/components/gui/include/gui.hpp +++ b/components/gui/include/gui.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "lvgl.h" @@ -47,6 +48,9 @@ class Gui { void update_rom_list(); + /// Select the first game whose name or rom path contains `text` + bool select_rom_matching(std::string_view text); + protected: void init_ui(); void deinit_ui(); diff --git a/components/gui/src/gui.cpp b/components/gui/src/gui.cpp index 6c73f8f..4a4157e 100644 --- a/components/gui/src/gui.cpp +++ b/components/gui/src/gui.cpp @@ -240,6 +240,18 @@ void Gui::update_rom_list() { } } +bool Gui::select_rom_matching(std::string_view text) { + std::lock_guard lk(mutex_); + for (size_t i = 0; i < rom_infos_.size(); i++) { + const auto &rom = rom_infos_[i]; + if (rom.name.find(text) != std::string::npos || rom.rom_path.find(text) != std::string::npos) { + select_rom(i); + return true; + } + } + return false; +} + void Gui::select_rom(int index) { std::lock_guard lk(mutex_); if (index < 0 || index >= (int)rom_infos_.size()) { diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 2d45124..20e19f3 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -23,4 +23,23 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" int "Seconds per load screen in the data viewer" default 4 + config TPM_AUTOPRESS + string "Test: scripted button presses (vblank:button,...)" + default "" + depends on TPM_ENGINE + help + Bring-up aid: press pad buttons at given vblanks without anyone + at the board, e.g. "300:cross,600:cross,900:start". Each press + is held for 8 vblanks. Buttons: cross circle square triangle + start select up down left right l1 r1 l2 r2. + + config TPM_SERIAL_SCREENSHOT_SECONDS + int "Test: print the screen on the console every N seconds (0 = off)" + default 0 + depends on TPM_ENGINE + help + Bring-up aid: the presented 320x240 RGB565 frame as base64 lines + ("TPMFB ..."), for tools/serial_screenshots.py to turn into + PNGs. + endmenu diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 2c51e20..0aec63d 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -29,6 +30,8 @@ #include #include +#include "sdkconfig.h" + #include "logger.hpp" #include "statistics.hpp" #include "tab5-emu.hpp" @@ -70,6 +73,78 @@ constexpr size_t RING_FRAMES = 8192; // ~186 ms int16_t *g_ring{nullptr}; std::atomic g_ring_w{0}, g_ring_r{0}; +// ---- bring-up aids (Kconfig "Test:" options) ---- + +struct Press { + uint32_t vblank; + uint16_t bits; +}; +std::vector g_presses; +constexpr uint32_t PRESS_HOLD = 8; + +void parse_presses(const char *spec) { + static const struct { + const char *name; + int bit; + } names[] = {{"select", 0}, {"start", 3}, {"up", 4}, {"right", 5}, {"down", 6}, {"left", 7}, {"l2", 8}, + {"r2", 9}, {"l1", 10}, {"r1", 11}, {"triangle", 12}, {"circle", 13}, {"cross", 14}, {"square", 15}}; + g_presses.clear(); + std::string all(spec); + size_t pos = 0; + while (pos < all.size()) { + size_t end = all.find(',', pos); + if (end == std::string::npos) end = all.size(); + const std::string item = all.substr(pos, end - pos); + pos = end + 1; + const size_t colon = item.find(':'); + if (colon == std::string::npos) continue; + const auto when = static_cast(strtoul(item.c_str(), nullptr, 10)); + const std::string button = item.substr(colon + 1); + for (const auto &n : names) { + if (button == n.name) { + g_presses.push_back({when, static_cast(1u << n.bit)}); + } + } + } + if (!g_presses.empty()) { + logger.warn("scripted presses: {}", spec); + } +} + +uint16_t scripted_buttons(uint32_t vblank) { + uint16_t b = 0; + for (const auto &p : g_presses) { + if (vblank >= p.vblank && vblank < p.vblank + PRESS_HOLD) b |= p.bits; + } + return b; +} + +// the frame as base64 lines on the console +void print_screenshot(const uint16_t *px, uint32_t vblank) { + static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const auto *b = reinterpret_cast(px); + constexpr size_t N = FRAME_W * FRAME_H * 2; + constexpr size_t CHUNK = 960; // bytes per line, a multiple of 3 + char line[CHUNK / 3 * 4 + 1]; + printf("TPMFB begin %u %u %u\n", static_cast(FRAME_W), static_cast(FRAME_H), + static_cast(vblank)); + for (size_t off = 0; off < N; off += CHUNK) { + const size_t n = std::min(CHUNK, N - off); + size_t o = 0; + for (size_t i = 0; i < n; i += 3) { + const uint32_t v = (b[off + i] << 16) | ((i + 1 < n ? b[off + i + 1] : 0) << 8) | (i + 2 < n ? b[off + i + 2] : 0); + line[o++] = tbl[(v >> 18) & 63]; + line[o++] = tbl[(v >> 12) & 63]; + line[o++] = i + 1 < n ? tbl[(v >> 6) & 63] : '='; + line[o++] = i + 2 < n ? tbl[v & 63] : '='; + } + line[o] = 0; + printf("TPMFB %s\n", line); + } + printf("TPMFB end\n"); + fflush(stdout); +} + // ---- the disc ---- bool read_sector(uint32_t lba, uint8_t *out) { @@ -174,6 +249,21 @@ void video_task(void *) { update_frame_time(static_cast(now - last_us)); } last_us = now; + static int64_t last_status_us = 0, last_shot_us = 0; + static uint32_t presented = 0; + presented++; + if (now - last_status_us >= 5000000) { + logger.info("vblank {} | presented {} | {}x{} at ({},{}){}{}", rt_frame_count(), presented, d.w, d.h, d.x, + d.y, d.rgb24 ? " 24-bit" : "", d.enabled ? "" : " (display off)"); + last_status_us = now; + } +#if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 + if (now - last_shot_us >= CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS * 1000000LL) { + print_screenshot(dst, rt_frame_count()); + last_shot_us = now; + } +#endif + (void)last_shot_us; g_video_busy = false; } vTaskDelete(nullptr); @@ -284,6 +374,7 @@ bool init(const std::string &data_dir) { g_paused = false; g_game_done = false; g_ring_w = g_ring_r = 0; + parse_presses(CONFIG_TPM_AUTOPRESS); xTaskCreatePinnedToCore(video_task, "tpm_video", 4096, nullptr, 6, &g_video_task, 1); xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); @@ -397,7 +488,7 @@ uint16_t plat_pad_buttons(int port) { if (s.b) b |= 1u << 13; // circle (east) if (s.a) b |= 1u << 14; // cross (south) if (s.x) b |= 1u << 15; // square (west) - return b; + return b | tpm::engine::scripted_buttons(rt_frame_count()); } void plat_audio(const int16_t *samples, int frames) { diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 8bba6ea..f238b94 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 8bba6ea19b3196b3d975016c6212a6e8df32d5d6 +Subproject commit f238b948a2e178ea5d5f06ca157ca00d5d782362 diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index a9b6953..91a7fd6 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -7,6 +7,13 @@ menu "tab5-emu" Bring-up aid: launch the first entry of metadata.csv automatically a little after boot, as if Play had been pressed. + config TAB5_EMU_AUTOSTART_MATCH + string "Auto-start the first game whose name or path contains this" + default "" + depends on TAB5_EMU_AUTOSTART_DELAY_MS > 0 + help + Empty: the first entry of metadata.csv. + config TAB5_EMU_USB_MSC_AT_BOOT bool "Test: turn the USB drive on right after boot" default n diff --git a/main/main.cpp b/main/main.cpp index c463f72..9c59ab6 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -167,6 +167,9 @@ extern "C" void app_main(void) { static bool autostarted = false; if (!autostarted && gui.get_selected_rom().has_value()) { autostarted = true; + if (sizeof(CONFIG_TAB5_EMU_AUTOSTART_MATCH) > 1 && !gui.select_rom_matching(CONFIG_TAB5_EMU_AUTOSTART_MATCH)) { + logger.warn("Auto-start: no game matches '{}'", CONFIG_TAB5_EMU_AUTOSTART_MATCH); + } logger.warn("Auto-starting the first game in {} ms", CONFIG_TAB5_EMU_AUTOSTART_DELAY_MS); std::this_thread::sleep_for(std::chrono::milliseconds(CONFIG_TAB5_EMU_AUTOSTART_DELAY_MS)); gui.ready_to_play(true); diff --git a/tools/serial_screenshots.py b/tools/serial_screenshots.py new file mode 100755 index 0000000..b9312ea --- /dev/null +++ b/tools/serial_screenshots.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Turn the "TPMFB" screenshot lines of a console log into PNGs. + + tools/serial_screenshots.py session.log out_dir/ + +The Tab5 prints them with CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS: a "begin W H +vblank" line, base64 lines of the RGB565 frame, an "end" line. Incomplete +frames (lines lost or interleaved) are skipped. +""" +import base64 +import os +import struct +import sys +import zlib + + +def write_png(path, w, h, rgb565): + rows = [] + for y in range(h): + row = bytearray([0]) + for x in range(w): + v = rgb565[y * w + x] + r, g, b = (v >> 11) & 31, (v >> 5) & 63, v & 31 + row += bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2))) + rows.append(bytes(row)) + + def chunk(t, d): + return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) + + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(b"".join(rows), 6)) + chunk(b"IEND", b"")) + + +def main(): + log, out = sys.argv[1], sys.argv[2] + os.makedirs(out, exist_ok=True) + frame = None + n = 0 + with open(log, "rb") as f: + for raw in f: + line = raw.decode("latin-1").strip() + i = line.find("TPMFB ") + if i < 0: + continue + body = line[i + 6:] + if body.startswith("begin "): + w, h, vb = (int(x) for x in body.split()[1:4]) + frame = (w, h, vb, bytearray()) + elif body == "end": + if frame and len(frame[3]) == frame[0] * frame[1] * 2: + w, h, vb, data = frame + path = os.path.join(out, "vblank%06d.png" % vb) + write_png(path, w, h, struct.unpack("<%dH" % (w * h), data)) + print(path) + n += 1 + frame = None + elif frame is not None: + try: + frame[3].extend(base64.b64decode(body)) + except Exception: + frame = None + print("%d frames" % n, file=sys.stderr) + + +if __name__ == "__main__": + main() From 0cf5ea67e3e19a9ea380153631bf364c60a9e5c0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 00:48:05 -0500 Subject: [PATCH 05/29] tpm: runs on the Tab5 into the first level - the runtime's statics in PSRAM (tpm_rt.ld gathers the bundle's .bss into .ext_ram.bss, brackets .bss/.data) and reset before every launch; its big buffers (guest RAM, VRAM, SPU, transfers) from the shared-memory pool, released when the cart quits; the rasterizer out of IRAM. Internal RAM use of the bundle 37.7 KB -> 192 B: the boot-time DMA reserve fits again - the displayed rows are copied at the vblank on the game's thread: the rasterizer draws synchronously there, and VRAM read later from the video task often held the next frame's clear without its polygons - plat_sleep_us for the runtime's throttle - status line: game fps (display-area flips); half-resolution serial screenshots from their own task; a one-shot VRAM dump and a GPU trace of one frame (CONFIG_TPM_SERIAL_FULL_SHOT_VBLANK, CONFIG_TPM_GPU_TRACE_VBLANK) - tpm-decomp 11bd43e Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 14 ++- components/tpm/Kconfig | 12 +++ components/tpm/src/tpm_engine.cpp | 168 +++++++++++++++++++++++++++--- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 6 ++ components/tpm/tpm_rt.ld | 23 ++++ 6 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 components/tpm/tpm_rt.ld diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 6d0dad3..dd51073 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -57,7 +57,7 @@ idf_component_register( INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS "src" "${TPM_DECOMP}/recomp/runtime" REQUIRES tab5-emu - PRIV_REQUIRES esp_timer heap statistics + PRIV_REQUIRES esp_timer heap statistics shared_memory ) if(TPM_ENGINE_READY) @@ -86,18 +86,22 @@ if(TPM_ENGINE_READY) # -msmall-data-limit=0 nothing in PSRAM may be addressed gp-relative target_compile_options(tpm_rt PRIVATE -O2 -w -fno-strict-aliasing -msmall-data-limit=0 -Wno-error) - # the rasterizer's inner loops decide the frame rate + # the rasterizer's inner loops decide the frame rate; they run from + # PSRAM-XIP, not IRAM (the runtime's default, as in the MGS port: internal + # RAM is what the boot-time DMA reserve and the HAL need) set_property(SOURCE ${rt}/rt_gpu.c APPEND PROPERTY COMPILE_OPTIONS "-O3;-fomit-frame-pointer") # ---- hide it: components/mgs links its own psyz, with the same names ---- # One relocatable object with only the runtime's API left global. set(bundle ${CMAKE_CURRENT_BINARY_DIR}/tpm_rt_bundle.o) + # tpm_rt.ld gathers the statics: .bss to PSRAM, both bracketed for the + # reset between launches (-d: COMMON symbols get their space here) add_custom_command(OUTPUT ${bundle} - COMMAND ${CMAKE_C_COMPILER} -nostdlib -r -Wl,--whole-archive $ - -Wl,--no-whole-archive -o ${bundle}.r.o + COMMAND ${CMAKE_C_COMPILER} -nostdlib -r -Wl,-d -Wl,-T,${CMAKE_CURRENT_LIST_DIR}/tpm_rt.ld + -Wl,--whole-archive $ -Wl,--no-whole-archive -o ${bundle}.r.o COMMAND ${CMAKE_OBJCOPY} --keep-global-symbols=${CMAKE_CURRENT_LIST_DIR}/tpm_rt.keep ${bundle}.r.o ${bundle} - DEPENDS tpm_rt ${CMAKE_CURRENT_LIST_DIR}/tpm_rt.keep + DEPENDS tpm_rt ${CMAKE_CURRENT_LIST_DIR}/tpm_rt.keep ${CMAKE_CURRENT_LIST_DIR}/tpm_rt.ld VERBATIM) add_custom_target(tpm_rt_bundle DEPENDS ${bundle}) add_dependencies(${COMPONENT_LIB} tpm_rt_bundle) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 20e19f3..8735c5e 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -42,4 +42,16 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" ("TPMFB ..."), for tools/serial_screenshots.py to turn into PNGs. + config TPM_SERIAL_FULL_SHOT_VBLANK + int "Test: print all of VRAM (half resolution) once, at this vblank (0 = off)" + default 0 + depends on TPM_SERIAL_SCREENSHOT_SECONDS > 0 + + config TPM_GPU_TRACE_VBLANK + int "Test: log the GPU state commands of this frame (0 = off)" + default 0 + depends on TPM_ENGINE + help + Compare with the desktop runner's --gpu-trace N. + endmenu diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 0aec63d..98faae7 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -15,6 +15,7 @@ #include "tpm_engine.hpp" +#include #include #include #include @@ -38,6 +39,9 @@ extern "C" { #include "rt_platform.h" +#include "shared_memory.h" +// tpm_rt.ld: the runtime's statics, for the reset between launches +extern uint8_t _tpm_bss_start[], _tpm_bss_end[], _tpm_data_start[], _tpm_data_end[]; } namespace tpm::engine { @@ -67,12 +71,40 @@ int g_frame_index{0}; std::atomic g_last_frame{nullptr}; std::atomic g_video_busy{false}; RtDisplay g_display{}; +std::atomic g_flips{0}; // display-area moves: the game's frames +// The displayed rows, copied out of VRAM at the vblank on the game's thread +// (same layout as VRAM, only the displayed span of each row filled): the +// rasterizer draws synchronously on that thread, so read later from the video +// task VRAM may already hold half of the next frame. +uint16_t *g_snap{nullptr}; +constexpr size_t SNAP_ROWS = FRAME_H; // SPU output: the game's thread writes, the audio task reads constexpr size_t RING_FRAMES = 8192; // ~186 ms int16_t *g_ring{nullptr}; std::atomic g_ring_w{0}, g_ring_r{0}; +// ---- a fresh machine for every launch ---- +// +// The runtime's statics are put back before each boot, as the MGS port does +// with the game's: .bss (in PSRAM, tpm_rt.ld) zeroed, .data restored from a +// copy taken before the first launch. Its big buffers (guest RAM, VRAM, SPU, +// transfers) come from the shared-memory pool (plat_alloc) and go back to it +// when the cart quits. +std::vector g_data_snapshot; + +void reset_statics() { + const size_t data_size = _tpm_data_end - _tpm_data_start; + if (g_data_snapshot.empty()) { + g_data_snapshot.assign(_tpm_data_start, _tpm_data_end); + } else { + memcpy(_tpm_data_start, g_data_snapshot.data(), data_size); + } + memset(_tpm_bss_start, 0, _tpm_bss_end - _tpm_bss_start); + logger.info("statics: {} B .bss (PSRAM), {} B .data", static_cast(_tpm_bss_end - _tpm_bss_start), + data_size); +} + // ---- bring-up aids (Kconfig "Test:" options) ---- struct Press { @@ -119,14 +151,21 @@ uint16_t scripted_buttons(uint32_t vblank) { return b; } -// the frame as base64 lines on the console -void print_screenshot(const uint16_t *px, uint32_t vblank) { +// the frame, at half resolution, as base64 lines on the console (the +// USB-serial console moves ~10 KB/s: a full frame would take 20 s) +constexpr size_t SHOT_W = FRAME_W / 2, SHOT_H = FRAME_H / 2; +uint16_t *g_shot{nullptr}; +size_t g_shot_w{SHOT_W}, g_shot_h{SHOT_H}; +uint32_t g_shot_vblank{0}; +TaskHandle_t g_shot_task{nullptr}; + +void print_screenshot(const uint16_t *px, size_t w, size_t h, uint32_t vblank) { static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const auto *b = reinterpret_cast(px); - constexpr size_t N = FRAME_W * FRAME_H * 2; + const size_t N = w * h * 2; constexpr size_t CHUNK = 960; // bytes per line, a multiple of 3 char line[CHUNK / 3 * 4 + 1]; - printf("TPMFB begin %u %u %u\n", static_cast(FRAME_W), static_cast(FRAME_H), + printf("TPMFB begin %u %u %u\n", static_cast(w), static_cast(h), static_cast(vblank)); for (size_t off = 0; off < N; off += CHUNK) { const size_t n = std::min(CHUNK, N - off); @@ -145,6 +184,16 @@ void print_screenshot(const uint16_t *px, uint32_t vblank) { fflush(stdout); } +void shot_task(void *) { + while (!g_stop.load()) { + if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)) != 0) { + print_screenshot(g_shot, g_shot_w, g_shot_h, g_shot_vblank); + } + } + g_shot_task = nullptr; + vTaskDelete(nullptr); +} + // ---- the disc ---- bool read_sector(uint32_t lba, uint8_t *out) { @@ -201,6 +250,10 @@ void video_task(void *) { continue; } const RtDisplay d = g_display; + if (!g_snap) { + g_video_busy = false; + continue; + } auto *dst = reinterpret_cast(g_frames[g_frame_index]); if (!d.enabled) { memset(dst, 0, FRAME_W * FRAME_H * 2); @@ -212,7 +265,6 @@ void video_task(void *) { const int x_pad = w < static_cast(FRAME_W) ? (FRAME_W - w) / 2 : 0; for (size_t y = 0; y < FRAME_H; y++) { uint16_t *out = dst + y * FRAME_W; - const int vy = (d.y + static_cast(y) * step_y) & 511; if (static_cast(y) * step_y >= h) { memset(out, 0, FRAME_W * 2); continue; @@ -223,14 +275,14 @@ void video_task(void *) { } const size_t n = std::min(w, FRAME_W); if (d.rgb24) { - const auto *row = reinterpret_cast(&g_rt_vram[vy * 1024]); + const auto *row = reinterpret_cast(&g_snap[y * 1024]); for (size_t x = 0; x < n; x++) { const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; const uint8_t *p = row + ((d.x * 2 + sx * 3) & 2047); out[x_pad + x] = static_cast(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); } } else { - const uint16_t *row = &g_rt_vram[vy * 1024]; + const uint16_t *row = &g_snap[y * 1024]; for (size_t x = 0; x < n; x++) { const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; const uint16_t v = row[(d.x + sx) & 1023]; @@ -253,13 +305,43 @@ void video_task(void *) { static uint32_t presented = 0; presented++; if (now - last_status_us >= 5000000) { - logger.info("vblank {} | presented {} | {}x{} at ({},{}){}{}", rt_frame_count(), presented, d.w, d.h, d.x, - d.y, d.rgb24 ? " 24-bit" : "", d.enabled ? "" : " (display off)"); + static uint32_t last_flips = 0; + const uint32_t flips = g_flips.load(); + logger.info("vblank {} | presented {} | game {:.1f} fps | {}x{} at ({},{}){}{}", rt_frame_count(), presented, + (flips - last_flips) * 1e6f / (now - last_status_us), d.w, d.h, d.x, d.y, d.rgb24 ? " 24-bit" : "", + d.enabled ? "" : " (display off)"); + last_flips = flips; last_status_us = now; } #if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 - if (now - last_shot_us >= CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS * 1000000LL) { - print_screenshot(dst, rt_frame_count()); + static bool full_done = CONFIG_TPM_SERIAL_FULL_SHOT_VBLANK == 0; + if (g_shot && g_shot_task && !full_done && + rt_frame_count() + 1 > static_cast(CONFIG_TPM_SERIAL_FULL_SHOT_VBLANK)) { + // once, at full resolution + // once: all of VRAM at half resolution (as RGB565) + full_done = true; + for (size_t y = 0; y < 256; y++) { + for (size_t x = 0; x < 512; x++) { + const uint16_t v = g_rt_vram[(2 * y) * 1024 + 2 * x]; + const uint16_t r = v & 0x1F, g = (v >> 5) & 0x1F, b = (v >> 10) & 0x1F; + g_shot[y * 512 + x] = static_cast((r << 11) | (((g << 1) | (g >> 4)) << 5) | b); + } + } + g_shot_w = 512; + g_shot_h = 256; + g_shot_vblank = rt_frame_count(); + xTaskNotifyGive(g_shot_task); + last_shot_us = now + 30000000; // the console is busy for a while + } else if (g_shot && g_shot_task && now - last_shot_us >= CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS * 1000000LL) { + g_shot_w = SHOT_W; + g_shot_h = SHOT_H; + for (size_t y = 0; y < SHOT_H; y++) { + for (size_t x = 0; x < SHOT_W; x++) { + g_shot[y * SHOT_W + x] = dst[2 * y * FRAME_W + 2 * x]; + } + } + g_shot_vblank = rt_frame_count(); + xTaskNotifyGive(g_shot_task); last_shot_us = now; } #endif @@ -294,7 +376,11 @@ void audio_task(void *) { void game_task(void *) { logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); +#if CONFIG_TPM_GPU_TRACE_VBLANK > 0 + rt_set_gpu_trace(CONFIG_TPM_GPU_TRACE_VBLANK); +#endif rt_boot(g_exe.data(), static_cast(g_exe.size())); + rt_shutdown(); logger.info("game stopped"); g_game_done = true; vTaskSuspend(nullptr); @@ -369,14 +455,24 @@ bool init(const std::string &data_dir) { emu.audio_max_wait_ms(30); g_ring = static_cast(heap_caps_calloc(RING_FRAMES * 2, sizeof(int16_t), MALLOC_CAP_SPIRAM)); + g_snap = static_cast(heap_caps_calloc(SNAP_ROWS * 1024, sizeof(uint16_t), MALLOC_CAP_SPIRAM)); g_resume = xSemaphoreCreateBinary(); g_stop = false; g_paused = false; g_game_done = false; g_ring_w = g_ring_r = 0; + reset_statics(); + logger.info("internal free {} B, PSRAM free {} B (largest {})", heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); parse_presses(CONFIG_TPM_AUTOPRESS); xTaskCreatePinnedToCore(video_task, "tpm_video", 4096, nullptr, 6, &g_video_task, 1); +#if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 + if (!g_shot) { + g_shot = static_cast(heap_caps_malloc(512 * 256 * 2, MALLOC_CAP_SPIRAM)); + } + xTaskCreatePinnedToCore(shot_task, "tpm_shot", 4096, nullptr, 1, &g_shot_task, 1); +#endif xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); // The recompiled code nests a C frame per guest call (<= 304 bytes on // RV32, guest chains seen up to 25 deep including interrupts): 32 KB, in @@ -418,11 +514,16 @@ void deinit() { } heap_caps_free(g_ring); g_ring = nullptr; + heap_caps_free(g_snap); + g_snap = nullptr; if (g_resume) { vSemaphoreDelete(g_resume); g_resume = nullptr; } g_last_frame = nullptr; + // the runtime's buffers (rt_shutdown dropped its pointers if the game got + // to return; the next launch resets the statics either way) + shared_mem_clear(); g_exe.clear(); g_exe.shrink_to_fit(); } @@ -462,10 +563,35 @@ void plat_vblank(void) { // present every other vblank at most (the game runs at 30 or less), and // never queue behind a conversion that is still running static uint32_t n; - if ((++n & 1) || g_video_busy.load() || !g_video_task) { + static int last_x = -1, last_y = -1; + RtDisplay d; + rt_gpu_display(&d); + if (d.x != last_x || d.y != last_y) { + g_flips++; + last_x = d.x; + last_y = d.y; + } + if ((++n & 1) || g_video_busy.load() || !g_video_task || !g_snap || !g_rt_vram) { return; } - rt_gpu_display(&g_display); + g_display = d; + if (d.enabled) { + // the displayed span of each displayed row (bytes, wrapping at the row's + // 2 KB like the hardware) + const int step_y = d.h > static_cast(FRAME_H) ? 2 : 1; + const int bytes = std::min(d.rgb24 ? d.w * 3 : d.w * 2, 2048); + const int x0 = (d.x * 2) & 2047; + for (size_t y = 0; y < SNAP_ROWS && static_cast(y) * step_y < d.h; y++) { + const int vy = (d.y + static_cast(y) * step_y) & 511; + const auto *src = reinterpret_cast(&g_rt_vram[vy * 1024]); + auto *out = reinterpret_cast(&g_snap[y * 1024]); + const int first = std::min(bytes, 2048 - x0); + memcpy(out + x0, src + x0, first); + if (first < bytes) { + memcpy(out, src, bytes - first); + } + } + } g_video_busy = true; xTaskNotifyGive(g_video_task); } @@ -512,7 +638,21 @@ void plat_audio(const int16_t *samples, int frames) { uint64_t plat_time_us(void) { return static_cast(esp_timer_get_time()); } -void *plat_alloc(uint32_t size) { return heap_caps_calloc(1, size, MALLOC_CAP_SPIRAM); } +void *plat_alloc(uint32_t size) { + shared_mem_request_t req{.size = size, .region = SHARED_MEM_CACHE_LINE, .storage = SHARED_MEM_PSRAM}; + void *p = shared_mem_allocate(&req); // zeroed + if (!p) { + tpm::engine::logger.error("no PSRAM for {} bytes", size); + } + return p; +} + +void plat_free(void *) {} // the pool is cleared when the cart quits + +void plat_sleep_us(uint32_t us) { + // the game is ahead of real time: give the core away for a tick or more + vTaskDelay(std::max(1, pdMS_TO_TICKS(us / 1000))); +} void plat_pause_wait(void) { if (tpm::engine::g_resume) { diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index f238b94..11bd43e 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit f238b948a2e178ea5d5f06ca157ca00d5d782362 +Subproject commit 11bd43e03ccf6dbb6bc88b44b2f59baac9d99573 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 9bdb2f1..3509d75 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -5,3 +5,9 @@ rt_set_realtime rt_gpu_display rt_frame_count g_rt_vram +rt_shutdown +_tpm_bss_start +_tpm_bss_end +_tpm_data_start +_tpm_data_end +rt_set_gpu_trace diff --git a/components/tpm/tpm_rt.ld b/components/tpm/tpm_rt.ld new file mode 100644 index 0000000..27af202 --- /dev/null +++ b/components/tpm/tpm_rt.ld @@ -0,0 +1,23 @@ +/* The bundle's statics (ld -r, see CMakeLists.txt): every .bss of the + * recompiled game, the runtime and its psyz pieces in one section that the + * IDF's linker script sends to PSRAM (.ext_ram.bss*: internal RAM is the + * Tab5's scarce memory), and every .data in one section, each bracketed so + * src/tpm_engine.cpp can put them back between launches: the runtime, like + * the PSX, expects to start from a fresh machine. */ +SECTIONS +{ + .ext_ram.bss.tpm (NOLOAD) : ALIGN(16) + { + _tpm_bss_start = .; + *(.bss .bss.* .sbss .sbss.* .ext_ram.bss .ext_ram.bss.* COMMON) + . = ALIGN(16); + _tpm_bss_end = .; + } + .data.tpm : ALIGN(16) + { + _tpm_data_start = .; + *(.data .data.* .sdata .sdata.*) + . = ALIGN(16); + _tpm_data_end = .; + } +} From 35eb771202882da9f793aeb8a78bcadbc223c680 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 01:57:43 -0500 Subject: [PATCH 06/29] tpm: the GPU on core 1, disc read-ahead: ~12 fps in the first level - rt_gpu_set_async: the rasterizer runs in tpm_gpu on core 1 behind a command queue; the displayed rows are copied by the worker at the vblank marker (plat_display_ready), so the picture is the finished frame - 32-sector read-ahead on the disc image (CD time 13% -> 2%) - status line: the game thread's time split (g_rt_prof); a PC-sampling profiler (CONFIG_TPM_PROFILE_VBLANK); numbered screenshot lines that survive interleaved log output; the screenshot task above the GPU worker - README: status, threads, memory, bring-up aids - tpm-decomp afc58b8 7 -> 12 fps in the Trade Federation ship level (the game's own rate is 30). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/Kconfig | 5 ++ components/tpm/README.md | 49 ++++++++++- components/tpm/src/tpm_engine.cpp | 130 ++++++++++++++++++++++++++++-- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 3 + tools/serial_screenshots.py | 21 +++-- 6 files changed, 191 insertions(+), 19 deletions(-) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 8735c5e..80be1b6 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -54,4 +54,9 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" help Compare with the desktop runner's --gpu-trace N. + config TPM_PROFILE_VBLANK + int "Test: sample the game thread's PC for 8000 ticks from this vblank (0 = off)" + default 0 + depends on TPM_ENGINE + endmenu diff --git a/components/tpm/README.md b/components/tpm/README.md index 45740d6..5a9f172 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -49,15 +49,56 @@ Only USA Rev 1 works: the recompiled code is that executable. ## Running -- Threads: the game on core 0 (32 KB internal stack), VRAM -> RGB565 - conversion and the SPU's audio on core 1. Time is real time: vblanks, CD - sectors and audio samples follow the clock. +Status (2026-10-13, on the Tab5): boots from the card through the memory-card +notice, the title menu and the loading screens into the first level (the +Trade Federation ship), drawn correctly, at about **12 fps** against the +game's 30 (the game runs in slow motion: emulated time never skips ahead). + +- Threads: the game on core 0 (32 KB internal stack); the GPU's command + stream on core 1 (`tpm_gpu`: the rasterizer, behind the game's back like + the real GPU's FIFO; a vblank marker in the queue copies the displayed + rows once the drawing before it is done); RGB565 conversion and the SPU's + audio on core 1 too. +- Time: emulated time advances with the work done and never runs ahead of + the wall clock; when the board is slower the game runs slower (jumping the + clock made libetc's VSync time out). +- Memory: the runtime's statics are in PSRAM (`tpm_rt.ld`) and put back + before every launch; guest RAM, VRAM, the SPU and the GPU queue come from + the shared-memory pool and go back to it when the cart quits. Internal RAM + holds only the guest register file and the game task's stack. +- Disc reads go through a 32-sector read-ahead. - Pads: the HAL's merged gamepad; A = cross, B = circle, X = square, Y = triangle, L/R = L1/R1. - Movies are skipped (no MDEC yet), XA speech / music is silent (not decoded - yet), there is no memory card (the game says so and carries on). + yet), there is no memory card (the game says so and carries on). At the + current speed the SPU makes samples at ~40% of real time, so sound breaks + up. - The pause menu pauses the game at its next loop back-edge. +Where the time goes (in the level): the game thread is busy all the time, +about 10% SPU, a few % CD, the rest recompiled game code and psyz's GTE; +the GPU worker is ~80% busy. Next: the GTE (16% of the game thread before +the GPU moved), the recompiled code, the rasterizer's spans. + +### Bring-up aids (menuconfig, "Test:" options) + +- `CONFIG_TAB5_EMU_AUTOSTART_DELAY_MS` + `CONFIG_TAB5_EMU_AUTOSTART_MATCH` + (main): launch the game whose name or path matches, e.g. `tpm/`. +- `CONFIG_TPM_AUTOPRESS`: scripted pad presses by vblank, e.g. + `300:cross,600:cross,900:start,1200:cross,1500:cross` reaches the first + level. +- `CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS`: half-resolution screenshots on the + console (`tools/serial_screenshots.py log out/` makes PNGs; printing + stalls core 1 for a few seconds each time); + `CONFIG_TPM_SERIAL_FULL_SHOT_VBLANK`: all of VRAM once. +- `CONFIG_TPM_GPU_TRACE_VBLANK`: the GPU state commands of one frame, to + diff against the desktop runner's `--gpu-trace N` (the emulated timeline + is deterministic: the same presses give the same frames). +- `CONFIG_TPM_PROFILE_VBLANK`: 8000 PC samples of the game thread, printed + as `TPMPROF pc ra` for addr2line. +- A status line every 5 s: vblanks, frames presented, the game's frame rate, + and the game thread's time split. + Without the engine (or without a `.bin` on the card) the cart shows the game's 44 load screens from extracted disc files (`FILE.000` and `SLUS_008.84` in `/sdcard/tpm/`), every `CONFIG_TPM_SLIDE_SECONDS` or on diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 98faae7..51f41dc 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -60,6 +61,8 @@ std::vector g_exe; TaskHandle_t g_game_task{nullptr}; TaskHandle_t g_video_task{nullptr}; +TaskHandle_t g_gpu_task{nullptr}; +volatile int g_gpu_stop{0}; TaskHandle_t g_audio_task{nullptr}; std::atomic g_game_done{false}; std::atomic g_stop{false}; @@ -178,7 +181,7 @@ void print_screenshot(const uint16_t *px, size_t w, size_t h, uint32_t vblank) { line[o++] = i + 2 < n ? tbl[v & 63] : '='; } line[o] = 0; - printf("TPMFB %s\n", line); + printf("TPMFB %u %s\n", static_cast(off / CHUNK), line); } printf("TPMFB end\n"); fflush(stdout); @@ -194,16 +197,69 @@ void shot_task(void *) { vTaskDelete(nullptr); } +// A sampling profiler for the game thread (CONFIG_TPM_PROFILE_VBLANK): a +// task above it on its core wakes every tick; the game task is then +// preempted, its register frame on its stack, the TCB's first word pointing +// at it (mepc, ra, sp, ...). The samples go to the console as "TPMPROF pc ra" +// for addr2line on the desktop. +#if CONFIG_TPM_PROFILE_VBLANK > 0 +void prof_task(void *) { + constexpr int N = 8000; + auto *pcs = static_cast(heap_caps_malloc(N * 2 * sizeof(uint32_t), MALLOC_CAP_SPIRAM)); + while (!g_stop.load() && rt_frame_count() < CONFIG_TPM_PROFILE_VBLANK) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + int n = 0; + while (!g_stop.load() && pcs && n < N) { + vTaskDelay(1); + if (g_game_task && eTaskGetState(g_game_task) == eReady) { + const uint32_t *top = *reinterpret_cast(g_game_task); + pcs[2 * n] = top[0]; + pcs[2 * n + 1] = top[1]; + n++; + } + } + for (int i = 0; i < n; i++) { + printf("TPMPROF %08x %08x\n", static_cast(pcs[2 * i]), static_cast(pcs[2 * i + 1])); + if ((i & 63) == 63) vTaskDelay(1); + } + printf("TPMPROF end %d\n", n); + heap_caps_free(pcs); + vTaskDelete(nullptr); +} +#endif + // ---- the disc ---- +// Read-ahead: the game streams the disc sequentially, and on the card every +// separate read pays the SD's latency; 32 sectors (75 KB) per read. +constexpr uint32_t RA_SECTORS = 32; +uint8_t *g_ra{nullptr}; +uint32_t g_ra_lba{0}, g_ra_count{0}; + bool read_sector(uint32_t lba, uint8_t *out) { if (!g_disc || lba >= g_disc_sectors) { return false; } + if (g_ra && lba >= g_ra_lba && lba < g_ra_lba + g_ra_count) { + memcpy(out, g_ra + (lba - g_ra_lba) * 2352, 2352); + return true; + } if (fseek(g_disc, static_cast(lba) * 2352, SEEK_SET) != 0) { return false; } - return fread(out, 1, 2352, g_disc) == 2352; + if (!g_ra) { + return fread(out, 1, 2352, g_disc) == 2352; + } + const uint32_t n = std::min(RA_SECTORS, g_disc_sectors - lba); + const size_t got = fread(g_ra, 2352, n, g_disc); + g_ra_lba = lba; + g_ra_count = static_cast(got); + if (got == 0) { + return false; + } + memcpy(out, g_ra, 2352); + return true; } // SLUS_008.84 out of the image's root directory (ISO9660, Mode 2 Form 1) @@ -311,6 +367,14 @@ void video_task(void *) { (flips - last_flips) * 1e6f / (now - last_status_us), d.w, d.h, d.x, d.y, d.rgb24 ? " 24-bit" : "", d.enabled ? "" : " (display off)"); last_flips = flips; + // the game thread's time over the same window + static RtProf last_prof{}; + const RtProf p = g_rt_prof; + const float win = static_cast(now - last_status_us) / 100.0f; // -> percent + logger.info(" game thread: gpu {:.0f}% spu {:.0f}% cd {:.0f}% vblank {:.0f}% sleep {:.0f}% (rest: the game)", + (p.gpu - last_prof.gpu) / win, (p.spu - last_prof.spu) / win, (p.cd - last_prof.cd) / win, + (p.vblank - last_prof.vblank) / win, (p.sleep - last_prof.sleep) / win); + last_prof = p; last_status_us = now; } #if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 @@ -373,9 +437,17 @@ void audio_task(void *) { vTaskDelete(nullptr); } +// the PSX GPU's command stream, behind the game's back (rt_gpu_set_async) +void gpu_task(void *) { + rt_gpu_worker(&g_gpu_stop); + g_gpu_task = nullptr; + vTaskDelete(nullptr); +} + void game_task(void *) { logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); + rt_gpu_set_async(g_gpu_task != nullptr); #if CONFIG_TPM_GPU_TRACE_VBLANK > 0 rt_set_gpu_trace(CONFIG_TPM_GPU_TRACE_VBLANK); #endif @@ -420,7 +492,9 @@ bool init(const std::string &data_dir) { logger.error("cannot open {}", disc); return false; } - setvbuf(g_disc, nullptr, _IOFBF, 4096); + setvbuf(g_disc, nullptr, _IONBF, 0); // the read-ahead buffers instead + g_ra = static_cast(heap_caps_malloc(RA_SECTORS * 2352, MALLOC_CAP_SPIRAM)); + g_ra_count = 0; fseek(g_disc, 0, SEEK_END); g_disc_sectors = static_cast(ftell(g_disc) / 2352); logger.info("disc {}: {} sectors", disc, g_disc_sectors); @@ -471,7 +545,18 @@ bool init(const std::string &data_dir) { if (!g_shot) { g_shot = static_cast(heap_caps_malloc(512 * 256 * 2, MALLOC_CAP_SPIRAM)); } - xTaskCreatePinnedToCore(shot_task, "tpm_shot", 4096, nullptr, 1, &g_shot_task, 1); + // above the GPU worker: printing mostly waits on the console + xTaskCreatePinnedToCore(shot_task, "tpm_shot", 4096, nullptr, 5, &g_shot_task, 1); +#endif + // the rasterizer, on the other core (below the video and audio tasks there: + // they are short and must not wait behind a frame's drawing) + g_gpu_stop = 0; + if (xTaskCreatePinnedToCore(gpu_task, "tpm_gpu", 8192, nullptr, 4, &g_gpu_task, 1) != pdPASS) { + g_gpu_task = nullptr; + logger.warn("no GPU worker: drawing on the game's thread"); + } +#if CONFIG_TPM_PROFILE_VBLANK > 0 + xTaskCreatePinnedToCore(prof_task, "tpm_prof", 4096, nullptr, 10, nullptr, 0); #endif xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); // The recompiled code nests a C frame per guest call (<= 304 bytes on @@ -502,6 +587,13 @@ void deinit() { vTaskDelete(g_game_task); g_game_task = nullptr; } + if (g_gpu_task) { + g_gpu_stop = 1; + xTaskNotifyGive(g_gpu_task); + for (int i = 0; i < 100 && g_gpu_task; i++) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + } g_stop = true; if (g_video_task) { xTaskNotifyGive(g_video_task); @@ -512,6 +604,9 @@ void deinit() { fclose(g_disc); g_disc = nullptr; } + heap_caps_free(g_ra); + g_ra = nullptr; + g_ra_count = 0; heap_caps_free(g_ring); g_ring = nullptr; heap_caps_free(g_snap); @@ -560,9 +655,6 @@ uint32_t plat_disc_sectors(void) { return tpm::engine::g_disc_sectors; } void plat_vblank(void) { using namespace tpm::engine; - // present every other vblank at most (the game runs at 30 or less), and - // never queue behind a conversion that is still running - static uint32_t n; static int last_x = -1, last_y = -1; RtDisplay d; rt_gpu_display(&d); @@ -571,9 +663,18 @@ void plat_vblank(void) { last_x = d.x; last_y = d.y; } +} + +// On the GPU worker (core 1), after the drawing queued before the vblank. +void plat_display_ready(const RtDisplay *dp) { + using namespace tpm::engine; + // present every other vblank at most (the game runs at 30 or less), and + // never queue behind a conversion that is still running + static uint32_t n; if ((++n & 1) || g_video_busy.load() || !g_video_task || !g_snap || !g_rt_vram) { return; } + const RtDisplay d = *dp; g_display = d; if (d.enabled) { // the displayed span of each displayed row (bytes, wrapping at the row's @@ -596,6 +697,21 @@ void plat_vblank(void) { xTaskNotifyGive(g_video_task); } +void plat_gpu_kick(void) { + if (tpm::engine::g_gpu_task) { + xTaskNotifyGive(tpm::engine::g_gpu_task); + } +} + +void plat_gpu_idle(void) { + using namespace tpm::engine; + if (xTaskGetCurrentTaskHandle() == g_gpu_task) { + ulTaskNotifyTake(pdTRUE, 1); // the worker: until kicked (or a tick) + } else { + esp_rom_delay_us(20); // the game's thread: the worker is on the other core + } +} + uint16_t plat_pad_buttons(int port) { if (port != 0) { return 0; diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 11bd43e..afc58b8 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 11bd43e03ccf6dbb6bc88b44b2f59baac9d99573 +Subproject commit afc58b82cae72a4b93db04545f864c93c1b801ff diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 3509d75..56a9d23 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -11,3 +11,6 @@ _tpm_bss_end _tpm_data_start _tpm_data_end rt_set_gpu_trace +g_rt_prof +rt_gpu_set_async +rt_gpu_worker diff --git a/tools/serial_screenshots.py b/tools/serial_screenshots.py index b9312ea..1f8743c 100755 --- a/tools/serial_screenshots.py +++ b/tools/serial_screenshots.py @@ -4,8 +4,8 @@ tools/serial_screenshots.py session.log out_dir/ The Tab5 prints them with CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS: a "begin W H -vblank" line, base64 lines of the RGB565 frame, an "end" line. Incomplete -frames (lines lost or interleaved) are skipped. +vblank" line, numbered base64 lines of the RGB565 frame ("TPMFB ", +960 bytes each), an "end" line. Lines other output broke are left black. """ import base64 import os @@ -45,10 +45,14 @@ def main(): continue body = line[i + 6:] if body.startswith("begin "): - w, h, vb = (int(x) for x in body.split()[1:4]) - frame = (w, h, vb, bytearray()) + try: + w, h, vb = (int(x) for x in body.split()[1:4]) + except ValueError: + frame = None + continue + frame = (w, h, vb, bytearray(w * h * 2)) elif body == "end": - if frame and len(frame[3]) == frame[0] * frame[1] * 2: + if frame: w, h, vb, data = frame path = os.path.join(out, "vblank%06d.png" % vb) write_png(path, w, h, struct.unpack("<%dH" % (w * h), data)) @@ -57,9 +61,12 @@ def main(): frame = None elif frame is not None: try: - frame[3].extend(base64.b64decode(body)) + idx, b64 = body.split(" ", 1) + chunk = base64.b64decode(b64, validate=True) + off = int(idx) * 960 + frame[3][off:off + len(chunk)] = chunk[:max(0, len(frame[3]) - off)] except Exception: - frame = None + pass # a line other output broke print("%d frames" % n, file=sys.stderr) From 539d3df8cf42c27561e92fd9e028c57210f121b6 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 01:57:58 -0500 Subject: [PATCH 07/29] tpm: tpm-decomp f850f7b (README) Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/tpm-decomp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index afc58b8..f850f7b 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit afc58b82cae72a4b93db04545f864c93c1b801ff +Subproject commit f850f7bd8a24a1c277b5644e6d1853b59a4dd003 From 0494ef5608780dda1b2154236d3f2f23bc02a4e1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 11:35:28 -0500 Subject: [PATCH 08/29] tpm: faster recompiled code, texture windows, display ranges, crash fix tpm-decomp (78b959a..): the recompiler passes only the registers a call needs (a third of the register traffic left, 2.1 -> 1.7 MB of code); the frame limiter's VSync spin skips to the vblank; texture windows (the characters' textures); the cutscenes' vertical display range (the letterbox wrapped around); a libsnd jump table that killed the game on some button presses; FPU triangle gradients; a second GPU worker option. Board side: the presenter honours the display range; the game's thread sleeps instead of spinning while the GPU's queue is full; RGB conversion two pixels a word; per-task CPU load and the GPU worker's rendezvous in the status line; CONFIG_TPM_GPU_WORKERS (default 1: two measured slower here, the cores contending for PSRAM and cache); the sampling profiler also samples the GPU worker (TPMPROFG). psyz: the tpm branch (texture window, desktop worker identity, exact bands). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/Kconfig | 10 ++ components/tpm/src/tpm_engine.cpp | 148 +++++++++++++++++++++++------- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 4 + 5 files changed, 132 insertions(+), 34 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 80483df..15691e3 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 80483df90b37461ff0d968d5d62c98e7854aafd6 +Subproject commit 15691e36016c3f47cdd7cffd1f019ad8d3df2f82 diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 80be1b6..eb5615d 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -23,6 +23,16 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" int "Seconds per load screen in the data viewer" default 4 + config TPM_GPU_WORKERS + int "GPU workers (1: core 1 draws; 2: both cores, a band of rows each)" + default 1 + range 1 2 + depends on TPM_ENGINE + help + With 2 the second worker runs on the game's core above it. On + this board it measured slower: both cores then contend for PSRAM + and the cache with the game's recompiled code. + config TPM_AUTOPRESS string "Test: scripted button presses (vblank:button,...)" default "" diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 51f41dc..8d53ebd 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -61,7 +61,8 @@ std::vector g_exe; TaskHandle_t g_game_task{nullptr}; TaskHandle_t g_video_task{nullptr}; -TaskHandle_t g_gpu_task{nullptr}; +TaskHandle_t g_gpu_task{nullptr}; // core 1: the GPU's command stream, top band +TaskHandle_t g_gpu0_task{nullptr}; // core 0: the bottom band, in the game's idle time volatile int g_gpu_stop{0}; TaskHandle_t g_audio_task{nullptr}; std::atomic g_game_done{false}; @@ -197,13 +198,16 @@ void shot_task(void *) { vTaskDelete(nullptr); } -// A sampling profiler for the game thread (CONFIG_TPM_PROFILE_VBLANK): a -// task above it on its core wakes every tick; the game task is then -// preempted, its register frame on its stack, the TCB's first word pointing -// at it (mepc, ra, sp, ...). The samples go to the console as "TPMPROF pc ra" -// for addr2line on the desktop. +// A sampling profiler for the game thread and the GPU worker +// (CONFIG_TPM_PROFILE_VBLANK): a task above the sampled one on its core +// wakes every tick; the sampled task is then preempted, its register frame +// on its stack, the TCB's first word pointing at it (mepc, ra, sp, ...). The +// samples go to the console as "TPMPROF pc ra" (game) / "TPMPROFG pc ra" +// (GPU) for addr2line on the desktop. #if CONFIG_TPM_PROFILE_VBLANK > 0 -void prof_task(void *) { +void prof_task(void *arg) { + const bool gpu = arg != nullptr; + const char *tag = gpu ? "TPMPROFG" : "TPMPROF"; constexpr int N = 8000; auto *pcs = static_cast(heap_caps_malloc(N * 2 * sizeof(uint32_t), MALLOC_CAP_SPIRAM)); while (!g_stop.load() && rt_frame_count() < CONFIG_TPM_PROFILE_VBLANK) { @@ -212,18 +216,22 @@ void prof_task(void *) { int n = 0; while (!g_stop.load() && pcs && n < N) { vTaskDelay(1); - if (g_game_task && eTaskGetState(g_game_task) == eReady) { - const uint32_t *top = *reinterpret_cast(g_game_task); + TaskHandle_t t = gpu ? g_gpu_task : g_game_task; + if (t && eTaskGetState(t) == eReady) { + const uint32_t *top = *reinterpret_cast(t); pcs[2 * n] = top[0]; pcs[2 * n + 1] = top[1]; n++; } } + if (gpu) { + vTaskDelay(pdMS_TO_TICKS(1500)); // after the game's + } for (int i = 0; i < n; i++) { - printf("TPMPROF %08x %08x\n", static_cast(pcs[2 * i]), static_cast(pcs[2 * i + 1])); + printf("%s %08x %08x\n", tag, static_cast(pcs[2 * i]), static_cast(pcs[2 * i + 1])); if ((i & 63) == 63) vTaskDelay(1); } - printf("TPMPROF end %d\n", n); + printf("%s end %d\n", tag, n); heap_caps_free(pcs); vTaskDelete(nullptr); } @@ -300,6 +308,36 @@ bool exe_from_disc(std::vector &exe) { // ---- tasks ---- +// Each task's share of its core since the last call (FreeRTOS run-time +// stats): where the board's two cores go +void log_task_load() { + static std::vector st; + static std::vector> last; + static configRUN_TIME_COUNTER_TYPE last_total = 0; + st.resize(uxTaskGetNumberOfTasks() + 4); + configRUN_TIME_COUNTER_TYPE total = 0; + const UBaseType_t n = uxTaskGetSystemState(st.data(), st.size(), &total); + const auto window = static_cast(total - last_total); + std::string line; + for (UBaseType_t i = 0; i < n; i++) { + const auto &t = st[i]; + configRUN_TIME_COUNTER_TYPE prev = 0; + for (auto &l : last) { + if (l.first == t.xHandle) prev = l.second; + } + const float pct = window > 0 ? 100.0f * static_cast(t.ulRunTimeCounter - prev) / window : 0; + if (pct >= 2.0f) { + line += fmt::format(" {} {:.0f}%", t.pcTaskName, pct); + } + } + last.clear(); + for (UBaseType_t i = 0; i < n; i++) { + last.emplace_back(st[i].xHandle, st[i].ulRunTimeCounter); + } + last_total = total; + logger.info(" cores:{}", line); +} + void video_task(void *) { while (!g_stop.load()) { if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)) == 0) { @@ -321,7 +359,10 @@ void video_task(void *) { const int x_pad = w < static_cast(FRAME_W) ? (FRAME_W - w) / 2 : 0; for (size_t y = 0; y < FRAME_H; y++) { uint16_t *out = dst + y * FRAME_W; - if (static_cast(y) * step_y >= h) { + const int line = static_cast(y) * step_y; + if (line >= h || line < d.top || line >= d.top + d.lines) { + // below the mode's height, or outside the vertical display range + // (the letterbox of the cutscenes) memset(out, 0, FRAME_W * 2); continue; } @@ -337,6 +378,16 @@ void video_task(void *) { const uint8_t *p = row + ((d.x * 2 + sx * 3) & 2047); out[x_pad + x] = static_cast(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); } + } else if (w == static_cast(FRAME_W) && (d.x & 1) == 0 && d.x + w <= 1024) { + // the usual case: two pixels per word, BGR555 -> RGB565 with the + // top green bit repeated into the low one + const auto *src = reinterpret_cast(&g_snap[y * 1024 + d.x]); + auto *o = reinterpret_cast(out); + for (size_t x = 0; x < FRAME_W / 2; x++) { + const uint32_t v = src[x]; + o[x] = ((v & 0x001F001Fu) << 11) | ((v & 0x03E003E0u) << 1) | ((v & 0x02000200u) >> 4) | + ((v >> 10) & 0x001F001Fu); + } } else { const uint16_t *row = &g_snap[y * 1024]; for (size_t x = 0; x < n; x++) { @@ -371,10 +422,17 @@ void video_task(void *) { static RtProf last_prof{}; const RtProf p = g_rt_prof; const float win = static_cast(now - last_status_us) / 100.0f; // -> percent - logger.info(" game thread: gpu {:.0f}% spu {:.0f}% cd {:.0f}% vblank {:.0f}% sleep {:.0f}% (rest: the game)", - (p.gpu - last_prof.gpu) / win, (p.spu - last_prof.spu) / win, (p.cd - last_prof.cd) / win, - (p.vblank - last_prof.vblank) / win, (p.sleep - last_prof.sleep) / win); + logger.info(" gpu worker (core 1) {:.0f}% busy, band split row {}; game thread: spu {:.0f}% cd {:.0f}% " + "sleep {:.0f}%", + (p.gpu - last_prof.gpu) / win, g_rt_gpu_split, (p.spu - last_prof.spu) / win, + (p.cd - last_prof.cd) / win, (p.sleep - last_prof.sleep) / win); last_prof = p; + static uint64_t last_wait[2]; + logger.info(" rendezvous waits: worker 1 {:.0f}%, worker 0 {:.0f}%", + (g_rt_gpu_wait_us[1] - last_wait[1]) / win, (g_rt_gpu_wait_us[0] - last_wait[0]) / win); + last_wait[0] = g_rt_gpu_wait_us[0]; + last_wait[1] = g_rt_gpu_wait_us[1]; + log_task_load(); last_status_us = now; } #if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 @@ -437,17 +495,23 @@ void audio_task(void *) { vTaskDelete(nullptr); } -// the PSX GPU's command stream, behind the game's back (rt_gpu_set_async) -void gpu_task(void *) { - rt_gpu_worker(&g_gpu_stop); - g_gpu_task = nullptr; +// the PSX GPU's command stream, behind the game's back (rt_gpu_set_async): +// worker 1 on core 1, worker 0 on core 0; each draws a band of rows +void gpu_task(void *arg) { + const int idx = static_cast(reinterpret_cast(arg)); + rt_gpu_worker_n(&g_gpu_stop, idx); + if (idx == 1) { + g_gpu_task = nullptr; + } else { + g_gpu0_task = nullptr; + } vTaskDelete(nullptr); } void game_task(void *) { logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); - rt_gpu_set_async(g_gpu_task != nullptr); + rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); #if CONFIG_TPM_GPU_TRACE_VBLANK > 0 rt_set_gpu_trace(CONFIG_TPM_GPU_TRACE_VBLANK); #endif @@ -540,7 +604,7 @@ bool init(const std::string &data_dir) { heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); parse_presses(CONFIG_TPM_AUTOPRESS); - xTaskCreatePinnedToCore(video_task, "tpm_video", 4096, nullptr, 6, &g_video_task, 1); + xTaskCreatePinnedToCore(video_task, "tpm_video", 8192, nullptr, 6, &g_video_task, 1); #if CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS > 0 if (!g_shot) { g_shot = static_cast(heap_caps_malloc(512 * 256 * 2, MALLOC_CAP_SPIRAM)); @@ -551,12 +615,21 @@ bool init(const std::string &data_dir) { // the rasterizer, on the other core (below the video and audio tasks there: // they are short and must not wait behind a frame's drawing) g_gpu_stop = 0; - if (xTaskCreatePinnedToCore(gpu_task, "tpm_gpu", 8192, nullptr, 4, &g_gpu_task, 1) != pdPASS) { + if (xTaskCreatePinnedToCore(gpu_task, "tpm_gpu", 8192, reinterpret_cast(1), 4, &g_gpu_task, 1) != + pdPASS) { g_gpu_task = nullptr; logger.warn("no GPU worker: drawing on the game's thread"); + } else if (CONFIG_TPM_GPU_WORKERS > 1 && xTaskCreatePinnedToCore(gpu_task, "tpm_gpu0", 8192, reinterpret_cast(0), 6, &g_gpu0_task, + 0) != pdPASS) { + // the second worker sits above the game (5) on its core: the drawing is + // the bottleneck, and at the rendezvous (VRAM moves, the vblank) worker 1 + // waits for it; it sleeps whenever it has nothing to draw + g_gpu0_task = nullptr; + logger.warn("no second GPU worker: core 1 draws everything"); } #if CONFIG_TPM_PROFILE_VBLANK > 0 xTaskCreatePinnedToCore(prof_task, "tpm_prof", 4096, nullptr, 10, nullptr, 0); + xTaskCreatePinnedToCore(prof_task, "tpm_profg", 4096, reinterpret_cast(1), 10, nullptr, 1); #endif xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); // The recompiled code nests a C frame per guest call (<= 304 bytes on @@ -587,10 +660,11 @@ void deinit() { vTaskDelete(g_game_task); g_game_task = nullptr; } - if (g_gpu_task) { + if (g_gpu_task || g_gpu0_task) { g_gpu_stop = 1; - xTaskNotifyGive(g_gpu_task); - for (int i = 0; i < 100 && g_gpu_task; i++) { + for (int i = 0; i < 100 && (g_gpu_task || g_gpu0_task); i++) { + if (g_gpu_task) xTaskNotifyGive(g_gpu_task); + if (g_gpu0_task) xTaskNotifyGive(g_gpu0_task); vTaskDelay(pdMS_TO_TICKS(10)); } } @@ -683,7 +757,12 @@ void plat_display_ready(const RtDisplay *dp) { const int bytes = std::min(d.rgb24 ? d.w * 3 : d.w * 2, 2048); const int x0 = (d.x * 2) & 2047; for (size_t y = 0; y < SNAP_ROWS && static_cast(y) * step_y < d.h; y++) { - const int vy = (d.y + static_cast(y) * step_y) & 511; + const int line = static_cast(y) * step_y; + if (line < d.top || line >= d.top + d.lines) { + continue; // black (the video task fills it) + } + // the display area's first row shows on the range's first line + const int vy = (d.y + line - d.top) & 511; const auto *src = reinterpret_cast(&g_rt_vram[vy * 1024]); auto *out = reinterpret_cast(&g_snap[y * 1024]); const int first = std::min(bytes, 2048 - x0); @@ -701,14 +780,19 @@ void plat_gpu_kick(void) { if (tpm::engine::g_gpu_task) { xTaskNotifyGive(tpm::engine::g_gpu_task); } + if (tpm::engine::g_gpu0_task) { + xTaskNotifyGive(tpm::engine::g_gpu0_task); + } } -void plat_gpu_idle(void) { - using namespace tpm::engine; - if (xTaskGetCurrentTaskHandle() == g_gpu_task) { - ulTaskNotifyTake(pdTRUE, 1); // the worker: until kicked (or a tick) - } else { - esp_rom_delay_us(20); // the game's thread: the worker is on the other core +// A worker sleeps until kicked; the game's thread, waiting for room in the +// queue, sleeps until a worker made some (it must not spin: the second +// worker shares its core) +void plat_gpu_idle(void) { ulTaskNotifyTake(pdTRUE, 1); } + +void plat_gpu_wake_producer(void) { + if (tpm::engine::g_game_task) { + xTaskNotifyGive(tpm::engine::g_game_task); } } diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index f850f7b..d5c4e8a 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit f850f7bd8a24a1c277b5644e6d1853b59a4dd003 +Subproject commit d5c4e8a19fbb282454c16a9b41f5157cf4a71c86 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 56a9d23..ea9bd71 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -14,3 +14,7 @@ rt_set_gpu_trace g_rt_prof rt_gpu_set_async rt_gpu_worker +rt_gpu_worker_n +g_rt_gpu_split +g_rt_gpu_barriers +g_rt_gpu_wait_us From 8278ca2cafdb2bef64a714b5c9defffbccf61581 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 14:56:59 -0500 Subject: [PATCH 09/29] tpm: faster rasterizer and GTE, DMA-able disc reads, pad mapping, GPU bench - tpm-decomp 43bcb36 / psyz tpm: the runtime's own GTE (inline register moves, direct command calls; IRGB / LZCR / SXYP as the hardware), the rasterizer's row loops per span type, pre-modulated 4bpp palettes, exact 32-bit gradient divides, statistics compiled out, word-sized fills; SPU visits active voices only; DMA packets pushed to the GPU queue at once. The level goes from ~12 to ~15-16 fps here. - Disc read-ahead at 512-byte file offsets into a 128-byte-aligned PSRAM buffer: the SD driver no longer bounces through internal DMA memory, whose exhaustion failed reads and stalled the game. - The pad: face buttons by position as in the MGS port (B Cross, A Circle, Y Square, X Triangle), L1/L2/R1/R2 apart, the left stick as the d-pad. - CONFIG_TPM_GPU_BENCH_FILE: a desktop GPU recording replayed through the rasterizer at cart start (TPMBENCH lines). - The frame is converted on the GPU worker at the vblank (on core 0 later, the first room's glass and the pause menu came out wrong). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/CMakeLists.txt | 9 +- components/tpm/Kconfig | 10 ++ components/tpm/src/tpm_engine.cpp | 277 ++++++++++++++++-------------- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 2 + 6 files changed, 174 insertions(+), 128 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 15691e3..868e7bc 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 15691e36016c3f47cdd7cffd1f019ad8d3df2f82 +Subproject commit 868e7bc75b99caae82213d15e4b208996cc333ec diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index dd51073..04faf98 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -67,7 +67,7 @@ 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/libgte.c ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.c) + set(psyz_srcs ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.c) # (the GTE is the runtime's own: rt_gte.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 @@ -106,4 +106,11 @@ if(TPM_ENGINE_READY) add_custom_target(tpm_rt_bundle DEPENDS ${bundle}) add_dependencies(${COMPONENT_LIB} tpm_rt_bundle) target_link_libraries(${COMPONENT_LIB} PRIVATE ${bundle}) + + # bring-up: a GPU recording to replay as a benchmark (TPM_GPU_BENCH_FILE) + if(NOT "${CONFIG_TPM_GPU_BENCH_FILE}" STREQUAL "") + configure_file("${CONFIG_TPM_GPU_BENCH_FILE}" ${CMAKE_CURRENT_BINARY_DIR}/gpubench.bin COPYONLY) + target_add_binary_data(${COMPONENT_LIB} ${CMAKE_CURRENT_BINARY_DIR}/gpubench.bin BINARY) + target_compile_definitions(${COMPONENT_LIB} PRIVATE TPM_GPU_BENCH=1) + endif() endif() diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index eb5615d..6832d39 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -33,6 +33,16 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" this board it measured slower: both cores then contend for PSRAM and the cache with the game's recompiled code. + config TPM_GPU_BENCH_FILE + string "Test: GPU benchmark recording to embed (rt_gpu_record file)" + default "" + depends on TPM_ENGINE + help + A recording made by tpm-decomp's desktop runner (--gpu-record): + embedded in the firmware and replayed through the rasterizer on + core 1 when the cart starts, printing "TPMBENCH us per + vblank". Only for measuring the rasterizer. + config TPM_AUTOPRESS string "Test: scripted button presses (vblank:button,...)" default "" diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 8d53ebd..01b08a5 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -5,8 +5,8 @@ // Threads: // tpm_game (core 0) the recompiled code; the runtime's hardware model runs // inline at its loop back-edges (vblank, CD, SPU, interrupts) -// tpm_video (core 1) converts the PSX display area of VRAM to RGB565 and -// hands it to the HAL, woken by the game's vblanks +// tpm_video (core 1) hands the frame the GPU worker converted at the +// vblank to the HAL // tpm_audio (core 1) moves the SPU's samples to the HAL's audio queue // // The disc is a raw 2352-byte-sector image on the card (the .bin of a @@ -43,6 +43,9 @@ extern "C" { #include "shared_memory.h" // tpm_rt.ld: the runtime's statics, for the reset between launches extern uint8_t _tpm_bss_start[], _tpm_bss_end[], _tpm_data_start[], _tpm_data_end[]; +#if TPM_GPU_BENCH +extern const uint8_t _binary_gpubench_bin_start[], _binary_gpubench_bin_end[]; +#endif } namespace tpm::engine { @@ -76,12 +79,6 @@ std::atomic g_last_frame{nullptr}; std::atomic g_video_busy{false}; RtDisplay g_display{}; std::atomic g_flips{0}; // display-area moves: the game's frames -// The displayed rows, copied out of VRAM at the vblank on the game's thread -// (same layout as VRAM, only the displayed span of each row filled): the -// rasterizer draws synchronously on that thread, so read later from the video -// task VRAM may already hold half of the next frame. -uint16_t *g_snap{nullptr}; -constexpr size_t SNAP_ROWS = FRAME_H; // SPU output: the game's thread writes, the audio task reads constexpr size_t RING_FRAMES = 8192; // ~186 ms @@ -240,33 +237,36 @@ void prof_task(void *arg) { // ---- the disc ---- // Read-ahead: the game streams the disc sequentially, and on the card every -// separate read pays the SD's latency; 32 sectors (75 KB) per read. -constexpr uint32_t RA_SECTORS = 32; +// separate read pays the SD's latency. The window is read at 512-byte file +// offsets into a 128-byte-aligned PSRAM buffer, so FATFS hands the SD driver +// whole sectors at a DMA-able address: an unaligned read makes the driver +// bounce through internal DMA memory, which the Tab5 does not have to spare +// (reads failed outright once it ran out, and the game stalled on the disc). +constexpr uint32_t RA_BYTES = 64 * 1024; uint8_t *g_ra{nullptr}; -uint32_t g_ra_lba{0}, g_ra_count{0}; +uint64_t g_ra_off{0}; +uint32_t g_ra_len{0}; bool read_sector(uint32_t lba, uint8_t *out) { if (!g_disc || lba >= g_disc_sectors) { return false; } - if (g_ra && lba >= g_ra_lba && lba < g_ra_lba + g_ra_count) { - memcpy(out, g_ra + (lba - g_ra_lba) * 2352, 2352); - return true; - } - if (fseek(g_disc, static_cast(lba) * 2352, SEEK_SET) != 0) { - return false; - } - if (!g_ra) { - return fread(out, 1, 2352, g_disc) == 2352; - } - const uint32_t n = std::min(RA_SECTORS, g_disc_sectors - lba); - const size_t got = fread(g_ra, 2352, n, g_disc); - g_ra_lba = lba; - g_ra_count = static_cast(got); - if (got == 0) { - return false; + const uint64_t off = static_cast(lba) * 2352; + if (!g_ra || off < g_ra_off || off + 2352 > g_ra_off + g_ra_len) { + if (!g_ra) { + return fseek(g_disc, static_cast(off), SEEK_SET) == 0 && fread(out, 1, 2352, g_disc) == 2352; + } + g_ra_off = off & ~static_cast(511); + g_ra_len = 0; + if (fseek(g_disc, static_cast(g_ra_off), SEEK_SET) != 0) { + return false; + } + g_ra_len = static_cast(fread(g_ra, 1, RA_BYTES, g_disc)); + if (off + 2352 > g_ra_off + g_ra_len) { + return false; + } } - memcpy(out, g_ra, 2352); + memcpy(out, g_ra + (off - g_ra_off), 2352); return true; } @@ -338,67 +338,73 @@ void log_task_load() { logger.info(" cores:{}", line); } +// The displayed part of VRAM as the panel's RGB565 frame, one pass from VRAM. +// On the GPU worker at the vblank marker, so it sees exactly the drawing +// queued before it: converted later from another core, the glass in the first +// room and the pause menu came out wrong (the game draws into the frame on +// display at times). +void convert_frame(const RtDisplay &d, uint16_t *dst) { + if (!d.enabled) { + memset(dst, 0, FRAME_W * FRAME_H * 2); + } else { + // centre narrower modes, sample wider ones; 480-line modes show every + // other line + const int w = d.w, h = d.h; + const int step_y = h > static_cast(FRAME_H) ? 2 : 1; + const int x_pad = w < static_cast(FRAME_W) ? (FRAME_W - w) / 2 : 0; + for (size_t y = 0; y < FRAME_H; y++) { + uint16_t *out = dst + y * FRAME_W; + const int line = static_cast(y) * step_y; + if (line >= h || line < d.top || line >= d.top + d.lines) { + // below the mode's height, or outside the vertical display range + // (the letterbox of the cutscenes) + memset(out, 0, FRAME_W * 2); + continue; + } + if (x_pad) { + memset(out, 0, x_pad * 2); + memset(out + FRAME_W - x_pad, 0, x_pad * 2); + } + const size_t n = std::min(w, FRAME_W); + // the display area's first row shows on the range's first line + const uint16_t *vrow = &g_rt_vram[((d.y + line - d.top) & 511) * 1024]; + if (d.rgb24) { + const auto *row = reinterpret_cast(vrow); + for (size_t x = 0; x < n; x++) { + const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; + const uint8_t *p = row + ((d.x * 2 + sx * 3) & 2047); + out[x_pad + x] = static_cast(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); + } + } else if (w == static_cast(FRAME_W) && (d.x & 1) == 0 && d.x + w <= 1024) { + // the usual case: two pixels per word, BGR555 -> RGB565 with the + // top green bit repeated into the low one + const auto *src = reinterpret_cast(vrow + d.x); + auto *o = reinterpret_cast(out); + for (size_t x = 0; x < FRAME_W / 2; x++) { + const uint32_t v = src[x]; + o[x] = ((v & 0x001F001Fu) << 11) | ((v & 0x03E003E0u) << 1) | ((v & 0x02000200u) >> 4) | + ((v >> 10) & 0x001F001Fu); + } + } else { + const uint16_t *row = vrow; + for (size_t x = 0; x < n; x++) { + const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; + const uint16_t v = row[(d.x + sx) & 1023]; + const uint16_t r = v & 0x1F, g = (v >> 5) & 0x1F, b = (v >> 10) & 0x1F; + out[x_pad + x] = static_cast((r << 11) | (((g << 1) | (g >> 4)) << 5) | b); + } + } + } + } +} + void video_task(void *) { while (!g_stop.load()) { if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)) == 0) { continue; } const RtDisplay d = g_display; - if (!g_snap) { - g_video_busy = false; - continue; - } auto *dst = reinterpret_cast(g_frames[g_frame_index]); - if (!d.enabled) { - memset(dst, 0, FRAME_W * FRAME_H * 2); - } else { - // centre narrower modes, sample wider ones; 480-line modes show every - // other line - const int w = d.w, h = d.h; - const int step_y = h > static_cast(FRAME_H) ? 2 : 1; - const int x_pad = w < static_cast(FRAME_W) ? (FRAME_W - w) / 2 : 0; - for (size_t y = 0; y < FRAME_H; y++) { - uint16_t *out = dst + y * FRAME_W; - const int line = static_cast(y) * step_y; - if (line >= h || line < d.top || line >= d.top + d.lines) { - // below the mode's height, or outside the vertical display range - // (the letterbox of the cutscenes) - memset(out, 0, FRAME_W * 2); - continue; - } - if (x_pad) { - memset(out, 0, x_pad * 2); - memset(out + FRAME_W - x_pad, 0, x_pad * 2); - } - const size_t n = std::min(w, FRAME_W); - if (d.rgb24) { - const auto *row = reinterpret_cast(&g_snap[y * 1024]); - for (size_t x = 0; x < n; x++) { - const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; - const uint8_t *p = row + ((d.x * 2 + sx * 3) & 2047); - out[x_pad + x] = static_cast(((p[0] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | (p[2] >> 3)); - } - } else if (w == static_cast(FRAME_W) && (d.x & 1) == 0 && d.x + w <= 1024) { - // the usual case: two pixels per word, BGR555 -> RGB565 with the - // top green bit repeated into the low one - const auto *src = reinterpret_cast(&g_snap[y * 1024 + d.x]); - auto *o = reinterpret_cast(out); - for (size_t x = 0; x < FRAME_W / 2; x++) { - const uint32_t v = src[x]; - o[x] = ((v & 0x001F001Fu) << 11) | ((v & 0x03E003E0u) << 1) | ((v & 0x02000200u) >> 4) | - ((v >> 10) & 0x001F001Fu); - } - } else { - const uint16_t *row = &g_snap[y * 1024]; - for (size_t x = 0; x < n; x++) { - const size_t sx = w > static_cast(FRAME_W) ? x * w / FRAME_W : x; - const uint16_t v = row[(d.x + sx) & 1023]; - const uint16_t r = v & 0x1F, g = (v >> 5) & 0x1F, b = (v >> 10) & 0x1F; - out[x_pad + x] = static_cast((r << 11) | (((g << 1) | (g >> 4)) << 5) | b); - } - } - } - } g_last_frame = g_frames[g_frame_index]; Tab5Emu::get().push_frame(dst); g_frame_index ^= 1; @@ -508,7 +514,34 @@ void gpu_task(void *arg) { vTaskDelete(nullptr); } +#if TPM_GPU_BENCH +// the embedded recording through the rasterizer, on the GPU worker's core +void bench_task(void *arg) { + const size_t n = _binary_gpubench_bin_end - _binary_gpubench_bin_start; + auto *rec = static_cast(heap_caps_malloc(n, MALLOC_CAP_SPIRAM)); + if (rec) { + memcpy(rec, _binary_gpubench_bin_start, n); + static const char *const modes[] = {"all", "no span pixels", "setup only", "no triangles", + "span pixels to internal RAM", "all, per-row dispatch", + "all, no pre-modulated palettes"}; + for (int mode = 0; mode < 7; mode++) { + rt_gpu_bench_mode(mode); + const uint32_t us = rt_gpu_bench(rec, static_cast(n / 4), 3); + printf("TPMBENCH %u us per vblank (%s)\n", static_cast(us), modes[mode]); + } + rt_gpu_bench_mode(0); + heap_caps_free(rec); + } + xTaskNotifyGive(static_cast(arg)); + vTaskDelete(nullptr); +} +#endif + void game_task(void *) { +#if TPM_GPU_BENCH + xTaskCreatePinnedToCore(bench_task, "tpm_bench", 8192, xTaskGetCurrentTaskHandle(), 4, nullptr, 1); + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); +#endif logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); @@ -557,8 +590,8 @@ bool init(const std::string &data_dir) { return false; } setvbuf(g_disc, nullptr, _IONBF, 0); // the read-ahead buffers instead - g_ra = static_cast(heap_caps_malloc(RA_SECTORS * 2352, MALLOC_CAP_SPIRAM)); - g_ra_count = 0; + g_ra = static_cast(heap_caps_aligned_alloc(128, RA_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA)); + g_ra_len = 0; fseek(g_disc, 0, SEEK_END); g_disc_sectors = static_cast(ftell(g_disc) / 2352); logger.info("disc {}: {} sectors", disc, g_disc_sectors); @@ -593,14 +626,14 @@ bool init(const std::string &data_dir) { emu.audio_max_wait_ms(30); g_ring = static_cast(heap_caps_calloc(RING_FRAMES * 2, sizeof(int16_t), MALLOC_CAP_SPIRAM)); - g_snap = static_cast(heap_caps_calloc(SNAP_ROWS * 1024, sizeof(uint16_t), MALLOC_CAP_SPIRAM)); g_resume = xSemaphoreCreateBinary(); g_stop = false; g_paused = false; g_game_done = false; g_ring_w = g_ring_r = 0; reset_statics(); - logger.info("internal free {} B, PSRAM free {} B (largest {})", heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + logger.info("internal free {} B (DMA-capable {} B), PSRAM free {} B (largest {})", + heap_caps_get_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA), heap_caps_get_free_size(MALLOC_CAP_SPIRAM), heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM)); parse_presses(CONFIG_TPM_AUTOPRESS); @@ -680,11 +713,9 @@ void deinit() { } heap_caps_free(g_ra); g_ra = nullptr; - g_ra_count = 0; + g_ra_len = 0; heap_caps_free(g_ring); g_ring = nullptr; - heap_caps_free(g_snap); - g_snap = nullptr; if (g_resume) { vSemaphoreDelete(g_resume); g_resume = nullptr; @@ -745,33 +776,11 @@ void plat_display_ready(const RtDisplay *dp) { // present every other vblank at most (the game runs at 30 or less), and // never queue behind a conversion that is still running static uint32_t n; - if ((++n & 1) || g_video_busy.load() || !g_video_task || !g_snap || !g_rt_vram) { + if ((++n & 1) || g_video_busy.load() || !g_video_task || !g_rt_vram) { return; } - const RtDisplay d = *dp; - g_display = d; - if (d.enabled) { - // the displayed span of each displayed row (bytes, wrapping at the row's - // 2 KB like the hardware) - const int step_y = d.h > static_cast(FRAME_H) ? 2 : 1; - const int bytes = std::min(d.rgb24 ? d.w * 3 : d.w * 2, 2048); - const int x0 = (d.x * 2) & 2047; - for (size_t y = 0; y < SNAP_ROWS && static_cast(y) * step_y < d.h; y++) { - const int line = static_cast(y) * step_y; - if (line < d.top || line >= d.top + d.lines) { - continue; // black (the video task fills it) - } - // the display area's first row shows on the range's first line - const int vy = (d.y + line - d.top) & 511; - const auto *src = reinterpret_cast(&g_rt_vram[vy * 1024]); - auto *out = reinterpret_cast(&g_snap[y * 1024]); - const int first = std::min(bytes, 2048 - x0); - memcpy(out + x0, src + x0, first); - if (first < bytes) { - memcpy(out, src, bytes - first); - } - } - } + g_display = *dp; + convert_frame(g_display, reinterpret_cast(g_frames[g_frame_index])); g_video_busy = true; xTaskNotifyGive(g_video_task); } @@ -796,24 +805,42 @@ void plat_gpu_wake_producer(void) { } } +// The HAL's merged gamepad (touch zones, USB HID pad, keyboard) as a PSX +// digital pad. Face buttons by position, as in the MGS port: the HAL +// normalises a pad's face buttons to A = east, B = south, X = north, +// Y = west, so B is Cross, A Circle, Y Square and X Triangle. A USB pad's +// trigger is folded into its shoulder bit and also reported alone (l2 / r2), +// so L1 is "l without l2". The left stick drives the d-pad. uint16_t plat_pad_buttons(int port) { if (port != 0) { return 0; } - const auto s = Tab5Emu::get().gamepad_state(); + auto &emu = Tab5Emu::get(); + const auto s = emu.gamepad_state(); + bool up = s.up, down = s.down, left = s.left, right = s.right; + if (emu.usb_gamepad_present()) { + constexpr int DEAD = 32767 * 4 / 10; + const auto ax = emu.gamepad_axes(); + left |= ax.lx < -DEAD; + right |= ax.lx > DEAD; + up |= ax.ly < -DEAD; + down |= ax.ly > DEAD; + } uint16_t b = 0; if (s.select) b |= 1u << 0; if (s.start) b |= 1u << 3; - if (s.up) b |= 1u << 4; - if (s.right) b |= 1u << 5; - if (s.down) b |= 1u << 6; - if (s.left) b |= 1u << 7; - if (s.l) b |= 1u << 10; // L1 - if (s.r) b |= 1u << 11; // R1 - if (s.y) b |= 1u << 12; // triangle (north) - if (s.b) b |= 1u << 13; // circle (east) - if (s.a) b |= 1u << 14; // cross (south) - if (s.x) b |= 1u << 15; // square (west) + if (up) b |= 1u << 4; + if (right) b |= 1u << 5; + if (down) b |= 1u << 6; + if (left) b |= 1u << 7; + if (s.l2) b |= 1u << 8; // L2 + if (s.r2) b |= 1u << 9; // R2 + if (s.l && !s.l2) b |= 1u << 10; // L1 + if (s.r && !s.r2) b |= 1u << 11; // R1 + if (s.x) b |= 1u << 12; // triangle (north) + if (s.a) b |= 1u << 13; // circle (east) + if (s.b) b |= 1u << 14; // cross (south) + if (s.y) b |= 1u << 15; // square (west) return b | tpm::engine::scripted_buttons(rt_frame_count()); } diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index d5c4e8a..43bcb36 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit d5c4e8a19fbb282454c16a9b41f5157cf4a71c86 +Subproject commit 43bcb3667af5447a3d9c0a4a09c657e95f5705b6 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index ea9bd71..28edf08 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -18,3 +18,5 @@ rt_gpu_worker_n g_rt_gpu_split g_rt_gpu_barriers g_rt_gpu_wait_us +rt_gpu_bench +rt_gpu_bench_mode From 86ba252b1b6dbf2e0f8e6e3339320455bcfc1e6f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 16:00:00 -0500 Subject: [PATCH 10/29] tpm: the pause menu stays up; inlined guest accessors; frames converted on flips - While the emulator's pause menu is up nothing is presented: the GPU worker still worked through the queued commands and drew game frames over the menu. - tpm-decomp: the plain guest loads/stores always inline (the game thread ~9% faster per frame, the generated code twice the size); psyz: constant texture row shift. - A frame is converted when the game flips (or every fourth vblank). - The second GPU worker sits below the game when enabled (still slower: CONFIG_TPM_GPU_WORKERS stays 1). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/src/tpm_engine.cpp | 26 +++++++++++++++++++++----- components/tpm/tpm-decomp | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 868e7bc..dd1fb1d 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 868e7bc75b99caae82213d15e4b208996cc333ec +Subproject commit dd1fb1dc3d6a0c39f6ee2227b27d070cac24b8e2 diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 01b08a5..4fbc7d7 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -405,6 +405,10 @@ void video_task(void *) { } const RtDisplay d = g_display; auto *dst = reinterpret_cast(g_frames[g_frame_index]); + if (g_paused.load()) { // converted just before the pause: keep the menu + g_video_busy = false; + continue; + } g_last_frame = g_frames[g_frame_index]; Tab5Emu::get().push_frame(dst); g_frame_index ^= 1; @@ -652,7 +656,7 @@ bool init(const std::string &data_dir) { pdPASS) { g_gpu_task = nullptr; logger.warn("no GPU worker: drawing on the game's thread"); - } else if (CONFIG_TPM_GPU_WORKERS > 1 && xTaskCreatePinnedToCore(gpu_task, "tpm_gpu0", 8192, reinterpret_cast(0), 6, &g_gpu0_task, + } else if (CONFIG_TPM_GPU_WORKERS > 1 && xTaskCreatePinnedToCore(gpu_task, "tpm_gpu0", 8192, reinterpret_cast(0), 4, &g_gpu0_task, 0) != pdPASS) { // the second worker sits above the game (5) on its core: the drawing is // the bottleneck, and at the rendezvous (VRAM moves, the vblank) worker 1 @@ -773,12 +777,24 @@ void plat_vblank(void) { // On the GPU worker (core 1), after the drawing queued before the vblank. void plat_display_ready(const RtDisplay *dp) { using namespace tpm::engine; - // present every other vblank at most (the game runs at 30 or less), and - // never queue behind a conversion that is still running - static uint32_t n; - if ((++n & 1) || g_video_busy.load() || !g_video_task || !g_rt_vram) { + // present when the game shows a new frame (the display moved to the other + // buffer), at most every other vblank (it runs at 30 or less), and every + // fourth anyway for screens drawn in place; a conversion is a tenth of the + // GPU worker's time, so not repeating the same frame matters. Never queue + // behind a conversion that is still running, and nothing while the + // emulator's pause menu is up (the GPU worker still works through what the + // game queued before it stopped, and its frames would cover the menu). + static uint32_t since; + static int shown_x = -1, shown_y = -1; + since++; + const bool flipped = dp->x != shown_x || dp->y != shown_y; + if (!((flipped && since >= 2) || since >= 4) || g_paused.load() || g_video_busy.load() || !g_video_task || + !g_rt_vram) { return; } + since = 0; + shown_x = dp->x; + shown_y = dp->y; g_display = *dp; convert_frame(g_display, reinterpret_cast(g_frames[g_frame_index])); g_video_busy = true; diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 43bcb36..4c46873 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 43bcb3667af5447a3d9c0a4a09c657e95f5705b6 +Subproject commit 4c46873d7db2722fb8f1764720fb37eec9f18a2f From c02057f84afa40fc9730eb63673c41d2fbeccf0d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 16:18:35 -0500 Subject: [PATCH 11/29] tpm: a pause test hook (CONFIG_TPM_TEST_PAUSE_VBLANK) Pauses the engine the way the emulator's menu does at a given vblank and logs how much of the frame the menu's screenshot would read is lit. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/Kconfig | 9 +++++++++ components/tpm/src/tpm_engine.cpp | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 6832d39..d1dca27 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -43,6 +43,15 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" core 1 when the cart starts, printing "TPMBENCH us per vblank". Only for measuring the rasterizer. + config TPM_TEST_PAUSE_VBLANK + int "Test: pause at this vblank and log the paused frame (0: off)" + default 0 + depends on TPM_ENGINE + help + Bring-up aid: does what the emulator's pause menu does to the + engine (pause, read the last frame for the screenshot), logs how + much of that frame is not black, and resumes. + config TPM_AUTOPRESS string "Test: scripted button presses (vblank:button,...)" default "" diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 4fbc7d7..1c1e2af 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -234,6 +234,33 @@ void prof_task(void *arg) { } #endif +#if CONFIG_TPM_TEST_PAUSE_VBLANK > 0 +void pause_test_task(void *) { + while (!g_stop.load() && rt_frame_count() < CONFIG_TPM_TEST_PAUSE_VBLANK) { + vTaskDelay(pdMS_TO_TICKS(50)); + } + for (int round = 0; round < 3 && !g_stop.load(); round++) { + pause(); + const auto f = last_frame(); + const auto *px = reinterpret_cast(f.data()); + std::string rows; + size_t lit_total = 0; + for (size_t y = 0; px && y < FRAME_H; y++) { + size_t lit = 0; + for (size_t x = 0; x < FRAME_W; x++) lit += px[y * FRAME_W + x] != 0; + lit_total += lit; + if (y % 24 == 0) rows += fmt::format(" {}", lit); + } + logger.warn("pause test {}: last frame {} (buffers {} {}), {} lit pixels; per 24th row:{}", round, + fmt::ptr(px), fmt::ptr(g_frames[0]), fmt::ptr(g_frames[1]), lit_total, rows); + vTaskDelay(pdMS_TO_TICKS(500)); + resume(); + vTaskDelay(pdMS_TO_TICKS(3000)); + } + vTaskDelete(nullptr); +} +#endif + // ---- the disc ---- // Read-ahead: the game streams the disc sequentially, and on the card every @@ -669,6 +696,9 @@ bool init(const std::string &data_dir) { xTaskCreatePinnedToCore(prof_task, "tpm_profg", 4096, reinterpret_cast(1), 10, nullptr, 1); #endif xTaskCreatePinnedToCore(audio_task, "tpm_audio", 4096, nullptr, 7, &g_audio_task, 1); +#if CONFIG_TPM_TEST_PAUSE_VBLANK > 0 + xTaskCreatePinnedToCore(pause_test_task, "tpm_ptest", 4096, nullptr, 5, nullptr, 0); +#endif // The recompiled code nests a C frame per guest call (<= 304 bytes on // RV32, guest chains seen up to 25 deep including interrupts): 32 KB, in // internal RAM since those frames are the guest's registers; PSRAM if the From 37dda9b8cd0c2213ee4ac9f16d8788fac97d729a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 16:48:46 -0500 Subject: [PATCH 12/29] tpm: stacks, guest registers and the CLUT cache in PSRAM On the P4 the cores reach cached PSRAM faster than internal RAM: the game thread's stack (the recompiled code spills guest registers to it all the time), the GPU worker's, g_cpu / g_gte and the CLUT cache now live in PSRAM. The game thread needs ~15% less time per frame; the GPU worker suspends at the end and the cart deletes it (PSRAM stacks). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/src/tpm_engine.cpp | 46 +++++++++++++++++++------------ components/tpm/tpm-decomp | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index dd1fb1d..2d4ba1d 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit dd1fb1dc3d6a0c39f6ee2227b27d070cac24b8e2 +Subproject commit 2d4ba1da9e0e526f1b16d000f69474534bb77ed8 diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 1c1e2af..e7ddaa1 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -67,6 +67,7 @@ TaskHandle_t g_video_task{nullptr}; TaskHandle_t g_gpu_task{nullptr}; // core 1: the GPU's command stream, top band TaskHandle_t g_gpu0_task{nullptr}; // core 0: the bottom band, in the game's idle time volatile int g_gpu_stop{0}; +volatile bool g_gpu_done[2]{}; TaskHandle_t g_audio_task{nullptr}; std::atomic g_game_done{false}; std::atomic g_stop{false}; @@ -537,12 +538,8 @@ void audio_task(void *) { void gpu_task(void *arg) { const int idx = static_cast(reinterpret_cast(arg)); rt_gpu_worker_n(&g_gpu_stop, idx); - if (idx == 1) { - g_gpu_task = nullptr; - } else { - g_gpu0_task = nullptr; - } - vTaskDelete(nullptr); + g_gpu_done[idx] = true; + vTaskSuspend(nullptr); // deinit deletes it (its stack is in PSRAM) } #if TPM_GPU_BENCH @@ -554,8 +551,9 @@ void bench_task(void *arg) { memcpy(rec, _binary_gpubench_bin_start, n); static const char *const modes[] = {"all", "no span pixels", "setup only", "no triangles", "span pixels to internal RAM", "all, per-row dispatch", - "all, no pre-modulated palettes"}; - for (int mode = 0; mode < 7; mode++) { + "all, no pre-modulated palettes", + "per-row dispatch, texels from internal RAM"}; + for (int mode = 0; mode < 8; mode++) { rt_gpu_bench_mode(mode); const uint32_t us = rt_gpu_bench(rec, static_cast(n / 4), 3); printf("TPMBENCH %u us per vblank (%s)\n", static_cast(us), modes[mode]); @@ -564,14 +562,18 @@ void bench_task(void *arg) { heap_caps_free(rec); } xTaskNotifyGive(static_cast(arg)); - vTaskDelete(nullptr); + vTaskSuspend(nullptr); } #endif void game_task(void *) { #if TPM_GPU_BENCH - xTaskCreatePinnedToCore(bench_task, "tpm_bench", 8192, xTaskGetCurrentTaskHandle(), 4, nullptr, 1); + TaskHandle_t bench = nullptr; + xTaskCreatePinnedToCoreWithCaps(bench_task, "tpm_bench", 8192, xTaskGetCurrentTaskHandle(), 4, &bench, 1, + MALLOC_CAP_SPIRAM); ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + vTaskDelay(1); + vTaskDeleteWithCaps(bench); #endif logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); @@ -679,12 +681,18 @@ bool init(const std::string &data_dir) { // the rasterizer, on the other core (below the video and audio tasks there: // they are short and must not wait behind a frame's drawing) g_gpu_stop = 0; - if (xTaskCreatePinnedToCore(gpu_task, "tpm_gpu", 8192, reinterpret_cast(1), 4, &g_gpu_task, 1) != - pdPASS) { + g_gpu_done[0] = false; + g_gpu_done[1] = false; + // stacks in PSRAM: on the P4 the cores reach internal RAM more slowly than + // cached PSRAM, and these stacks are hot (the recompiled code spills guest + // registers to its stack all the time) + if (xTaskCreatePinnedToCoreWithCaps(gpu_task, "tpm_gpu", 8192, reinterpret_cast(1), 4, &g_gpu_task, 1, + MALLOC_CAP_SPIRAM) != pdPASS) { g_gpu_task = nullptr; logger.warn("no GPU worker: drawing on the game's thread"); - } else if (CONFIG_TPM_GPU_WORKERS > 1 && xTaskCreatePinnedToCore(gpu_task, "tpm_gpu0", 8192, reinterpret_cast(0), 4, &g_gpu0_task, - 0) != pdPASS) { + } else if (CONFIG_TPM_GPU_WORKERS > 1 && + xTaskCreatePinnedToCoreWithCaps(gpu_task, "tpm_gpu0", 8192, reinterpret_cast(0), 4, &g_gpu0_task, + 0, MALLOC_CAP_SPIRAM) != pdPASS) { // the second worker sits above the game (5) on its core: the drawing is // the bottleneck, and at the rendezvous (VRAM moves, the vblank) worker 1 // waits for it; it sleeps whenever it has nothing to draw @@ -704,8 +712,7 @@ bool init(const std::string &data_dir) { // internal RAM since those frames are the guest's registers; PSRAM if the // internal heap cannot spare it. constexpr uint32_t STACK = 32 * 1024; - if (xTaskCreatePinnedToCore(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0) != pdPASS && - xTaskCreatePinnedToCoreWithCaps(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0, + if (xTaskCreatePinnedToCoreWithCaps(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0, MALLOC_CAP_SPIRAM) != pdPASS) { logger.error("could not create the game task"); deinit(); @@ -724,16 +731,19 @@ void deinit() { for (int i = 0; i < 200 && !g_game_done.load(); i++) { vTaskDelay(pdMS_TO_TICKS(10)); } - vTaskDelete(g_game_task); + vTaskDeleteWithCaps(g_game_task); g_game_task = nullptr; } if (g_gpu_task || g_gpu0_task) { g_gpu_stop = 1; - for (int i = 0; i < 100 && (g_gpu_task || g_gpu0_task); i++) { + for (int i = 0; i < 100 && ((g_gpu_task && !g_gpu_done[1]) || (g_gpu0_task && !g_gpu_done[0])); i++) { if (g_gpu_task) xTaskNotifyGive(g_gpu_task); if (g_gpu0_task) xTaskNotifyGive(g_gpu0_task); vTaskDelay(pdMS_TO_TICKS(10)); } + if (g_gpu_task) vTaskDeleteWithCaps(g_gpu_task); + if (g_gpu0_task) vTaskDeleteWithCaps(g_gpu0_task); + g_gpu_task = g_gpu0_task = nullptr; } g_stop = true; if (g_video_task) { diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 4c46873..6792747 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 4c46873d7db2722fb8f1764720fb37eec9f18a2f +Subproject commit 679274714ecf83a98a1c1307c55b172c6ceebaeb From 0e278675fc639b27dc5ae85209fd1afe0d5e5da2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 21:00:21 -0500 Subject: [PATCH 13/29] tpm: SPU and GTE at -O3; GPU worker reads the queue in batches Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/CMakeLists.txt | 4 ++++ components/tpm/tpm-decomp | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 2d4ba1d..d93f685 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 2d4ba1da9e0e526f1b16d000f69474534bb77ed8 +Subproject commit d93f685d42cea8e8ca7903013029534da5f19036 diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 04faf98..57d0157 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -90,6 +90,10 @@ if(TPM_ENGINE_READY) # PSRAM-XIP, not IRAM (the runtime's default, as in the MGS port: internal # RAM is what the boot-time DMA reserve and the HAL need) set_property(SOURCE ${rt}/rt_gpu.c APPEND PROPERTY COMPILE_OPTIONS "-O3;-fomit-frame-pointer") + # ...as are the SPU's per-sample loop and the GTE's commands, on the game's + # thread + set_property(SOURCE ${TPM_PSYZ}/psyz/src/psyz/psyz_spu.c ${rt}/rt_gte.c APPEND PROPERTY + COMPILE_OPTIONS "-O3;-fomit-frame-pointer") # ---- hide it: components/mgs links its own psyz, with the same names ---- # One relocatable object with only the runtime's API left global. diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 6792747..302b416 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 679274714ecf83a98a1c1307c55b172c6ceebaeb +Subproject commit 302b4166cfa0d6a0e50fc51f52f8bff4ddc62598 From da1a24a7de801f45782fb37f93fdc98f34fd8000 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 13 Oct 2026 22:24:24 -0500 Subject: [PATCH 14/29] tpm: frameskip (CONFIG_TPM_FRAMESKIP, default 2) The GPU worker drops a frame's drawing when the game is a frame ahead of it; stale buffers are not presented. Status line shows game vs drawn fps. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/Kconfig | 14 ++++++++++++++ components/tpm/src/tpm_engine.cpp | 18 +++++++++++++++--- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 2 ++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index d1dca27..0e9ed61 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -33,6 +33,20 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" this board it measured slower: both cores then contend for PSRAM and the cache with the game's recompiled code. + config TPM_FRAMESKIP + int "Frameskip: most frames in a row the GPU may leave undrawn (0 = off)" + default 2 + range 0 4 + depends on TPM_ENGINE + help + When the game has queued a whole frame more than the GPU worker + has drawn, the worker drops the next frame's primitives into the + display buffers (texture uploads, VRAM copies and other drawing + still run) and the screen keeps the previous picture. The game + then runs at the speed its own thread allows instead of the + rasterizer's. Effects that read back the frame buffer may show + the older frame for a moment. + config TPM_GPU_BENCH_FILE string "Test: GPU benchmark recording to embed (rt_gpu_record file)" default "" diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index e7ddaa1..c54c9a5 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -452,10 +452,15 @@ void video_task(void *) { if (now - last_status_us >= 5000000) { static uint32_t last_flips = 0; const uint32_t flips = g_flips.load(); - logger.info("vblank {} | presented {} | game {:.1f} fps | {}x{} at ({},{}){}{}", rt_frame_count(), presented, - (flips - last_flips) * 1e6f / (now - last_status_us), d.w, d.h, d.x, d.y, d.rgb24 ? " 24-bit" : "", - d.enabled ? "" : " (display off)"); + static uint32_t last_skipped = 0; + const uint32_t skipped = g_rt_gpu_skipped; + logger.info("vblank {} | presented {} | game {:.1f} fps, drawn {:.1f} | {}x{} at ({},{}){}{}", rt_frame_count(), + presented, (flips - last_flips) * 1e6f / (now - last_status_us), + (static_cast(flips - last_flips) - static_cast(skipped - last_skipped)) * 1e6f / + (now - last_status_us), + d.w, d.h, d.x, d.y, d.rgb24 ? " 24-bit" : "", d.enabled ? "" : " (display off)"); last_flips = flips; + last_skipped = skipped; // the game thread's time over the same window static RtProf last_prof{}; const RtProf p = g_rt_prof; @@ -578,6 +583,7 @@ void game_task(void *) { logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); rt_set_realtime(1); rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); + rt_gpu_set_frameskip(CONFIG_TPM_FRAMESKIP); #if CONFIG_TPM_GPU_TRACE_VBLANK > 0 rt_set_gpu_trace(CONFIG_TPM_GPU_TRACE_VBLANK); #endif @@ -827,6 +833,12 @@ void plat_display_ready(const RtDisplay *dp) { static uint32_t since; static int shown_x = -1, shown_y = -1; since++; + if (dp->stale) { + // frameskip: this buffer's frame was not drawn; keep the last picture, + // and the next drawn frame counts as new wherever it is + shown_x = shown_y = -1; + return; + } const bool flipped = dp->x != shown_x || dp->y != shown_y; if (!((flipped && since >= 2) || since >= 4) || g_paused.load() || g_video_busy.load() || !g_video_task || !g_rt_vram) { diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 302b416..5601415 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 302b4166cfa0d6a0e50fc51f52f8bff4ddc62598 +Subproject commit 5601415bf1abdee06f105e7d18c42dcda81ac3b0 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 28edf08..09439b2 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -20,3 +20,5 @@ g_rt_gpu_barriers g_rt_gpu_wait_us rt_gpu_bench rt_gpu_bench_mode +rt_gpu_set_frameskip +g_rt_gpu_skipped From 02596122afd642f340fd4a6c33e2d974bbe011c4 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 00:01:57 -0500 Subject: [PATCH 15/29] tpm: GTE in 32 bits, SPU mixed per voice; bench reads its recording in place; PSRAM at boot logged Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/src/tpm_engine.cpp | 19 +++++++++++++++---- components/tpm/tpm-decomp | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index d93f685..c53dbdb 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit d93f685d42cea8e8ca7903013029534da5f19036 +Subproject commit c53dbdb21576fbde2e5ff99b29fc1f1746da7143 diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index c54c9a5..9f148c1 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -551,9 +551,17 @@ void gpu_task(void *arg) { // the embedded recording through the rasterizer, on the GPU worker's core void bench_task(void *arg) { const size_t n = _binary_gpubench_bin_end - _binary_gpubench_bin_start; - auto *rec = static_cast(heap_caps_malloc(n, MALLOC_CAP_SPIRAM)); + // read in place when aligned (the rodata is in PSRAM too): a copy is 3 MB + // the cart's own buffers may need + const bool in_place = (reinterpret_cast(_binary_gpubench_bin_start) & 3) == 0; + auto *rec = in_place ? reinterpret_cast(const_cast(_binary_gpubench_bin_start)) + : static_cast(heap_caps_malloc(n, MALLOC_CAP_SPIRAM)); + printf("TPMBENCH recording %u bytes, %s; PSRAM free %u KB\n", static_cast(n), in_place ? "in place" : "copied", + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); if (rec) { - memcpy(rec, _binary_gpubench_bin_start, n); + if (!in_place) { + memcpy(rec, _binary_gpubench_bin_start, n); + } static const char *const modes[] = {"all", "no span pixels", "setup only", "no triangles", "span pixels to internal RAM", "all, per-row dispatch", "all, no pre-modulated palettes", @@ -564,7 +572,9 @@ void bench_task(void *arg) { printf("TPMBENCH %u us per vblank (%s)\n", static_cast(us), modes[mode]); } rt_gpu_bench_mode(0); - heap_caps_free(rec); + if (!in_place) { + heap_caps_free(rec); + } } xTaskNotifyGive(static_cast(arg)); vTaskSuspend(nullptr); @@ -580,7 +590,8 @@ void game_task(void *) { vTaskDelay(1); vTaskDeleteWithCaps(bench); #endif - logger.info("booting SLUS_008.84 ({} bytes)", g_exe.size()); + logger.info("booting SLUS_008.84 ({} bytes); PSRAM free {} KB, largest block {} KB", g_exe.size(), + heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024, heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); rt_set_realtime(1); rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); rt_gpu_set_frameskip(CONFIG_TPM_FRAMESKIP); diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 5601415..65639a4 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 5601415bf1abdee06f105e7d18c42dcda81ac3b0 +Subproject commit 65639a4f02de1335d3c539a998ed6780e6af9b4e From fd2e0d1992b5778d3fdb27f2d1cef47ef1d016dd Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 11:06:56 -0500 Subject: [PATCH 16/29] tpm: scratchpad sites, GTE per command word, local calls; unpaced benchmark tpm-decomp: scratchpad accesses check the scratchpad first (the sites in config/scratch_sites.us.txt, passed to recomp.py), a GTE function per command word, calls between a function's entries as local jumps, the GTE dispatch always inlined; psyz: the SPU's voices run through a block in locals. The level benchmark below: 23.8 s -> 20.2 s of game thread (15% less); the level holds ~22 game fps (was 17-20). TPM_BENCH_FROM / TPM_BENCH_TO: run unpaced (with TPM_AUTOPRESS the same work every run) and log the time between two vblanks, less the game thread's waits for the GPU. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/CMakeLists.txt | 10 ++++++++-- components/tpm/Kconfig | 15 +++++++++++++++ components/tpm/src/tpm_engine.cpp | 19 ++++++++++++++++++- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 1 + 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index c53dbdb..6bd99cd 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit c53dbdb21576fbde2e5ff99b29fc1f1746da7143 +Subproject commit 6bd99cd9891ba91075b2431dbefc11f4089582a3 diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 57d0157..f94483c 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -32,11 +32,17 @@ if(CONFIG_TPM_ENGINE) set(python python3) endif() # Re-run when the executable or the recompiler changes + # the access sites that go to the scratchpad check it first + set(sites ${TPM_DECOMP}/config/scratch_sites.us.txt) + set(sites_arg) + if(EXISTS "${sites}") + set(sites_arg --scratch-sites "${sites}") + endif() set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${exe}" "${TPM_DECOMP}/tools/recomp.py" "${TPM_DECOMP}/config/recomp_entries.us.txt" - "${TPM_DECOMP}/recomp/hle.us.txt") + "${TPM_DECOMP}/recomp/hle.us.txt" "${sites}") execute_process( - COMMAND ${python} ${TPM_DECOMP}/tools/recomp.py "${exe}" ${TPM_GEN} --files 16 + COMMAND ${python} ${TPM_DECOMP}/tools/recomp.py "${exe}" ${TPM_GEN} --files 16 ${sites_arg} RESULT_VARIABLE rc OUTPUT_VARIABLE out ERROR_VARIABLE err) if(rc EQUAL 0) set(TPM_ENGINE_READY ON) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index 0e9ed61..b005f19 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -102,4 +102,19 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" default 0 depends on TPM_ENGINE + config TPM_BENCH_FROM + int "Test: run unpaced and time the vblanks from this one (0 = off)" + default 0 + depends on TPM_ENGINE + help + Emulated time runs as fast as the board can (no pacing to real + time; audio that does not fit is dropped), so with + TPM_AUTOPRESS the game does the same work every run, and the log + gives the time from this vblank to TPM_BENCH_TO. + + config TPM_BENCH_TO + int "Test: ... to this one" + default 0 + depends on TPM_BENCH_FROM > 0 + endmenu diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 9f148c1..4870ba7 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -592,7 +592,7 @@ void game_task(void *) { #endif logger.info("booting SLUS_008.84 ({} bytes); PSRAM free {} KB, largest block {} KB", g_exe.size(), heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024, heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); - rt_set_realtime(1); + rt_set_realtime(CONFIG_TPM_BENCH_FROM > 0 ? 0 : 1); rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); rt_gpu_set_frameskip(CONFIG_TPM_FRAMESKIP); #if CONFIG_TPM_GPU_TRACE_VBLANK > 0 @@ -821,6 +821,23 @@ uint32_t plat_disc_sectors(void) { return tpm::engine::g_disc_sectors; } void plat_vblank(void) { using namespace tpm::engine; +#if CONFIG_TPM_BENCH_FROM > 0 + { + // (the game's thread time is the total less its waits for the GPU) + static int64_t t0; + static uint64_t w0; + const uint32_t n = rt_frame_count(); + if (n == CONFIG_TPM_BENCH_FROM) { + t0 = esp_timer_get_time(); + w0 = g_rt_gpu_prod_wait_us; + } else if (n == CONFIG_TPM_BENCH_TO) { + const int64_t all = esp_timer_get_time() - t0; + const int64_t wait = static_cast(g_rt_gpu_prod_wait_us - w0); + logger.info("bench: vblanks {}..{} in {} ms, {} ms of it waiting for the GPU: game {} ms", + CONFIG_TPM_BENCH_FROM, CONFIG_TPM_BENCH_TO, all / 1000, wait / 1000, (all - wait) / 1000); + } + } +#endif static int last_x = -1, last_y = -1; RtDisplay d; rt_gpu_display(&d); diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 65639a4..bf546a9 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 65639a4f02de1335d3c539a998ed6780e6af9b4e +Subproject commit bf546a9c858950ff1c07523ef959943f3b1d4073 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 09439b2..bbe6ffd 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -22,3 +22,4 @@ rt_gpu_bench rt_gpu_bench_mode rt_gpu_set_frameskip g_rt_gpu_skipped +g_rt_gpu_prod_wait_us From 87a728f2cd3ed9e1bdfaaa39ee3f14f37ec056b0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 13:42:38 -0500 Subject: [PATCH 17/29] tpm: save states from the emulator's menu; SPU mixer speedups The runtime's buffers and the game thread's stack live in one PSRAM arena kept across quits (released before any other cart starts), so a state of raw memory loads at the same addresses. Taking a state, the game's thread checkpoints itself (setjmp) in plat_pause_wait; loading, it waits on a spare stack at the bottom of its own while the runtime's statics, the arena and the live part of its stack are read back, then jumps into the state's checkpoint. The GPU workers stop around both. States load only into the build that wrote them. CONFIG_TPM_TEST_STATE "A:B" saves at vblank A and loads at B; both passes log a guest RAM hash per vblank: the replay after the load matched all 120, also loading a state written before a reset. psyz: the SPU mixer's gauss window in registers, the envelope only where it changes (game thread benchmark 20.2 -> 19.0 s, audio identical). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/CMakeLists.txt | 2 +- components/tpm/Kconfig | 11 + components/tpm/include/tpm.hpp | 9 + components/tpm/src/tpm.cpp | 26 ++ components/tpm/src/tpm_engine.cpp | 465 +++++++++++++++++++++++++++++- components/tpm/src/tpm_engine.hpp | 7 + components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 4 + main/carts.hpp | 5 + main/tpm_cart.hpp | 22 ++ 11 files changed, 538 insertions(+), 17 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 6bd99cd..0e46fe9 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 6bd99cd9891ba91075b2431dbefc11f4089582a3 +Subproject commit 0e46fe9a92384f85831fd8068acd040d3c0fda03 diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index f94483c..e285baa 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -63,7 +63,7 @@ idf_component_register( INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS "src" "${TPM_DECOMP}/recomp/runtime" REQUIRES tab5-emu - PRIV_REQUIRES esp_timer heap statistics shared_memory + PRIV_REQUIRES esp_timer heap statistics shared_memory esp_app_format ) if(TPM_ENGINE_READY) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index b005f19..afa6765 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -57,6 +57,17 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" core 1 when the cart starts, printing "TPMBENCH us per vblank". Only for measuring the rasterizer. + config TPM_TEST_STATE + string "Test: save a state at vblank A, load it at vblank B (\"A:B\")" + default "" + depends on TPM_ENGINE + help + Bring-up aid: at vblank A pauses, saves a state to + /sdcard/tpm_state_test.sav and resumes; at B pauses, loads it and + resumes. From the save on, both passes print a hash of guest RAM + for 120 vblanks ("TPMHASH vblank hash"); the run after the load + must repeat them. B must be past A + 120. + config TPM_TEST_PAUSE_VBLANK int "Test: pause at this vblank and log the paused frame (0: off)" default 0 diff --git a/components/tpm/include/tpm.hpp b/components/tpm/include/tpm.hpp index 2143636..5c3b263 100644 --- a/components/tpm/include/tpm.hpp +++ b/components/tpm/include/tpm.hpp @@ -37,5 +37,14 @@ void resume(); std::pair video_size(); /// The last presented frame as RGB565, for screenshots. std::span video_buffer_rgb565(); +/// Save states, while paused (the emulator's menu). A state loads only into +/// the firmware build that wrote it, with the game's memory at the same +/// address, which the game normally gets back when it is launched first +/// after a restart. +bool save_state(const std::string &path); +bool load_state(const std::string &path); +/// Free the game's PSRAM block, which is kept across quits for the save +/// states; the launcher calls this before it starts any other cart. +void release_arena(); } // namespace tpm diff --git a/components/tpm/src/tpm.cpp b/components/tpm/src/tpm.cpp index a49ec5d..ddf7f60 100644 --- a/components/tpm/src/tpm.cpp +++ b/components/tpm/src/tpm.cpp @@ -205,4 +205,30 @@ std::span video_buffer_rgb565() { return f ? std::span{f, SCREEN_W * FRAME_H * 2} : std::span{}; } +bool save_state(const std::string &path) { +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + return engine::save_state(path); + } +#endif + logger_.warn("save state: the game is not running"); + return false; +} + +bool load_state(const std::string &path) { +#if defined(TPM_HAVE_ENGINE) + if (engine_) { + return engine::load_state(path); + } +#endif + logger_.warn("load state: the game is not running"); + return false; +} + +void release_arena() { +#if defined(TPM_HAVE_ENGINE) + engine::release_arena(); +#endif +} + } // namespace tpm diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 4870ba7..d1200e9 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include +#include #include #include #include @@ -43,6 +45,7 @@ extern "C" { #include "shared_memory.h" // tpm_rt.ld: the runtime's statics, for the reset between launches extern uint8_t _tpm_bss_start[], _tpm_bss_end[], _tpm_data_start[], _tpm_data_end[]; +extern uint8_t *g_ram; // guest RAM (the save state test hashes it) #if TPM_GPU_BENCH extern const uint8_t _binary_gpubench_bin_start[], _binary_gpubench_bin_end[]; #endif @@ -86,6 +89,71 @@ constexpr size_t RING_FRAMES = 8192; // ~186 ms int16_t *g_ring{nullptr}; std::atomic g_ring_w{0}, g_ring_r{0}; +// ---- the arena ---- +// +// The runtime's big buffers (guest RAM, VRAM, transfers, the GPU queue, the +// SPU) and the game thread's stack, in one PSRAM block, as the MGS port +// does: a save state is full of absolute pointers into them (the recompiled +// code keeps host pointers in its stack frames), so it loads only while they +// sit at the same addresses. The block is kept across the cart's quits for +// that reason; the launcher releases it before it starts any other cart. +// Launched first after a boot, the game normally gets the same block back. +// +// The stack is the block's first STACK bytes. Its lowest ALT bytes above the +// canary are where the game thread waits while a state is put back over the +// rest (the P4's stack guard faults on a stack pointer outside the task's +// stack, so the spare stack is inside it); a save point is far above them. +constexpr uint32_t STACK = 32 * 1024; +constexpr uint32_t ALT_LO = 1024, ALT_HI = 6 * 1024; +constexpr size_t ARENA_ALIGN = 64; +uint8_t *g_arena{nullptr}; +size_t g_arena_size{0}, g_arena_used{0}; +bool g_arena_overflow{false}; // some allocation came from the heap: no states +StaticTask_t g_game_tcb; + +size_t arena_bytes() { + int count = 0; + const uint32_t bytes = rt_mem_bytes(&count); + return STACK + bytes + static_cast(count) * ARENA_ALIGN; +} + +bool acquire_arena() { + const size_t total = arena_bytes(); + if (g_arena && g_arena_size != total) { + heap_caps_free(g_arena); + g_arena = nullptr; + } + if (!g_arena) { + g_arena = static_cast(heap_caps_aligned_alloc(ARENA_ALIGN, total, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!g_arena) { + logger.error("no {} KB of PSRAM for the game (free {} KB, largest {} KB)", total / 1024, + heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024, + heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); + return false; + } + g_arena_size = total; + logger.info("arena: {} KB at {}", total / 1024, fmt::ptr(g_arena)); + } + memset(g_arena, 0, g_arena_size); + g_arena_used = STACK; + g_arena_overflow = false; + return true; +} + +// ---- save states ---- +// +// Taken and put back by the menu's task while the game's thread waits in +// plat_pause_wait, where it checkpoints itself (setjmp) for a save, or for a +// load moves to the spare stack and waits there until the memory is back, +// then jumps to the state's checkpoint. A state is: the runtime's statics +// (.data, .bss), the arena's buffers and the live part of the game's stack. +enum SnapReq : int { SNAP_NONE, SNAP_SAVE, SNAP_LOAD }; +std::atomic g_snap_req{SNAP_NONE}; +std::atomic g_snap_ack{false}; // the game's thread: checkpoint taken / parked +std::atomic g_snap_done{false}; // the menu's task: go on +jmp_buf g_snap_jb; +uint8_t *g_snap_sp{nullptr}; + // ---- a fresh machine for every launch ---- // // The runtime's statics are put back before each boot, as the MGS port does @@ -262,6 +330,51 @@ void pause_test_task(void *) { } #endif +// Save states (CONFIG_TPM_TEST_STATE "A:B"): at vblank A a state is saved, +// at B loaded. From the save on, both passes log a hash of guest RAM at each +// of 120 vblanks: the game is deterministic in emulated time, so the run +// after the load must repeat the first pass's hashes. +std::atomic g_hash_from{0}; +constexpr uint32_t HASH_VBLANKS = 120; +#define TPM_STATE_TEST (sizeof(CONFIG_TPM_TEST_STATE) > 1) +void state_test_task(void *) { + const uint32_t a = static_cast(strtoul(CONFIG_TPM_TEST_STATE, nullptr, 10)); + const char *colon = strchr(CONFIG_TPM_TEST_STATE, ':'); + const uint32_t b = colon ? static_cast(strtoul(colon + 1, nullptr, 10)) : 0; + const std::string path = "/sdcard/tpm_state_test.sav"; + const uint32_t marks[2] = {a, b}; + // Only the load when the card already has a state from this build (an + // earlier boot's, after a reset): the hashes then start from the loaded + // vblank, to compare with that boot's + bool have = false; + if (FILE *f = fopen(path.c_str(), "rb")) { + uint8_t head[40]; + have = fread(head, 1, sizeof head, f) == sizeof head && memcmp(head, "TPMS", 4) == 0 && + memcmp(head + 8, esp_app_get_description()->app_elf_sha256, 32) == 0; + fclose(f); + } + if (have) { + logger.warn("state test: a state of this build is on the card: loading it at vblank {}", b); + } + for (int step = have ? 1 : 0; step < 2 && !g_stop.load(); step++) { + while (!g_stop.load() && rt_frame_count() < marks[step]) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + pause(); + vTaskDelay(pdMS_TO_TICKS(300)); + const uint32_t at = rt_frame_count(); + const bool ok = step == 0 ? save_state(path) : load_state(path); + logger.warn("state test: {} at vblank {}: {} (vblank now {})", step == 0 ? "save" : "load", at, + ok ? "ok" : "FAILED", rt_frame_count()); + if (step == 0 || (have && ok)) { + g_hash_from = (step == 0 ? at : rt_frame_count()) + 1; + } + vTaskDelay(pdMS_TO_TICKS(300)); + resume(); + } + vTaskDelete(nullptr); +} + // ---- the disc ---- // Read-ahead: the game streams the disc sequentially, and on the card every @@ -542,9 +655,45 @@ void audio_task(void *) { // worker 1 on core 1, worker 0 on core 0; each draws a band of rows void gpu_task(void *arg) { const int idx = static_cast(reinterpret_cast(arg)); - rt_gpu_worker_n(&g_gpu_stop, idx); - g_gpu_done[idx] = true; - vTaskSuspend(nullptr); // deinit deletes it (its stack is in PSRAM) + for (;;) { + rt_gpu_worker_n(&g_gpu_stop, idx); + g_gpu_done[idx] = true; + // stopped for a save state (started again after it), or for good: + // deinit deletes the task (its stack is in PSRAM) + while (g_gpu_stop) { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + g_gpu_done[idx] = false; + } +} + +// the workers out of the GPU's state (each finishes what it took from the +// queue; what is left stays queued, which is part of the state), and back +bool gpu_stop_workers() { + g_gpu_stop = 1; + for (int i = 0; i < 300; i++) { + if ((!g_gpu_task || g_gpu_done[1]) && (!g_gpu0_task || g_gpu_done[0])) { + return true; + } + if (g_gpu_task) xTaskNotifyGive(g_gpu_task); + if (g_gpu0_task) xTaskNotifyGive(g_gpu0_task); + vTaskDelay(pdMS_TO_TICKS(10)); + } + logger.error("the GPU workers did not stop"); + return false; +} + +void gpu_start_workers() { + g_gpu_stop = 0; + for (int i = 0; i < 300; i++) { + if ((!g_gpu_task || !g_gpu_done[1]) && (!g_gpu0_task || !g_gpu_done[0])) { + return; + } + if (g_gpu_task) xTaskNotifyGive(g_gpu_task); + if (g_gpu0_task) xTaskNotifyGive(g_gpu0_task); + vTaskDelay(pdMS_TO_TICKS(1)); + } + logger.error("the GPU workers did not start again"); } #if TPM_GPU_BENCH @@ -675,6 +824,10 @@ bool init(const std::string &data_dir) { emu.audio_sample_rate(AUDIO_RATE); emu.audio_max_wait_ms(30); + if (!acquire_arena()) { + deinit(); + return false; + } g_ring = static_cast(heap_caps_calloc(RING_FRAMES * 2, sizeof(int16_t), MALLOC_CAP_SPIRAM)); g_resume = xSemaphoreCreateBinary(); g_stop = false; @@ -724,13 +877,17 @@ bool init(const std::string &data_dir) { #if CONFIG_TPM_TEST_PAUSE_VBLANK > 0 xTaskCreatePinnedToCore(pause_test_task, "tpm_ptest", 4096, nullptr, 5, nullptr, 0); #endif + g_hash_from = 0; + if (TPM_STATE_TEST) { + xTaskCreatePinnedToCore(state_test_task, "tpm_stest", 8192, nullptr, 5, nullptr, 0); + } // The recompiled code nests a C frame per guest call (<= 304 bytes on - // RV32, guest chains seen up to 25 deep including interrupts): 32 KB, in - // internal RAM since those frames are the guest's registers; PSRAM if the - // internal heap cannot spare it. - constexpr uint32_t STACK = 32 * 1024; - if (xTaskCreatePinnedToCoreWithCaps(game_task, "tpm_game", STACK, nullptr, 5, &g_game_task, 0, - MALLOC_CAP_SPIRAM) != pdPASS) { + // RV32, guest chains seen up to 25 deep including interrupts): 32 KB, the + // arena's first part (PSRAM: on the P4 the cores reach cached PSRAM faster + // than internal RAM, and save states need it at a fixed address) + g_game_task = xTaskCreateStaticPinnedToCore(game_task, "tpm_game", STACK, nullptr, 5, + reinterpret_cast(g_arena), &g_game_tcb, 0); + if (!g_game_task) { logger.error("could not create the game task"); deinit(); return false; @@ -748,7 +905,7 @@ void deinit() { for (int i = 0; i < 200 && !g_game_done.load(); i++) { vTaskDelay(pdMS_TO_TICKS(10)); } - vTaskDeleteWithCaps(g_game_task); + vTaskDelete(g_game_task); // static: its stack is the arena's g_game_task = nullptr; } if (g_gpu_task || g_gpu0_task) { @@ -782,8 +939,9 @@ void deinit() { g_resume = nullptr; } g_last_frame = nullptr; - // the runtime's buffers (rt_shutdown dropped its pointers if the game got - // to return; the next launch resets the statics either way) + g_snap_req = SNAP_NONE; + // the runtime's buffers are the arena's, kept for the next launch (see + // release_arena); the pool holds only what did not fit in it shared_mem_clear(); g_exe.clear(); g_exe.shrink_to_fit(); @@ -810,6 +968,223 @@ std::span last_frame() { return f ? std::span{f, FRAME_W * FRAME_H * 2} : std::span{}; } +void release_arena() { + if (g_game_task) { + return; // the game is running in it + } + heap_caps_free(g_arena); + g_arena = nullptr; + g_arena_size = g_arena_used = 0; +} + +namespace { + +constexpr uint32_t kSnapVersion = 1; +struct SnapHeader { + char magic[4]; + uint32_t version; + uint8_t elf_sha[32]; // the build that wrote it: return addresses in the stack + uint32_t arena, arena_size, arena_used; + uint32_t bss, bss_size, data, data_size; + uint32_t stack_lo; // the saved part of the stack: [stack_lo, arena + STACK) + uint32_t jb_size; + uint8_t jb[sizeof(jmp_buf)]; +}; + +struct SnapRegion { + uint8_t *p; + size_t n; +}; + +// in the order of the file, after the header +std::vector snap_regions(uint8_t *stack_lo) { + return { + {_tpm_data_start, static_cast(_tpm_data_end - _tpm_data_start)}, + {_tpm_bss_start, static_cast(_tpm_bss_end - _tpm_bss_start)}, + {g_arena + STACK, g_arena_used - STACK}, + {stack_lo, static_cast(g_arena + STACK - stack_lo)}, + }; +} + +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; + } + p += chunk; + n -= chunk; + } + return true; +} + +// ask the game's thread (waiting in plat_pause_wait) for a checkpoint or to +// park; true once it has +bool snap_request(SnapReq req) { + g_snap_ack = false; + g_snap_done = false; + g_snap_req = req; + for (int i = 0; i < 300; i++) { + xSemaphoreGive(g_resume); + if (g_snap_ack.load()) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + g_snap_req = SNAP_NONE; + logger.error("{} state: the game's thread did not answer", req == SNAP_SAVE ? "save" : "load"); + return false; +} + +void snap_release() { + g_snap_done = true; + xSemaphoreGive(g_resume); + xTaskNotifyGive(g_game_task); +} + +} // namespace + +bool save_state(const std::string &path) { + if (!g_game_task || !g_paused.load() || g_game_done.load()) { + logger.error("save state: the game must be running and paused"); + return false; + } + if (g_arena_overflow) { + logger.error("save state: part of the game's memory is outside its arena"); + return false; + } + const int64_t t0 = esp_timer_get_time(); + if (!snap_request(SNAP_SAVE)) { + return false; + } + // the game's thread is at its checkpoint, waiting below it + uint8_t *const stack_lo = reinterpret_cast(reinterpret_cast(g_snap_sp - 256) & ~uintptr_t{15}); + if (stack_lo < g_arena + ALT_HI) { + logger.error("save state: the game's stack is too deep here ({} bytes)", g_arena + STACK - g_snap_sp); + snap_release(); + return false; + } + SnapHeader h{}; + memcpy(h.magic, "TPMS", 4); + h.version = kSnapVersion; + memcpy(h.elf_sha, esp_app_get_description()->app_elf_sha256, sizeof h.elf_sha); + h.arena = reinterpret_cast(g_arena); + h.arena_size = g_arena_size; + h.arena_used = g_arena_used; + h.bss = reinterpret_cast(_tpm_bss_start); + h.bss_size = _tpm_bss_end - _tpm_bss_start; + h.data = reinterpret_cast(_tpm_data_start); + h.data_size = _tpm_data_end - _tpm_data_start; + h.stack_lo = reinterpret_cast(stack_lo); + h.jb_size = sizeof(jmp_buf); + memcpy(h.jb, &g_snap_jb, sizeof(jmp_buf)); + bool ok = gpu_stop_workers(); + size_t total = sizeof h; + if (ok) { + FILE *f = fopen(path.c_str(), "wb"); + ok = f && fwrite(&h, 1, sizeof h, f) == sizeof h; + for (const auto &r : snap_regions(stack_lo)) { + ok = ok && io_chunked(f, r.p, r.n, true); + total += r.n; + } + if (f) { + ok = fclose(f) == 0 && ok; + } + } + gpu_start_workers(); + snap_release(); + logger.info("save state: {} {} ({} KB, stack {} B, in {} ms)", ok ? "wrote" : "FAILED to write", path, + total / 1024, g_arena + STACK - stack_lo, (esp_timer_get_time() - t0) / 1000); + return ok; +} + +bool load_state(const std::string &path) { + if (!g_game_task || !g_paused.load() || g_game_done.load()) { + logger.error("load state: the game must be running and paused"); + return false; + } + const int64_t t0 = esp_timer_get_time(); + FILE *f = fopen(path.c_str(), "rb"); + if (!f) { + 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 || + h.jb_size != sizeof(jmp_buf)) { + return refuse("not a save state of this format"); + } + if (memcmp(h.elf_sha, esp_app_get_description()->app_elf_sha256, sizeof h.elf_sha) != 0) { + return refuse("written by another firmware build; states load only into the build that wrote them"); + } + if (h.arena != reinterpret_cast(g_arena) || h.arena_size != g_arena_size || + h.arena_used != g_arena_used) { + return refuse("the game's memory sits elsewhere in this session (another game ran first?): " + "launch the game first after a restart to load it"); + } + auto *const stack_lo = reinterpret_cast(h.stack_lo); + if (h.bss != reinterpret_cast(_tpm_bss_start) || h.data != reinterpret_cast(_tpm_data_start) || + stack_lo < g_arena + ALT_HI || stack_lo >= g_arena + STACK) { + return refuse("layout mismatch"); + } + const auto regions = snap_regions(stack_lo); + { + // all of it must be there before the first byte of the game is overwritten + size_t expected = sizeof h; + for (const auto &r : regions) { + expected += r.n; + } + fseek(f, 0, SEEK_END); + const long actual = ftell(f); + fseek(f, sizeof h, SEEK_SET); + 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 + void **slots[16]; + void *kept[16]; + const int n_slots = rt_snap_preserve(slots, 16); + for (int i = 0; i < n_slots; i++) { + kept[i] = *slots[i]; + } + bool ok = true; + for (const auto &r : regions) { + ok = ok && io_chunked(f, r.p, r.n, false); + } + fclose(f); + for (int i = 0; i < n_slots; i++) { + *slots[i] = kept[i]; + } + if (!ok) { + // half restored: the game cannot go on; the cart quits + logger.error("load state: short read from {}: the game's memory is inconsistent, stopping it", path); + g_game_done = true; + return false; + } + rt_snap_restored(); + memcpy(&g_snap_jb, h.jb, sizeof(jmp_buf)); + gpu_start_workers(); + snap_release(); // the game's thread jumps into the state's checkpoint + logger.info("load state: {} restored in {} ms", path, (esp_timer_get_time() - t0) / 1000); + return true; +} + } // namespace tpm::engine // ---- what the runtime needs from the board (rt_platform.h) ---- @@ -838,6 +1213,17 @@ void plat_vblank(void) { } } #endif + if (TPM_STATE_TEST) { + const uint32_t from = g_hash_from.load(), n = rt_frame_count(); + if (from && n >= from && n < from + HASH_VBLANKS && g_ram) { + const auto *w = reinterpret_cast(g_ram); + uint32_t h = 2166136261u; + for (size_t i = 0; i < 2 * 1024 * 1024 / 4; i++) { + h = (h ^ w[i]) * 16777619u; + } + printf("TPMHASH %u %08x\n", static_cast(n), static_cast(h)); + } + } static int last_x = -1, last_y = -1; RtDisplay d; rt_gpu_display(&d); @@ -962,6 +1348,16 @@ void plat_audio(const int16_t *samples, int frames) { uint64_t plat_time_us(void) { return static_cast(esp_timer_get_time()); } void *plat_alloc(uint32_t size) { + using namespace tpm::engine; + // the arena, in rt_boot's fixed order (rt_mem_bytes sized it) + const size_t off = (g_arena_used + ARENA_ALIGN - 1) & ~(ARENA_ALIGN - 1); + if (g_arena && off + size <= g_arena_size) { + g_arena_used = off + size; + memset(g_arena + off, 0, size); + return g_arena + off; + } + g_arena_overflow = true; + tpm::engine::logger.warn("{} bytes outside the arena: no save states this run", size); shared_mem_request_t req{.size = size, .region = SHARED_MEM_CACHE_LINE, .storage = SHARED_MEM_PSRAM}; void *p = shared_mem_allocate(&req); // zeroed if (!p) { @@ -977,9 +1373,50 @@ void plat_sleep_us(uint32_t us) { vTaskDelay(std::max(1, pdMS_TO_TICKS(us / 1000))); } +// the game's thread, on the spare stack while a state is read over its own; +// then into the state's checkpoint +[[noreturn]] void tpm_snap_parked(void) { + using namespace tpm::engine; + g_snap_ack = true; + while (!g_snap_done.load()) { + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } + g_snap_req = SNAP_NONE; + longjmp(g_snap_jb, 1); +} + +// after the checkpoint: below it, until the state is written +static void __attribute__((noinline)) snap_saved_wait(void) { + using namespace tpm::engine; + g_snap_req = SNAP_NONE; + g_snap_ack = true; + while (!g_snap_done.load()) { + xSemaphoreTake(g_resume, pdMS_TO_TICKS(10)); + } +} + void plat_pause_wait(void) { - if (tpm::engine::g_resume) { - xSemaphoreTake(tpm::engine::g_resume, pdMS_TO_TICKS(100)); + using namespace tpm::engine; + const int req = g_snap_req.load(); + if (req == SNAP_SAVE) { + // a load of this state comes back here (setjmp returns 1) and returns to + // the runtime's pause loop, as the saving run does + if (setjmp(g_snap_jb) == 0) { + uint8_t *sp; + asm volatile("mv %0, sp" : "=r"(sp)); + g_snap_sp = sp; + snap_saved_wait(); + } + return; + } + if (req == SNAP_LOAD) { + // to the spare stack: the state is read over this one + uint8_t *const top = g_arena + ALT_HI; + asm volatile("mv sp, %0\n\tjr %1" : : "r"(top), "r"(&tpm_snap_parked) : "memory"); + __builtin_unreachable(); + } + if (g_resume) { + xSemaphoreTake(g_resume, pdMS_TO_TICKS(100)); } else { vTaskDelay(pdMS_TO_TICKS(10)); } diff --git a/components/tpm/src/tpm_engine.hpp b/components/tpm/src/tpm_engine.hpp index 45cbb32..f628e1a 100644 --- a/components/tpm/src/tpm_engine.hpp +++ b/components/tpm/src/tpm_engine.hpp @@ -16,5 +16,12 @@ void pause(); void resume(); /// The last presented frame (320x240 RGB565), or empty. std::span last_frame(); +/// Save states, while paused. A state loads only into the firmware build +/// that wrote it, with the game's memory where it was (see release_arena). +bool save_state(const std::string &path); +bool load_state(const std::string &path); +/// The game's PSRAM block is kept across quits (save states need it at the +/// same address); the launcher frees it before another cart starts. +void release_arena(); } // namespace tpm::engine diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index bf546a9..1200137 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit bf546a9c858950ff1c07523ef959943f3b1d4073 +Subproject commit 120013716a227c3badfc956f6d6fbebf0ae02e0d diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index bbe6ffd..794fc1d 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -23,3 +23,7 @@ rt_gpu_bench_mode rt_gpu_set_frameskip g_rt_gpu_skipped g_rt_gpu_prod_wait_us +rt_mem_bytes +rt_snap_preserve +rt_snap_restored +g_ram diff --git a/main/carts.hpp b/main/carts.hpp index 9980b77..b0770f8 100644 --- a/main/carts.hpp +++ b/main/carts.hpp @@ -14,6 +14,11 @@ inline std::unique_ptr make_cart(const RomInfo &info) { if (info.platform != Emulator::MGS) { mgs::release_arena(); // its 3.8 MB of PSRAM: Jedi Knight needs the room } +#endif +#if defined(ENABLE_TPM) + if (info.platform != Emulator::TPM) { + tpm::release_arena(); // its ~5 MB, likewise + } #endif switch (info.platform) { case Emulator::JEDI_KNIGHT: diff --git a/main/tpm_cart.hpp b/main/tpm_cart.hpp index 6744c87..ca917f2 100644 --- a/main/tpm_cart.hpp +++ b/main/tpm_cart.hpp @@ -40,6 +40,28 @@ class TpmCart : public Cart { #endif } + // Save states: the game is paused while the menu is up (pre_menu), which + // is what the snapshot needs. + void load() override { +#if defined(ENABLE_TPM) + const auto path = get_save_path(); + if (path.empty()) { + logger_.warn("load: no state in slot {}", get_selected_save_slot()); + return; + } + if (tpm::load_state(path)) { + Cart::load(); + } +#endif + } + void save() override { +#if defined(ENABLE_TPM) + if (tpm::save_state(get_save_path(true))) { + Cart::save(); + } +#endif + } + bool run() override { #if defined(ENABLE_TPM) if (!tpm::update()) { From 1f4a608baeed2a629fb5c6dc7cc1be363b24db0a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 17:45:14 -0500 Subject: [PATCH 18/29] tpm: recompiler value flow, GPU queue packets, faster loading, silent voices tpm-decomp c2eacd2 and psyz 0c2cfcb: - recomp.py proves the scratchpad accesses (renderer's base register) and skips their address checks - the GPU queue takes a DMA packet as one entry - libcd's command wait skips ahead to the next interrupt: the level load takes ~4 s of board time instead of ~25 s - frameskip drops a skipped frame's clear with its primitives - silent SPU voices skip interpolation and mixing (bit-exact) Board bench (vblanks 1800..3000): 19.0 -> 17.1 s. In level ~25-26 game fps, both cores busy. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/psyz | 2 +- components/tpm/tpm-decomp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/mgs/psyz b/components/mgs/psyz index 0e46fe9..0c2cfcb 160000 --- a/components/mgs/psyz +++ b/components/mgs/psyz @@ -1 +1 @@ -Subproject commit 0e46fe9a92384f85831fd8068acd040d3c0fda03 +Subproject commit 0c2cfcb60ab0e9638e8c7ab7f0e7d1abce766f65 diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 1200137..c2eacd2 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 120013716a227c3badfc956f6d6fbebf0ae02e0d +Subproject commit c2eacd228b7657128d95a48d8a94229c4fd1adbd From 48fc34585413d44563026c369465b1121b6fc038 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 18:22:27 -0500 Subject: [PATCH 19/29] tpm: the game's code keeps guest RAM's address in s11 tpm-decomp 75a9340: the bundle builds with -ffixed-s11 and RT_RAM_REG=s11, so the generated code reads g_ram from the register instead of loading the pointer before every access. Also GTE commands whose FLAG nothing reads skip it (ed1d607). Board bench (vblanks 1800..3000): 17.1 -> 16.4 s; save-state replay 120/120. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 5 ++++- components/tpm/tpm-decomp | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index e285baa..71c59f2 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -90,8 +90,11 @@ if(TPM_ENGINE_READY) # -O2 the game's code: speed over the IDF's size setting # -fno-strict-aliasing guest memory is read through any type # -msmall-data-limit=0 nothing in PSRAM may be addressed gp-relative + # -ffixed-s11 s11 holds the guest RAM's address for the game's code + # (RT_RAM_REG): nothing in the bundle may use it target_compile_options(tpm_rt PRIVATE -O2 -w -fno-strict-aliasing -msmall-data-limit=0 - -Wno-error) + -Wno-error -ffixed-s11) + target_compile_definitions(tpm_rt PRIVATE RT_RAM_REG=s11) # the rasterizer's inner loops decide the frame rate; they run from # PSRAM-XIP, not IRAM (the runtime's default, as in the MGS port: internal # RAM is what the boot-time DMA reserve and the HAL need) diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index c2eacd2..75a9340 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit c2eacd228b7657128d95a48d8a94229c4fd1adbd +Subproject commit 75a9340d54b855ec0c3d577b096e830d914cdec8 From 4e8dc9835d3f97b0de28eaa99a37850536753584 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 19:02:42 -0500 Subject: [PATCH 20/29] tpm: build the generated code with -fsched-pressure The generated functions are huge and short of registers; the scheduling pass that minds register pressure saves 1% of the game thread (bench 16.4 -> 16.3 s). Without the pass it was 2% slower; inlining RTPS/RTPT into the generated code was 5% slower (more pressure). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 71c59f2..03b61bb 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -98,6 +98,9 @@ if(TPM_ENGINE_READY) # the rasterizer's inner loops decide the frame rate; they run from # PSRAM-XIP, not IRAM (the runtime's default, as in the MGS port: internal # RAM is what the boot-time DMA reserve and the HAL need) + # the generated functions are huge and short of registers: scheduling + # that minds the pressure (-1%; without the scheduling pass, +2%) + set_property(SOURCE ${gen_srcs} APPEND PROPERTY COMPILE_OPTIONS "-fsched-pressure") set_property(SOURCE ${rt}/rt_gpu.c APPEND PROPERTY COMPILE_OPTIONS "-O3;-fomit-frame-pointer") # ...as are the SPU's per-sample loop and the GTE's commands, on the game's # thread From ab64eb7b017765534e0ffe2d213b477b013824f7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 19:08:43 -0500 Subject: [PATCH 21/29] tpm: the generated code without a frame pointer (-0.5%) Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 03b61bb..6fadf0e 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -95,12 +95,13 @@ if(TPM_ENGINE_READY) target_compile_options(tpm_rt PRIVATE -O2 -w -fno-strict-aliasing -msmall-data-limit=0 -Wno-error -ffixed-s11) target_compile_definitions(tpm_rt PRIVATE RT_RAM_REG=s11) + # the generated functions are huge and short of registers: scheduling + # that minds the pressure (-1%; without the scheduling pass, +2%), and s0 + # for the code instead of a frame pointer (-0.5%) + set_property(SOURCE ${gen_srcs} APPEND PROPERTY COMPILE_OPTIONS "-fsched-pressure;-fomit-frame-pointer") # the rasterizer's inner loops decide the frame rate; they run from # PSRAM-XIP, not IRAM (the runtime's default, as in the MGS port: internal # RAM is what the boot-time DMA reserve and the HAL need) - # the generated functions are huge and short of registers: scheduling - # that minds the pressure (-1%; without the scheduling pass, +2%) - set_property(SOURCE ${gen_srcs} APPEND PROPERTY COMPILE_OPTIONS "-fsched-pressure") set_property(SOURCE ${rt}/rt_gpu.c APPEND PROPERTY COMPILE_OPTIONS "-O3;-fomit-frame-pointer") # ...as are the SPU's per-sample loop and the GTE's commands, on the game's # thread From 7f74fef6227eb7ce9511b687da721e85fca17d73 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 19:20:14 -0500 Subject: [PATCH 22/29] tpm: save states without the GPU queue and the idle transfer buffer Saving lets the GPU workers drain the queue, then leaves out the ranges rt_snap_dead gives (the queue, the transfer buffer past any transfer in progress); the header (v2) lists them and a load skips them. 5.2 -> 3.6 MB, save 2.2 -> 1.5 s, load 0.83 -> 0.58 s; the replay after a load still matches 120/120 vblanks. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/src/tpm_engine.cpp | 65 +++++++++++++++++++++++++++---- components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 2 + 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index d1200e9..0ea8332 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -979,7 +979,8 @@ void release_arena() { namespace { -constexpr uint32_t kSnapVersion = 1; +constexpr uint32_t kSnapVersion = 2; +constexpr int kSnapSkips = 4; struct SnapHeader { char magic[4]; uint32_t version; @@ -989,6 +990,10 @@ struct SnapHeader { uint32_t stack_lo; // the saved part of the stack: [stack_lo, arena + STACK) uint32_t jb_size; uint8_t jb[sizeof(jmp_buf)]; + // arena ranges not in the file (rt_snap_dead: the drained GPU queue, the + // transfer buffer past any transfer), offsets from the arena, ascending + uint32_t n_skip; + uint32_t skip_off[kSnapSkips], skip_size[kSnapSkips]; }; struct SnapRegion { @@ -997,13 +1002,47 @@ struct SnapRegion { }; // in the order of the file, after the header -std::vector snap_regions(uint8_t *stack_lo) { - return { +std::vector snap_regions(uint8_t *stack_lo, const SnapHeader &h) { + std::vector r{ {_tpm_data_start, static_cast(_tpm_data_end - _tpm_data_start)}, {_tpm_bss_start, static_cast(_tpm_bss_end - _tpm_bss_start)}, - {g_arena + STACK, g_arena_used - STACK}, - {stack_lo, static_cast(g_arena + STACK - stack_lo)}, }; + // the arena past the stack, around the skipped ranges + uint32_t at = STACK; + for (uint32_t i = 0; i < h.n_skip; i++) { + if (h.skip_off[i] > at) { + r.push_back({g_arena + at, h.skip_off[i] - at}); + } + at = std::max(at, h.skip_off[i] + h.skip_size[i]); + } + if (g_arena_used > at) { + r.push_back({g_arena + at, g_arena_used - at}); + } + r.push_back({stack_lo, static_cast(g_arena + STACK - stack_lo)}); + return r; +} + +// the ranges rt_snap_dead gives, as arena offsets in ascending order (only +// those wholly inside the arena past the stack) +void snap_skips(SnapHeader &h) { + void *p[kSnapSkips]; + uint32_t n[kSnapSkips]; + const int k = rt_snap_dead(p, n, kSnapSkips); + h.n_skip = 0; + for (int i = 0; i < k; i++) { + const auto *b = static_cast(p[i]); + if (n[i] == 0 || b < g_arena + STACK || b + n[i] > g_arena + g_arena_used) { + continue; + } + h.skip_off[h.n_skip] = static_cast(b - g_arena); + h.skip_size[h.n_skip++] = n[i]; + } + for (uint32_t i = 1; i < h.n_skip; i++) { // a handful: insertion sort + for (uint32_t j = i; j > 0 && h.skip_off[j] < h.skip_off[j - 1]; j--) { + std::swap(h.skip_off[j], h.skip_off[j - 1]); + std::swap(h.skip_size[j], h.skip_size[j - 1]); + } + } } bool io_chunked(FILE *f, uint8_t *p, size_t n, bool writing) { @@ -1078,12 +1117,18 @@ bool save_state(const std::string &path) { h.stack_lo = reinterpret_cast(stack_lo); h.jb_size = sizeof(jmp_buf); memcpy(h.jb, &g_snap_jb, sizeof(jmp_buf)); + // the workers finish the queue first (it need not be saved then) + for (int i = 0; i < 500 && !rt_gpu_queue_empty(); i++) { + if (g_gpu_task) xTaskNotifyGive(g_gpu_task); + vTaskDelay(pdMS_TO_TICKS(2)); + } bool ok = gpu_stop_workers(); 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; - for (const auto &r : snap_regions(stack_lo)) { + for (const auto &r : snap_regions(stack_lo, h)) { ok = ok && io_chunked(f, r.p, r.n, true); total += r.n; } @@ -1132,7 +1177,13 @@ bool load_state(const std::string &path) { stack_lo < g_arena + ALT_HI || stack_lo >= g_arena + STACK) { return refuse("layout mismatch"); } - const auto regions = snap_regions(stack_lo); + for (uint32_t i = 0; i < h.n_skip; i++) { + if (h.n_skip > kSnapSkips || h.skip_off[i] < STACK || h.skip_off[i] + h.skip_size[i] > g_arena_used || + (i && h.skip_off[i] < h.skip_off[i - 1] + h.skip_size[i - 1])) { + return refuse("layout mismatch"); + } + } + const auto regions = snap_regions(stack_lo, h); { // all of it must be there before the first byte of the game is overwritten size_t expected = sizeof h; diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 75a9340..ee9dc51 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 75a9340d54b855ec0c3d577b096e830d914cdec8 +Subproject commit ee9dc51a4d434894a793ebfa8e4d8c30be0250bf diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 794fc1d..0b36925 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -27,3 +27,5 @@ rt_mem_bytes rt_snap_preserve rt_snap_restored g_ram +rt_snap_dead +rt_gpu_queue_empty From bc3521ba1a01d2aedf51c3c563df6fcc7160b1e9 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 20:52:22 -0500 Subject: [PATCH 23/29] tpm: the cutscene movies, with their audio tpm-decomp 880a0a8: the MDEC is emulated, and the movie player's play function is replaced by a native one (the game's own VLC decoder, the MDEC's decoding, XA-ADPCM into the SPU's CD input), since the original only keeps up at the hardware's exact pace. Start or Cross skips a movie. CONFIG_TPM_DISC_PATH (the disc image on the build machine) recompiles the FMV.CPE overlay; without it the movies are skipped as before. CONFIG_TPM_MOVIES turns them off; builds with CONFIG_TPM_AUTOPRESS skip them for the scripted timeline. Board: the LucasArts and Big Ape logos and the 110 s opening crawl play at 15 fps (full speed), the game thread ~81% busy. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/CMakeLists.txt | 16 +++++++++++++++- components/tpm/Kconfig | 19 +++++++++++++++++++ components/tpm/README.md | 14 ++++++++++---- components/tpm/src/tpm_engine.cpp | 7 +++++++ components/tpm/tpm-decomp | 2 +- components/tpm/tpm_rt.keep | 1 + 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/components/tpm/CMakeLists.txt b/components/tpm/CMakeLists.txt index 6fadf0e..a9ce0c4 100644 --- a/components/tpm/CMakeLists.txt +++ b/components/tpm/CMakeLists.txt @@ -33,6 +33,20 @@ if(CONFIG_TPM_ENGINE) endif() # Re-run when the executable or the recompiler changes # the access sites that go to the scratchpad check it first + # the movie player overlay comes from the disc image, when there is one + set(disc_arg) + set(disc "${CONFIG_TPM_DISC_PATH}") + if(disc) + if(NOT IS_ABSOLUTE "${disc}") + set(disc "${CMAKE_SOURCE_DIR}/${disc}") + endif() + if(EXISTS "${disc}") + set(disc_arg --disc "${disc}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${disc}") + else() + message(WARNING "tpm: ${disc} not found (CONFIG_TPM_DISC_PATH): no movies") + endif() + endif() set(sites ${TPM_DECOMP}/config/scratch_sites.us.txt) set(sites_arg) if(EXISTS "${sites}") @@ -42,7 +56,7 @@ if(CONFIG_TPM_ENGINE) "${exe}" "${TPM_DECOMP}/tools/recomp.py" "${TPM_DECOMP}/config/recomp_entries.us.txt" "${TPM_DECOMP}/recomp/hle.us.txt" "${sites}") execute_process( - COMMAND ${python} ${TPM_DECOMP}/tools/recomp.py "${exe}" ${TPM_GEN} --files 16 ${sites_arg} + COMMAND ${python} ${TPM_DECOMP}/tools/recomp.py "${exe}" ${TPM_GEN} --files 16 ${sites_arg} ${disc_arg} RESULT_VARIABLE rc OUTPUT_VARIABLE out ERROR_VARIABLE err) if(rc EQUAL 0) set(TPM_ENGINE_READY ON) diff --git a/components/tpm/Kconfig b/components/tpm/Kconfig index afa6765..69ce846 100644 --- a/components/tpm/Kconfig +++ b/components/tpm/Kconfig @@ -19,6 +19,25 @@ menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)" tpm-decomp/tools/extract_disc.py. It is only read at build time; nothing derived from it is committed. + config TPM_DISC_PATH + string "Path of the disc image (.bin), for the movies" + default "" + depends on TPM_ENGINE + help + Your own raw image of the game's disc (2352-byte sectors), absolute + or relative to the project. It is only read at build time: the + movie player overlay (FMV.CPE) is recompiled from it. Empty: the + cart skips the movies. + + config TPM_MOVIES + bool "Play the cutscene movies" + default y + depends on TPM_ENGINE + help + The movies play natively (tpm-decomp's rt_movie.c); Start or Cross + skips one. Needs TPM_DISC_PATH. Builds with TPM_AUTOPRESS skip them + regardless, for a fixed timeline. + config TPM_SLIDE_SECONDS int "Seconds per load screen in the data viewer" default 4 diff --git a/components/tpm/README.md b/components/tpm/README.md index 5a9f172..e05d2d4 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -21,6 +21,13 @@ cp /tmp/tpm/SLUS_008.84 components/tpm/tpm-decomp/disks/us/ idf.py build ``` +For the cutscene movies, point `CONFIG_TPM_DISC_PATH` at your disc image +too (read at build time only): the movie player overlay (`FMV.CPE`) is +recompiled from it, and the movies then play natively (tpm-decomp's +`rt_movie.c`; Start or Cross skips one). Without it they are skipped. +`CONFIG_TPM_MOVIES` turns them off; builds with `CONFIG_TPM_AUTOPRESS` skip +them anyway, for the scripted timeline. + CMake runs `tools/recomp.py` when it configures (and again whenever the executable or the recompiler changes); the C lands in the build directory. Without the executable the build still succeeds, with a warning, and the @@ -69,10 +76,9 @@ game's 30 (the game runs in slow motion: emulated time never skips ahead). - Disc reads go through a 32-sector read-ahead. - Pads: the HAL's merged gamepad; A = cross, B = circle, X = square, Y = triangle, L/R = L1/R1. -- Movies are skipped (no MDEC yet), XA speech / music is silent (not decoded - yet), there is no memory card (the game says so and carries on). At the - current speed the SPU makes samples at ~40% of real time, so sound breaks - up. +- 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). - The pause menu pauses the game at its next loop back-edge. Where the time goes (in the level): the game thread is busy all the time, diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 0ea8332..14498cc 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -744,6 +744,13 @@ void game_task(void *) { rt_set_realtime(CONFIG_TPM_BENCH_FROM > 0 ? 0 : 1); rt_gpu_set_async(g_gpu_task ? (g_gpu0_task ? 2 : 1) : 0); rt_gpu_set_frameskip(CONFIG_TPM_FRAMESKIP); + // the movies (when the overlay was recompiled: CONFIG_TPM_DISC_PATH); a + // test build's scripted presses assume the fixed timeline without them +#if CONFIG_TPM_MOVIES + rt_set_movies(g_presses.empty() ? 1 : 0); +#else + rt_set_movies(0); +#endif #if CONFIG_TPM_GPU_TRACE_VBLANK > 0 rt_set_gpu_trace(CONFIG_TPM_GPU_TRACE_VBLANK); #endif diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index ee9dc51..880a0a8 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit ee9dc51a4d434894a793ebfa8e4d8c30be0250bf +Subproject commit 880a0a8dcaa23fc1cb80a8e6646a8bebd27b2879 diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep index 0b36925..3a51aa1 100644 --- a/components/tpm/tpm_rt.keep +++ b/components/tpm/tpm_rt.keep @@ -29,3 +29,4 @@ rt_snap_restored g_ram rt_snap_dead rt_gpu_queue_empty +rt_set_movies From f9bfebc1ece4ab03cca627f6d4abcce51cc2cfab Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 23:04:15 -0500 Subject: [PATCH 24/29] mgs: mix the SPU sample by sample again With the psyz update the SPU mixes a block a voice at a time by default. MGS clocks its sound driver off the SPU interrupt (voice 23 loops a blank sample, the IRQ address toggled ahead of it), and mixed that way the beats came irregularly: about a third re-armed behind the voice and were caught up, against a tenth sample by sample (old psyz: similar). The opening's streamed audio was choppier. MGS now selects the per-sample reference mixer (still ~10% cheaper than before the update); the rasterizer speedups stay (the boot sequence reached frame 1320 at vblank 4824 instead of 5426). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/mgs/src/mgs_platform.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/components/mgs/src/mgs_platform.cpp b/components/mgs/src/mgs_platform.cpp index 9c16a78..e84119d 100644 --- a/components/mgs/src/mgs_platform.cpp +++ b/components/mgs/src/mgs_platform.cpp @@ -40,6 +40,7 @@ void mgs_platform_init(const char *data_dir) { g_sd_root = data_dir ? data_dir : extern "C" { void Psyz_SpuInit(void); +extern int psyz_spu_by_voice; // psyz_spu.c: 1 mixes a block a voice at a time void Psyz_SpuPullSamples(short *out, int num_frames); extern volatile unsigned psyz_spu_irq_raised, psyz_spu_voices_active, psyz_spu_irq_caught_up; // psyz_spu.c extern volatile unsigned mgs_spu_irq_delivered; // port/esp32_vblank.c @@ -55,6 +56,12 @@ std::atomic g_audio_stop{false}; std::atomic g_audio_done{false}; void audio_task(void *) { + // MGS clocks its sound driver off the SPU interrupt (voice 23 loops a blank + // sample, the IRQ address toggled ahead of it): mixed a voice at a time, + // the beats came irregularly (a third of them re-armed behind the voice + // and caught up, against a tenth sample by sample) and the opening's + // streamed audio was choppier. Sample by sample costs ~2.5x the mixing. + psyz_spu_by_voice = 0; auto &emu = Tab5Emu::get(); static int16_t buf[kAudioFrames * 2]; int64_t last_report = esp_timer_get_time(); From 806d8162668c132fa5cb64e2c46866e2365b17d0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 23:22:12 -0500 Subject: [PATCH 25/29] tpm: launch without the arena when PSRAM is in pieces After another game (MGS) the heap may have no ~5 MB block left; TPM then refused to start. Now it takes the game task's stack alone and the rest piece by piece from the heap, without save states for that session (the next launch tries for the whole block again). The MDEC's input buffer is also halved (tpm-decomp cf507fa). Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/src/tpm_engine.cpp | 24 +++++++++++++++++------- components/tpm/tpm-decomp | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/components/tpm/src/tpm_engine.cpp b/components/tpm/src/tpm_engine.cpp index 14498cc..66ca284 100644 --- a/components/tpm/src/tpm_engine.cpp +++ b/components/tpm/src/tpm_engine.cpp @@ -125,14 +125,24 @@ bool acquire_arena() { } if (!g_arena) { g_arena = static_cast(heap_caps_aligned_alloc(ARENA_ALIGN, total, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - if (!g_arena) { - logger.error("no {} KB of PSRAM for the game (free {} KB, largest {} KB)", total / 1024, - heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024, - heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); - return false; + if (g_arena) { + g_arena_size = total; + logger.info("arena: {} KB at {}", total / 1024, fmt::ptr(g_arena)); + } else { + // no block that size (another game ran first and the heap is in + // pieces): the game task's stack alone, everything else from the heap + // piece by piece (plat_alloc), and no save states this session; the + // next launch tries for the whole block again + logger.warn("no {} KB block of PSRAM (free {} KB, largest {} KB): running without save states", + total / 1024, heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024, + heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) / 1024); + g_arena = static_cast(heap_caps_aligned_alloc(ARENA_ALIGN, STACK, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!g_arena) { + logger.error("no PSRAM for the game's stack"); + return false; + } + g_arena_size = STACK; } - g_arena_size = total; - logger.info("arena: {} KB at {}", total / 1024, fmt::ptr(g_arena)); } memset(g_arena, 0, g_arena_size); g_arena_used = STACK; diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 880a0a8..cf507fa 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 880a0a8dcaa23fc1cb80a8e6646a8bebd27b2879 +Subproject commit cf507fa4eaa73a81c9a026a981cb812ce541f421 From 51b86e08856c20af4a9eff46da835fca710afb5f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 14 Oct 2026 23:40:27 -0500 Subject: [PATCH 26/29] tpm: arena 5043 KB (tpm-decomp 9918aaa: GPU queue halved), fits the block left after MGS Co-Authored-By: Claude Opus 5.5 (1M context) --- components/tpm/tpm-decomp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index cf507fa..9918aaa 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit cf507fa4eaa73a81c9a026a981cb812ce541f421 +Subproject commit 9918aaa375bbdfff9090d2f2e775ebe13abb6f77 From 511df1a8c66633278bad0ed3c9d492475c480e5d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 15 Oct 2026 09:58:58 -0500 Subject: [PATCH 27/29] readme: The Phantom Menace (video, screenshots, status) Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 32 ++++++++++++++++++++++++++---- components/tpm/README.md | 35 ++++++++++++++++++++++----------- docs/images/tpm-conference.jpg | Bin 0 -> 38250 bytes docs/images/tpm-corridor.jpg | Bin 0 -> 46133 bytes docs/images/tpm-pause.jpg | Bin 0 -> 47778 bytes 5 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 docs/images/tpm-conference.jpg create mode 100644 docs/images/tpm-corridor.jpg create mode 100644 docs/images/tpm-pause.jpg diff --git a/README.md b/README.md index 04daf19..19a0cb4 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ states, and a `Cart` per game or emulator core, in the same shape as [esp-box-emu](https://github.com/esp-cpp/esp-box-emu) so glue can move between the two. -Two of the carts are not emulators. They are native ports of PC and -PlayStation games, running the games' own C code on the two 360 MHz RISC-V -cores with a software rasterizer. +Three of the carts are not emulators. They are native ports of PC and +PlayStation games, running the games' own code as C on the two 360 MHz +RISC-V cores with a software rasterizer. ## Metal Gear Solid, natively @@ -31,6 +31,25 @@ drawn by two rasterizer workers, one per core, each taking a band of rows. Save and load states from the pause menu. See [components/mgs/README.md](components/mgs/README.md). +## Star Wars Episode I: The Phantom Menace, recompiled + +https://github.com/user-attachments/assets/a1001e13-553d-4048-aa23-dba5409316e3 + +

+ +

+ +The 1999 PlayStation game, USA Rev 1, through +[tpm-decomp](https://github.com/esp-cpp/tpm-decomp): its static recompiler +turns the game's executable into C at build time, and its runtime is the +PlayStation around that C (BIOS, interrupts, DMA, CD-ROM, MDEC, GPU ports) +on the same psyz rasterizer, GTE and SPU as the Metal Gear Solid port. It +boots from the disc image on the card through the title, the full-motion +movies (MDEC video with XA audio at the movies' full 15 fps) and the levels, +at about 27 fps in game with sound effects, music and speech. Save and +load states from the pause menu. See +[components/tpm/README.md](components/tpm/README.md). + ## Star Wars: Jedi Knight – Dark Forces II https://github.com/user-attachments/assets/2e6b5173-f53a-4591-8347-96aa53e222f2 @@ -87,6 +106,7 @@ components/ statistics/ frame timing jk/ OpenJKDF2 port: engine submodule + ESP32 platform layer + glue mgs/ Metal Gear Solid: mgs_reversing + psyz submodules + the Tab5 platform layer + tpm/ The Phantom Menace: tpm-decomp submodule (static recompiler + runtime) + glue gba/ genesis/ snes/ the emulator cores and their glue docs/PLAN.md the Jedi Knight plan and history ``` @@ -110,16 +130,20 @@ docs/PLAN.md the Jedi Knight plan and history mgs/ # Metal Gear Solid, extracted from your own US disc STAGE.DIR RADIO.DAT FACE.DAT ZMOVIE.STR VOX.DAT DEMO.DAT BRF.DAT # (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 ``` Example `metadata.csv` (one line per game: rom path, boxart, name; the platform comes from the path: `.md`/`.gen` Genesis, `.sfc`/`.smc` SNES, -`.gba` Game Boy Advance, `jk.cd` Jedi Knight, `STAGE.DIR` Metal Gear Solid): +`.gba` Game Boy Advance, `jk.cd` Jedi Knight, `STAGE.DIR` Metal Gear Solid, +`SLUS_008.84` The Phantom Menace): ``` jk/jk.cd, boxart/jk.jpg, Star Wars: Jedi Knight - Dark Forces II mgs/STAGE.DIR, boxart/mgs.jpg, Metal Gear Solid +tpm/SLUS_008.84, boxart/tpm.jpg, Star Wars Episode I: The Phantom Menace genesis/sonic.md, boxart/sonic.jpg, Sonic the Hedgehog snes/smw.sfc, boxart/smw.jpg, Super Mario World gba/mk.gba, boxart/mk.jpg, Mario Kart: Super Circuit diff --git a/components/tpm/README.md b/components/tpm/README.md index e05d2d4..b9e19ba 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -56,10 +56,14 @@ Only USA Rev 1 works: the recompiled code is that executable. ## Running -Status (2026-10-13, on the Tab5): boots from the card through the memory-card -notice, the title menu and the loading screens into the first level (the -Trade Federation ship), drawn correctly, at about **12 fps** against the -game's 30 (the game runs in slow motion: emulated time never skips ahead). +https://github.com/user-attachments/assets/a1001e13-553d-4048-aa23-dba5409316e3 + +Status (2026-10-15, on the Tab5): plays from the card through the +memory-card notice, the intro movie, the title menu and the levels, with +sound effects, music, speech and the full-motion movies. In the levels the +game runs at about **27 fps** against its 30 (with frameskip; both cores +~95% busy); a level loads in about 4 s. Save and load states from the pause +menu (3.6 MB each; save 1.5 s, load 0.6 s). - Threads: the game on core 0 (32 KB internal stack); the GPU's command stream on core 1 (`tpm_gpu`: the rasterizer, behind the game's back like @@ -74,17 +78,24 @@ game's 30 (the game runs in slow motion: emulated time never skips ahead). the shared-memory pool and go back to it when the cart quits. Internal RAM holds only the guest register file and the game task's stack. - Disc reads go through a 32-sector read-ahead. -- Pads: the HAL's merged gamepad; A = cross, B = circle, X = square, - Y = triangle, L/R = L1/R1. +- 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). -- The pause menu pauses the game at its next loop back-edge. - -Where the time goes (in the level): the game thread is busy all the time, -about 10% SPU, a few % CD, the rest recompiled game code and psyz's GTE; -the GPU worker is ~80% busy. Next: the GTE (16% of the game thread before -the GPU moved), the recompiled code, the rasterizer's spans. +- 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. + +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 +a register and scratchpad accesses resolved at recompile time), the GTE +(~24%) and the SPU mix (~10%). Next: a native renderer for the game's +drawing code and the rasterizer's spans. ### Bring-up aids (menuconfig, "Test:" options) diff --git a/docs/images/tpm-conference.jpg b/docs/images/tpm-conference.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a6409464cbdf1c7d661c356e8613f25c523b55d GIT binary patch literal 38250 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

28Q|2i|`(lKz}?#b_7&wKaXevHvta19jCS zNqBH)l5oBEB$@F$$x|eA@gHgT8EgQ44DVtT?ZP&DX&W^NF_@B?6e^m#3 z%DLKLzvcR3VcXl@?C)dT+Uj(k_Y-48y^MMtea^50J%8A_OrgGYDtF4xnf9`z@8z=< z!5{5p#s*jQ7W#!Sha1a(X5Rf@GY@Vu{*Rd-cde^5&it5sO;KfdB@h{Pda>md1wIi_ z^L_8McA1n#7_Su~B*xLznCc*)zumMr9<`XIcC-?G` zc*&wk5xQz_AyewRrQ$3JCXBpOg$1rNlCIt=leY_8vWDP&hZ{*3Rhes1nP*4#k#sq^ z8LicWrZdHZ){X*^DiZU(7}Ij_u02=z4|w6FQ1AJ`X!?#6k5-1o%MloCgp&J66+}cD4F}3p-49u3H1jQ# z3lZ_Lp2arfC7DLdwxgWFG2&rptSD%SLO>d)DJ{)NnsN-9m^PJ48v@=OWBq97IaHsN z>14+%s99|D1u>8|2xId6re7tT#VaJ1@qAD>Ns3-PJ6fa-p2O&dja)E5RIbQ$@afO~q;Go(3rgWo{k|6P-d3OLITh8# z+K!uOz&iFWG?(EKI%XcMubab>Ui@7o2GV@snb{)ZBKsZ&hMB!MZEXuvN)mwDOR?-O z*=!0TVge@eHP5&Q8tF-cU(cCJ=jmWRPp*s6WKKNG{3)p2-jtbCEX>58XDt)3)+sdV zMJyD9+|RNTvoYyYav6)kKL*c_T*gi+ZXeSf`?npmeyEbsp!pVb)wt69J9X~8U{vEE z_@vcleEH|oG3{-e5FwU#mZbm891EWf1OftLD8a5~E#kx5D0Xcs^WTeK+Mn}PG|qBR zzJ6pHs5%IXx~OIR`YkAy!*`{GYK2j2{jvH{7cbG=u&wob?t>q`T^5hDR^tWfK9rtb zSnqfPsEpC~ z$2n8**fp_gB%1jN&9-m5cVZVv*NvIGc$TmEN9D%yqnUYeZ|P3;K<^}yx5(w! z{7fjnT)%l#|9A0rUz$(VcFO&1n&*;%vKmF1xw)mHtDHBxL<MS@l)k6s?j~>2cb=*u(y$RzXyJ_$Jnw{?7P)YUVd4^QoGqK#0>N^!M9L_qK5W|8NKYL~ci-w{;+riigkKN5 zc}cFx>A62_#H}r^>68@zao(C8aT(jNz8L$mBaJYg_7xnqpLn#tua*rEKhOy0!lI6LmH(-GI@x@j%V25=m80;nd-& zV=-b`&6hm{gj=*^^{Y=j!5+lDBfEUv`@+U!UNTE}X%6nP)djH|W0lYLDH0H}u^DsrUlL#gk=Mb6_`zkqxbQ-}!t_Pmuq9vKhLDw#p{Lu$eh+7$e zhQiMetghC3J1R*we?iWI=aP?&Zr|VP{UHE>lE3M#$%8;4fFH%i0!s8P6(LpOVS=>7 z+=CUV5pA1u+7WC%{NcK>nT4`O5p8eB`uSyc(?+GTC#UWack4%NYz+!nriiNzwT=GP zASqCO6@*xNCjY59z_6~d5x-{2W7oU-MC@DUyb;)_R%1w9@RBsum?jT3pQQWil5QNTIg5!J z=GFGgte#rDnXS@M*s|iQ!5ZUydUSk5oBdC=(VvgFeyvJsGD&Ulw4I$mPQJ>Bnw33% z+a8QxA?GvYd8Q}As6egsk-QSTmYA)yr{YGTp6u)^? zSEtYhOx6u*VO_No;Y27fvQE(OyPewsdq# z&Eh%-0XN$DXr8+oMc0d+CIZ~(0PW981O4<7dV1Pwu9BW?pFaN7t*@Oq$~!j~Fn#c_ zJEc}xy=lXKDYt60uAS})PpJ55nk?ewj`&=`tJp{6nmZd37_o)@69W)@#9&hO8rQv-n@Vk5z=)8i#4R zJ(Xwdc1oPS{X2dhs$KSCece*s(DCmzk|&wB_F8Eg@0{BOqce| z-aVKy)BFn>06*<|?X@5G;D_|P_PYm@fv|6to53${zYMestHRQ~rE-a^-O8{j8E>yZ>&sPIjpmw5{ju1FQu6`~)6k>Jx)Ew9a-+`Db4R zd)Rm{_MKbC{Sz&txBcB%&0FfWTl7@iBQ1THwj%ljSK-0z++A{)zE25_EUv zCxJ30{`NN+=W(=gfR-5KHigjAA#6iG8nTXEB%mIQ+wrqZrS8+@+`bfF+MH=z(5i;x zI$_5%zH}7(iU_qe%vOtD2Fk-#*O&9?mNuo(|6opNJGvaOT5ZlnSHcMX<5FAmX63di zG<*xLS?I08Qkmi)KiW-4lT#+9n%(ECnu=ElU7!QL4^Ev;{?d$qv2N@1sd&vM8=e&B zltweJX}G`c?sJIxSz!5*oAc#n&FYSNq?kjNZNj`ae&POL`P|pj+!EThdJlwwq)4Zf zEj3b{b;z-#`wZvz;z1WCo_F2Nb)`nF_98l3A?#fe%Rm zuhc!vl;e70Fs3OR2A*oJ+ZB9Xo(6$saJKreZ0Hs#$t6YsGWhL2tPSEMXUZqAzUG9B-@cgPZXQqMe5;K zAIuXQGXr&9L-0=1vY@g}#BQ?wz~xnP3Ac;4HcwBHq>CTx0=Gl0D37>>9%D}4h7w_9 z^FYM#{4gcjX~scVLakE8e#%SHBzW6y6wr!;G? z1^!&J>Xjas;Kw@jFb%q8oD$pw6~F8A^?g5~?7A&UBp|oID1(H$Mu@$9UGJeCTjt-r zIcZ~0m$_0{e*LNDa=e*!X}C{MFg9cpq6ev@u1zk~CoWClgv7HWzhjP#SK8fAI?EEzUHp!b+DYsQB^L}%8-v_Jq_GJ_vq>P5 z5iXAqES5ESFmnM7AgxIXA#@2vsDeod@{!7y_f1&&^P;2q<2Q%DvoK=N0;wvc=Q3yC z1oC=@9?IsdDk#y8?Bw`6-K!EeqJNM7?q|JgcEjhbRzS_Fd_ zbtRWVK!r5DAbB-%W>hXs?;r>V0?bUZUNo5kX{-_fOA@r8+Yik}^sQ@=5iOA5wX1*m zt7pAvd1i}Y^z~=z-%SBhTnkSDQ=QXfj0HX7n3>0>3BO`i1!F5{eJXtkKm2|iBgtq06LuL3j~wHDJs5cP#jtxa=3bm}jC$vopkf() z#Ju2Aisxdgl%`k-p`wAey**p}77=>yTIRFrd+SEJ-VsJsdOue3kV}5(qyD>uGTYW;J{mYHyHRKtC5gBZKLUAw{R#_^%cKqcD z|Ez3i_|FQb%emO+(WVo(KigJ=a)THn8sE5n`7jao;@WJ@z_DwzyLdfrxz)9!<3#en zWMOphRG4LJOG;Gm+|twYxJ+WqHpU#89P3gkAu71VI2vd7<9O#`bWx^#x_KV5>Z`j| zeWM?h*_>-2|7hQu8~4@ z0*w`pgBA5a0XL+(*M?C^S^0!B)*my%BC3ZZSM3l&c4@Y36O41lolZtgYmu=|`m+@Z zH?TS+>Lkr)Y{nAVxzYGeM6y&b+`jZ@HJKYSV)xYTK`C|o+RIuxtD>VJc6OZh3xZVEUc2^@c>LwWvik_FUyCR zxbspdZCWW37Zn|-|8i3~Z<>*mnV{#`SfXJ&@Pn2BiO!5JQFKyVLWYb^$_&cc7!X9; zJz;cJK7`K4BKAd@_n~c1Lj_d%n*A~kZO4c61_j9)A4Ulp2yRW)Yqugd{IYhJco>L| zTkSV&#e&51Ie!MB&F{}hkva@?${r8vcw>bm=I`Ib#w(}bkjFz8P#G)ATk|1Zl39ln zb>0@65+sg7!v=H^GsPOVZAom5m_$16ixayobHkva>eHOFuf+*`yM~g@0l7n^r>RcaEmfUfxZCAa5HnyMt^G!`EG!#ZB4#(JDO$&YURU9=5+A z%yqwoe>1=SUy$zgRPl>DqDOy0x50N@6H;A`wx>`2sO<5P(n<+|Ez_IHI zHe{bSv}+kl&1)V-9=4N>ME)uH0?1u?*2d?PfJwH+RA)}L1_WF^p%u%zQm#&)4rRdeFnO?8z4W)!sysvCu&EvRm0Os-~$@j(l#XiqkRp z25GdQJM@jDdU@&dg|-IVAe*>MV@Tkt@u}e*h2z#&JLRf)$TN@lOE6Df=bY%wj`c{X ziblx5jBcZt=Xks}bqUe<@|Swgv=QO>mt?+Zf6wKh-vK6}z9M4OcJFxJ_wZ8U&4J^; z7tyJf=Z;kfD$#DT&3bdGE=nwGv!yJoT>un<`poy82c+m+Kye4nZsQgypC6LO48q{z zq6GCrH#<{%zDW1@rcP zR!%X5tWDg!$S z{|G-=HJcLnmYy>0zvfvz7KYCTpnlZtA8 z=$rDjS(p#2^f#Q`l%ZvM)-*diR=hIzb++~3+H>zEJ%(#DvHGt~-;q8$kTJw1Wut1P zOdj+$y^V}jDl5nBUil%cKqp4ESvAe3SSq!AqTh4;V@7R(&LP*JIc>UfMn$q7t0v0I zj;T|o?Nturl=ibx-w5RKy}q+{005|3;OjAav0!|@vioEZ_e=Y#~s(*Qy_c2tEK#j8g=u1QX=vRj0VIQ zwPVE+RY)@!CPIOYK$EF1GLj8~sa9i=-P3iHAten;z=i=sh|fZ%!1;!1#UP0}ruqq; zApfMwbl_B9hRIWw!@#nPb7)V+)L~xk=(y~&!;#1fz6Z9Td&IUC_)%6!mTTG}kDnGe z(?{AWac~PNbv~*o|D7^w=9MXO-iy4@sEC(RQVhOTrEBd{#pCT@ zH^GEwvwI9C1bW9Xfoz zB~Og#TT)fJzIYW|7hf;gEd4}~FH*$>@6tmz2*;~nYAVXzeKcywqSNb@>MBFRV{~uc zN!+PVc093lZNKI5qOBB{p=6$0ATMJ!_qr>2Y^Fh6`MB;GTMuEfN^Y8nGA?xFgw#!A zY1|3SNXDj9L4_?(NkjkG8q|9)C<>pKln9wSW1eVJ68gt{9)4X zab>k|scUJ2nfw*Jk-Bu$$Tau4GBHuvO7P1Pi>*9|t6$M{hT9fBK_@~dn}VB&Raf}3 z%29=jNu~XliyThyPN?IxwqsF?oJ4o0WS|V!s%rr0r~QNXVU>SDgp=dv9~am-KmT+% zUYf}))jZL0Za7w)bapG>I{iR9<4N!0+vw+FxTPg2JzKRgVizc(rq;sG@cK-4&m-c; za-_$&k{qPTdD9BFrrzMcNT4okuDX7O)PD0RtIR71K zR3T)XiZD;^P?DwCz7QK4TGbqlLVdFR#8KIzjCXrrOP%AFQ{Iz&|L=Sl$u>C+=Gr4j zB%izn!0Ek$-x%BZ;n_tqpLYVo*CUx9L_6i8>i^^o1P}fNZ3Yg+-?@_mNR(VoSi_Gy z@saTNf6B%sTC>Tcf`nhcus))F1?~kMtFU1aDw9kDX32ejg!TSRlbY=V9a;ohH4)Ih zlxIeuSlJZVlLYH-ox1?E{bI&LINg3`Z5Eb6`*cQ@FXdd@p*RogV*28P{u3RIk?7W? zIRY9yd&%iU5LQa(gJA|sM$Q^8^GFAeHUHa6sb8cYVB%L2-Ov7lPLY2e1t{n`3fD&e zU`zb^9mZvk^Hrt!(Ea6IIaz_n66ln>(oe)%VXbN~Aq!mG~2(O-!?92Qvh!DAq_XM}g z!AuxLM?}gO=c?k9T|KFWv|IAFl&PY|Mu*C_BeKJNj{|W%;rU-0Dp|5%b28dSpE$=m z$xDdB5LqHWQVDNm4t=fDhSIY{#tQ|gbYfH#0~p3b0D(tbW9)J~>>^6+w<4;13LrHG zgu1swc|DN{IHzy|c^eJ)J=B%KrMmm%?%2}qs*#Av@thxgA}K~9=e1k9?+9)*v`LP7|XpySsqCDHL|)`ML#>HhfrxV~bL zeJ6kPb9HQK=h1TpWOpJY^Z1p@Y;H0x9&JMVA^0DnBn66!YH$f9Aw^hhY9+cPqFGoC zn1|7-F(epy9YT^>juaHMgTq648VX6U+vU0*H=38$7FidL+I>puG$~9xufWO-&h|v)yZa9QkKEa93&rVHNzY|L#oAlhgaL!c>3$KXt3po&z}> z%_@Ys75DP-?FW)>zc3vEw;O|p9!3Ka_Ds;E1hy~;mD>*_-;QhbrhK? z?1E4%I;ldV%$VN=?P!z%yTDKE)x{dC(04}J~J{{?N? zi+Z>s35VOeQ-oKCX1*x@A+u!8v{`}26uOMh#Gp_$ZE>}19@QQ3uN7~!t2roWurR)Y zVM5p=RuuPz@^P?3ut{}rK{hN9#rzpTWqnxXpNz}Yg0`e3=XyTJ2p8Y@JV97-G==T- zGY>tq$f1V@S>uAqy06r<);u)xIoV^8531A93Y~nBnQEl7XUFtNy>EoHQcS^%RN({} z*G9hw20BO@)6AmQ#1G9<$&C>ng@j5DiU^1*Ai7Hi!r}&j*wKto)({PUgJF1GDle`X z5_Z34fV0Cd%gh&>nm0*`XBEta6mRQuINtIUQDa!?)#iJ`yqNQ?ZJ)7Zn&5NdZkBAd z*?QV;=|(ye-2Qp*^S5iU^GVx}dyxvaEk{G))g>vMPzr0Atf)7<4|!lr+lFTjCa`FQ zeQO0VMg1kRcS>lgQLa~E2BS$@GXZS|d_}Qd0xqXIaQFc71A81F0;AVRd;uCF= zuailCtNHavUhGQu_b&mV-JIpcJMbo>n@0Eb$H+#RaAuHiPA^MTKKOsL+Z!4IjzR)5 zJgh)#I1b(Unhs(i)gY=u-*<5orKaw)zi3s%moMIlh_shV#}q~u3s1CX+~Qy5CGgIV zrCiL+L;>oFyV^4yG$68(>w8)ls^6OYpU~OK5B1-^be_zv85?&DKNxxNW8A++P^3p` zOeFeGfpWX%1^0`GNMw)O~S?DT_0TKU9uBkLB0a z0%%L;gYGd&mqxw=AZ0)SF%|PlbHL7m<>hkpRPcizHB&jO9_+>~q|laZ6MC9Y%e5U7 zsrzD9W})E8yX3Nm;BpH7F$JVVe8~&}4*m{d35tYXkwge9Burja9to8f;=x6jL`Jn{ zOcYSNLO5F#Pxlrk1AHsx0^P1);AY1kmkOI|^& zwiN6z^4EA2kx7^)Z6H)0XdQKrnuVmiQ-#xxB}1MyG`odI6QvJgvQ}cgIz^!Smb14D zV|OvV%aa}gmDZ1N?F&3a2ae`t?sm_UlPVr-?d#o$vys-b zG$vk^+_KVUc89HHMy20GOTS43ND2PAWx=xlnfVk{bK=@} z0mQ$DuZJ@m?K=+q4fyX@(ni4z@HsNWGinPxNg!^DSa8t|&|pa>w}fq{1wmfn&6TID=*AW>J~e=A^^lMC(Li!A*H!(@pni zqd#U2Zdbflw==fWw{GjoekLHhIS78L`Btm70@vcwmY9^MR#yzO+*# zhT_X(JD(4N^EEXYsGp5J=q-oZoVDm#s@7U*((#V|)*$=c?k zDsMI=rt~@)x{EuORR-fU1f(5(8VO(7jGw)*N5fkimrV8bl9ROjb>mC$UB`heU;6-E zs_#a+3FW}cUq?n9i$7h%?pqcG-{cx}H;)x%-dE3^ zlO`OI-_0#ESpC_y{@jXpRy6vF_V8hG?5{B$wD1m0qoHVDGF?}eb1V7`t;OWj)=4{R z%JgKwn{;>R%;;#X$AKxKPK?R#w4Z$XX*^Bdn{%b@V7uL5>(3K~m9@#?X12j;ET~x;r{-0se=zajgm|R^ zXa%!mfTKGa=+Sr>IYcj4A@%j_Y0K^}sZ6QF)Q+4f;8OXW#LDY^hOg$4m$zd}s~ho# zhE7P#CFf;9piO_yT#kEVDT8K(KwMk7(`&GG{}vTPobEN>spl;{A~svUU`iv(WoA0O zmZde*$fFuk3~iHS&nxwxgl9o#H3&(br3J0cUnLqQ1^PdBOm~42{5HNB z_Q-jq>!e28CHbUO?T9CLlv-UUofqu(o_o~Ni*|FkxJA@VfrFpJ;{0v4v<27kk+qnD zY$UM}cj={gHv>bN!_7PS*m*xI6yBpJUi;)$U?K`;5Fr-pa1uhvMmZ6&Oohb^#2F_1 zg&>d;0ec82!c1)-Iog0vIdMZn5dAH1Tp_4T>VoOb(3#WP!R!l}iPt=WTU~n(F7>~& zM4umipJA%)bf;Utq@mZHoo=-(a)=&L6indEy`KmVq=p2!x^s?`mi4P1~N`+6QP+McIaM}Dt-7nc_mU7)CH6U!lL(8m4v1r-qHqv(6n z#D?c-DoxnRD|w>}Lk5?ru<>YSAb1%%`0ykIE`1>_(m2_N5=Jx9KpTiu^p7UxV6$Y8 zU32LXvg0cs_Hg&Z)&Q9f|A*ERNQ@u#IB(JDoqMHk_}xRzj)fB z4-~IiOW$k@h^h`7US6&V?*B6MgO?zzoZ>Oty^jO|1X0`o zgd12rHt_h3F1AuWyPTmM_br)HK1-&g>D80L48*1*>s(Uru)fa5my3J*>D1>U z(|xtAVlXqas*(pxYD-fnWAG2ZpH%ossK z$B?H2JU>hnTaoP*e%MXTN z&rG`!hi{vwEJG{ogDg4U8jF?Wafkq2b8Hw?O*INd1%NrmsDHqY2|qgs6rslsy5Fdp zq+GWFk8%CZkrMD2^2@|*UT{Kz%T_DloqQ+7x?}HPPtD@JDq$#FFg{iB>nI~YRVg}{A zpAgfrXBb^a1o;_} zY9e5kq<#gR@=V4;5%qj1K@1ZLi$aYk9FVL_UJ0{;;sFk$MKuPSA3K{-5x9W>?#R3W z(4blBO?e_|`A1t@I##K;ki=oQ&pq2^<_fxSD0?L7+e<>1AS9a z&+8B>fOJ6#6=Es8Axf7A^`^nqFdZ9b8R@1QxF*Lkqm!fqpS9J*8KVwfovU zi0o6wb@Qu-U(esEohDqGlKurT`X^+w{g$|u^Q_Cq;onKU{a7 zj=voKFaJP91?M%=o%bOkFRxTA&`Sv!QA-^`b%(pwyHHS4s^=lzf{05q&1Q@Ca)=MkQn_LFz7H@BGLAp7fWy0Mo1b zV}=j*n3Kq8rN}-#Q6?BMj)W{c<>oK^sAH0@YNupHORDY=VYQToWnsNbPu3?&Wh3yW zo=c~~TnS7?$6rA;yh{nY0xgB>R5%`CMe$PJvtm@yqa=7c3cQCP9M%t=V>iqcc}N=4 zZ{>oGbwmPC28g0Spr!Z%Jppt%zlIYf-#GurIx0XPGn7*ygcTQrQH8V^k1mt*06UR~^knp8?A$pTHQ#wqKHZz6vR;`} zn7uW>9RGbY2|4?xE&L6aqtkaREx8=BKEk;rw8(JP)$^*s*VM0VU>RRa)C93kkQEi{=U zBLwb(lM1v?a#4fxDi-K0sm=TFmBU>$cp*({LKQeHsDfA*DP2<(LrD{fOBU}aXN-e# zzVVDP&@eVV&rVikCui&hgj&9IL!ibCF?4|Ntqo3MnL`B z%^D8;M=~S(Atj4W1&B*m43z{B$bnm%=I zKqDYgO4yJmaC6|h09O@(tJ6SkO^=xq1?Pp{cgR9S&#=^$KtrO4^3yn<|11ARa*CMh zrq28Dek{(QoaYr2(TpL8x+hPF*CIy8ItO1%u!|}J)NrhMd3q8Mb`250!g2*VePA@K7!?b#UH%O@01Sih8dPn7 z3|a&W(2S#=4w1~PcAq*T}Kk6ExMr-$xqnnPIb4y!g9=fi@(BYtDMOdRsgom4W7W$k^6HQvbL$0v+rUZC+ zuit5fAR&UJ>w7>$f<&JuN#3ZV`zCImoIR=j*egOp!^hG-w;gPVMXJ#ereeQQDvo*- zN)!{t3Wd?*CX!$tDwP0Etzfn{i3~bRub^wC%?dQw@@~LD*veh#XoevOY-Fpw1zqb zwonJLNF`=o^89jog=yGZhg=-_=ZqqT7_|Z&Dms$BPjSRW@q2o z_t}~K^ZwcUdG7lXjnujzc!to*3><{303d)#M&m3nQwH<_fYeFRu3X^G9M29|p9HjM zqV=B9Yw+K;1fp%&>>dC;DDHEz9t`9l?yD{|ps_(d%#{L=>pecYWw@Tf^g>kZF?2iKW?+AR%2Z2_G0%G82scy{^dub ztPefb&fPb8DS*=wIk2@DTwp59D%qdrM94f23e?YrV@-J~*Ji_M65sz+a&!TU>)?uo zi3-GEZV~-i4!cLXEn?-868kmXPBZlqqaWEEJ4c+AF0f<0JrxFmQ&9X{0&bZv92gp1 zL!XL0^XC#$D*Zwi3vRYc@bm#NSxf|+SzY2<)Jb2Y8J-*Uws;=YitvTkS*)!C>wQm0MorQKP$--KDiN;cX63^MHV4p@2IOU zCe%u;D^Ci#W0tRmwYM+!L%`w>;&YL4uxI}+FOJEk$;+^&9a+hqbOBf_@_ep(07KdD zTeU)CZVA?;;}r0<{PR)d7 z$O|+(R>rieuzDiNo^jo!j8C>*1H|`qW#&;HWeaD=sRG9jN744VGQ1vR;S~qY_74YGvs$(m(aVV%DPm zDhCwr_lrM#D{W!ELHCUoLf-Ranav>_O>T}ILE9N_ycPFXsCo)5xRHj%eK|3O9|AXW6uxA!>c(a=@;~^ zfqa-L{1sYn^W$Rp*=r955+t*{*o#FQ>AFZ zwu4AQ)Kqi$`!31LUPlepYq~*dq{;M{iAvSY?D#-={71YrNqQ`=Pvc)y6BCv z=i2J)CGbwO-MgsNIO_S+V%D51gU>bd;r~5yj*k(36M?TRw?l6`s%|Y$2=%?w4duRf1KuX<)gw7G&mu5h(@%!J3>5q|9>-xJ0XStbZrvWu@{ht|Y?Uk)>{BZ`=H2C1qv2k$z#)ijq)-Z3oy z6~?PjEix;mw}!a!ID5{lN&k*jXv>6}w?T`ZiEq`_Vccv;iv?D4_nXK!kq}$0i@8q% z0uYfFMp`{D$vc`Lx%P@RYYkeO*ojhpuNT~46NLnfbW+^h%n6sM`gK__SgjMQa(m%( z3Al&JZ9SuI<9*js@~V=m+2u7QL;@qbS`Pjc{?4Bi}1<^ug|bZ!Cjgw0{fJ{}$x3IfvZ19HD5ZAI@uoy07q(bQ zIt$OF5;;us${m*cFYe=<08Z;uGP!V^q{c9JR$85d&vIgi_wA$;Ir=m>DBMyw$DOJ5 zfLQ?&L8&V~#GLEfBd+RD)|)brX9+uIZ&5}8y@D7Y| z=(iC%wNrKRR3aHbq`lR0mh;$G&r|xNcUieN1Wim58q;o3 zK-OK{)!1UG7<1`|K-{U69}4`k(UF1F=ZH1@y&7LvL| znrw&3kWoA}6SH004!VQOW>HumIsWr45)10&;XsGQPVusRR9;Q(i)C$ttWDR#G<44g z@2SJ?2Mb^JN5G&}SQfQoxN+$4!g8M}wn_znjE8#%j;7z^QtqY*jX6Z- zbPJ`mTIg}Tl(kvyMsWqZ!5$v@L5A-SN>*xm z9jyFn?JkYVw}a({^q%`2pNLay)R^4o)ueFG@gS@>7Dq_%5|`zZCGS~4D1zmNyrr|p za-R;Zg*=6{l?=znSiM7&imk}%Q)auF$?#|eA85YJuoL6iSQp~1)HqUR_1>zETfru2 zUwl_!Nh%)NfAllxqg2dp`pKaqU~Ho67?%CIxjOl%-%?;tiq-M^hTW=u&sxmq)RR+3 zp9WR8(jVg+N#TsVm#!t|H;Dh!Q}qLF$=hanWtWefn9X_3JNAfN+l54qs!w#6qFXy$ zR;FKN%d7&C)N_SQVXpyIE1XxMe_wX(;TFZ=4=6~o5_Us{SI1$Ng%f+A8!TwG#T&V> zr>p*)7luTPs!tl1RQB_Fr%m~JCU0MUH?WR4oPlr$Sej2R=qAmW@?Z~844Zc;sbw9M z1>H;6x;xCRlKAEtBn#*~Ex}EzkLBLzOH=3D;np9wv&4l}Wpg(8 zQWI9Z#I9`dPfOpziO%oyi=nr!*4OPx7oF8??as^&*l~@dC=(7l z*Chkfy;|~%?<}}FLC36%^~oelp?PK2H_R4~k^}khm#Ccm zI+Q_vM|;StrS4ap3n#n_7ni1Ar7x6y*j5{whVm!`9&LQyiw0cv?@q=zUHVx4U!?Pqdk-(qW5WD!0KLx zTkN!T*go^-6K;-Ba%PBRqRtlCMUmNz=R_FR@+?%_Zyd6KBbE+R(Fza!OtIB zJcbj!18p{K9|aI5aDTSKOsmXJ zV2*x#)|_sWX6Q~Ny~TN=#Rb<2I_KI*eJ$oR_Y0|HQRcI*t6%|h!U{`nrB;@qwKgt@ zMM3BNbYTURVmHJl2qc1*Px4P<$E@;%Em;8 zHn+@AYvPNi=@A8k&PjU{`a@m-Z6 z6VqriGGplc$ndo9vW%55hc?M}W9vYwy5#=O=3dtJJ2oP^%I@x`;(^EvIeL=WjMR%b zz04P_&^nH_31>5Od4S0i#l!eCXCvziq*H@*8c9r#(@eI+$oDGRqj-0*tX;uvJ#g-d zbP}R{Xn|J&kwXzujJ?r~9hOHCeOpl-|3FrUlw<*6p?JBoSME@p<=cDSJHAwrv8(d4nop$`&WDQeK|1(#B;ZrTQx zE&z6n_1}%wa=8T3k<{E~d`;u{8g$0iJ?WJ5@alcR@Y#Jt=7QBu4uXQMYi&eN{(@u7 zhETuzg0~X0b|K@&qHZ5jirx;cYzdFkDc(?YFy!Bl_6vCh`U=v~0LGsHEIJ5u6CidQ z7(3hDxj58x9Yk{-1TxwKf!IM0m~I*nnWKps|Gr|(o*TnN7jTJf37jkWp)X z1xR6hG|>;(0G$j37~?@)fUivukb3y9gV<@Joxx)uP}<)?tN*=Zq95`*0=gfq43jyK zARZ0)Y-wd^aUhTk^eOjLUElYn*3GHisV94;$ikwpdvfcUl|5qIJwFhAT-XuGxQt7D zXOIPO9b-m;>QWAUf2ME1fZ}V~IgM;BAQaZv!rIuJD{Gxr=7QkM=p+nVG{8~oylhqj zWq2THz-8erwkUF?p^MLi-ykkl17l@?Z*#Y2M~FO4IxmndFc4>7o~R|eBwwBS?dQ(hUcH z|K~Z^d2{B)%aa1X+QsQ2mz=lXsBptPb*`f zqoAQ-{9^#oF+j9$G2aoXAi!8A&YU%>#GfQ`Nl9Gjc(~MDCCN;`aYNJ-%;=%DZb9aM zCw~R==5f{1FVMd;>WRg`vV~5yeN5KS$k~N zUqFy1B~f0*9fA<)zTuxZIc}iNmK*63qIYhjXTl=h4 zWNZ;i%apGRyV_+7`$#+JtBdn>GjHiI)ASv?Ct2y}-$lv0-|AT>Y^p211+qP}U~-Pb zL=QGb^*6grz7xsqBo>cHp-m;t2}DJ!)xukce~FyO)HhQVW8q~;zsJ9p;H3#7?Mk^V zVRr81cu(Crf%qfuCDvBt=hy$sHQ<$~$|b zvzGm2AwIHlO8tbZkzk;sF^ouQx&03a1hQ^R-=D>D30B)^U&1d@A3=wv%>c;{ST&*C&1HXYW#Nt}F&j#6eXUv!Z`x2> z#fd8^1CtCIIfyB$S6;|()`vTcRNP=)#~)a@A2v(ttIFp26fzC7PsL$e%a=V&KLQF# zaJz;z@)DxKvp?US%lBqNnk%0hJ_0IkqSZ&f44BVJ%nwXNU9)fCFwJJ(I^w_}okNYFLefP&xq5c9jkND2h00#6ajENRo(6If(MQJlx_IWuBD=<`d0bXgwe4 zQBbwmN2`q?>bXP3eXl(GcBAih;-^2GJu$<8@%plnslUtdyOM?(C6>tp32-%VV`Tpd zJ2Zx%E*MBy3$5Dj`$!n=(0mm_Ys~CbY|)pCX1d%RMboz13HX8hX|&{0Jl;2~^(qt; zqgd&pzxAqW{vB^}5MA|`gb;npQlw>*AG$=i2{fOCB{4~O*x^IePHS&+lk3)~8UH|f z6Q7jS>9!EK*Qw7t*u7_N(4p7(x#84SK!)7@g!c(?C6%kb*~qNH>Sfa1xs|`+7h}cY z*FReCLwWecbgRUZv(^vJU z$2VNHvG?*3zz7^`DjWEzAB7b7Htl?$l)V37EXb~YiR>b=dKI6SgTwk2jjNkhCR1pa zmlA`VxVUJjSb#9|;`0&cK&db3n3Kh)+OpqE+uL2_eA)h5f2ToA_Da{6e#PFAeYo%Y zRO1ALLQBiqqCaKo$qUVlsMAYi4u9K!VFvYl2hXhvl z$1@KQQ6KL&KC{>zf3EMPXEo3E$Fzyj-rn%iS zS}(UvRA+J@p}W{_CA$=#Ub)Q{+O^p%&G-9>Wp*C#Y+B_7sBueQEauEBdP{z%pJ=_s z*WQ3cPo-`B;C?w@^i%ktU7`PugEoM{P}+Obdjo0LfVJYaEQI$6c(E7ZF?o{bF^MPa z&(E`Qm*z!IZlZZV+7^8w)+=agq#KtE>Qq*a%oge7mUJcBJP!CgcR$k>?Qf$Kz+x!v zBfWyQwebiTroC=d9{K(K5s)P5dL0=@dc?x9H8MERTyop&MSf@$=63hcK+Qt^?TqBS z`1-x0iACW8S7{k!BL?+9PElKY-HXcPeQgfVOlcT;$Y&k8ckBv2q^)%B5GGG@UX(MBi&ud6@`ZdyAHw4CmzU=R9FoPuPIN zC1!zt9d%QKksTfyoZix-^TYmCwclWAt5H1LbKV};8b2bfzGV5lvX3K!q*zRqLrlwi zMpEV%a!}DNOLkQRY1d9`$rb2mYt6}GN-)H~zAZGs{dA6xgC=llu}k{*(YUhXADa95 z!M~~TfEh?calXx^4})+%;~Cmt;VJ|N4NAr0r_<|UNOjAdSN|{IywQocm&AGXCn~QN$=J*^8YZ4eTfc)%ft<(#tZu^ zw1tRE{A)gKoLFoE0CC?3c8yWxApT)uk|zK%26@lj#q*?n z(TIj`q9i~QQ57#Z^^AgFCB}&#HK}3Ft-%=AxcfokDyc)y7kuNZ31jEhcukDKTXuR4 zOT)DKqG&aoda4;;a?&uG#q^2AB@8>)3PMNFuql2i+PwJN z$l01$xFf%dBASmQ7&8$1MFC00*{<}GnnCGJ`ZjkjY-tdG?zCCH=0ZCvUf^4mHZOYT zBY=uymU1=n@aeW0SE^h9tDmozHrVQoUbex49r`iO%H-UHzFu7f37I+i3du^bcdwQi z*Gez`YQ_uO$yxMlRHer;=u~Nr8$14BVOXB>+kj3!W&perY8dbSVo#TAfm%>*aH+7r zDv`lA#Vg0)+`e{kpg*x=J1gy5^N*Dq*ihsAf?#5@?s$E3qf1Mi@1wxR8WAx@cq3q6B`~*ALjbt=9d2h@-BicGwzb-5wJb13ZW$X;>dA5E_0jy2sjOR zD6F1w*>kBfcm$l+5%O8{Vs2RB`|whaRXhS1v~LnQRTfKU8@Da+6;tXA=Lh3RxXF47InYWIIt^HDa;>JI!tZn1~!+a5o-wIKzsfTr`5v9sX?2CA9F zJ_B2vV#PN(f2fnzP@1peKkDJ{dx{@wz%hKR39qd4=}037a&Xi~Uxrpt6Oe7DcD||L`JU zcHH#nyTI&P_CE0AFNVZ-e+_V!q5KT1(*xS}$eXN5ju&WzN-FMX%^Dpnv_h~RI`*#`?3)I6)RcjYWDMmpEpAA=?e1ByjN z{NLX`i}a5dwEE2wU~)?BC6;to{%Z3{DKFFi)lc#Fi zqDg-B4?SDT+2$PE^26PAC!IcGtoWa-ei+5MwRJRzv0!Z&aIvc*FR0lnBsngpPipN{ zM~%jFlEb>{_Qm?V-A|IA#;hBNUd!bqQ*%7LPVdYSW2L|9cX{x=E9b??QD)1+Mb07z zW`j@tNRflL1fMk^VNv;Fhzn}Y`@K0b zw#95s>UQs&tqir{zI8m&99^n}YbL)CtLa7iW@&1ZQ}A#0ytsd(%+-E7=G`p((3<*p z0wkiJyZjKh*nYF$&i%qCa{L+|0hi~#AIP>Le z|3rLlXZXF^k^1j6mjA~Wb1VKRb0DlFn#eP;DnG180K4fG*{`^#t>&Yeq>9VuJ#6s8 z@f$>LP*)#M?DrQESMc@B66_z9oUA7Cg49G3kD$V47kIny3;z4?*nz447`39QJjy9| zxz&8xt(Oc2v0^4w{fOH?!aJez-p1})1wXfK-+xQ}7;CvSoALF%{UabB z`0&-)C^zoy$H61PoMsJ~BMmi`d&hy=dvpP;o0Yg*IDxSvXW5y*zLoU}lOKKNiyOPH z)J{3(6b?lSwH@r_j@+jHR5>feZB-7D1%Uzuar(oL`U*S zr_I;6LZx(H&)!<9q#H-&7$^*KABL1hoP9MauQ(gmJ+RUufEm1*J2bl>t&(kj&F)Tj zLEtp#ukPIG>2mkt0VS!Ge9`Z24;CPZH>l{IBQJk_e2Zogp8Qf~O-J-u$eF_DvW2WW z@47;ZyQ}-q$$j?JlAg|@yWr#SWGcHWP32(RY0gwL5w7Fx^n()EJyQVbUP%CFYBGVb z7XhEHqw*(?o?0Q5y#Rr|y#U@Pvd{EHc!2zatR9-zwv$rDV));i@Ipad^hdynP1Qre zyESX46a%qqnt3Y8v8`ap^L=&U&CbSQZ<$eU3Vo|&CD_R+fcO4wEY|^xYwWcu zYxC04^;}~F^^~YhcGi_rs3=Q=P{&dp-n%JkmioJ58_d(OY&2)&jZ_~ZQ~hPGd)L|d zbc-^9XhvhjvlcB_*5c99MXo4T)5#uBOK1*`+|&i8(%TZXZ$fmF`)*-mKaTG86<&74 z7wxr27|n>Y{nB;*wKSTtGJD{=`UuFa8Jl8VmpkGJx2bIStbLH+d|#>=-S&fO=1u|y zLK*Y` zWv@GwBf~^}b;sV&DWX*=n!G5(Dxz_ll{g`~ZHiHuOfo02_*2L;1hpv}YA*r^fhUNg zK+=GcBnbex3n>I1LdXCFqRhi%y4d*E#)^_dpY6M@ysm|{QuH27-=g6*Fwd+iIZ_p{ z;^C|33rKE$jhr`M^7LIGB_)+YKn9~Fz%)Fja=kD>R?xR<_*>L(KoSge>2DAeC<1Su z4?qsYj);Z<`2*wd zP0>*o8$3{)jStQ213()N#|T<$3PkPk;4>z~gM40ws{(3e!GT?D_@SI41@Xmf2)s^G zg$UB1?u=tICgxLn6&gHNb(&Tk_9OHV)Ni#+yb`qHyyA`ZU}91{9~s;85Q#uattiaC z^&#x{I#frZHAfr2A++zzg&NieS`FDghhjvYYQTqa^@niuLMlSzkIdG4RtEYKfcEh^ zucG=wrB}+qa&PU5wbctc>gb=;04J&fTdJ2>DhIRaa21tWYEl7{NcYHtPvY&67wokp zyKw2mu$u8a6WgF}rOSicUBO0~)x(Xy8mK!cXe`=2OY*)?`I=K+dq!+o{SlDJv&)Bz zX7$QQ?0(GXn)65b{*zb2P<5A9qDtS!#I`7JI70#3ocVR%-4gXajxFsGFgQK7oQrTK zvXY@H{b6#nB_DoC#@;YH$A)|a&?U8E%rnJsRT;Qev5N^l0&b&B_UMcXdpaC$mEQ6y z!gA#xNwMqU1+lsAX3QGNRekzrj>%^kxC!cY^!)}E*+ASWj_clhrXk*RR%Z&t`45ZW zN@D@DCIS8x-MQu+@1hJX-29&vy*nb*?(W)~s{P3_W9)3_4~5euTQ>J6m*rXarJL3s zUh!deX3mFDc3I0ZR`s}uqS2YmX;__+a4P#WRiWZgW<#W5xT>0r!*>!3E%r{b!JML; z`8DK%B8fMj(Z1a+o(Rv#*+(M;MfHYlbfRxvUyj%pGPG3cFOH7=CFCxmk{{xmAqsKT zF+sc#si1F^=;#1=3=SL~7(zoxfgLC%4g`dv&^Dp-R#)?ayI4rK_Sqt6v=i`0W>6{d z0~0a>Y5|VCz+A_u-Tn<{LX;*)JoI-Dn;|F=(spq$4Qcz+HUFL&F9CqtN&o_19Ebx^ z04Rh2Y$yP&3k+Zld;yhMMtz-tCeF!683iT{O#b!(`w##R>jDtY+XDd0_Jn|Ce0U)0 zuO>LaQT7vSWFQzXrcWG*(f{lJR46oG03o#EP#E@mAV`839U32s0>lp7eTu4m!G(bc@XJ&95D?SrBM~SI1Epe0g#s>BjQG zZv1GD;7cee-9(+bh7QlVnf}nYqI&X`^ILW(V!JM@nfS5_4mpAr#VBS+?N0uzu-eBe z8rc5Dabt33-nKt6qqSG~Q=9bxVGm+Xw2E;BspwQx8_?g!2K9m=2vUG{ z{5(1orfpA*ZvS*ff_Q+0>Uky~1d!fXn~6_5L@^9AgKf8ws(Y}<8E3+#(p>Rm?^^UH zBvoO?;`p%HkR`s86rM-2YJP*wima?a=gIO_8eJ`bEZ&+m8UUO{|sD z2R5Vp{SLExHF*x@dBhCO|Q;S?s5^Pj;}0=Kwvh>4>Ef=$Qf=JOvpU~s*a2y$~MVY z1|Kiv2gSH5sLMEL38r{hQ_b!r@c2PcPo$_=O9#(nA$RML|79P8NS>1+Jsb0{=9ClKS#K58M(<>^O!crauB z6B!H%qP`vJn#Sa-VRQqxgp?=4aMs|Vf8b_2Oy5y5@>Q=l z=B9QUL<=`EoouFD0Jcndx#MZS_2Fr!?h1 z0_NNWb;{s5n1j+JNFNb}rfdu~}&>Cykc3D4BB0sZ} z!hz1_)6hyjFvKR#g@Hx_pR>6-tXE@*1{P0=f!~`z0eZSeX4vAv#$T{Qq{IQ0L|U6P z)$j4O$k<=PiK^Mq5txyi`Wm#Hvheq)pU@c6xHY4|%s7;>S@(7kcT&fqNaRgz>fc8| z*R>fkKzBU=_#`D?4hyFP&Yox$DRkpJ%tm0l*-<)HwU#pGtD(ErMxk-e~j2xPzTq9KVhob6YCq7)(}H zEb1YRfSNTkuU=_oksq~gJJqBoLk?`rs7?|PHQy>e9*9r{u?COYW;Syr7-F|%k_}0z ztc?x>n`fiV41mK!%-*%Fk|{G1ayYleTt2pk6iJ0X2}{&a&BE6s>lPkyL(qh{!MJ#j zdDcLOxI9MYkz29lp|G*IvkxaP5~H1tWCqREJR@-9?LI%TNv{LE{5R=d+`+FgKNMY0 zhsVFM`Ag4K!(RoZ+`8YoO}ZrBx@+>n5ys~4oa`A?5++YTZdWf*?+5!n0@^xV6S9e} zf0S4FOHXVAmyRR{xs3L`JkrLZ`mi404?SvG_b2z`o^;CX%I@UE^;ZjCh!b-6`;$eN zH3DUfLw}}L4aLVjw;wX!Tm|i$y`rb5eTwdj6s7rP?t%9) zH#*8rs~pS2`N5K{@~v_4NDkS7gqBF@;V3zJ0nSdF>^EV5zKT_E2}eAwI)?S@eJ8|n zKz3lUvME*Sm@{{)$K8y2PO>PHq9DXem)=})k) zlF>YwJJ%(TfHBId_b0WjD0xdLy;~!@hZZdzFT8YOAX;V&Dy)lEIn#oJg2!WtIyxF* zeXTM!IUFW)>sr6E{LuynnZj*MgBiYzl5?lG1$gjwG9@G@IbcM`9z0JYt#wPXPGG{Z za)YUXpvffK$x*ym#=G_vU>GSAmhpSIw!KchJqv0peiR!X28te3UYFGr(gL(lWw%hJ zrA-b2LkPa8n9EI?%g{(~{9j}#E`2CMUXyiQ`yj>shc(5>o4lM&Bh*gQUazmne$DgG zDVvu2c<$2emaO^}^XjrEy@WLJQd1&ylQ}F;vawfpUX*-?=Da)RAKQ9PVWh}@Q(X>r^!7R-)~)q`rznSNHT=$HB7#RCe%Rp>=UmJ)9>KN6N?)c2ZdpF0P6+W5%1G?eSg zDJdFwytK@(n4Mvf<3H>pFDyUKdc_c_8>`fPm2o+ow{pnH7R@uzW~eq+D7^Z!lUXm@ zZb#!<&SJSN$<1t$;9XT>aNnPe!#9kjlSNlt(MAG?87DFO_cmPIx62#dufy#l4W8E) z)hIbTAHOZuj7+o=!>MC!_fe=m;+#lKv5$=~3o4Y6f0jIto48!*dUlejyKY}%DGg(| zO1~@8pZ7?Wy6*R*ce7icwqBU}MQjwakil{##|kYCZ!-0%##Qch6J|s(pw1fJRQ90V`;O+62!^o`p^cwQut;p;FkuSc?gVh!5Q1X&AL$8t1 zPgZIF@(y9(`*+S#!6uX58E=tP5MavQSFIKKE|l%OFfE9Aiz;|`q^sn9FlxvWeE}LB zIqB@U+nXU$H9geMm2a3eU&}^@W@X7DUs6h2jgB8CZ;RmVrUAv&#oa)GfFj1^FZ3qW zA%#212^eADK=K;mc=$nd589|b#_#|(44O|M`d4zPs+=Z3kiYc}q6P@O&fRA<0Rqdb ztG|N5hP4}&@Ye`uy6S4#wx)ZsgYf3$gIA$JQk#ABic(eG_@l8iFj|~A(KxBJrEg>p z-!`r7F@rF@CQ4iV6G`J<)|hH<`8FKs#X3BBoH%sd-*j_^QkkC=CN3l>LWelmRHDaP z2c?FV^!u`O_v=93uys!6RL(tO`7|6bENBxV8v^H+8-&B-yQ9GW3;}$~jx?#(*RZfQDwAJ_85_B(cXh0GXq>!m6SOK#!fkE?UG+nenH~<0wV8a1$I3T8y7Cuvw zJkF~Z^>Hp(X<4aVxh=$qH&&3tK2gvB(`9%S#@b3*?|Kz2`1ymQGbd3(L`})JegpWV zpc9o!>ZMLbsjCKamC2r)(q!+fS)_+UvV~z#AWx3qG|9N?=CuZRm zoh!1Nhf-_ZqyD&Q;oFZ1`>!e6Fhe~riaRf_iAAd&$<`JgH>1q^0#}2Cfk%K&!vS~_ zayKKP%GyeE6VNOepf_)}u-p-RT6nqJUc#0u2s2gUHH8P=n4;C7_bMf$;#^ zbwfA~*upxUR@gtpTox+J1S13jDb)kPVAU;gF!#BE-=KJElRxREugmKNyZTG z2U0mf(Df^@1awfgB6k4@1j8^$p%tW1)m=r(pDFjw;s0#z%o#DYfs?9Fr_}QPO+63b zs~m;>IL8RG`?56+_E7lgXq;aXutN3JIJ)xvO@eUthCji4UtC;ukaPr9-EKP)si~!9 z^Y>6dSzhTT8~2*~DvKa5TkS^4XKM9#DRojE1#8+)!nX7VySW~BUt0PmEjR*HoeP17 zqN|W9B!DBpKS21Q=qL>Ew?JTEAQ%7u0lEXhwBlDlAk|bOO-6S5@by>sEmo<%Q|@2U zUr%^!9!M=m%MWYIqzfqrws$qOQ zn!)7to#!dNmv(05ZiXcP!>`t(y6fe6StS|lrL{@o(6xwC)~_UbJR^e6 zlHNI=vWZ0twUlAS7hmtWSEDQqSKpki)KSeB++=g?}=idtl&;r2DzJ@?Wz*&<5C%Rs;WQsnw`jh`pXQh`xym)_q2J#+XAcTuccg` zAHY(ab`inA)=4J&@g zBn~Gjnp6sSa^#PbBa`ofu*$OdlS6)zCb~zVDY(f5L3629SCBozT*HOneF~JwGZklh zC!Q9?*gKTkVhTG3kATBA&1o!D&5KcK=*$x!|5J`2>C3`SeUnGPlTTcD9x!9~Ph_oR z9$NnsS-IaiB@dy0SyCl>z1)rbKEVSE0E-dy?E)0q_maYXPlq^br_v7=}0n$ojM`!$QvD0+5t zMIcv4UIYrd8Q~WUPz0tT2&w|n1AQe7ggmM6Kx}w4VIU1kAXJ(G42F?#1Dhfo8Hb=h z(iS~rJXMSm1t~PxrVWtvI1UL`?;>=R-UN~7?X+ECNMyHwhD}a0J#K`M~5=XS+QA{Gs@dCR?jWD7~cH83h z-f(I8PV}ms=G&GyaYLP}~>v@_!Hbs@EgM{0PJsjOz_-M4cM<>m~Xdm!F9g%oGUbFQ#T zr%zjyX@g_b@U!-0xu{e?>A8te$?(rtp?abEeL2t1JZqG+E1P&f%+#9UBg*&(Hj!PG zUTStfeHEZe{R$s>6?M7SX3hIN*$eXiux8*o$no;jtc|wm9A<3|{@zPae0gYZk;-s! zFp_uCZQTef&(|I4o%=B?r^v3*s8ppyvN4b$*tByj5gm(THe_Xy)F>He%RL=7JN84% z+u1)~#h|s$GS^q^;KQ;%<29>P(*0TFn$B~Q{DZeD=0J)zmg0oe3?6DwTlhnf=+to4 zJyaxEwk6uOHMf_nj}CT!ajSi3DgA98PB3V82+1?3dH$XB zq=E2|t=c30b0diZu2hC}zZd2jM6Sn#rsu=FJs73Qo>HGF(^P6FOn|9=$=m{rB$di% z?)em_qW$e0UA_KlQDevv=Zw~rEdfs^c8I<`-}3B=m6FIA1CBz8AE7OdT?I5Ti4RXPkuiO_}wP9&Jl6;weco@ z*9dJpJMP}|KdqZG`;;vA!~0Lgp4|MrWo7$60z39QLb|+g$xMUljq;4c`Gd;^MPxvo zVOJuXOwm&WJ~!hc+h>nwRaYk0TQFtWY0Pz8giierPjgkm;xl_S{$^LOl58+{@AyrK zI3$o(9G4!%mJWe~q-t2rg8+$8C{!FBDvd2p2zat=bKl~J64lt7stG~aP#u6Zj{IuO z9=y#tp&H#Vqfa4GHmxvSu=$ev zV%x-Ndy2EgiQVOo_1H;rg1}=G2&L(Er5m6CK6UmOeuA1p^%tFJO6=Ra9=aLhXPCE9;k;^O~W(5`gB zTOvVv%{05&V`nK%Vt%|P!>qrznVzZP{W7AzXf8JxWMm-U^-LtpvN(yKrR>&POvgd_ z^vwnF%;nxKY%Y5lF7`f^tP{Ayv(s|i+y7LMEzUVz$L!etY$ce@ES$yvU@70tbe@#s1D5BaCmp@?7DSa;u}cW0 zR&fEP`Sq6?Oeh)d^C??&01oCi+2p_&!s_{~us#tYd^WkA9H%nAr9{DN-KFpsD4$4l@52)o*VnL+-@m-2w3}LX3hoQsUR?l zl7>6DNv|nFjkTHr(u*3IhN-8%WZ8K5KM9pam5rc50TQABiTTfgOnF!RTPmDjyS`U_I_A>qM`|ncqjGD>tm$T< z3(&%EaD)SWiw&u#aJjwno7A>BB29cD`>_mqQnTs#nAvAX<)beq*>bg7vto<*uO=r( zSozc$m(>*(XZ_AaMzUgFU(&Nr+r%)k2=k|mtjA$-sSI`QwS}vZy5;aM4^8i!D*P1Y zlby7_<{)CcHNVO*FlZs?W|8^5@Y&tDScubr>&w2q1|mkOUM7FNko@REdm@Q>)8DLHCXsuhX`hFf*cvp3G)Q~A|=MLhVjOz>>D zO7oi!1Pl`@%T(O(qE~hbU~Vt88u;(bKa8ETTvBCgmD>v zyp$A+%tyYTi*(p({~*M9onJpZUFU}YX+EEPuWfVaxzf|cN3&sKi0V*@W8Yv=Z_zMw zfuXJ`r6*a_NG9 zHj~3Z;cb%|+K2RZHU`E?W`k7#G_uyBnj7_(0h#LOz+;W79fs=ds9wlzom}QHMPn}=FU3&dd zq`ver4kw?;Gtp_)1n?xtqnR8mTg$$n2j--6t~H-Dms6oo z`>r>Chl{{_K@X~99sg5kxNrt10)L}TT!D?H}43bi%(Q~GOkwlz51&zK0 z%g+>(5=OBU?gMZr6Yxvt=xSBsKYbSZ@S;9Gbwpc)&i=Cg)CT6=VN$!Mkd#`i#Y(r7$7 zG?D@m^+h#mO%b2+A^F}Z>UysCMqce+nP9jBYixz}W|iz;M2Pk&g!>c`LKAf?grC}H z@Nr*@TBtdlIj0^>Z8s+ju5ZG{c!7p_K|R@X*CX>bdk;hYnvOjG=~TJ1xh>-n0);dG z&dF~#3)>>Te=4=^4$e7bjvoPyQjY*v;j&5#KPSf1nw}DzGl4J-uu~eQbwt`ZAeR{S zj1~ZcDnKSdvMuzgxY%D(*h9o=37=foC+!MFWs*R(c*-)x5wT>xJV1P(+^`=i$)36) zbZJJ&h3#1C#VMQ1|9Z}7MU^tOwj=+Z;-lMS@(ZRDI_&xSl_=C0k_q};>DRIiDll?- zo~)*nt?*oEa)!Q5$u~-2fnq~ZMzL_Mbl!SKySD=&rejn^-A4JI=UF7)94fuJDm*v* zWtlTOKc%?7OqP|HDdpHi8DP#~vLOWVQL6&T;uzhMV?4}79nv?b@Vuxp$B|JvDjL4@ z-J;VRh69;)EJI$k8?MR3YmWf1t&sMI-jY`%B{&eZy}6iK z?r=L#s(y2f{E~wrzn2#t=PKR|FEtA~ULBuvlCr|V&7`Ubb%daGhFgTY zwDj6?1v|rz!d|gp=wy?-s!;Hsq!VUt>E6` z)x<-ZKfXaFY|H$6EpyBSPR^gy%|FO4D5~ZgI668A1U-_Lq*3isM4$x!hYwYWZ%SQR z)6BgIfuOVmKupL#fLMF~4>l+IE)YUu$(;cNUVI`#OzxFizey?tiR0 zd_oAPrWz_6y;XDS)&oYPr{>^k7Z~9pRnAWacDcnz4l~~Lk>#xeo~GrFFdc|uHuC$V z%5?srRor>qBcOfD-}6``Do;#vU~z)-x6z!JQEvcH<{vH~N1t(gCU;Z#Q;Z5oki>qE z8Ua;6(14^SS>h#4-myWo*?6%7KeGV|37_J}k%X1TEuZ-FEg~dT>o$!iWB2Ha`UdVP z);xS1Nq4s|8S6uA_{{Yv*y;D9^OOdP1|lF${KJZ0)sur15gzol5euX$MhN=?M4YBg za7>C!l05Jb(NrjknG#j=b-+`$2-Z81jyay#rdX6{pjP~AiB2u{M_OV85AM*-aBan; ze-T@a`dG(A#-?=LW??}6?*=(#wJ8$>tf7XraM7BFLL~)XFv8h);fLGy@FH~Y^wjHR z7St|zb`+twP_noD*{Ibij@5hk21(vmnsU$_9`g{c>bchV2#A9RX@=Q9|s=LE= zX?ztoSG;{yIP=|vv2<65cqV;`H2rwZi^QH$xPE1!3AiXuqDDe0h#T_j$UcaSu8vM&8xkNjo!Fw~kC((u$DRiA{^cCtmd(huf8nvLO~4!j>e$#A+mk@z*j*n0-gUyD{gHw&jay8ET~+R-7$Jhjw6y;)B4ohGFw)b&Wi_-0qzG&j^&tzm&c)m%gn;ChF^YTGcCuKH0a#xIhI9FG` z!Dzwtr`xb|N{-YAxlAo;Q{f}~+Ee#S5lqpM#Kw)4lzrRl>uj|>Wu>4QQ7=XR1RG`! zl^wG63FF_6zgaXOiyoRXaV=`0x{XS69(*?EBE)GqSD+b5NG{nxlOrF?sy}vu;0N3C z4gPWbetqtdyh=R^|2%GmMd#+3?~1*|%L-b`Gx*v0qVLZMJpFr+JIRISzIW$50>X4_ z)|}T_(r#?cFZ(^)%`9<-ZnIuNF6&KdA@Ho1e)!*@iQu51cu>^L>7!x|LfJlIdh<$TO7@weO45ZP~#==Ryh5p3lP} zrRq-At2?a{{Y9inxWQ-@hdxa$+Qe`6&fo>|GF!Fl``|knFRu zKCC$1AvwicIXE!y3H8BmQN6{BH^|RcQW9-b#{!F^WembO!7>&jt=y69Lh!f3mW94f zC*B`B@YwliofW=R*ZyiY=y8T;b$$E53k7t&j_JZ4?9Sohlzw>~?}w8I25WGN69z_0 z2mJ?ZI#l`6Pk+y=y88OM^J53&6h+m(_JhV>(GPn@vsazHMnt>!M(1n+ELY@po$wke zE2CA5*MDm)tafUP)Ma;gaBDLB>##^|rZcWNYYMWzZaF?{MPqC)RJ+}dqF}dba;8U6m z9R(8hF8~NnFwc#H{Pt^%?-~q{-!DfE5cM-`O_Zpb7A^HYBY-StH1QBp3+?HgH0|* zATEdabv?UKXhOd>Tw;3y3lak$1S(khEmUnbTMsA9P@JCQKg|Mg=*Pe$XxK z7!4~|{I$7st99~%8Km8-D5{MhHCQ3vW?;60!t@wB?_6owjVZhwtUeE?C$8(nU`yD1 zDk@mN&U2dsg@51O$e6b$&XPk|Ie))F{z@*yFX=BxUg+779`aHAnw7Y0OHB5|LH}D0 zrHcr)%|UvW;p@Z2)I}c;O+^xNMV+57Cm8nIS~N1bE>*GgH05=?BX;i4JTDiHlsOHz5A z&olc+ocJ+Q>xuNJ!Ls~bMSqK{ppV?sN4dQl4H>1Nm^H3fp>o$J~O}eL?{oF`!A46{z|gA*G)a} zxY7}L`(7oam4E&$6W^(6atpV^KGUmHCnmrBRSzldn`D@T=*xxVid_=h5{dMBt0B5^ z%a9ZqyXAB3)`|U^Smqy6^tV)2<+h-q(%pVXt@xQqoA3Rq6;{msds&7VyAK*dH_5M_ z3@!b^89|S-p8D)P3HCmpf?DX&uZg94L5B+IMPZi?VP=g4{Yr^`%UsyxPzjC)ugMBk z4otN-ZLM{*WON~_JMl*$}E)hivh^i3J#t>8{YQ<1iFMwDMhIp68 zUWV&j+uYulEI@A-jf`KnGTV18{H7m<1o>|XHkAoIOGjwCYhBb0HZc08-5T{Cy12_F z_UBapVx9TJGSo?wTKcW-v-j&?H2BHCj zpJ8`AL0(!MB3#tmFXBJkdwR?Je{oMqL^?LOFhFnpzf?zlL(UB1u58VBW2&>_H~{`+ z(zVR(^FIM0*UoJeaO*rLdr+UJ{o)--g0S!K^gh3V(KWp9b?@5TF_2f!YSMs~RG%a zl!=Il(FGO{i6N9yWe7r-t%jpNWhdc*pZ~qrK|!O?WEcyJBV(Do$ciaX5O_^+QGJo+ z)|_3omua(+cxFY=K6Xvh0euz8A~%?&WrpicX;lzSWclpFvjR=L1o{TjA0S*5nnhC3 z2Xh?4B*~HtIenG-f#Ldm<=)B3#O72btuk^WM5?H$Ao*#}ue!pn9QX7bljiaJdQPHC zdso|vgyQD>fN-^)CZf#d-c4K68v^q1hp4?N%YMhIZbB5#N?GYM z=lFg%8-p?X9~ufF)kLHuB?;=kaTZ+^A9#2pfW)O|RxK6r%BedUJ{47cbSGO_COtTc z%}d@6?&O`IV%gar@6X*7lH(HhhV)Wy3{i}%a{#-xn?JuAB`;Q;-(Rf?hEX~exan=EQwi z0={3x(N<8cVsyJqVocpol;z}N&^Il4bf~uCP>N2EZFO(Su9cHx5_cT<#oQ|WfC1XyGoC@)2io|oN0UkeSlp=QHvP{ zf);J?NbQ8@)9!*)W}^9m4&hy+lYsthZL=CrzVz=5`WZNLKGE^$kJ9_(MJVERAaWr4 zc}TzU<6Y)!L`m%QwqvF?ezsQfRra67Y>jdBz zcop^8o8(0!Rf@k*F+k&p;g_-Nvx@D+uU+siaE#p_lJS5oIa?pOfJW+^1#1y&?{xYB zA_z$yYR-$dM;sX`Tc%3+jJA>?axSy*WfXyZrXe=|3U%I;EBD6GPp!sOwq??2cbi|D zd0;q|?0CFO`jGEfaH_L9!G<}+_jPnVvs@RjFX^5fxVtvb|e_9vLC=B-LtQX?{+hWnKJ z+>OPWQ$W?Axj6pic=E3XvG&s}2D`9550lZ4sX)0lLOt#(f3J2!k7H5foKmBN`m2(6 zAgzZI&=p6U+2qUxA4T#R@;3Fta9cR}z9jv}X%b_GM%AZ&lUkw>JC$LsKXGVL8&^c` z$?eRrh>uvZBL;NO$O!SV&}5$j&{R-4Y-`;}<|X_i6)T;Nj!72YA2%q=dmiJ8<>-OCuqs8R3B&MAYR2opJ8z%Hp4s*0GNYZiV9#t~Pa{VAeg7fE z=z#>rXz|%dr{{h!yF{%gz}P3E#4GCUg(@#C{F-;1%7K{PhjOW%nXu5b3+A z){!qpX}$oYF%*=Q=v8>33^kBr)h^85wyEKBen7Sh_a?6|d+fyJ1{p?Z-p*B;KQif& zHh&kX$j_+{v0V31NyYhQ)3cF@_RYT3v&J`IOCTy~{(H8poa{;nLV2v%uswfZ%`c%_pWuRVF# zBbAZfHT5n${X}7c_O;94&_A?iPFYJoa~@;pqXeCimitIP#)OC`qcAS4^5ldKvmgA< zP%94UDmNWy;#uWER%DS_SrXi+y4{cUxc!?#qW?#Pvj35RmXegx`E^=_OIDG_rERnV z)%q#P3%2IwH=fR;`KT4Pc&X$L&Y^a-V&?{9a5h0aPMRrqo{FfJQZqB7m3%yq1)(xe zszP&fbuEe{Pk^%29d-1Wiq!0OJVr=q=cW#Cs`2G*q}$fj%OUw-{@Ut&<)d+i6k2+5 z0zr&*6Z{kqV@tk9kl3g|48q9W#5XIZaR1i#6jc6u^I-hgj&rho9x-qiFXLBIlBQe;CF8h)u`wM4?%@{S-sWf$B*Zkj=hw0ojC+8S4e*xa>aywpyJ@0h(Sc z-jpU3g7%w8kD9y80?6wZH#|aEWy*us@ts&$CEiP%=8Og9 zzF8|xPjHyw$S|a8$e@sg&+ zR_w$p)>Ih|d9honXMAxWc^pEtQd{s8SH2zc;&<(v3rIWfO?piu!#Y%Q&-oCwzdaln ztBFtJ!FB_`6{iwG|Hy!Yn&3ZwkB=6Kj%L%>mCXwcA$45HaU%5BL zbT_!T)cP`P2R~P#fxsLQ@ISOg7o_=%-y+XNYX{z;e)C5vh2(;+xV~mHFAWmx@SE?G zlPpk_k6LqnOxUun4BCE>mBPecDQ!p6St*P|SxWOJ{|jh4ZP5e2)o#?k7I7ElqtHH@ zf5QBBMYLRE+kO|n!AHb5vGZJO=lNW+M4lW*M>zK@G>v3KQpH3%H42Nd(y;f~bSTmt zD>5<`H7NmDVi1yuujVCs9#RP0aUBs}K^bm%r6!%cRVPnH0Atm5HFqw0>Aj{<+xA$+=^!Crslv?7li*C( ztm-IOboA2OyOny9Xqv5i>(5_@8}h64A(22=J^du~P5onB9C$IZ#fnm)YkB(;LyyBq zmwOSCn(1#wni`WYCH?KNGKo_8u}&6!waUYRa$)WAn}jZ2&^sj&`=GD|mnhnRak+(QUwGc=1?RralG)8A|XsdC$< zRSlRoT(a78wiH+!ru%kW96d*pjEr~dT&}Y?qDpNynmYp3!bAoQBbxOjm5h#rf1S+5 zUiZ(hAcH8s3o^@NQ;1M=XhA$T)vp7|;m5u)gIjMSWopzO<&OUH5-ex z7J^0;GCSrcP&<7Gr7F+RJkG(HM?D+~VkuH-wxjvSmK=gu3pA(MbbD$g1;(Rq6}yl@ zTKP%-f){0Wo+CMc={@DoE2%`L`^IX8a3Ed$fJu;S_12RZj65LfPj%|Rl%xJ8a{$a- zSSe9WsOKLVqlqu2o4PRItAay})(-ox;qQt+C*ppnMh3^m2DSc(+vr+hTTU)7*LXS*k9r^(J$Ne4mYqX zjjV4SOfO&06RER{ni*7+>fHI{Icj{qOoXbGYl~UYO|$8s;Pw!QjG9j=E;{G4xT$J~ zNXx%UU;n(F1IH_{6S(i`ZgbmF(l=as7056HcvFS?sY1Vx@3+OAnwS*vX{V^*u}8h# z9B85`t1~qdtkGg@<2Ku{6*#C<|0|YR#h;~hrqJU;1{9&gS?P z`VWme(S8eIz1X!uF?EoCgmCWzq6k}IA3?>7li61K+N01vhVKH2wD@B2}dc@xDe zuCK~m`Qdrtaf9OnZOtkd^h^Gbr|;$ZH#9_J{B{aPd7Go|H(3W$65fD}oy{kVz^a=V zf}PqB2QCLfT}k`LU$Pv8V&CyFkJ_JPS+bz-CPN+~E9~zSr2e7Z1;=mwLoHULEFv?v z?WoqtDxZQ222D}OnlE0%Nzsnve}T`Mj_^RU8G2dCit3!8TRmftMPXC_E7R*3ooR^) ztqsuWo}=0(SkL9Y7%UNOUxNGkPw%8n$prt5U5?&125T1zfexs{0 z$Xg|5%O~jV*sVwnVy2k7Vz=9y{K~NMQ>U=9@?(N6D;(60dcDA**pqKG5ajB3qZ#(s zV&Z`CB%ljl!}VMWMs`%r7+6HZ_-OboLb;r`&zX$q=UI~1kNT3AMU$TnurW7|)z1VVDynh3!( zoqTc%W@;T5YV}1Dy|wFm}o*;D53j zal6m-=s}HE-}aQFx0M1^OPTU2dG-$j7uNJ*K&f{F5>rryQmt4^6xNC@wj+8uzJ4q;zN)gIiial}3P@G&$DPIb!!r*+#__5tKi+71u+Z&T`dG~Gm zYIv5&V&B*mx+#@N{Jmkd3UrWN_dbrY-ct-l*EBE^>~h3L9WKty?#oCcu;n2ljx{xy znZDVW^))84PJRUwHk#cTCmEXa5G6w>AMWdM%gEE6?ALW*2B;_2)FUBcV857m6SsM6cW;k z)-Iq7z;&(7`!wI=rg=>tcb}W0%#9?;2i2{qN4qDQRf~95PAn4q=~1t$r=cwzC6}wl z5SkjFbP!s!B68h_tZU4ZrMW9$N|;AMhX`&6qFBgk9mvn4RM*|%sXsDJFZtcSf3X{h z+lbF-;FV>HUV4|K^GQvc1I*Kn<0rpGX$V6q9(5GozWaT9A^I?Fj>_k8ab1iGKs&1B zezEI3dchyUdz0$0GsS!Mlw@wNTiEv`FHEW!BUI&5Z+~fUhvE=k=F-vADawAdq+=la zeM<%rPWzk?rJZIHMaS}9imo3ku`BF?xhm5rn0+)QD)pt0#q>5QuQ z<5QP{2kDY&fj}Rm1Q|idLK|kdC$W!{X9y1}u%}~eUrVBst{Gxn4dP;PAGF?T4Cg5Xm&bH&#eh1$cLZF-t zf6B3PEp%y2SahxV1^mf_!ccD|x!h{ZjsIrf(?S`4zSeM_S|W9F&R_ktJ!9k1W(Z;9 z6_9Gi(L(0glRUFJO}9~q#>Up|^tSDTYD;MduoDJoo=*HwednQ` z6T^BuH_^^J;2$A;oFXm~r_mlYII%Bw>ym#<&$(Yb(e1}k--}_cb0u~aU8MuR<5Xa) zTWY{%`Rh1~BW^~`(_OIcu5r!j+&6t{q%G_8}u22C|=R$AEsLfXa~PvsVWt}xg=4;PY8dbC<&vqGoXERa>6 z4g-!wwQ9xSR@=$)O1jk%D@w_l?Ok0cSDLjEECQjO79qiy=v)fL>Wz~gCpL)8Gl-$b z?PBUT1gEiq`){HTzo$`>CREG-a9+QU323OQ=|>P2Nau5=e(ELlNTyd8g|~`gg2paq z_(iS+g?qHjH3Pc5CtulVmRRVNPX0X8Y>w4M)$T6JS_Q1~$|JkAyVJd}JZFR!A9DUF z4mfg7Bbg>~K?hqu;e{^#S}z%NDv5F5TFPz34bWo?R8498aa`uT)y76LFU}Jm06~Ay zJWi(~p~8~$rSB85A_r>2-}d*@%t}WWkdX8<_+=x!B*cF7w2qIB_1wM+RNMj%F|y46 z^mDJO+}S)+oJ923@|x?KLJHp+ZERWvQoXURc!64y^W-H~8IXDcbf8(zs-B_zglQ8~WHq6rn=%Q@g2dd{v_{U_ zT&~clv=e)II6YJ<-;t_k+FKP`%;?2VT09cpD)DM2Ok5R?++29G8M(q0lAIAXa>V1c z#nO0U!!#x&tani(Z8=H{8cCH!Q&%EIoV;*;rUo|wi+)NZi_~&@tGws=`F@X>^9|IS ziP&MHz@GLY4?3$ob{I@J%yVRx@+G@sVWULO?RAt& zkMnklr>VUgo^(DE@gNjmQg?)MObb9qftCZrH|M;6sS?jl2P5~3< zN{`K{eTP$qEh+R}n-GH8?yipmODbr3QFVH`-}duWU5&A^iW@6LlONzEFHhBFDWj40 z{;tb=-HT(H1M0^D_i@hy=O@@0iDznJC>~#m zzo3Y<(kdZbD;xQipOLQ&n6afMAKZ!Rj9zfR@>*)JEz&GtCcd2urQo?sEkN|(<&jRi zc7|#!YWDugf~P_*N=SD{KYy;cV9yq$3m!AYh$|T1_^(dpiaKt!uUnTv?NO~~#aV8b zX@xAcF*l%4h>1TTj{oJZP2~AW$>8U1E*hJl@&)#<^u5PaF9Qccy5hTHI4AY*n#@t( zD!)mjj7`_1K9HP>Q6hYijqjijz<}{~UCA}BuWLbyCq-K*c8p3YUFm<3+gFAfb=pf4 zqva~bFenkc;*fLxsR$m#`T+jpE=U$7fM)U*%>oULPnLcI?FJmxEWet+T-;CXOr7s8 zE_m-p?HRN=?+tts3&A96<+Qrtwd%^RV(&@MyiT>Xbk>h)veQx^2k>j#Xd^0_N5N6k zp{lE%*{Xy3Bk=*j$FsDl5@KY3m8Rcr`g9;ob3CVNMUw44^VfRJd%DmF5wF3BleG?6 z_K&mS`K~egDhchGjf{V2WLo~(WN^+Y&-NYc)fC&_{?ajqH^As>Kn{(g6^+C4oLUYH zZl;D)XBUN&shpU+z;A}tfTq8kQ#%Hq_GhVa&Go!YijQ;T^-LOgzb1LO^e^@8Z9~;WlNLw{v{$2 zb9=I(W)VDoaRp5^=zQdJn5`;h$WVBe>qs==E_&w%g`>~-ixupReHTaLsPud{TVeboey;vI_vAK zd$ON?!V9iCRoofNO$SjwI==lNPcv%sE}S-LRPn$1hxRps6~s8FPscskt>P}Hg)}5_ z*NPj&4+}NYSF9SF6lsg^jJ=|857eD~G22}G)~4?QF9HUDAp6d+8qB$289nO=L| z0qza+ri$rHH_Ct`HvtdZR1`mNQOl`7kIoU(&?Lugoo8V+15rfq0{)5!&0>;!eE8ax z$l5nh0H&U%PK~l|!v1y6RygE0BAd$lQomFTI1#cCg7ZIYWJ^2()Bo1lMw1$d%TOZ?wb865_!p5 zEa4gPaiz-Gv~F|OC#)Hb9JiFTJW)M6&02+(IgRH6Gs11Kz6+Q_ZFM*Ihz%gLz~L*WG}Fgr7pYz~9ZXIUjfg(l-WY00dW(5uO1KmR}4oTcXe?y0qq zm9yY68HIqoKdg?Y*RXk!8S~t4PphY59^>w9my(Zv4_n()SS`7fbpJu@ZsxH`xS7 zHcP>0lqvYu8EBmHn*<*S8a|TAzQr;mcoT_3PZ`(ceCPUjzveX=V*-|xb7+_7?^EVz z0=2kocwT)d`m^Ac7~DDMt(su)KEo7`*$-6{W8kqb0ZcOrwf%_GfiYX3I$wC0jJI@l zCqjhaPV=sTac%k}G~_)&xUSIL*`fa9OykX=YuDs4`~92#f%H{Z8*xe4C#%ku%pAa? z>ylArW9&}$&%=1;<*Ui$?U=d!>qC67HI%2nzoRtO!Kd`xWUhxhSx_Z?$AemBN~04d z!k3*nsW0?l63B3)wkTp))mh|OoD((uC?_9g%I=@#!))uPhmyJigw^WigY{&K)wPl{ zT&{m=Z;a~B^Ko~BkM*AHD7B#@zzjJHd}uaBv`*pR+kSjPYo#9Az)Au_Pts?W?4M}S zGb^&*N!LyC|JkOl8B58=6Z-Lv&UV<%9rX?C5 zie0l!e4CEC)ljtgXX?8Ws<&K?ENc$p*MgE2cN@9^CW9EY`^=F>qLWh|+Ibl(6L#U3(qH15t%znA~3xO>8(9Ef#zdYA- zv*KoI2nDstWjY}GY-N{1@qPd8<&xFLbZn8SQiPuJDrhW<+ z{qb%wf2|aLwH2p;BzkID>o%67;4lGRPI5j+wxY@q#lMJi`!iD&NuBWvDAr6;G$g%o z)?Q>pPrYxrS;8hu967cf4boTeFK`nk9`jMG{E-hO&!1-*m{7>nA1p5PqC&{)hcR2# zPgP6U#26LdRffEX*rx!pi8Aw;8@ZITmIEQZ1VSA7!mBM8xrC zoR|NUb!JWcw@k9ERg|V~z*|tD%rZdu%7@W|M%8t20wow{&@p64_*%RbxDW;(3>AA* z#E+SpEf}np)ji{fM>Yb`Tl1AKJb^GD*y-0x!>oykP@_@ZOvr>SZ{`$i*E@~q`+QQ& zm*SbcY-R=-kx{W0r94mT++<%G~ zf7}sg){E~Oe=tx={Ha)Sl>$HSnr(W_Q{wWvm%^oq*n^uJm`fdqJ3jP1nV*Vfk4qlJ z(;}B0j#0=@+WBL53}P1)`1mUcJ*3O}W7;+nLmvF*>ardDE((V{ogrja^NBB*VXG_X znc>kS56WX@-+m?Wc@R>~(~bfY#_kqD<6bzT{9z4cqkOl2&h%Z6>b>uMqX~&fe?Q~v z%`wK}q~k+v10~8DC=p;gvVF34<@nKP1F2Kqa>a>!)# zy*+_l-5t?*;Y@i#@>X7^@9)c?&(d95nae_D(|s3`GU>JAA|&eEy`L?I4w0x}5)qb9pC@0|LR6J))Vfc*^!9DAZUlc2rUUO8SeSH56#Q=W!k%0B= z{YoEpOd!_Wp9a?CMiAb*Hu4o~8rz&{6z-MP5%(r1hHG;DwlocU?Di@DIgtr$?#_i} ze{jyGPDcLMo`tjoOH&@ZZ`r2D6mV;#N2D7|zlj?O3>CsFnX22}wBpoC{5;@8{&rE) zO3cY*%FJAIB0obpbHe3bY45CF=hHFnmR-eoq9<#x;=}U=ZV=#a4WjVO5vx&xL&Bz! zdsx_9hI9sUpJ-arHU1&_0y&%9I(0z$U7GX+frMcI{D^y4 za5*m1R1i2nxHbEi)R@<{W_hW%3u=RH1YR%#w{IF>xXo11*(&N+|GQ)^od>$~A$RkL zf03G0yPo8cxP3nK^YZ-cIQx3@)WM7DZ>?^FYx50znH&v2TtIfuGR#nJJoDPoh~%r|j80EbAydh9aeA$`e;{ia zrNAiVpJ-gKAoWRL4uvP(pt~(ZfZ~k}S=TI5H(eo9JIhq&Ass;Qr>%`XYcvGz+|60` zS26F<;=AmIZJ1q>2eRp{c4jKGl|%|$L?yP&@x-!GvAQGApq3M3$LQgglowyp23U>V zVSCg@!scRMO}VIrh&Bm>WTs?1hSTuk@bjJ*3AV9^;=n@y9=H2ahDyTknMq!5rgbeI zHHyQBe`r7DuLRxM{^WNi2G$r-HFCiiXT)X1(gp^`4;3I57|qC* zF*DNG9lMk$f>%q01?ae3zmwdSoSb?X-3VwmL)J*XDxd&{#=oOotA#hgm3-^8mx3wF zUNt*u58T*)|NY*MPHlWO8vZ*H1r1l}5xnfudqzO0^97~F6_BgKO|-GmVkU5Y&Ok0g zfcC@k6}!st`xsGtTG&J@!KP1B$#&DC?x@m%(agGQ=V;KIr|RNc^}nS-C%;CMQ1zlZ zp5zAhhpyLOc}K`ob3?LV4+amadaCnBm{_$bS6$ji;_Zrd#?%5hQ`Thgv8P99f z_P~Gk5C+*FXqrN~SXJ}{OP4lgDE$rG2^JuL3|LaPg1_S}2plz>uk10fT-b>hUY>o( z^M?1mP9TlTmr3k1i8Okq^?59jfPkxKHHVgf&{VcdR_Z-I`inij-x&DZ`6$s?6rCO& z-7`y+MOf|&JNgea*^kfPW1&Sl3!tHecYgYX7T!VpJc+sL;74a#(()|=yt&cS_`ybf zIm|mvmt>$Wx7}6%@k3wGlv~=60wT&bpB`gyDDnb(dU$osdTP*CSTf<=l_t`YbO03p zO}2nCY0+X~l)R{K#jrhXFMs~b+WYHV*c!soT4&m26s#vC`lH}4EJRvv#2?eZ*Z9qAa**_C(A)U7v!s%Oj^ z_~{m0L_>#Hajv0it}&AZKug#gmpD74OBXQ9ae?RTJHCAm*K95$om9t24lRuQK`!u?>HLTt;48o_C zTOQ^+;z9wrDA299W43j=us=c!9WqC*)hj2hBZaA*m;g-|j3d3d<4D>nRwYkChn1OF z8)b7nK`xlm)LQfFJ7M2Bvin`i#wKOP_mzG$wF^kQ+nW@Zn=n# zi)UKPG(w)tcPdqs6TOPhuUF*Nbvt2t^DaU<^deb^yp}lirJtAYb&uf4O87stkK@Z; z=(nfhL^#hZ$2Iv(1;KT$T54|V9te*$H~z+jnmF0>mXwni7Ps1wcO|v<<1oP6+L7(4 zXs}X$UT!?uf%7&5A|7|`+z+qiRNAY!y&{N;pZpomr3GVpm=?`i%^x=*Jgkjp-g^}y zlG@xuT~KHL^RK>r(4zQ8^d+AYvtQP$4ID%&Ngc}sGO1dyQ6N8U$g#F;L?UK$NQP6n z3wF+5z0o%p0~vf@=2JHzthV?G1W10JL6Dz}WVwkNbff|a_tQH}+*GA#LmFse;(?Zj ze67M?DTI*T8zbKUed|tFJS+QI)pg!`zA0NywbX2HIeC)Tl3QGcF4`5v#iPIlKKpEW z{&~6Zq4R82|N7xGi+Iw8GFGx0k)bC;PX83A=3sWs*n~AhURMv()HN&F0R+zN3L~C? z;P!B$YnuFPKvQCD?4ejUSdX9aO3jCk$F<9CH!CF|vFi4d)?X2*d0_~C=VCj!v}Wu6 znOk^)$(0~Q5!b}N#oONJbWBV)0!MkW&IEL56=Aa6C}%cm5$OHEAi435E*%K%iy{x| zx&AECWH5k0i#hn?R~qF56x8Jrt&6~9kx>2>zXwc@_$3dK@@)ZfRZBpb$Oxr z4{e?65tvr)*_Iu7R;H->{8?iW3v7wO7@ts)Axz56l_|m4IxI3tPRp5&PK}OqE;7tB zG@(Mis|1&U-n1h7>sSC9r zWLTgZ)2G!bi6k??W_fNcYoJi${fvN)n*jegJNi#-+Aq@dXfLsx(VpRk|EDW|j#5B; zRL2NXCnmZg3S!PxN@%*GZ- zptI@K8|A^8L>&B1Tfju=o>+k}8SaDr6v{eYAat0l9siZAIW_l0?9otO4Bs90ye_mr zKYx9xa5bZH2pJqxQPqMp{yef$-4Kt~*w$l_{K`3q(APNzvLw8v&_%m zq%Wz0uu&Qy*tQOE&Ir7Ls6&LyMF3`#us4P0MawqiZi059+iP|@#j8Tfr)F)K?+TF6 z8F&&Pel+YJ70lJ3T*MsRl zK)Prf9trfiwycNDixK4$5K-WaH97W{G6p*VX zHb}i}YD61gy6Gc=YgBGAJUWuZa#Vp}CFdvCx>05~#k5v%qNMiWCEO0z=IF>r+DMUA z3oeU?A*W+kEwVM4a+n+^PqkEIZ+ceQ| zImVi?Hb!-5KdVW)$Y%-jBMB6C-rF z&$uj`laY(M3o2_y}Mm_*B#MC?r;p1Kgw~Gd?5LUhPdD}-MmC$9-}&Jp2sk*r+!qY zYWU53@BU3<=Tw;mJ%~t)6VnyR?(~}8ws4*DE#OQOX^W`p@ z4Bglpav`hCbinxI^}`fLg;WGh1(UaL(rtAmQZpNuf^}4Gv@V6aEtRO9MTGknH$79t z9N?VRJdRqjn>kKJV&^Nl$~=L&^C$z1=b6e`lN}!Tg!owJN-=$Ge#dm_N#&dfEL`sr zA76O&=J$s-Y1Cmep}?uPXyK)-%zG-A2b)u4m&4@aq1t+lV+M68?6k}_)=`I|cXCcU186)a< z`^#7svDxvBZ?2eij6-p6iT|PTh{F`ZtiG$SV6C+s1XI5@`s=D|UDonOc5;)#cPygt zJX;%S#afREg1LS-=E?1U&cdA0%drIg3CKC5>-Q!IjFF+$NmGqUGZ|yFHHnQi^iPOQ zaAaSw4J)!~?tr^jbVsF&rSw-Ar`F;g>n7=r<%kR-*>2Hc&PjI3{C}uDt8lT9sM{4aCalFz2fos<(EDqw*B0lN1h}pq5A5mbMc^po z^qF%|Vt^hq1H;@lf6vPU`uZg{6A2vztn227w=N~JQ^lX0gF|TwU@hX~P}aQ3##t6R z?|Rzc0!b~kHe6Sx%rjcDmFqPd<-PWwj5HYHdLfcpxS2E6TFT!1eC2EzLAE2sm1Q|N zCX0(tT^_vUx5h7`OcIhe^jR_#rQ9upeDBpS3MzFRSm!p+&hC+8X4MmXO|MeK^b1Ez z`(WTFyaK1rc0Sd;Q|S(7h#W}+j}%62A2;X9A=O7NfP-5UdGj4c9gXA(?vs>^{Aj7zjxU6d4C}$4GEg- z>9x&Y&}BF^C*5JG+G62s$D4W-aM7yh?fxzW6X2K8$);9(GfAy^pUQYdK`0(Nc5?9Q zzjQKrn%jKDd#XU(YR+EG9>v~DoOijEghE2yn;s|#ncT1z_PE=EUj9SVk|fe;%*e`8 zLJIvu%aSDEGghd(GOAZ|eD}crUr14N%`2c@O6pfWc)0>Vsv+=DA4qdVG2iMJ&A1lHFS`^$6KbJ7mkrx>p zJ|yml9W(fki2<|TcTqXBWe;29_1*g%lg5qItyIbyX2`~bS4!@3W+7?06r_zXwLE*` zn*9-Ppnqk9K~0-7rv|$Q(U42;cX>t}itK(V9lGY%;G6}Wi`rwOO4)qAL!g5=%#v_m zOW0Be7ghra@1mMZp%eYMp(@APWSz{Ke7J#k*w$Ht00RD6U&|jyi&h>Ffn&WZk5Y@7 zQCIx{KM()&*B652h*64Zl#kv;s+RZ=ugkc`Sxp?a5baF>;;N+1vtwz7{%%(fcvi7&9IGoW=cio&=X^SUpO8HE_z_)4}? z1+9iF^dCoX<^qBl$*e6lI~>pE8iFvnS{?tPg@(RPTB!FDaNs+2sP4I3N$W2c>Nzqq zyK12M`fGoDAE^yD!eR2F-sEeY%qccj=!o~Kc2RMw2~fk(^CZ#j{W?6ja%I~2E*jrm zw+nV`GfYASrguvGhlYPlTkYC!wlfmP8$qNN@gr-5D{WGvX4508%&!F?tQ~s-J5T`D zIVA7To9KSIP3<>;UvRH^Hi{(mR>%FKryC@&Z<|HvjBBdKY(ZoFJlLb+d2?ByeGJN7 zLjotimH;0sa&bv}0#mxvM{-8O@iL4(c1i6DZ2fxkO64_j_j^L*20hPK`4X%PF2n+K zvPWtGiMvf98v&SdpE|;`NgHe8>BiTpys%C-cWHJfYc|#KWA*Vce`%hrU9J^*1mVTd zKyz)%q(I6ur_XlJn=|Oi=479L)ZX}}nP%PuXNtKeM@%yv*{lgmHJoY-%BR;V7>@)q z(EbiCC!{R#tlHEr>SDTrXTU10W!WW{fDamBK@nV&WCF$u zBAwtRFSegq+0_nBipqegW5h_oH!k6o(pk`@nn+2psIXjNrh(juC!~4L|8*Lv(8ZoF za~@RPaG_g2_-$pccKGfea*7q_Y7f9{U;*=I3tI>o|1Lsrd-*9Dk9GW7yg+?^+xwuV#qRp@C@N1{gg z?hIX~q)K(XNJ{@u-HzFoPNn?{q!fmpC(l-vf4uu5tIoG@B|>0{Vgzz6WD|%5@%Nf= znce5~k13NW^*{Wbi}@MuLOVsC95i3e8LzX|y9ya`*J4Zlf;T{I0Igq~vFR=1t3T0> z|8ep7+c*$Q(bJ!K1(&8ieZMyzLFOl9#H0^>RSCYwd*?1Yt=H)GzTZVeIUOC)Y^9{K z&v#Y9JvQ_84y{&{W!V=eSb>*q8(hmGjza zAZj#8S*Oi>wd2|ZGS|wvO~}_1gmShY^M&wWjF(#+QR^-FJGPfayraDksoh@pIg?05 zE+nLU+2?eJf37%<4NfoBDsH+k4k2*W#EcyK;}h3!G7SHwp-UZ2c<*}g)!26I^tTY|8?{Epw5yS3*G^T+f z3bR%+#r=QliU8p-I2*>XKEA{D_ZzNzPK7(gRtzPRJ{_5`l=%n3m2PcS8hC z;&}9AUS-a}$%Pig*hk$aO)Kq#l$?~D#ChMSxqaY>?ZN&C#hAIs@pPpSP&d(=+(*2)Chv6)%ZgK7J|y3ic2 z4#$JJz_bGStTe}D+iifyDrMVZw$$YtjopKvo&~9DGR>$HZ?(ihDKtX?<%P}jM- z>MO z6;o6IdsSM`jNa1`*~ybxl4SpF0742eE>4+`VX(o=-~6JAwL4bdW(?$ks1qdK8Di2E z*4enVzqRUUWwt9Q97tviFt52*0}2TFdbu+kCY!cI47bYZGaF77>xW(lwn-FcG@A%o zF9AIQeE;&ImgNvh5Q-?b#-k<o+8;~{E!0s52yWPI4Lqoce-?46MillPI9{@t`>B3PaM!9@+`vjR7&k`w zlYA>`6Cah7TpHwS`Yp3F4yxyhAQWf+; z{U2Hxbo}=nr8$yeg!!gCf)j#y_S#9dU8wa>#K>?<8K z67cdKn+1EQ-VqS1G*|h44l$ZAuKQ&$9b+-_{}lBWP;E5dA2+2fMOv&l#X@i^?j9sq zahDc?d$8hCTA*kGfd(z^7Bo1NqQzZWC|0zT;85tB=l^@(^PaP3C$oFA+3eY!ow;-G z=gV27=?|+1axf|YaHuBp>!KBi@cOXqHa9XlLNc6HL{ju4i^q$mdeVs_zr_K4am}jO z>BJ~n>380^thm82hJFPcjV+6Zfc}Aq#m6u-`8cOI%S#k__dAh2n?c6#*@V*=@{KD8 zS^a|LVzv2O!xpFd(-78ehP&_ssJCTZ>_Z>3Ayxot-k2SXqr?hIdBlSE^gs10B>=8J zL(A)%M{VTAJf;UXCYpgrvh2d0!NfJzWZed>-#3g^x8p+YZz)EPuuKJ2BLdk`OhD%< zT+UP2OK^(BYZ&>qsu=+fN?kRP5g#EPSv=rbLxykVbAEyO+vqtA=#7i zjN;&&h`XlLM46JY?C2N7gJZBd*&;`6e`U>?~fiJ_-Xt$Msu$wX^!Xn(nT(!RL^(aoiRQ} z?E*U*uS3W5^Tsg%O`TS#ViL4)uJyfL|xk zqvWDb`-(BVY+srijCEtS#dEZk0`uN%qpw{%e=Ymn8uNYEqKlK$+TEw+`-5)$I-XYO z?DJCaU~@XZ%N@!&IIN!q520W8+y3xPUps3Vwc1M0nDZ0%btSI6SX3lo&}gP4M_$b2 zkb5F|-J7E{=^HGgP}YV~&#*91fXgLEK0R6ZLreNbVdo#!Fu8W?mjM4CiJveIKPw!y8R8lE$goV*RX!c&*BgG|AQZ+aya#HioWk zcxmtS1dC?lmn`kfPurtkks=s_9kZLZaOjq`KR3a#1T@@wvvEi<~RsbnjH8Lrgw zBVp0~_VR%rPb3!|cNTn%-FhTCPld*S8TAc{$z`!|gEiVGhS=9H%1L8KtHu@r!&9lN z6Vx4QRzzvVKlqIQ018aSk5EUAVpk#$T0MKCdNnpG9tB|qXq#@#gqse+mqwSp8DqAa z8`OLjh|b6_kSO_B*stL~k9*K0xSYgM6r*z@G(Amfk#S?WNIUhY60lPLAs8Ty6s3r4 zlqQHyOsIA8PbIBmZIsppaXx7D{~SA@=1c&X7NTQdrL)BUP{`u-ghJ;(4fl1G0uuuI z#6w&lFyL7a0jV3mivDXUq7-pTG}&s8uSIJBr3XU~Hy@fFCH{Drg}jxYC~Eyd>uUV# zHXPv4D)r-DC{~%?Lscu7jN12md_qyjkI?A4Gb>ki9gDR5#aPus)yonL3hLuLvxXwn z+>`pAM;IvCk764h_`TfGS~u#TU0pg{@NEWFpSSmU^NdD+J@}QO)2vY4?P9No?w@l; z*^F21<(y5f!r%SfeMJSnJVv$FD*rMcatx3e8*aX5rK~r3qmFvH;cxY|DU>+y$xv07 zH;OZ)9|?Rw#{M6d?Abef1uCr9DcZ!@%oY)SLy#^}7(9cd+})Zg-RNX1?<_`yURmiFIkgbZA8k zRK1{+n^x1^v|Qjxzbn`R8Q*3NOe+;=iTXuj)ijJyeohyE#~@Mrd9=|OPOG^HpI2hgPFUAn*Kab_Y@(VO!iHtH z+B4aKL}`SujskMSVRv4G*Z@#8`-0w%ss9O^L#2Lnwt;F1@u|2u;%%Rp$YR~G%bydm zFQ0OQ4U2jILOo9;6sg7wVyIN3#l8ZJI0Lc?9obqBob%x{wN78y1sYqRPW^A8F#wBI zGD~=#HMrB-EyP!4EMFH3HnEHry0}Q>tBe|Q6@>i5L2)%mnGV-HCL!ETJAEz`yjUM- z&dMj2%G;v9wIIXAuS#XLtNJSAVR)CANKl_aF^|w+p&wK67KLAFR>{?v@)8Z?-kXVS zT`doiW#!Mdg|61F^yb#)+p4jEUm>ot+Lo>PG})9GJvr?AkDNq5{n6a?O)~k@a%*-v zV8op^TW*+u_Og6S;oP*ZU%omflE*&bGrrrwI|8qmj(3HN1m=(=YZP(dms`yG+JE=G z{4-30adIfy=iNFvZlZOuc5bOIHZJWG5%AP|&- z@As%(W=1bou7M!C9tx+CffR1Mzo(+jIA&-wQ$`23c4<+|pe2 z2ZOAX^JoV&3{TcFq*YoKam#O!%+7Z4M<%dgBft{_y|$8EGrW(X}y*G7R)PwX|KH$;onBcK_-NTC)p@>da?YIKG)Jd-gB3<-C+w`!7fLo z%jJ0aS3NhqV$LxE86C-@rbaBs>~R&^3_HdMjTTCZL?i77Cr$}xx;8FFX7M((6F!dg zNFo}iwf*Ig`c&2EpO@9*F>V>?U1@9et#~I8Xj3>Ct6&>%2QssacQavmMj4M!(^C}> zREdS)^x>%_^(z3u?CL7`NFce_jzYF1ER=wCu9NH^4)W%e;z2lOTIOAJS)Os@GK z4j(J~t5(>~L5c$Uk*{#=M~>G;8iSI#wa)a9K#EFaF3j?AuO0DRt)ETGf@QfgAbct zmedc-><_W{#8nZ1GL-u90Knw&(CV}IrsQb)s4WjLGh1u)r)Co_z#%CH%&wOuoh`AjEMQm}F4Ctris`4LZr^Eg>4r}v@5w3$aScNXuq>)n&&*ZY2~eMfKU ziA(mi)zl`7^`l&>u2e&t9nbLIb7rgrWN*eY# zjP={wPh^Qm%3n6&=JQ1vtcGoN4T6H_N)2nuz&sQ19pzV&#voYu`Cc2!&Wak!^ z>&^MFDAQr%Ive{>$g=n7M!qXsFpgM4)&$Jr3@V_O*P^zvdZqXDGGWqLh~GZvE-m^x zccpfXyWS4*K32rWg}m1#{;g7H2cm7pQctbID6Yo>U-6fwazgD#xobxpR=;Q9y|Vq} z(qOkcVN+V8RAC*kY_F`lHlmg?J$GPWygHUdtv6fr_hQ_*w7!qzr^+anKJHj-UutFJ zwO4LX?udop%(hjCgS+rnNXqJ6{y^(tyN>wtQ_2>6`&!2Z=_^0(>q;`5C-mp@zb^~5 z5zaGT5A|lL!K9bR@3Y!By}(mbT<9G=N4?YEVtUWSe-7u*)+TAf zeR}neIMWf~RgjsXglnOrdz7fWsvaLZ!El)zwX%r}cN$tcWz7VEY;A*B(VU`ajkRB# zbiZ#f-ZyMyN@sc-5#OP~98q>{*VFGdc(ne{ zh`*2eWv`wMtI8GRWf~+NH+daNAtzU>oX&YEbuKf64MkH8k5lt%ju`P}%QpSE&sNf9 z)2h#5#nt#Tme0D1OWOR>e)4?B2>qN%9V;lN9>9i$xKMu-3N@FxwMaD5J){0l=6{GNFDg|Tcm^!;D%hS;)eAfj_6r#%>3GRQQ)rv z`BNM4Zm9dcxvEy4H|y8}b0idh^6)4n>f*tetRH@CgdjdHiy&!K4-098T|OBpAq%^a zh~_7Ce2i-$0Y5)RIM zkFU^jk7?lyVfp~_LkW&d4bD>jN=AwH3ssZAjYA{J(&5$aC$#nxkXBY7gAx`Rt{KFN zthn2a=cn!TR~Islfj5JU1ez4yD|?PY$1J*HbG{X6t?r(?f;@V@vb#-ZjDsrp&V_o; z`Z8g)=uW$qL3SCVIRAH0r|3kz%se(%j&JRLNH>>HA!@-OxK?!Trai)-BVR|{k~U^p zMY}H9j)Ov7-7XM6tz%&8V()TQP zXGL+wW#3G86_~ydEH~3J*Jgjvs@q?YW(0)wIb~M)c6^%arkXIEa@CZQ{aeBFDzV1v zHXXd#mW-Dm$x!%9r*JPJhsWZ&sC*7V7h+f#EL_+>*~D9>WU5zHkU?(~`I}ZsmDtEc zzmvu&jE{Na^<@8dIx^k(5V#t-`TLMRzTTF}M8kXc<<_17R<(>A+LN7ZWwzS^*Y%l4 z9W_C-<;|VjTdJZi@O&N1w-!C`W;uRqaww1A9I1>GNVhg5{!VMn8ZQ||gp{xKPkqfz zMp^V(ITmZK~`?Cob;tc(!DDuJf3I;QXt2>`rl1!E=XXD2oNclJJ;;mMS z#?h7*=p5y8!<-_hWmE-cN`~hk+lca2MQUN!;6fP&y1C&vGq(q>o#r^pb$<~x_i5AB zN;oU8z6yR@Cb&HucOn){g;!3`H?7$#=P)mo$iKwM^9%hIrxbA$4Zj73tMx3Y~wrOn_DsU9+l{yqlAVI>os`2tPdYl`iSXT zpegJPtn?U&Wr8xT9@r7_8+~2~@ONuA!^>t3Vh@4@d&fQWMe}tWX zc^+=q8xBV20b)L;uE&d;sfNlGYO_&_!$+w?CA>0lb-xvg_o;uWE|seF5ww5LZD|J; z9k$l?uY9bs;gKm`p%+@h7)oj7wc*<3Z7m~_T8cFAdDYYWGF8lBK~08gcj3wN$}K6g zyRj?F!=RuQ*1=4PME}&F5683&$vS^@tqal5rl!q9iUQqyl62NL7U+{cbje|d@XgJM zI&CiATB}`e+LGT%5?Q4SyDaUY{5xgpOW@lts0yb(HviJL=$X2kn;2|g;_%elzwJ~W z2b-DQ#CIDFfAqF;+xd0)@>}kL+PAwmN}bel7g_H+Ude?l9W#FND%7DbfVZL7kIZ)x z9Rl(w_%efaV7s3;XLTdslD|3h$?Ia{@Z$BzAXRwpm3f@r@zqm(c+5v?KSt{P9QT7_ zhKLDJrNAkIz|;){$HkA12mb$!TICIEbguSAyA5*fydORQ8wg^&M9!bLb#mQmA>}Y? zd8IubTxK{aNsAAjrw3z0@1K)KC`2+_VzDE<0HRppK7guD&r2~Qy?B@vn8%0kIfBC2 zfzp7z@+HP1QEV7EG~!fX!19B}r^fgoT~P?BP5b>%+t7m4L4P#LeOUu(F#>BmPLV!y z3G-#j&VPxa*SUUffR5BSf4Wa$W|~&j%!iu88=f9?aN{&pZXmK#k9S+rWZWIh6}E~_ zF^k$4Q8IF~kO@liuTMrK8hNSPLTR>wM&}F{g!NEGO!RPryg10~BIDCmdhL@FZp2%D z2dq#e<1}*KN89=bUV^zjzod|hbX0#e(g@RWhPXne)pJXJfrf=sbgJ5D?Y@%aM)0wP z&4eS~(b1BD2gi(G^_<D# zI?K-D=?2OfS)~Ip8qhACs9Y7=+o`yx-(<8#JctENjzp%0TpOFVhy4@H0rmG?WmI%zh1yEK)6HAvZ>eb zH4Knq`C{IhEVrwQ(e$|ARLvpy zUCyY+rff7eTJpIi*l5fnOx0?YqqFj@a89zaJfFOt!$j}cfsvg7f3VfuSU{U5f7(xT z{mpNcK8~m(^KdFU$<)*=AoFuK7(*4{0h(^zSs$O~(R;x~pdvq8g8l<@Knf%ZI2S~^ z9>aXxLfU8o9Ol3f6WPIj_H=#lGZ)eZe-b0e(#s#r4c;kE3@SlFH8q*-;;7TRa?0xz=ET}ifS8vGNkuugS<;?sDMg0+_N*|kUl715z--8sv=j*gO% zbnD}X(ld}`(*{*5YnkJpUci%?L!MlwCr$pe444y@l=6TxvNcZJ-gp63v`n{ULb{#x z)8$>sCcATs`o!7#m2&N3hyX?{t1lWGqo*gfN)u1P>1S&M?^Ei>Jvdl*)x+09r7 ziIvCV#Tm}$AchcpCBqI+7w<@dza0dvT^L+sXXO7Dg3X_IAC?{oL2ic`!xwRCl!nQq z_3(+!4#jmNnCXl;c(AcZuFQ)b;;i1Sd$3h?YE~#EluIvZZ4%2!MBm|O|v?9q=c-x-VP#oIOL;{9l>$r$V;h?BN6|310e2r+w zFvnsKqq(v@~GDj!X6Z zbiv0d#9+Tx2T$}a1M z$|=W}Cvs~SAQfvM;iluAAKJz8ZgP`fO?#LO$D}mfXw=FFf=*R>Qa6~Yx9JYsJoker zU#SbZ4|B3#|9Q#nm*CR;W924EVC+xpvcP_Z+Ww2FOZIkl7(>R7HmeTh%ZeB`dWo@e z;+?pEIPjfwraklU{iKCs9S%33XsJMz=M-E$0>{SdjKp}Zd< zHV-3?*)|vs;WxDc9lX))a(T;~$6aT(SELz+n>Fe`1L{0p-&YLHy&aO9h?zWMn`>Nn z!_;~9mzXbVs5%s!aTBvAk@Q2>iUfC}6V~!D8dx&ras#)HywMSE$}rSY}fb9f}DqIwppt@p)+Lt@pg%k&4gRj zHhq-l(gdAx{gm-CJ(+(xk)D*P07+G@X@CYQ-0Ok2yIiea(ID(Tf9yE;NXo7eDmb-a@LE`Gk2#%*9@bqx+r-3!IP zkHN??oxBM6I~4P)_4pcw$@S?fJ!VP^9_jALNDJSl!yW`{Lh|dXr6rJwOCZ;-dq-^~ zaC|@ec5s^{&?sCG8&<Z_F-DL6nC&a-zE;t)ryW1jRm1#9_$I(Y)UfS3;wlWyXfX3 zxXfM4n2b6^Zyu>t>o53tegP2eiJOLkVMN@>mzZ3dCQHP5UYdQSz4raI=AONzXlI*u zB`CvXg3-Wns`XA!^8lhYlDI>yXKUno_NTjviFeR=B>A*>+d-SZaIEn6Tm~Y0wf+dx zmdQ6*I@f#Z8+>V?L&+mmS5D1DkLAN!Cr%r&4$foq#!P?BmVO_Hx(*!XGMW8)jg1$x zlyz&TYM_!4pbM3vVRD@vHO5GER%0e=oTitE38YWZbhB($?uhDrIrsd%O3T5h7+Rp! z9dtq7cU-Onnr=MZ7-T8s<~>uk)*p&j2Q2oOAiw&#yl^5HV4c&{dYTV)o!O~plovr; zYv~8O#&erH4PccEDrbO(lncXHFjKF^#&|{!QtA&rzF!ffmaQ zqg@>dR_ZpM+Im+Ac2Wbj3W50oraaCVh6=p`NbQH(p$tQ%UgEI&DW4Sav}1<8RiT4- z-p{Z}nvb%0Qan^(9{o7@DZq#xdK^$HA+L%i(l+EPN3X4H>g2CZ*2;s}`wyP;UR`mh z+mE%qIW1-;Mr9h`t48^o)z<}j`2J86_cZN9*-E>9rj$&6n`A2siTB_`1jO>&*Q*4_ zS;pH#Gp$PGtPMG5psR6Zlv~3pGt~|8FJ1QQO4*SGv!cW9!L(x#mva{}Z98F2$vMY! zqQ*1^dI6SsrCCO&aw>A^b66H!^pL3{W(g3%C6w2+j`2#nQL8q7jkHqzrgH z^(;6A08OW(7jE&fWvU_H7?d^NzwN?H+A8a*-KvkY0zricdDq#^V=zuGtp|!5IX<#l@sbSC$e8zhoB=bs+yW^GqFsN zYBY)T^+BN(c7*Gn>!?r8{bWz-mLn8d8ZxV>fh7(Bu1x)L7M1ks zQ*Gm72_tRYad`0`vA(Xuk9{BUNI@$R2ZTdGhLb4rGCDRHifclE14BGuUX4&c*~RAQ zrf7*1%RU;m^uUG!p6mil2qFCRF*w>n;1tli2p~BpOAo#R=PRc^03g`2-~o<;sCCp9 z2qbYv3}VKg3wnahIkO~kirV0nI8*W_{&UO?@t~3|QF|-nnjkAvG8C8& z!|$&(oX%i_+wxw|Ur5Lw%sSagXV|#V_**KycyDoCmPyk?Kk86AH-F<}CeTNmDB$tC zH~72T`u)M1j6U25!*(sw$bEZO@ot5{l#fu)C{fA=)<;(uQAw#PZvAYE>i#Z?Z%aZ` ztrH90n>$}mBt!1>n>5QNA}fb;wzf)J`w?Kd>7kzC3|%Xkww$W+*;NB}uJkgRZDhI! z36(1Mb_HCPbWgKx zqL@gC<`c?Unzkl=)QdZNG(TxT5S^v^g0Or>)sN1SkVDvmSLMtOV&6-tB*w3zy;Q8w z%obZt#cn)m#{{zlsyqjbI>#zdFn^8);l|+;u#||!E}8?yfJG*p2%X&BMxVBHkyLlq z62XJ%gqb$pgm2gN<`T}mHIQ;2vCMQC+bbTr);|sj9^=-QzTa9gf|dPU*)_37@lH+Z z#XY~y<-N20{BG2aq_uUZ<0W6jn!B|Lai?i1ET#g??1L4|Xer+l4sBl~1 z%edh85l75ZUc6L~aqQtdPQ3VacwcOdaUMuNOED!SH-7xy_P^Z3I-F>w9wnU556H+C zvG;NU_G29(uU3RQe*I(8)&LxG3J|9f5Sw`&p&*T=Y|y`tP{Q8CCHS9GT2~Qp)N5uy zJrVXXu=F3QTcn~-?B`eg^4ijaUC`;Fm-k~ke1#f~FF5#`Zzxpq3tGQ+_1TES>IMvd z*CbYPZFWBp5mtUmh&r?vJ^7{iPR)Y&_YW6U;(S#~YJdp%#kT;^s#9ZrmYz~ob^iiH zxqV$vr&EK(m*G=crmRwo_Ul%wG3f%~y}z7tPq)vWK`Y@#6<7eo;(&K0rNprGO2NmtFn*c#k{?yF)Y}c zR#Pk8bOSFYAj4$*A=86;$$+Y|A~(LC-jR(f3iX9#GAl|Ssf>r@AB9Vwj|zr{P@HYRp?;VJuOjIqQZPBGC00}r%ZdU`;wX^51exLwTaiDoE1+UiA(hj2oc^vBtN z2D(srnXgWj&0uYSuv#A2wCap)_duTY!Z(Zgj`Bb@4JitvJZzXaeBKcjFIrUlU6|22 z@w}`(fSd_InlAa`bxy|FFN=RTo-Fs-2jNFsjD`X)k|YGBN%qED?tiElNjZ1$9eVCNokC4!nO8!VX=-j~ut&Y(GnwJ7V}S=R4kXzZ|9tKHI75J}Jg-C!rVl z&6|kqOa>nqv3A;x{OBVo^6OsJ9AY$-rx$6qbMWZeJqT~jND2F2AyX#fHoX)zuw%tl z-5Z{fdPB(c$`zZbOl+;BDMX!^IZA*E!fUu64^tvhoR?pk#f0A`kGvaX#r-KP|8VXf z&i`#m3DPM;ZyMStVE@ZfvZwAMdan(B5BeKn?q4Y0q?dCSxvjWqZ%oMlm&4%h+m|cr zVn$!KEfQ{~#I#kme>j3w=sxi6!&B~Vc55YT^_av@mxOSEYlKBG978!ug}Omup1@f) zU#~_L#a6kjVU29Wt1;LE0F?&@u{{`hvHxmJS;p7o|F2<$W@`ny&st$w@0a#9{sd5E z6N0OAd2fLsAt53)!(MQO$64jcpRNJchV(#Vw$9~;QHuXvYSZ)p_sD5DHS$T4idN!q zt9p!|K@UpOmQjoh`1w}#3r1~L#x0g$k&1*@c@TiB(L(}M+kCCl+Zr1PU=*W&uNAA- z#Zr$HEO*MV+rnlj;vNP_e4JsNDJnz~&zryy+BJ^yrysiNpO}pvJbVP%)_w(x0hqWE z%hf-B>LN->apW=gZN)irEX1sOCN;S_v-nX=M*?Ud$Qn^?=U^sTbmwp(GCTK^jcs4X zoEBQhGm{$EHgi)bO!6)@lX;TybHNO+*5=EgX-WyB9S4I0UWPHF+!fMg;ff!OC#Jvl z&aE?S-b;08ecB1A_!FQ-N9z-OP|^|n^t-Y1Cfo8GyJ1DNFugF}Y*@Z-f(d&}AT>{< zApVR?pg5w&d0%H6Noi$*{sa$Td6e`aBq9|c5UVdTkN{7yt$`?U){ybX=INJ6Yud)b z<4Mb!fm8UXkbW>g9j<`oe~M{?;Cvss(MTow*2Q?38?)mc7A54Prl~})7g;jtLhUjz zEkhYANmt)P9`cl~;&8a?(Duh+b-T#d=f(82_$7SQ9MGp-cpzFgjn`J>kIUd3j1WD} z6v$Kvwl@+hqAf!D8DDp$?mg}XOOf_-5ShqJCjbEd9Y|pqC)3_w*KY0-2wlo+bTl*h zSU>J!=s&VM^-)dQ0?ia!zwG@I$(4tITfI^GxE}RC;vz;7kOXpEXT_ls-8t+;NAU zgz4*bgf4+Y0VnE-d0Jf^lX>%{+aS5Wy8;IWn8KaT(`zWEeoa0@fQN)4YNEJsK9d5i zk%yqFshHxvZZ8-8*>jCBsz4iNq@g5{F9VG1!=2t1QH65fq$*jZh4qAPPj+yH#*Ed< z^IW1Z?|xm|bY`T5iBW}eT&60Aqi^x=#S11b<=>nJOklc$1vu|nvzE-QjTx~P!pcTd zkmlb-)rTBcN%5Gy(3o$Nb3SkWUfV1WhY9E0?p>R9=o86dA*(9azFoclE57U`5BPM4 z%JVS&ulT={z7H)JJ(#)0|GTN2+PrV`=G11>?BMzoQ~%dhHe4>u7;Kwjb~Zl7a2I6r zhFVP}+&W`1Wa09iAy$A|o`>YeGi(d;w<_bKqT{qCH7MiR*?phhR3qWUT50&_>uNvZ zvTlsywtfA6qVWq9$F}Xs5buqK72oI49F6cdkEe4a!N-0q+iK%^!RL9G2ADd{O20U- zKw;PA!42%s27LdWb+;u`MpDw|Vq+!GathxcGYXdqGdBG%->PzSfCA>*Y83 z@blWWmn+N-s~PIP6TjC|;CsZ#yA=%U7ZaNUo31xj;niW+AC?~HZeA*vg!})E4cq z8Wtb4W&`^s^fC&ox&By$^`V1!r(ADmTIxl>S8tXo!^_AteU%O!0OfhS`Pn%xp(?=5o z@wUv1B0@PGMglZl{;=F>jfJe?YLF8Nm+n@}`8bH&Q5OFlz`xO~U9LTgxk(j251U+J zMj>zb;ji4)!oE;hCkkb7vD&|ymZyA+)STwSyPDCQr-x8E+3zm!LE&6^g$ZY^BEEUE zygKvm?v(qQephc`cS9*Odw##6HU)-J> zL4^B!vi^fZn#YU}w_An;bAzw=i!u(^QfmKnDqZ-7eVMe;ebb$~rOaSQaH@CvXdv(( z&iaz)^{XM8y(Mo%sjBfg>(pko^IZkgYkAt4Z63E6CVf_g!0w;utYboL0dxMp55-z? zP-}{NLc=}Y-C0{I&u6^>RcUIC#KPKp*<*W0YIZS^CXBiBNW!3MDQE{dUvA?LlIjCQ z!vZi>Rn0HX%3E&eaaiJqKl%Iz!;Vk}xy5@W$#RWtUeqeSdcteBkts&VG_z@ZLBcd| z97JPEqRy%6(~q83dVq+wdbWdJEB)b9B(gigPJf3fr1?YiDw5!N9IB+nbk|F;3NckN zvnzSz*^ec}ssIf*RI79wq!QdI;xpGx)SH@YOIfvSZRZ4=k)l}A$j4DqRw{@=98DoR zQA8Gh9483Pti(0~qi-ZcxOznR5vo*VI5@m1|52dcV6Cw(cMBp8Z!~@4 zSH=HTbKcj+Uux^-EPTriM|Ev{Tv2u~C9u)L2p89rHuT@TqIIDNQ9i|4P z`JRO3Y|CF~GvWa(iXGBLw`P1tl~yUFpMMa^rww{usT9}c z)%NnptOvv(l4{u0_7?_;P#octMiBtxRAL>1hEk7!6%FJuSYhBuUmQz5)HiQ7CCXDv)pH3SO z)j*Jx-$3!7A!@MXhX9#~X0}QGI4z>Wp*RsRRY8$C8fVFtmXH>wK;cm=%zg@hMeRb_ zLZW>P7f8X`hQAAAQ)DvDE7bQe+sDo-}(^%fQzO6JcC=M1t2}*hJZ|VO5T+Vh4 literal 0 HcmV?d00001 diff --git a/docs/images/tpm-pause.jpg b/docs/images/tpm-pause.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2183bc6eb3cd34f83e0065f64b045557f0521ad GIT binary patch literal 47778 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%0D6=-ijrIwu+|c+ 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 Date: Thu, 15 Oct 2026 12:11:58 -0500 Subject: [PATCH 28/29] ci: check out the tpm-decomp submodule The tpm component's CMakeLists includes tpm-decomp/recomp/runtime, so configuring without the submodule fails. The README's clone line gets it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build.yml | 2 +- README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ed91ae..3659750 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Checkout the game submodules (without their desktop-only nested submodules) - run: git submodule update --init components/jk/OpenJKDF2 components/mgs/mgs_reversing components/mgs/psyz + run: git submodule update --init components/jk/OpenJKDF2 components/mgs/mgs_reversing components/mgs/psyz components/tpm/tpm-decomp - name: Build uses: espressif/esp-idf-ci-action@v1 diff --git a/README.md b/README.md index 19a0cb4..1a6dac4 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ Requires ESP-IDF v6.x. git clone --recurse-submodules=components/jk/OpenJKDF2 \ --recurse-submodules=components/mgs/mgs_reversing \ --recurse-submodules=components/mgs/psyz \ + --recurse-submodules=components/tpm/tpm-decomp \ https://github.com/esp-cpp/tab5-emu cd tab5-emu idf.py set-target esp32p4 From f81f8eb9fcc2a93824b9222729ecda4625cdf305 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 15 Oct 2026 13:46:54 -0500 Subject: [PATCH 29/29] readme: TPM is recompiled from your own executable, not decompiled (yet) Explain why nothing generated from the game is committed, why the CI firmware has only the load-screen viewer, and that the goal is a matching decompilation replacing the recompiled code function by function. Bump tpm-decomp for the same note in its README. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 23 +++++++++++++++++++++-- components/tpm/README.md | 37 ++++++++++++++++++++++++++++++++++--- components/tpm/tpm-decomp | 2 +- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1a6dac4..ac71c5b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ between the two. Three of the carts are not emulators. They are native ports of PC and PlayStation games, running the games' own code as C on the two 360 MHz -RISC-V cores with a software rasterizer. +RISC-V cores with a software rasterizer: Jedi Knight and Metal Gear Solid from +decompiled source, The Phantom Menace from a static recompilation of your own +copy of the game (see below). ## Metal Gear Solid, natively @@ -47,7 +49,24 @@ on the same psyz rasterizer, GTE and SPU as the Metal Gear Solid port. It boots from the disc image on the card through the title, the full-motion movies (MDEC video with XA audio at the movies' full 15 fps) and the levels, at about 27 fps in game with sound effects, music and speech. Save and -load states from the pause menu. See +load states from the pause menu. + +**Recompiled, not (yet) decompiled.** Unlike the other two ports, TPM does not +run from decompiled source. `tpm-decomp/tools/recomp.py` translates every +MIPS instruction of the executable into C, mechanically. That C is +effectively Sony's executable in another form, so it is never committed or +distributed. It is generated in the build directory from **your own** copy +of `SLUS_008.84` (`CONFIG_TPM_EXE_PATH`), so a playable TPM needs a local +build. The firmware from CI, built without the executable, carries only the +cart's load-screen viewer. + +The goal is a real, matching decompilation: hand-written C that compiles back +to the byte-identical executable, as mgs_reversing is for Metal Gear Solid. +tpm-decomp has started one (the executable is split and rebuilds +byte-identical, and the first functions are in C), and decompiled functions +can replace recompiled ones one at a time. Once the decompilation is +complete, TPM would build from source like the MGS port, with only the game +data on the card. Contributions are very welcome. See [components/tpm/README.md](components/tpm/README.md). ## Star Wars: Jedi Knight – Dark Forces II diff --git a/components/tpm/README.md b/components/tpm/README.md index b9e19ba..ee1b192 100644 --- a/components/tpm/README.md +++ b/components/tpm/README.md @@ -6,13 +6,44 @@ The PlayStation game (Big Ape Productions / LucasArts, 1999), USA Rev 1 its static recompiler turns the game's executable into C at build time, and its runtime is the PlayStation around that C (BIOS, interrupts, DMA, CD-ROM, GPU ports) on psyz's software rasterizer, GTE and SPU, the same pieces the -Metal Gear Solid port in `components/mgs` uses. The decompilation in that -repo continues alongside. +Metal Gear Solid port in `components/mgs` uses. + +## Recompiled, not (yet) decompiled + +The game code in this cart is **not** decompiled source. `tools/recomp.py` +translates the executable instruction by instruction into C (one C function +per guest function, with the registers and memory of the PlayStation as +variables). No human writes, names or restructures anything. The output is +the executable in another encoding, and the executable is Sony's copyrighted +code. That decides how the port is built and shared: + +- Nothing generated from the game is committed, here or in tpm-decomp. The C + is produced at configure time in the build directory from your own + `SLUS_008.84`, and from your own disc image for the movie overlay. +- A playable TPM firmware cannot be distributed. Each user builds it from + their own disc. The CI firmware is built without the executable and shows + only the load screens below. +- tpm-decomp itself holds only original work: the recompiler, the runtime + (BIOS, interrupts, DMA, CD-ROM, MDEC, GPU ports), the HLE replacements, and + configuration that names addresses in the executable. + +**Where we want to go:** a real **matching decompilation**, hand-written C +that compiles with the original toolchain (PSY-Q 4.3, GCC 2.8.1) back to the +byte-identical executable. That is how +[mgs_reversing](https://github.com/FoxdieTeam/mgs_reversing) works for the +Metal Gear Solid port. tpm-decomp has started on this: the executable is +split with splat and rebuilds byte-identical from `INCLUDE_ASM` stubs, the +PSY-Q library functions are identified, and the first functions (`main`, the +file layer) are in C. Decompiled functions can replace recompiled ones one at +a time, so the port keeps running while the decompilation grows. When it is +complete, this cart can build from source like `components/mgs`, with only +the game data read from the card. Help is very welcome; see tpm-decomp's +[docs/PLAN.md](https://github.com/esp-cpp/tpm-decomp/blob/main/docs/PLAN.md). ## Building The recompiler needs **your own** copy of the executable at build time -(nothing derived from it is committed). Extract it from your disc image and +(see above: nothing derived from it is committed). Extract it from your disc image and put it where `CONFIG_TPM_EXE_PATH` points (the default): ```sh diff --git a/components/tpm/tpm-decomp b/components/tpm/tpm-decomp index 9918aaa..83289bd 160000 --- a/components/tpm/tpm-decomp +++ b/components/tpm/tpm-decomp @@ -1 +1 @@ -Subproject commit 9918aaa375bbdfff9090d2f2e775ebe13abb6f77 +Subproject commit 83289bded547087e4b31c1e7973dd8c77cba59d3