Metal Gear Solid running natively on the ESP32-S3
Metal Gear Solid running natively on the ESP32-S3
Sep 27, 2026
|

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 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 SDK.
- The GPU.
- The GTE geometry coprocessor.
- The CD drive.
- The vertical blank interrupt.
- The controller.
- MGS port: davidmonterocrespo24/mgs\_reversing, branch `esp32-port`.
- Handheld hardware guide: HARDWARE.md.
- psyz fork: davidmonterocrespo24/psyz.
- The engine libraries (libgv, libdg, libgcl, libhzd, libfs) compiled almost at once with `xtensa-esp32s3-elf-gcc` 14.2 against the psyz headers: 57 of 58 files.
- None of them contain absolute `0x800xxxxx` addresses.
- All graphics output goes through 11 libgpu calls (`DrawOTag`, `LoadImage`, `ClearOTagR` and so on). That is exactly the layer psyz’s software renderer replaces.
- `-std=gnu17` and `-mlongcalls` are both mandatory. GCC 14’s default gnu23 rejects this code, and Xtensa `call8` only reaches +/-512 KB.
- psyz and MGS both have a `common.h`, so MGS’s include paths must come first.
- 12 cast-as-lvalue expressions, a GCC 2.x extension, were rewritten.
- MGS replaces libc’s `printf` with its own stub. I renamed it `mts_printf`, and that later caused one of the most misleading bugs (see “The boot that looked hung”).
- 346 literal scratchpad addresses (`0x1f8000xx`) across 16 files became `SCRPAD_ADDR + offset`, backed by an array.
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

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-2 | DIY handheld (Seeed XIAO ESP32S3 Sense) | | --------- | ---------------------------------------- | -------------------------------------------------------------- | | Panel | ST7789, 320x240 | ILI9341, 320x240 native | | Flash | 16 MB | 8 MB | | Game data | microSD, or a trimmed STAGE.DIR in flash | microSD (full STAGE.DIR) | | Input | keyboard 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 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
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
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:
Running each build without flashing the board
That method only works when trying something is cheap, and after several hours of trial and error, flashing the board after every change was not: write the image to the XIAO over USB, wait for the reboot, read the serial log, repeat. So to run and check each build I used `velxio-cli`, the command-line client of Velxio. It takes the same `.bin` my toolchain produced, runs it on Velxio’s simulated ESP32-S3 for a fixed amount of simulated time and hands back the serial output, so I could read the probes above without touching the hardware. The board only got a new image when a build was worth it.
# velxio.toml, next to the build: board = "xiao-esp32-s3", firmware = the merged .bin
velxio-cli run --timeout 5000 --timeout-exit-code 0 --serial-log-file serial.log .Installing it and doing a first run takes a few minutes: see the velxio-cli quickstart.
The bugs

Strict aliasing deleted a rotation
The white screen: five wrong hypotheses, three real causes
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%.
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

The handheld is built from:

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.
---
Back To Top
Share this post on:
Share this post via WhatsApp Share this post on Facebook Share this post on X Share this post via Telegram Share this post on Pinterest Share this post via email
Written by
David Montero
Creator of Velxio, the open-source circuit and Arduino simulator.
Related posts
Castlevania: Symphony of the Night running natively on the ESP32-S3 Oct 2, 2026
Seeed Studio hardware comes to Velxio: XIAO boards, the Round Display, Grove and ReSpeaker in your browser Aug 18, 2026
222 Seeed Grove modules now run in the browser Sep 14, 2026 ---
Previous Post Castlevania: Symphony of the Night running natively on the ESP32-S3 Next Post 222 Seeed Grove modules now run in the browser
