Skip to content

Metal Gear Solid on the Tab5 (mgs_reversing + psyz, native) - #1

Merged
finger563 merged 47 commits into
mainfrom
feat/mgs
Oct 13, 2026
Merged

finger563 merged 47 commits into
mainfrom
feat/mgs

Conversation

@finger563

@finger563 finger563 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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_reversing decompilation compiled to RISC-V with psyz standing in for the PSY-Q SDK, GPU, GTE and SPU, both on esp32p4 branches of new esp-cpp forks (same pattern as esp-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.
  • Linker fragment placing the game's statics in PSRAM (bss) and the PSRAM-XIP rodata (data), reset on relaunch. The game's own .sbss/.sdata/.bss sections are renamed .mgs.* so they land in the same regions. Needs CONFIG_ESP_SYSTEM_MEMPROT=n for the writable rodata.
  • Save states: the game's static regions, the main task's stack and every mts thread's stack and saved context, 4.2 MB per slot, keyed on a layout signature rather than the ELF hash so a rebuild that leaves the game code in place keeps old states loadable. OS handles that live inside the snapshotted regions are registered and preserved across a restore.
  • Two rasterizer workers, one per core, each drawing a band of rows of the same command stream; the split row adapts every frame from the two workers' times. Audio mixer on core 0; virtual CD read-ahead ring on core 1, also pumped from the vblank tick.
  • Gamepad: the shared GamepadState gains l2/r2 so a cart can tell triggers from shoulders (USB pads and the keyboard's E/R).

Notable fixes along the way (in the submodules)

  • mts cooperative scheduler on FreeRTOS: vsync and SPU interrupts delivered under the game's critical-section gate, never while a thread holds the stdio lock, one SPU interrupt per sound-task pass; the tick's own prints go through a logger task.
  • SPU emulation: voice attributes, IRQ address catch-up, 44.1 kHz pull.
  • Packet layouts for psyz's two-word tags (merged, priority and mask packets).
  • GTE: depth-cue, overflowed square roots, 16-bit camera wrap in cutscenes.
  • Rasterizer: PSX mask bit, blended textured pixels keep the texel's STP bit (feedback effects), exact integer DDA edges, a per-triangle modulation table for flat spans, FPU gradients corrected to the exactly rounded quotient (a one-unit error picked the neighbouring texel row for whole triangles), workers rendezvous before frame-buffer feedback primitives and VRAM block moves so cutscenes also use both cores.
  • The decompilation targets MGS Integral; the US disc's RADIO.DAT addressing differs and the codec loader handles it.
  • Scanout presents only the rows the display environment declares (a 256-line codec screen left a stuck tile in the 224-line view).

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

  • One ordering-table link per frame reads as a texpage word in some scenes (logged, dropped safely).
  • Occasional bad object chain links on topside (skipped, logged).
  • Memory-card game saves are not implemented (use save states).
  • Full-motion video (MDEC) is not implemented; movies are skipped.
  • States are tied to the game-code layout; a port-layer change that shifts it invalidates them.

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

finger563 and others added 23 commits October 3, 2026 21:16
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
…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
Copilot AI balanced review requested due to automatic review settings October 8, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Lifecycle races, failure handling, a disabled-configuration build break, and stale documentation remain unresolved.

8 open findings
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.

Comment thread components/mgs/src/mgs.cpp Outdated
Comment thread components/mgs/src/mgs.cpp Outdated
Comment thread components/mgs/src/mgs.cpp
Comment thread components/mgs/src/mgs.cpp Outdated
Comment thread components/mgs/src/mgs.cpp Outdated
Comment thread components/mgs/src/mgs_platform.cpp Outdated
Comment thread components/mgs/README.md Outdated
Comment thread docs/PLAN.md Outdated
finger563 and others added 4 commits October 8, 2026 21:32
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
finger563 and others added 2 commits October 9, 2026 09:21
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
@finger563

Copy link
Copy Markdown
Contributor Author

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

finger563 and others added 14 commits October 9, 2026 10:05
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
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread main/carts.hpp
Comment thread components/mgs/src/mgs.cpp
Comment thread components/mgs/src/mgs_platform.cpp Outdated
Comment thread components/snes/src/snes_glue.c
Comment thread components/tab5-emu/src/tab5-emu.cpp Outdated
Comment thread components/mgs/include/mgs.hpp Outdated
Comment thread components/mgs/src/mgs_platform.cpp Outdated
finger563 and others added 4 commits October 12, 2026 17:48
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
@finger563
finger563 merged commit 64403d0 into main Oct 13, 2026
1 check passed
@finger563
finger563 deleted the feat/mgs branch October 13, 2026 02:38
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.

2 participants