Go back

Metal Gear Solid running natively on the ESP32-S3

The XIAO ESP32S3 handheld running Metal Gear Solid at the dock, with the radar in the top-right corner

I have loved Metal Gear Solid since I was a kid. When I got a tiny ESP32-S3 board, one question stuck with me: could a PlayStation game run on something this small?

The Seeed Studio XIAO ESP32S3 Sense: 21 × 17.8 × 15 mm with its camera board attached (photo: Seeed Studio)

The board is the Seeed Studio XIAO ESP32S3 Sense: a dual-core ESP32-S3 with 8 MB of PSRAM and 8 MB of flash, on a module of 21 × 17.8 mm. The Sense version adds an expansion board with a camera and a microSD slot, and that microSD slot is where the game’s data lives.

I spent a good while finding out. First I looked for a version of the game that had already been rebuilt as C code that runs on a PC, and then I checked whether all of that logic could fit in the ESP32-S3’s flash and on a microSD card. It took several attempts, but in the end it worked. A wonderful game now runs on a microcontroller board that costs about 15 USD.

Gameplay on the handheld: watch it on YouTube.

In this article, I describe how Metal Gear Solid (PlayStation, 1998) runs on the ESP32-S3 as a native port. The game’s C source, reconstructed by the FoxdieTeam mgs_reversing decompilation, is compiled straight to Xtensa machine code. Everything the PlayStation hardware used to provide is replaced with software:

The article covers the hardware, the platform layer, memory placement, the debugging method, the bugs that mattered most, performance, the handheld, and what does not work yet.

The code and related projects:

You need your own disc. The repository contains nothing derived from the game, and port/extract_disc.py pulls the files out of your own disc image.

Hardware

Playing Metal Gear Solid on the handheld at the dock

The ESP32-S3 has two Xtensa cores at 240 MHz and 512 KB of internal SRAM. Both boards used here also have 8 MB of PSRAM, which sits behind the same cache as the flash.

Waveshare ESP32-S3-Touch-LCD-2DIY handheld (Seeed XIAO ESP32S3 Sense)
PanelST7789, 320x240ILI9341, 320x240 native
Flash16 MB8 MB
Game datamicroSD, or a trimmed STAGE.DIR in flashmicroSD (full STAGE.DIR)
Inputkeyboard over serial (play.py)drone-gimbal stick, 6-button resistor ladder, 2 direct buttons

Measuring the gap before writing code

Before writing any port code, I measured how far the decompilation was from building for Xtensa:

I left the one failing file, libdg/chanl.c, failing on purpose. The cast that would have silenced it would also have silently changed the stride (see the ordering-table section below).

The rest took mechanical passes, each one fixing a whole class of problem:

The result was 497 of 497 C files compiled to Xtensa. The ELF had 0 undefined references and 0 duplicate definitions (text 987,073 B, bss 2,434,276 B). The game’s own memory is 804 KiB (428 KiB heap plus two 188 KiB packet buffers), which fits comfortably in PSRAM.

Architecture

The port has three layers between the game and the SoC:

The three layers of the port and the two cores: all mts tasks and the vblank tick on core 0, LCD scanout on core 1, and the LCD and microSD sharing SPI2

The layers, from the game down to the hardware:

  1. MGS engine: decompiled C, compiled to Xtensa
  2. psyz: PlayStation SDK and GTE in C, software rasterizer
  3. Platform layer: mts scheduler on FreeRTOS, vblank tick, virtual CD, LCD, input
  4. ESP32-S3

psyz and the GTE

psyz, Xeeynamo’s reimplementation of the PlayStation SDK, already modelled the whole GTE register file and every GTE operation in C, validated against the PCSX-Redux GTE test suite.

My first estimate was that 64 to 70 macros were missing. That estimate was too high. MGS ships its own MIPS inline-asm headers (inline_n.h, inline_x.h), and -fsyntax-only does not check register names. Once those headers were diverted to psyz, the real gap was 41 macros. I added them to psyz, using the game’s own assembly as the spec for which COP2 registers each one touches. Then I rebuilt my other psyz-based projects against the modified psyz to make sure nothing broke.

When you alias a GTE macro, check whether each constraint is an input or an output. For example, gte_read_opz has an "=r" output, so its alias has to take &x.

