Skip to content

Star Wars Episode I: The Phantom Menace on the Tab5 (statically recompiled) - #2

Merged
finger563 merged 29 commits into
mainfrom
feat/tpm
Oct 16, 2026
Merged

finger563 merged 29 commits into
mainfrom
feat/tpm

Conversation

@finger563

@finger563 finger563 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Runs Star Wars Episode I: The Phantom Menace (PSX, USA Rev 1, SLUS-00884) natively on the Tab5 (ESP32-P4) as a cart, from the disc image on the microSD card. No decompilation existed, so the game is statically recompiled: esp-cpp/tpm-decomp (new, MIT) translates the executable's MIPS code to C at build time (tools/recomp.py) and runs it on its own runtime (BIOS, interrupts, DMA, CD-ROM, GPU ports, GTE, MDEC), with psyz's software rasterizer and SPU. Nothing derived from the game is committed; the build reads your own SLUS_008.84 (and, for the movies, your disc image).

What works: the whole boot chain with its movies (LucasArts and Big Ape logos, the opening crawl), the title, menus and the levels, with music, speech and sound effects; pause, reset and quit from the emulator menu; save and load states; USB and built-in gamepads.

Gameplay runs at about 27 game fps in the first level (the game's cap is 30) with frameskip 2; movies play at their full 15 fps with their audio; a level loads in about 4 s.

tab5-tpm-save-states-compressed-2.mp4

Recompiled, not (yet) decompiled

Unlike the JK and MGS carts, TPM does not run from decompiled source:

  • tools/recomp.py translates each MIPS instruction of the executable into C mechanically. That output is the game's code in another form (Sony's copyrighted executable), so it is never committed or distributed. It is generated in the build directory from the builder's own SLUS_008.84 (CONFIG_TPM_EXE_PATH) and disc image (CONFIG_TPM_DISC_PATH, for the movie overlay).
  • So a playable TPM firmware can't be shared: each user builds their own. CI builds without the executable, so the CI firmware's TPM cart is only the load-screen viewer.
  • tpm-decomp holds only original work: the recompiler, the runtime, HLE replacements, and config naming addresses.

The goal is a real, matching decompilation, as mgs_reversing is for MGS: hand-written C that rebuilds the byte-identical executable with PSY-Q's GCC 2.8.1. tpm-decomp has started it: the executable is split with splat and rebuilds byte-identical, the PSY-Q functions are identified, and main and the file layer are in C. Decompiled functions can replace recompiled ones one at a time, so the port keeps working while the decompilation grows. Once it is complete, TPM can build from source like the MGS cart. Contributions welcome. The READMEs (top level, components/tpm, tpm-decomp) now say all of this.

