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/.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/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/README.md b/README.md
index 04daf19..ac71c5b 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,11 @@ 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: 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
@@ -31,6 +33,42 @@ 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.
+
+**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
https://github.com/user-attachments/assets/2e6b5173-f53a-4591-8347-96aa53e222f2
@@ -87,6 +125,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 +149,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
@@ -136,6 +179,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
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/mgs/psyz b/components/mgs/psyz
index 80483df..0c2cfcb 160000
--- a/components/mgs/psyz
+++ b/components/mgs/psyz
@@ -1 +1 @@
-Subproject commit 80483df90b37461ff0d968d5d62c98e7854aafd6
+Subproject commit 0c2cfcb60ab0e9638e8c7ab7f0e7d1abce766f65
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();
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..a9ce0c4
--- /dev/null
+++ b/components/tpm/CMakeLists.txt
@@ -0,0 +1,147 @@
+# 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
+ # 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}")
+ 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" "${sites}")
+ execute_process(
+ 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)
+ 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" "${TPM_DECOMP}/recomp/runtime"
+ REQUIRES tab5-emu
+ PRIV_REQUIRES esp_timer heap statistics shared_memory esp_app_format
+)
+
+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/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
+ ${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
+ # -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 -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)
+ 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.
+ 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,-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 ${CMAKE_CURRENT_LIST_DIR}/tpm_rt.ld
+ VERBATIM)
+ 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
new file mode 100644
index 0000000..69ce846
--- /dev/null
+++ b/components/tpm/Kconfig
@@ -0,0 +1,150 @@
+menu "Star Wars Episode I: The Phantom Menace (tpm-decomp)"
+
+ config TPM_ENGINE
+ bool "Build the game (static recompilation of your SLUS_008.84)"
+ default y
+ help
+ 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_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
+
+ 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_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 ""
+ 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_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
+ 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 ""
+ 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.
+
+ 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.
+
+ 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
+
+ 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/README.md b/components/tpm/README.md
new file mode 100644
index 0000000..ee1b192
--- /dev/null
+++ b/components/tpm/README.md
@@ -0,0 +1,153 @@
+# Star Wars Episode I: The Phantom Menace, natively
+
+The PlayStation game (Big Ape Productions / LucasArts, 1999), USA Rev 1
+(`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.
+
+## 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
+(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
+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
+```
+
+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
+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 USA Rev 1 works: the recompiled code is that executable.
+
+## Running
+
+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
+ 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, 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).
+- 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)
+
+- `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
+left / right.
diff --git a/components/tpm/include/tpm.hpp b/components/tpm/include/tpm.hpp
new file mode 100644
index 0000000..5c3b263
--- /dev/null
+++ b/components/tpm/include/tpm.hpp
@@ -0,0 +1,50 @@
+#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();
+/// 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
new file mode 100644
index 0000000..ddf7f60
--- /dev/null
+++ b/components/tpm/src/tpm.cpp
@@ -0,0 +1,234 @@
+#include "tpm.hpp"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "logger.hpp"
+#include "tab5-emu.hpp"
+#include "tpm_files.hpp"
+#if defined(TPM_HAVE_ENGINE)
+#include "tpm_engine.hpp"
+#endif
+
+// 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 {
+
+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 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) {
+ 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;
+#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);
+ 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() {
+#if defined(TPM_HAVE_ENGINE)
+ if (engine_) {
+ engine::deinit();
+ engine_ = false;
+ }
+#endif
+ 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 defined(TPM_HAVE_ENGINE)
+ if (engine_) {
+ running_ = engine::running();
+ return running_;
+ }
+#endif
+ 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;
+#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);
+ show(current_); // the menu drew over the panel
+ }
+}
+
+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{};
+}
+
+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
new file mode 100644
index 0000000..66ca284
--- /dev/null
+++ b/components/tpm/src/tpm_engine.cpp
@@ -0,0 +1,1493 @@
+// 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) 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
+// 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
+#include
+#include
+#include
+#include
+
+#include "sdkconfig.h"
+
+#include "logger.hpp"
+#include "statistics.hpp"
+#include "tab5-emu.hpp"
+
+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[];
+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
+}
+
+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_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};
+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{};
+std::atomic g_flips{0}; // display-area moves: the game's frames
+
+// 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 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) {
+ 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;
+ }
+ }
+ 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
+// 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 {
+ 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, 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);
+ 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(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);
+ 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 %u %s\n", static_cast(off / CHUNK), line);
+ }
+ printf("TPMFB end\n");
+ 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);
+}
+
+// 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 *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) {
+ vTaskDelay(pdMS_TO_TICKS(100));
+ }
+ int n = 0;
+ while (!g_stop.load() && pcs && n < N) {
+ vTaskDelay(1);
+ 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("%s %08x %08x\n", tag, static_cast(pcs[2 * i]), static_cast(pcs[2 * i + 1]));
+ if ((i & 63) == 63) vTaskDelay(1);
+ }
+ printf("%s end %d\n", tag, n);
+ heap_caps_free(pcs);
+ vTaskDelete(nullptr);
+}
+#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
+
+// 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
+// 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};
+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;
+ }
+ 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 + (off - g_ra_off), 2352);
+ return true;
+}
+
+// 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 ----
+
+// 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);
+}
+
+// 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;
+ 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;
+ 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;
+ static int64_t last_status_us = 0, last_shot_us = 0;
+ static uint32_t presented = 0;
+ presented++;
+ if (now - last_status_us >= 5000000) {
+ static uint32_t last_flips = 0;
+ const uint32_t flips = g_flips.load();
+ 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;
+ const float win = static_cast(now - last_status_us) / 100.0f; // -> percent
+ 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
+ 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
+ (void)last_shot_us;
+ 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);
+}
+
+// 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));
+ 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
+// 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;
+ // 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) {
+ 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",
+ "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]);
+ }
+ rt_gpu_bench_mode(0);
+ if (!in_place) {
+ heap_caps_free(rec);
+ }
+ }
+ xTaskNotifyGive(static_cast(arg));
+ vTaskSuspend(nullptr);
+}
+#endif
+
+void game_task(void *) {
+#if TPM_GPU_BENCH
+ 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); 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(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
+ rt_boot(g_exe.data(), static_cast(g_exe.size()));
+ rt_shutdown();
+ 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, _IONBF, 0); // the read-ahead buffers instead
+ 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);
+
+ 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);
+
+ 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;
+ g_paused = false;
+ g_game_done = false;
+ g_ring_w = g_ring_r = 0;
+ reset_statics();
+ 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);
+
+ 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));
+ }
+ // 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;
+ 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 &&
+ 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
+ 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);
+#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, 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;
+ }
+ 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); // static: its stack is the arena's
+ 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_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) {
+ 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_ra);
+ g_ra = nullptr;
+ g_ra_len = 0;
+ heap_caps_free(g_ring);
+ g_ring = nullptr;
+ if (g_resume) {
+ vSemaphoreDelete(g_resume);
+ g_resume = nullptr;
+ }
+ g_last_frame = nullptr;
+ 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();
+}
+
+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{};
+}
+
+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 = 2;
+constexpr int kSnapSkips = 4;
+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)];
+ // 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 {
+ uint8_t *p;
+ size_t n;
+};
+
+// in the order of the file, after the header
+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)},
+ };
+ // 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) {
+ 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));
+ // 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, h)) {
+ 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");
+ }
+ 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;
+ 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) ----
+
+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;
+#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
+ 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);
+ if (d.x != last_x || d.y != last_y) {
+ g_flips++;
+ 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 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++;
+ 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) {
+ 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;
+ xTaskNotifyGive(g_video_task);
+}
+
+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);
+ }
+}
+
+// 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);
+ }
+}
+
+// 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;
+ }
+ 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 (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());
+}
+
+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) {
+ 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) {
+ 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)));
+}
+
+// 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) {
+ 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));
+ }
+}
+
+} // extern "C"
diff --git a/components/tpm/src/tpm_engine.hpp b/components/tpm/src/tpm_engine.hpp
new file mode 100644
index 0000000..f628e1a
--- /dev/null
+++ b/components/tpm/src/tpm_engine.hpp
@@ -0,0 +1,27 @@
+#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();
+/// 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/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/components/tpm/tpm-decomp b/components/tpm/tpm-decomp
new file mode 160000
index 0000000..83289bd
--- /dev/null
+++ b/components/tpm/tpm-decomp
@@ -0,0 +1 @@
+Subproject commit 83289bded547087e4b31c1e7973dd8c77cba59d3
diff --git a/components/tpm/tpm_rt.keep b/components/tpm/tpm_rt.keep
new file mode 100644
index 0000000..3a51aa1
--- /dev/null
+++ b/components/tpm/tpm_rt.keep
@@ -0,0 +1,32 @@
+rt_boot
+rt_request_exit
+rt_pause
+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
+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
+rt_gpu_bench
+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
+rt_snap_dead
+rt_gpu_queue_empty
+rt_set_movies
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 = .;
+ }
+}
diff --git a/docs/PLAN.md b/docs/PLAN.md
index bc333b0..5a8ac96 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -263,3 +263,35 @@ 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](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.
+
+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. **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/docs/images/tpm-conference.jpg b/docs/images/tpm-conference.jpg
new file mode 100644
index 0000000..4a64094
Binary files /dev/null and b/docs/images/tpm-conference.jpg differ
diff --git a/docs/images/tpm-corridor.jpg b/docs/images/tpm-corridor.jpg
new file mode 100644
index 0000000..e756401
Binary files /dev/null and b/docs/images/tpm-corridor.jpg differ
diff --git a/docs/images/tpm-pause.jpg b/docs/images/tpm-pause.jpg
new file mode 100644
index 0000000..f2183bc
Binary files /dev/null and b/docs/images/tpm-pause.jpg differ
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/carts.hpp b/main/carts.hpp
index 40c26f7..b0770f8 100644
--- a/main/carts.hpp
+++ b/main/carts.hpp
@@ -7,12 +7,18 @@
#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)
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:
@@ -21,6 +27,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/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/main/tpm_cart.hpp b/main/tpm_cart.hpp
new file mode 100644
index 0000000..ca917f2
--- /dev/null
+++ b/main/tpm_cart.hpp
@@ -0,0 +1,108 @@
+#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 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)
+ : 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
+ }
+
+ // 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()) {
+ 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"; }
+};
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
diff --git a/tools/serial_screenshots.py b/tools/serial_screenshots.py
new file mode 100755
index 0000000..1f8743c
--- /dev/null
+++ b/tools/serial_screenshots.py
@@ -0,0 +1,74 @@
+#!/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, 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
+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 "):
+ 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:
+ 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:
+ 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:
+ pass # a line other output broke
+ print("%d frames" % n, file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()