The ordering-table tag: one word on PSX, two in psyz

This is the most important layout difference, and it caused the most bugs. On the PlayStation, every primitive starts with one 32-bit tag: 24 bits of next-primitive address and 8 bits of length. A host pointer does not fit in 24 bits, so psyz uses two words, tag and len. Every primitive grows by 4 bytes, and every field after the tag moves by one word.

MGS also abuses the length byte. libdg runs a two-pass radix sort on 16-bit depth:

  1. divide.c buckets by the low byte and parks the high byte in the tag’s length.
  2. sort.c re-buckets by that high byte and writes the real length.

Because psyz separates the two fields, the translation is exact:

pack->tag = (hi << 24) | *ot;  *ot = pack;     /* PSX */
addPrim(ot, pack); setlen(pack, hi);           /* psyz */

Retyping 42 local declarations and 100 parameters to OT_TYPE* was the easy part. The hard part was code that assumed the packed layout without saying so:

There is also an aggravating factor. On the console, addPrim() rewrote the whole tag word on every insertion, so a forgotten length could never be noticed. In psyz, a length that was never set is simply never written.

Konami’s scheduler inside FreeRTOS

MGS runs on Konami’s own cooperative scheduler. The boot banner still reads Multi Task Scheduler for PSX ver2.02 Jul 11 1998. It sits on the BIOS thread API (OpenTh/ChangeTh) and uses the vblank interrupt to wake tasks whose timers have expired.

In the port, each mts thread is a FreeRTOS task, and only one of them is ever runnable at a time. The final design:

mts_GetTcbEntry also read the BIOS table of tables at the fixed address 0x100 and wrote through the result. Outside a PSX that address holds garbage, so it now uses a private TCB table.

The vblank tick and the two clocks

port/esp32_vblank.c fires the game’s VSync callback every 16 ms. That needed VSyncCallback() to be implemented in psyz, where it did not even store the pointer.

VSync(-1) has to return a free-running vblank count. The first version returned the number of frames presented, which stops advancing when nothing draws, so no timer ever expired.

The frame rate varies: 2 vblanks per frame in a corridor, 4 to 5 in an open room. Because of that, two things that looked like vblank business actually belong to the game frame:

Memory placement

Where everything lives: task stacks and hot loops in internal SRAM, the game's RAM map and the 1 MB VRAM in PSRAM, firmware in flash, and STAGE.DIR on the microSD

Internal SRAM is scarce and fast. PSRAM is plentiful and slow. Flash reads disable the cache that PSRAM also depends on.

One class of bug only exists on this memory map: pointer tests based on the sign bit. MGS tells a script-block pointer apart from a 16-bit proc id with proc_id < 0. PSX RAM is at 0x8xxxxxxx, which is negative as a signed value. PSRAM is at 0x3Cxxxxxx, which is positive. The result was PROC 3C1987D2 NOT FOUND, followed by a LoadProhibited at EXCVADDR 0x00000003. A GCL_IS_BLOCK_PTR macro fixes the site that was hit. Other sites with the same pattern still need an audit.

A virtual CD over FAT

The virtual CD: libfs asks for an absolute sector, virtual_cd.c turns it into a file and an offset on the microSD and hands back one sector at a time

libfs works in absolute sectors. It reads the ISO9660 directory once and never uses a file name again. port/virtual_cd.c replaces the CD BIOS:

The data first lived in PSRAM, which capped the game at a handful of stages. The real fix was the microSD. The card is an SPI peripheral, separate from the XIP flash, so reading it does not disable the instruction cache. Files stay on the card and cost no PSRAM.

The card and the LCD share SPI2, and ESP-IDF does not arbitrate between them. One LCD frame is 30 queued asynchronous transfers, so a card read in the middle of a frame fired assert failed: spi_hal_iram.c:134 and caused a reboot loop. The fix is explicit bus ownership (Mgs_SpiBusTake/Give), draining in-flight transfers before releasing the bus.

Method: measure, don’t guess

About a third of the obvious explanations in this project turned out to be wrong. What worked was getting the board to print a number that decides between hypotheses. The most useful lessons came from probes that gave wrong answers:

The bugs

In game on the handheld, with the radar in the top-right corner

Strict aliasing deleted a rotation

The white screen: five wrong hypotheses, three real causes

The GPU size rule: a polygon pinned at the GTE's -1024 saturation limit reaches into the 320x240 screen; the PlayStation GPU refuses anything larger than 1023x511, the software rasterizer drew it

The culling fix from hypothesis 4 was real, even though it was not the cause. The bounding-box test treated a box that crosses the near plane the same as one entirely behind it. Separating the two cases took kept-saturated from 16,090 to 4,947. An earlier attempt keyed on saturated coordinates, and it rescued 97% of everything the screen test rejected (+40% rasterization), because a big, distant wall saturates too. Asking about Z directly (SZ <= H/2) gave 1,440 instead of 15,000.

Square0 shifted 12 bits: every distance /64

Snake would take three steps, hit a wall, bounce back and never make progress. Frame rate was the first suspect. One observation ruled it out: the world moved fine and only the player did not. A probe in GM_ActControl showed a step of +4481 followed by a -14641 rebound.

In PSY-Q the numeric suffix is the shift: Square0 does not shift and Square12 does. The port’s Square0 shifted by 12, so every Square0 -> sum -> sqrt length came out /64. That broke:

Removing the shift made the trajectory monotonic.

Five GTE opcodes copied from the wrong row

Snake had fallen out of the world (Y = -3250), movement was pinned at 0,1 per frame, and the dock had no water. psyz’s Psyz_GteRtv0/Rtv1/Rtv2/Ll/Llv0 used MVMVA opcodes from the wrong row of MGS’s own inline_n.h table. “Rotate this vector” became “rotate it and add the stale translation”, and lighting used the rotation matrix instead of the light matrix.

After the fix:

The empty dock was a two-pass script