Port layer (this repo)

  • components/tpm: the cart (launch, pause at the game's next loop back-edge, reset, quit without reboot), LCD present, audio task, disc reads with a 64 KB DMA-able read-ahead, a load-screen viewer when no executable was given.
  • The recompiled game and runtime build as one relocatable object (tpm_rt.ld, tpm_rt.keep): only the runtime's API stays global (MGS links its own psyz under the same names), its statics go to PSRAM and are reset on relaunch. Built -O2 -fsched-pressure -fomit-frame-pointer -ffixed-s11: s11 holds guest RAM's address for the generated code.
  • Everything the runtime allocates is one PSRAM arena (with the game task's stack in it), kept across quits and released for other carts, so its addresses stay put for save states.
  • The GPU's command stream goes to a worker on core 1 through a lock-free queue (a DMA packet per entry); frameskip drops a frame's primitives and clears into display buffers when the game is two vblanks ahead.
  • Save states: .data, .bss, the arena (minus the drained GPU queue and an idle transfer buffer) and the live part of the game stack: 3.6 MB, about 1.5 s to save and 0.6 s to load. A state loads into the firmware build that wrote it.
  • Kconfig: the executable and disc paths, movies, frameskip, GPU workers, and bring-up aids (scripted presses, profiler, benchmarks, serial screenshots, a save-state replay test).
  • Small shared changes: CI checks out the tpm-decomp submodule; CONFIG_TAB5_EMU_AUTOSTART_MATCH picks the auto-started game by name; rom_info knows the cart; the factory partition is 12 MB (the recompiled game is large).

In the submodules

  • tpm-decomp: the recompiler (register liveness and clobber analysis, a value flow that proves scratchpad accesses and narrows local returns, GTE FLAG liveness, per-command-word GTE calls), the runtime, the MDEC, and the movies: the game's movie player only keeps up at the hardware's exact pace (its stream ring holds one frame), so its play function is replaced by a native one that reads the STR sectors, runs the game's own VLC decoder and the MDEC's decode (a fast integer IDCT for the standard table) into VRAM, and decodes the XA audio into the SPU's CD input. Start or Cross skips.
  • psyz (components/mgs/psyz, now at esp-cpp/psyz tpm): rasterizer speedups (per-triangle row loops, pre-modulated 4bpp palettes, exact small divides, texture windows) and SPU speedups (voices mixed per block, envelope work only when it can change, silent voices not mixed), all verified frame- and sample-identical on TPM's desktop runs.

Measuring

A deterministic desktop runner (make -C recomp, then tpm-host with a scripted press list) gives the same frames and audio on every machine; every optimisation was checked identical over 3000 frames, with the debug build verifying the recompiler's proofs and the GTE checked in lockstep against psyz's. On the board, an unpaced benchmark over a fixed stretch of the first level times the game thread's work (23.8 s at the start of the performance work, 16.2 s now) and a sampling profiler covers both cores.

Known open items

  • Both cores are near full in the level (game thread ~95%, GPU worker ~93%); the game's hand-written terrain renderer is the biggest remaining piece of the game thread (~27%).
  • Memory-card saves are not implemented (the game says so and carries on; use save states).
  • MGS was not re-tested on this branch. The psyz changes are output-identical on TPM, but MGS uses the same rasterizer and SPU; a quick run through the dock before merging would be worth it.

Testing

Played on a Tab5 through the intro movies, the title and the first level; save and load states from the menu (and an automated save-at-2000 / load-at-2400 replay that matches guest RAM for 120 of 120 vblanks); pause, reset and quit. Desktop regressions as above.

🤖 Generated with Claude Code

@finger563

Copy link
Copy Markdown
Contributor Author

MGS re-test: the opening's audio was a little choppier with the updated psyz. Bisected on the board: the SPU's new block-a-voice-at-a-time mixer makes MGS's SPU-interrupt clock (voice 23, which the sound driver and its streamed audio run on) irregular, with about a third of the beats re-armed late instead of a tenth. 793d242 puts MGS back on psyz's per-sample mixer. The rasterizer speedups stay, and MGS's boot sequence actually runs faster with them (frame 1320 at vblank 4824 instead of 5426). TPM keeps the block mixer (it doesn't use SPU interrupts).

@github-actions

Copy link
Copy Markdown

ESP-IDF Size Report for 'Tab5 Emu'

Metric Base PR Delta
FLASH 0 bytes (0.00%) 0 bytes (0.00%) 0 bytes (0.00%)
DRAM 266,374 bytes (60.36%) 266,582 bytes (60.41%) 🔺 +208 bytes (+0.05%)
IRAM 0 bytes 0 bytes 0 bytes
RAM (DRAM+IRAM) 266,374 bytes 266,582 bytes 🔺 +208 bytes (+0.05%)

FLASH uses app .bin size or json2 flash sum. RAM sums DRAM+IRAM via idf_size. Percentages shown when totals are available.
DRAM/IRAM usage does not include memory used by the heap allocator at runtime.
This report was generated by esp-idf-size-delta.

finger563 and others added 28 commits October 15, 2026 15:25
New native-port target alongside MGS. No decompilation of the game existed;
it is being built in tpm-decomp (SLUS_008.84, USA Rev 1). Until its C runs,
components/tpm proves the platform half: it opens SLUS_008.84 + FILE.000 from
the card, resolves names through the game's own file table (read out of the
executable at 0x800715A0, as the game does) and shows the 44 load screens
(320x256 24-bit, cropped to 240 rows; left/right to step). Metadata entries
point at <dir>/SLUS_008.84. CONFIG_TPM_ENGINE is reserved for the game C.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The decompilation is public now (esp-cpp/tpm-decomp, tracking main), so
point the component's docs at it and vendor it as components/mgs does with
mgs_reversing. Nothing compiles from it yet (CONFIG_TPM_ENGINE stays off).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CONFIG_TPM_ENGINE (now on by default) recompiles the user's SLUS_008.84 to
C at configure time with tpm-decomp's tools/recomp.py and builds it with
the tpm-decomp runtime on psyz's rasterizer, GTE and SPU (the copy in
components/mgs). The whole lot is linked into one relocatable object with
only the runtime's API left global, since components/mgs links psyz under
the same names.

