Repository navigation
Conversation
The decompiled game and psyz as submodules (components/mgs), built the way the upstream ESP32-S3 port builds them minus the Xtensa flags, with the private shim headers reconstructed (libpad.h, gtemac.h, libsn.h), the game's statics moved to PSRAM, mts task stacks in PSRAM, and a Tab5 platform layer: VRAM -> RGB565 -> the HAL's frame path, pads from the HAL's merged gamepad, disc files on the card, pause by stopping the vblank. MgsCart boots the game into CONFIG_MGS_START_STAGE; leaving it reboots the board (the game cannot be restarted in place). Three platform hooks live on a local esp32p4 branch of mgs_reversing. Builds (5.5 MB image, 31% of the app partition free); not yet run on the board. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
The ESP32-P4 build reserves a 128 KB contiguous internal pool for DMA at boot; libmgs.a's .data/.sdata (46 KB) plus its .bss had fragmented internal RAM so the reservation failed and the firmware aborted before app_main. tools/data_to_psram.py renames every .data.* section of libmgs.a into .ext_ram.bss.di.* and records the original initialiser as a mgs_di_* symbol; the generated mgs_data_init.c (built by the new mgs_data component with weak references, so it cannot pull extra archive members) copies the initialisers over at startup, before the game threads run. se_tbl becomes const in the submodule, and the software rasteriser no longer asks for IRAM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
The copy tool was unsound: it captured each .data section's bytes before the link, so every pointer-valued initialiser (a relocation, not bytes) came back as zero -- _spu_RXX was the first one the game dereferenced, in CdInit(). This build runs XIP from PSRAM, so .flash.rodata is already copied there at boot; bss_to_psram.py --data renames libmgs.a's .data sections into .rodata and the linker and image loader do the rest. The P4's memory protection PMP rules mark that range read-only (locked), so protection is off in sdkconfig.defaults: nothing untrusted runs here, and the loss is the IRAM/DRAM W^X split. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
- the game task runs at the mts threads' priority (5) and the cart's poll loop sleeps between polls: at priority 1 on core 0 it never got the CPU while the main task spun - lcd_init() is the cart's job (the S3 app_main did it): without it the scanout had no frame buffers and the screen stayed black - the presented-frame interval feeds the menu's frame statistics - libpad.h: PSY-Q's real PadState values - submodules: psyz and the game no longer test addresses against the ESP32-S3's memory windows (OT walk, packet allocator) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
The menu now freezes the game the way esp-box-emu's glue does: wait for the mts scheduler to reach its idle task outside a critical section, hold the vblank tick and the scanout, suspend the live PSX thread. Withholding only the vblank let a polling thread spin and wedge the game on the second resume. Quit and reset no longer reboot: the game's tasks are torn down and every static of libmgs.a put back as linked. A linker fragment places them (bss / COMMON / the PSRAM arrays in PSRAM, .data in the PSRAM-XIP rodata) and brackets each range with _mgs_*_start/_end, replacing the objcopy step for this core; .bss is zeroed and .data restored from a snapshot taken before the first launch. A hang detector reports a stalled tick or frame counter through esp_rom_printf. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
psyz's SPU, pulled at 44.1 kHz by a task on core 1 into the HAL's audio queue (resampled to the DAC's 48 kHz), held while the menu is up. The game's own driver runs on the SPU interrupt the submodule now emulates; its wave / song / effect files load with the stage again. A statistics line every 5 s (pulls, loud samples, voices, interrupts raised and delivered, the IRQ registers). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
The start stage is "title": the retail chain (title -> opening -> dock) is what sets the state the cutscene scripts expect, and it runs now that the virtual CD streams. The rasterizer file gets -O3 without a frame pointer and SOFT_RASTER_RECIP. sdkconfig.defaults notes that this Tab5 does not boot at 400 MHz. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…counters Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…tes) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
There was a problem hiding this comment.
🟡 Changes recommended
Lifecycle races, failure handling, a disabled-configuration build break, and stale documentation remain unresolved.
8 open findings
Abort initialization when snapshot creation fails · New Recheck scheduler after suspending vblank · New Guard GPU diagnostic for disabled engine builds · New Use an atomic flag for cross-task access · New Avoid unconditional 1 MiB VRAM dumps on pause · New Handle audio task creation failure · New Update status block to reflect implemented features · New Update plan status and quit lifecycle description · New
What changed in this PR
Adds Metal Gear Solid as a native Tab5 cart using mgs_reversing and psyz.
Changes:
- Integrates MGS discovery, cart lifecycle, video, audio, input, and diagnostics.
- Places game state in PSRAM and adds reset support.
- Adds build configuration, dependencies, and documentation.
| File | Description |
|---|---|
.gitmodules |
Adds MGS and psyz forks. |
CMakeLists.txt |
Enables the MGS component. |
sdkconfig.defaults |
Configures writable PSRAM-XIP and console speed. |
tools/bss_to_psram.py |
Adds initialized-data relocation support. |
docs/PLAN.md |
Documents the MGS port plan. |
main/mgs_cart.hpp |
Implements the MGS cart. |
main/carts.hpp |
Registers MGS cart creation. |
components/rom_info/src/rom_info.cpp |
Detects MGS metadata entries. |
components/rom_info/include/rom_info.hpp |
Defines the MGS emulator type. |
components/mgs/CMakeLists.txt |
Builds the game, SDK, and port. |
components/mgs/Kconfig |
Adds MGS build and stage options. |
components/mgs/README.md |
Documents setup and status. |
components/mgs/linker.lf |
Places and brackets game statics. |
components/mgs/psyq_sources.cmake |
Lists PSY-Q source units. |
components/mgs/include/mgs.hpp |
Exposes the MGS lifecycle API. |
components/mgs/src/mgs.cpp |
Implements lifecycle and diagnostics. |
components/mgs/src/mgs_platform.hpp |
Declares platform hooks. |
components/mgs/src/mgs_platform.cpp |
Implements audio, video, storage, and input. |
components/mgs/shim/gtemac.h |
Supplies GTE compatibility macros. |
components/mgs/shim/libpad.h |
Supplies controller declarations. |
components/mgs/shim/libsn.h |
Supplies the required stub header. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A state is the game's PSRAM regions, the main task's (now static) stack and the game threads' contexts, written while paused and validated against the build's ELF hash on load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Save states now cover every game static (the player's status and the alert state came back stale), and a relaunch resets them too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…nd checks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
|
Added since opening: save states from the pause menu (the game's PSRAM regions, the game threads' stacks at fixed addresses and their saved contexts; validated against the build's ELF hash), the fix for the opening's submarine shot (blended textured pixels keep the texel's STP bit, which the zoom blur and water refraction depend on), and the relaunch fixes that fell out of testing (read-ahead task teardown, the game's small-data sections now inside the reset and the snapshot). |
The panel copy took 240 rows from the top of the buffer, so the 16 rows below the game's 224-line drawing area -- VRAM the game never clears, which a 256-line codec screen does draw into -- stayed on screen: a snow tile stuck and flickering through everything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
A state loads into any build whose game code and data sit at the same addresses: the region table plus a sample of function and buffer addresses, instead of the ELF hash. Rebuilds that move the game are still refused, with a message that says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
psyz 1a55403: second raster worker on core 0 by screen band with an adaptive split, DDA edges, flat-modulation LUT, per-core profiling; mgs_reversing 914c8edb5: port reset/telemetry for the two workers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…from the pause menu psyz: exactly-rounded FPU gradients, worker rendezvous for frame-buffer feedback and VRAM block operations (cutscenes on both cores), frame recording and a pixel-exact replay comparison at cart start. mgs_reversing: the matching port switches and frame marks. The pause menu now writes the last frames' GPU batches next to the VRAM dump. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…laceholders Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
The shared GamepadState gains l2/r2 (USB pad triggers, keyboard E/R); the shoulder bits keep counting either, as the emulators expect. MGS maps L1/R1 to the shoulders alone and L2/R2 to the triggers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…e (TEXT.DAT) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…fter the level fills memory MGS: the game's big buffers live in one PSRAM block acquired at the first launch, kept across quits (save states, version 4, cover it as a region) and released by the launcher before any other cart starts; the frame recorder and replay benchmark are a Kconfig option (off). JK: the level loader fills PSRAM by design, so the frame conversion buffers are reserved at init, the profiler report is allocation-free, and the engine allocator returns zeroed memory (its material table is walked before it is filled). main: a global operator new that reports the failing size, task and backtrace, and a terminate handler naming the exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…tatics The cores never run at once, so their PSRAM is handed from one cart to the next through components/shared_memory (as the Genesis core already does) instead of being held for all of them: gpSP's EWRAM, IWRAM, VRAM, backup, sound buffer and object priority lists (1.2 MB), snes9x's memory map, cheat table, SPC7110 registers and SDD1 buffer (0.5 MB). Allocated and zeroed at cart init, released at deinit; the core sources keep their array spelling through pointers and a macro. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…der the menu The engine loop looked for the menu's requests only between frames; under the pause menu it sits parked inside the frame, so a save or load timed out after ten seconds and nothing was written while the menu showed a slot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…agnostic The Jedi Knight cart re-reserved the 6.3 MB ROM arena in its own destructor, before the base's pause menu was torn down, so whatever the menu still held in the released hole made the reservation fail and the next SNES start depended on luck. The launcher reserves it once the cart, menu included, is destroyed and before the GUI decodes boxart into the hole. With CONFIG_HEAP_TRACING_STANDALONE the launcher traces every allocation from boot and, when the reservation still fails, lists the live allocations inside the hole by caller. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…mic flag, audio task check, dumps behind the bench option) - init() refuses to start when the statics snapshot cannot be allocated: a later launch would restore an already-mutated .data as pristine. - freeze() re-checks the scheduler after the vblank tick is held regardless of the first observation: the tick could wake a game thread in the gap and the pause would freeze it holding a lock. - The hang detector's enable flag is std::atomic (timer callback vs cart task); Mgs_DumpVram compiles with CONFIG_MGS_ENGINE=n. - The pause-time VRAM and frame dumps (1 MB per pause) are part of the replay-benchmark option, not play builds. - mgs_platform_audio_start() reports a failed task creation and init() unwinds instead of running without audio. - docs/PLAN.md: the MGS phases as delivered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
There was a problem hiding this comment.
🟡 Changes recommended
Incorrect MGS button mapping and unsafe save-state and low-memory failure paths can cause wrong controls or crashes.
7 open findings
Unconditionally referenced MGS symbol breaks disabled builds · New Truncated state load corrupts live memory before read failure · New Face buttons are mapped to the wrong PlayStation buttons · New Null memory pointer causes crash during initialization cleanup · New Keyboard triggers fail to set shoulder-button state · New State compatibility documentation contradicts implementation · New Mixer scheduling comment has incorrect core and latency · New
8 resolved since last review
Guard GPU diagnostic for disabled engine builds Recheck scheduler after suspending vblank Abort initialization when snapshot creation fails Handle audio task creation failure Avoid unconditional 1 MiB VRAM dumps on pause Use an atomic flag for cross-task access Update plan status and quit lifecycle description Update status block to reflect implemented features
🧠 Review effort: Balanced
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
…tate) - components/tab5-emu: the three espp overrides onto a local branch are gone; the published 1.3.7 carries every finger through the touch interface and the ST7123 driver. - MGS pad: face buttons by position as the HAL normalises them (B south = Cross, A east = Circle, Y west = Square, X north = Triangle); the mapping was mirrored. - load_state checks the file holds the whole payload before the first region is overwritten; a truncated state is refused, not half-applied. - carts.hpp guards the arena release with ENABLE_MGS; snes_glue_deinit tolerates a failed shared allocation; the keyboard's E/R count as shoulders too, as the USB triggers do; comments on the audio task's core and period and on state compatibility corrected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo



Summary
Runs Metal Gear Solid natively on the Tab5 (ESP32-P4) as a cart, from the extracted US disc (SLUS-00594) on the microSD card. The game code is the
mgs_reversingdecompilation compiled to RISC-V withpsyzstanding in for the PSY-Q SDK, GPU, GTE and SPU, both onesp32p4branches of new esp-cpp forks (same pattern asesp-cpp/OpenJKDF2).What works: the retail boot chain (Konami, title, opening cutscene, dock, topside), cutscenes with correct cameras and the frame-buffer feedback effects (zoom blur, underwater refraction), codec calls with portraits and text, sound effects, music and streamed speech, save and load states, pause, reset and quit from the emulator menu, item and weapon menus on L2/R2.
Gameplay runs at about 28 fps at the dock against the game's own 30 fps cap (up from 16 to 18 when the PR was opened); cutscenes and audio are smooth.
Video: (to be added)
Port layer (this repo)
components/mgs: cart integration, lifecycle (launch, pause at idle, resume, reset, quit without reboot), LCD present, audio task, hang detector that samples each game thread's saved PC and stack..sbss/.sdata/.bsssections are renamed.mgs.*so they land in the same regions. NeedsCONFIG_ESP_SYSTEM_MEMPROT=nfor the writable rodata.GamepadStategainsl2/r2so a cart can tell triggers from shoulders (USB pads and the keyboard's E/R).Notable fixes along the way (in the submodules)
Measuring
The pause menu writes the last five frames' GPU batches next to a VRAM dump. The next cart start replays them: each frame rendered by the exact single-worker path to the card, the newest compared pixel for pixel under every optimisation, then timed. Dock frame: 35 ms single worker, 21 ms two workers. Opening sub scene: 89 ms and 74 ms. Serial telemetry every 120 frames covers both workers' raster time, the split row, the display-list walk, CD and audio counters.
What did not pay off and is left switched off: an 8x8-tiled shadow of VRAM for texture fetches and decimated copies for minified textures. The game's triangles are small and touch fresh cache lines either way, and a new line costs about 125 cycles on this board.
Known open items
Testing
Played on a Tab5 through the opening, the dock, the elevator and topside, with save and load states across resets, pause, reset and quit from the emulator menu. The replay comparison reports 0 differing pixels for every optimisation on the recorded frames. Serial telemetry reviewed for audio starvation (none) and frame pacing.
MGS:
tab5-mgs.mp4
JK:
tab5-jk.mp4
GBA:
tab5-gba.mp4
🤖 Generated with Claude Code
https://claude.ai/code/session_01BAeyJSuzVyqvSe4mffGUZo