The dock had only a handful of object groups, and the script seemed to run one command. The real structure of s00a is if (var[5] < 6) { set up the Colonel's call } else { build the dock }, and the call’s proc is a restart. The world is built on the second pass, and answering the codec triggers it.

I had masked the codec off myself to avoid a crash, and that disabled every radio -c ... -p proc in every stage. MENU_RadioDrainHeadless now answers the call and dispatches its proc without opening the codec screen. Object groups went from 480 to 3,240 per 120 frames, and the water area was created. The lesson: a temporary stub needs something that forces you to revisit it.

Division by zero: MIPS survives it, Xtensa reboots

libhzd/near.c divides by a squared 2D length that can be zero in degenerate cases. The R3000 keeps going with an undefined result. Xtensa raises IntegerDivideByZero and the board resets. HZD_DIV was applied to the 7 runtime-divisor divisions. After that, 80 s of walking in all four directions, the pattern that used to crash, gave 0 panics. In this port, every division by a computed divisor needs a guard.

The 17.6-minute freeze

psyz’s VSync truncated its result to 16 bits in every mode, but VSync(-1) must return the 32-bit field count. At 65,536 fields (17.6 minutes at 60 Hz), the mts clock wrapped from 64,800 to 1,064 while a task waited for 65,537, and it waited forever. The fix returns the full count for mode < 0. It affects every project built on psyz. Bugs that depend on elapsed time never show up in 60-second tests.

The boot that looked hung

For hours the boot seemed stuck. Konami’s mts_printf was an empty stub on the PSX, and my #define printf mts_printf sent all of the game’s output into it, so the boot was progressing silently. Once mts_printf called vprintf, the log showed the game had reached memcard_init(). Before diagnosing a hang, make sure the output goes somewhere.

A priority deadlock that was a failed malloc

The file daemon (task 10) never got CPU time, and the trace looked like a priority deadlock. Two real fixes (ChangeThFromISR semantics and the VSync(-1) clock) changed nothing. Then one line settled it: [thread] OpenTh: xTaskCreate failed.

I had raised stacks to 32 KB, FreeRTOS takes them from internal SRAM, and the fourth task did not fit. OpenTh returned -1, and ChangeTh rejected that tid forever. On this SoC, always check the return value of xTaskCreate, even with megabytes of PSRAM free.

Black screen after adding the cutscene data

Copying DEMO.DAT, VOX.DAT and ZMOVIE.STR to the card turned the screen black: 720 frames, 0 panics, and zero primitives sent.

The stage-change code waits for GM_StreamStatus() == -1. The cutscene’s DEMO.DAT stream never advanced, because the virtual CD serves sector reads but not the continuous ring-buffer streaming path. Before the files existed, the stream had been rejected early and everything kept going.

The current handling:

Smaller bugs

Performance

Raster time is an esp_timer counter in the rasterizer, summed over 120 frames at the same position.

The first pass was in s07a, a small room. Total raster time went from 923,000 to 903,900, then 794,000, then 741,700 us (7.69 to 6.18 ms per frame). The three steps:

  1. SOFT_RASTER_IRAM was empty in the MGS build, so the per-pixel loops ran from flash. It should be IRAM_ATTR. Fixing it gave -2%.
  2. PSRAM and flash at 120 MHz gave -12%. I first concluded that this board could not do it. The error “FLASH and PSRAM Mode configuration are not supported” only means CONFIG_IDF_EXPERIMENTAL_FEATURES=y is missing. Check CONFIG_SPIRAM_SPEED 120 in the generated sdkconfig.h.
  3. Scanout ran at 62.5 Hz for a 30 fps game. Half the pushes resent the same image, each one reading about 120 KB of PSRAM and evicting the rasterizer’s textures on the other core. Matching the game’s rate gave -6.6%.

Frame time: rasterization in s07a dropping from 7.69 to 6.18 ms over three steps, and a dock frame of 40.9 ms rasterization plus about 25 ms of logic, about 15 fps against a 33.3 ms target

The dock (s00a) is much heavier. A profile there measured 46,400 textured and 71,680 flat pixels per frame: 40.9 ms of rasterization plus about 25 ms of logic and GTE, which is about 15 fps. The README currently gives about 15 fps for the dock with enemies. The status document gives 8 to 15 fps by scene, against a target of 30.

A 32-bit fast path for flat fills gained only 2%. The cost is in textured pixels, at about 207 cycles each. A 2D game runs much faster on the same rasterizer: a tilemap walks textures in a straight line, and one 64-byte cache line serves 128 pixels. In MGS’s rotated triangles, U and V both change per pixel, and nearly every pixel is a cache miss. The data cache is already at its 64 KB maximum.

The next step is to move hot texture pages into internal SRAM, which the status document estimates at 40 times faster. That needs the roughly 90 KB trapped in oversized mts stacks (24 KB for the big task and 6 KB for the rest). Shrinking textures is not an option, because UVs are baked into the models.

Loading is slow too: about 11.5 s for 1.1 MB from the card (roughly 95 KB/s).

Bringing up the handheld

Wiring of the handheld: the XIAO ESP32S3 Sense, the ILI9341 panel, the analog stick on D0/D1, a six-button resistor ladder on D2 and two direct buttons on D5/D7

The assembled handheld on perfboard: the analog stick on the left, the button ladder's resistors in the middle and the XIAO at the bottom

The handheld is built from:

What comes in the XIAO ESP32S3 Sense box: the board with its camera, a Wi-Fi antenna, pin headers and heatsinks (photo: Seeed Studio)

All 11 XIAO pins are used. The wiring is documented in the hardware guide. Bring-up notes:

MGS now boots on the handheld, mounts the card, loads the dock and plays without panics, at a frame rate the README describes as similar to the Waveshare board.

What does not work yet

Next steps

  1. Right-size the mts stacks and move hot texture pages into internal SRAM.
  2. Measure 120/120 MHz memory clocks on the XIAO.
  3. Fix the RADIO.DAT index, then check whether the elevator follows.
  4. Implement streaming in the virtual CD, to get cutscenes and voices back.
  5. Add audio through a software SPU.
  6. Remove the debug probes.

Conclusion

A PlayStation tolerates type punning, division by zero, reads past arrays and sign-tested pointers. A modern compiler and CPU tolerate none of them. The approach that worked on this port was to design every measurement so that it can fail.


Share this post on:
David Montero

Written by

David Montero

Creator of Velxio, the open-source circuit and Arduino simulator.

GitHub velxio.dev

Related posts


Next Post
222 Seeed Grove modules now run in the browser