src/tpm_engine.cpp is the platform: the disc is the .bin on the card (the
executable is read out of its ISO9660 directory), the game runs on core 0
in real time, vblanks hand the display area to a core-1 conversion task,
the SPU's samples go to the HAL through a ring and a core-1 task, the HAL's
gamepad fills the pad buffers. Without the executable at build time or a
.bin on the card, the cart falls back to the load-screen viewer.

The app partition grows to 12 MB (image: 7.9 MB). Built; not yet run on
the Tab5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- CONFIG_TAB5_EMU_AUTOSTART_MATCH: auto-start the first game whose name or
  path contains the text, not just the first entry
- CONFIG_TPM_AUTOPRESS: scripted pad presses at given vblanks
- CONFIG_TPM_SERIAL_SCREENSHOT_SECONDS: the presented frame as base64 on the
  console; tools/serial_screenshots.py turns a log into PNGs
- a status line every 5 s (vblanks, frames presented, display mode)
- tpm-decomp f238b94: VRAM alias follows psyz's pointer on ESP32

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- the runtime's statics in PSRAM (tpm_rt.ld gathers the bundle's .bss into
  .ext_ram.bss, brackets .bss/.data) and reset before every launch; its
  big buffers (guest RAM, VRAM, SPU, transfers) from the shared-memory pool,
  released when the cart quits; the rasterizer out of IRAM. Internal RAM
  use of the bundle 37.7 KB -> 192 B: the boot-time DMA reserve fits again
- the displayed rows are copied at the vblank on the game's thread: the
  rasterizer draws synchronously there, and VRAM read later from the video
  task often held the next frame's clear without its polygons
- plat_sleep_us for the runtime's throttle
- status line: game fps (display-area flips); half-resolution serial
  screenshots from their own task; a one-shot VRAM dump and a GPU trace of
  one frame (CONFIG_TPM_SERIAL_FULL_SHOT_VBLANK, CONFIG_TPM_GPU_TRACE_VBLANK)
- tpm-decomp 11bd43e

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- rt_gpu_set_async: the rasterizer runs in tpm_gpu on core 1 behind a
  command queue; the displayed rows are copied by the worker at the vblank
  marker (plat_display_ready), so the picture is the finished frame
- 32-sector read-ahead on the disc image (CD time 13% -> 2%)
- status line: the game thread's time split (g_rt_prof); a PC-sampling
  profiler (CONFIG_TPM_PROFILE_VBLANK); numbered screenshot lines that
  survive interleaved log output; the screenshot task above the GPU worker
- README: status, threads, memory, bring-up aids
- tpm-decomp afc58b8

