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()