7 -> 12 fps in the Trade Federation ship level (the game's own rate is 30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tpm-decomp (78b959a..): the recompiler passes only the registers a call
needs (a third of the register traffic left, 2.1 -> 1.7 MB of code); the
frame limiter's VSync spin skips to the vblank; texture windows (the
characters' textures); the cutscenes' vertical display range (the
letterbox wrapped around); a libsnd jump table that killed the game on
some button presses; FPU triangle gradients; a second GPU worker option.

Board side: the presenter honours the display range; the game's thread
sleeps instead of spinning while the GPU's queue is full; RGB conversion
two pixels a word; per-task CPU load and the GPU worker's rendezvous in
the status line; CONFIG_TPM_GPU_WORKERS (default 1: two measured slower
here, the cores contending for PSRAM and cache); the sampling profiler
also samples the GPU worker (TPMPROFG). psyz: the tpm branch (texture
window, desktop worker identity, exact bands).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… bench

- tpm-decomp 43bcb36 / psyz tpm: the runtime's own GTE (inline register
  moves, direct command calls; IRGB / LZCR / SXYP as the hardware), the
  rasterizer's row loops per span type, pre-modulated 4bpp palettes, exact
  32-bit gradient divides, statistics compiled out, word-sized fills; SPU
  visits active voices only; DMA packets pushed to the GPU queue at once.
  The level goes from ~12 to ~15-16 fps here.
- Disc read-ahead at 512-byte file offsets into a 128-byte-aligned PSRAM
  buffer: the SD driver no longer bounces through internal DMA memory,
  whose exhaustion failed reads and stalled the game.
- The pad: face buttons by position as in the MGS port (B Cross, A Circle,
  Y Square, X Triangle), L1/L2/R1/R2 apart, the left stick as the d-pad.
- CONFIG_TPM_GPU_BENCH_FILE: a desktop GPU recording replayed through the
  rasterizer at cart start (TPMBENCH lines).
- The frame is converted on the GPU worker at the vblank (on core 0 later,
  the first room's glass and the pause menu came out wrong).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed on flips

- While the emulator's pause menu is up nothing is presented: the GPU
  worker still worked through the queued commands and drew game frames
  over the menu.
- tpm-decomp: the plain guest loads/stores always inline (the game thread
  ~9% faster per frame, the generated code twice the size); psyz: constant
  texture row shift.
- A frame is converted when the game flips (or every fourth vblank).
- The second GPU worker sits below the game when enabled (still slower:
  CONFIG_TPM_GPU_WORKERS stays 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pauses the engine the way the emulator's menu does at a given vblank and
logs how much of the frame the menu's screenshot would read is lit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the P4 the cores reach cached PSRAM faster than internal RAM: the game
thread's stack (the recompiled code spills guest registers to it all the
time), the GPU worker's, g_cpu / g_gte and the CLUT cache now live in
PSRAM. The game thread needs ~15% less time per frame; the GPU worker
suspends at the end and the cart deletes it (PSRAM stacks).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The GPU worker drops a frame's drawing when the game is a frame ahead of
it; stale buffers are not presented. Status line shows game vs drawn fps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n place; PSRAM at boot logged

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…chmark

tpm-decomp: scratchpad accesses check the scratchpad first (the sites in
config/scratch_sites.us.txt, passed to recomp.py), a GTE function per
command word, calls between a function's entries as local jumps, the
GTE dispatch always inlined; psyz: the SPU's voices run through a block
in locals. The level benchmark below: 23.8 s -> 20.2 s of game thread
(15% less); the level holds ~22 game fps (was 17-20).

TPM_BENCH_FROM / TPM_BENCH_TO: run unpaced (with TPM_AUTOPRESS the same
work every run) and log the time between two vblanks, less the game
thread's waits for the GPU.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The runtime's buffers and the game thread's stack live in one PSRAM arena
kept across quits (released before any other cart starts), so a state of
raw memory loads at the same addresses. Taking a state, the game's thread
checkpoints itself (setjmp) in plat_pause_wait; loading, it waits on a spare
stack at the bottom of its own while the runtime's statics, the arena and the
live part of its stack are read back, then jumps into the state's
checkpoint. The GPU workers stop around both. States load only into the
build that wrote them.

CONFIG_TPM_TEST_STATE "A:B" saves at vblank A and loads at B; both passes
log a guest RAM hash per vblank: the replay after the load matched all 120,
also loading a state written before a reset.

psyz: the SPU mixer's gauss window in registers, the envelope only where it
changes (game thread benchmark 20.2 -> 19.0 s, audio identical).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… voices

tpm-decomp c2eacd2 and psyz 0c2cfcb:
- recomp.py proves the scratchpad accesses (renderer's base register) and
  skips their address checks
- the GPU queue takes a DMA packet as one entry
- libcd's command wait skips ahead to the next interrupt: the level load
  takes ~4 s of board time instead of ~25 s
- frameskip drops a skipped frame's clear with its primitives
- silent SPU voices skip interpolation and mixing (bit-exact)

Board bench (vblanks 1800..3000): 19.0 -> 17.1 s. In level ~25-26 game fps,
both cores busy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tpm-decomp 75a9340: the bundle builds with -ffixed-s11 and
RT_RAM_REG=s11, so the generated code reads g_ram from the register
instead of loading the pointer before every access. Also GTE commands
whose FLAG nothing reads skip it (ed1d607).

Board bench (vblanks 1800..3000): 17.1 -> 16.4 s; save-state replay 120/120.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The generated functions are huge and short of registers; the scheduling
pass that minds register pressure saves 1% of the game thread (bench
16.4 -> 16.3 s). Without the pass it was 2% slower; inlining RTPS/RTPT
into the generated code was 5% slower (more pressure).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Saving lets the GPU workers drain the queue, then leaves out the ranges
rt_snap_dead gives (the queue, the transfer buffer past any transfer in
progress); the header (v2) lists them and a load skips them. 5.2 -> 3.6
MB, save 2.2 -> 1.5 s, load 0.83 -> 0.58 s; the replay after a load
still matches 120/120 vblanks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tpm-decomp 880a0a8: the MDEC is emulated, and the movie player's play
function is replaced by a native one (the game's own VLC decoder, the
MDEC's decoding, XA-ADPCM into the SPU's CD input), since the original
only keeps up at the hardware's exact pace. Start or Cross skips a movie.

CONFIG_TPM_DISC_PATH (the disc image on the build machine) recompiles the
FMV.CPE overlay; without it the movies are skipped as before.
CONFIG_TPM_MOVIES turns them off; builds with CONFIG_TPM_AUTOPRESS skip
them for the scripted timeline.

Board: the LucasArts and Big Ape logos and the 110 s opening crawl play
at 15 fps (full speed), the game thread ~81% busy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the psyz update the SPU mixes a block a voice at a time by default.
MGS clocks its sound driver off the SPU interrupt (voice 23 loops a blank
sample, the IRQ address toggled ahead of it), and mixed that way the
beats came irregularly: about a third re-armed behind the voice and were
caught up, against a tenth sample by sample (old psyz: similar). The
opening's streamed audio was choppier. MGS now selects the per-sample
reference mixer (still ~10% cheaper than before the update); the
rasterizer speedups stay (the boot sequence reached frame 1320 at vblank
4824 instead of 5426).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After another game (MGS) the heap may have no ~5 MB block left; TPM then
refused to start. Now it takes the game task's stack alone and the rest
piece by piece from the heap, without save states for that session (the
next launch tries for the whole block again). The MDEC's input buffer is
also halved (tpm-decomp cf507fa).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lock left after MGS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tpm component's CMakeLists includes tpm-decomp/recomp/runtime, so
configuring without the submodule fails. The README's clone line gets it
too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Explain why nothing generated from the game is committed, why the CI
firmware has only the load-screen viewer, and that the goal is a matching
decompilation replacing the recompiled code function by function.
Bump tpm-decomp for the same note in its README.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@finger563
finger563 merged commit 18a44c5 into main Oct 16, 2026
1 check passed
@finger563
finger563 deleted the feat/tpm branch October 16, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant