Metal Gear Solid running natively on the ESP32-S3

velxio.dev · 2026-10-06 · by Long Nguyen ·generic
This article describes a native port of Metal Gear Solid (PlayStation, 1998) to the ESP32-S3 SoC. It covers the platform layer that replaces the PlayStation hardware, memory placement, the debugging method, the main bugs, performance, and a DIY handheld that runs the game.

Metal Gear Solid running natively on the ESP32-S3

Metal Gear Solid running natively on the ESP32-S3

Sep 27, 2026

|

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 SDK.
  • The GPU.
  • The GTE geometry coprocessor.
  • The CD drive.
  • The vertical blank interrupt.
  • The controller.
  • 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:

  • MGS port: davidmonterocrespo24/mgs\_reversing, branch `esp32-port`.
  • Handheld hardware guide: HARDWARE.md.
  • psyz fork: davidmonterocrespo24/psyz.
  • 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-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:

  • 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.
  • 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:

  • `-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 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:

  • `DG_PrimInfos` put the first vertex at +8 instead of +12.
  • `LinkModelToParent` precomputed parent-vertex offsets as `*52 + 8` instead of `*56 + 12`.
  • The primitive constructors described in “The white screen” below.
  • 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:

  • All mts tasks are pinned to core 0, along with the vblank tick at priority 10, so the tick pre-empts the game the way the PSX interrupt did. LCD scanout runs on core 1. Crossing those assignments caused races that revived parked tasks mid-stack. One log showed two copies of `Main()` interleaved line by line.
  • A new task suspends itself in its trampoline. `xTaskCreate` followed by `vTaskSuspend` is not atomic, and on two cores the task ran before its control block was filled in.
  • Critical sections are real. Each task has an absolute flag, saved and restored on every switch like the MIPS status register. It is a flag rather than a counter because the game calls `ExitCriticalSection` without a matching `Enter`.
  • The tick runs the mts callback only while the idle task (11) is active. That task is Konami’s SIO console loop, the only code that never touches scheduler state. On the console it ran with interrupts enabled for exactly this reason. The game passes through idle every frame, so wake latency stays under a frame. This change removed intermittent hangs at the source, where earlier flag-based versions had only narrowed the race window.
  • `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:

  • Serial key holds are decremented in `DG_ActFirst`, right after `GV_UpdatePadSystem`, the only point that means “a frame has read the pad”. Counting vblanks there either walked Snake too far or released keys before the game saw them.
  • The LCD sends a frame only when a sequence number bumped in `DG_SwapFrame` has changed, with a 250 ms fallback.
  • 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.

  • The game’s RAM map hangs off one define, `MEM_BOTTOM`. Both packet arenas, the heap and the libfs sector buffer come from it by subtraction. It is relocated onto a static PSRAM array (`mgs_main_ram[0xC9000]`, `EXT_RAM_BSS_ATTR`). It has to be static because `RESIDENT_BOTTOM` is used as a static initializer, which rules out `malloc`.
  • The 1 MB PSX VRAM lives in PSRAM. Without `EXT_RAM_BSS_ATTR`, dram0 overflows by about 900 KB.
  • Task stacks went back and forth.
  • 1. 32 KB stacks from internal SRAM made the fourth `xTaskCreate` fail silently (see “A priority deadlock that was a failed malloc”). 2. Stacks moved to PSRAM worked until a task called `fopen`. The flash driver disables the cache, the task loses its own stack, and the SoC resets without printing a panic. 3. Stacks are now 20 KB in internal SRAM. High-water marks show five tasks using about 2 KB and one using 14,484 B.
  • A stub with the wrong type ends up in read-only flash. `_spu_transferCallback` is a function pointer. Declaring it as a function put it in `.text`, so the first `= 0` crashed. I found it by running `nm` on a working psyz-based ELF and on this one, comparing them with `comm`, and listing symbols that are `B`/`D` there and `T` here.
  • 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:

  • Each file gets a synthetic range of 1,000,000 sectors, so a sector number maps back to (file, offset) with a division.
  • `CDBIOS_ReadRequest` delivers data sector by sector and calls a callback after each sector. The callback may rewrite the remaining size. `FS_CdStageFileInit` depends on that to learn the `STAGE.DIR` table size from its first 4 bytes. Treating the request as a plain byte copy and ignoring the callback made every stage lookup return `NOT FOUND`.
  • Delivery must be asynchronous. The parser and the “disc” are coroutines sharing one buffer window. Delivering everything at once let file 2 overwrite file 1 before it was parsed. `ReadRequest` now only records the request, and `ReadSync` pumps sectors until the callback returns 2 (“buffer redirected”).
  • 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 full 71,892,992-byte `STAGE.DIR` (all 96 stages) is read on demand.
  • Of 93 stage files, 65 have fully symbolic actor tables. The other 28 still hold raw MIPS addresses because their actor code is not decompiled.
  • A generator builds one deduplicated actor table and a whitelist: 56 stages and 149 actor classes. The script’s stage-change command checks the whitelist while the current world is still intact.
  • 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:

  • A counter nobody increments invents a finding. `[trap] scanning 63 triggers, 0 entered so far` nearly became “the trigger system is dead”. The counter had no `++`. With a real one, triggers fired from the first second.
  • A tautological check gives false confidence. The skeleton audit compared `|R·t|` with `|t|`. Those are equal for any rotation, so the check stayed green for weeks while every skeleton was deformed (see “Strict aliasing deleted a rotation”).
  • Check where a field is written before you interpret it. `eye_inv.t` looks like a camera position but is a rotated direction vector. Reading it as a position produced a detailed, false story about a camera flying away.
  • Keep a probe broad until the data narrows it. I filtered the full-screen-primitive probe to untextured primitives, on the idea that a flash has no texture. A searchlight cone has one.
  • Split counters by cause. “65% of seams are stale” was four causes in one bucket, and the big one was harmless root objects. After the split, my fix fired zero times, and I removed it.
  • Instrument all N writers. When `DG_UnDrawFrameCount = 0x7fff0000` blacked out the screen, I guessed wrong twice. Tagging the four writers with `"file:line"` named `game/gamed.c:476` at once.
  • Decode the data before you suspect the interpreter. The dock’s script appeared to run one command. Decoding the retail bytecode showed 19 nodes, only one of which prints (see “The empty dock was a two-pass script”).
  • Check that the probe is in the binary. A breadcrumb that read “(nothing)” had never been inserted, because a scripted `str.replace` had silently failed. `grep` the string in the `.bin`.
  • Use hardware watchpoints for rogue writers. `esp_cpu_set_watchpoint(0, addr, 4, STORE)` showed that about 230 GPU desyncs came from `DG_PcxRead4Bpp`, a texture decoder whose scratch buffer overlapped live packets.
  • Be suspicious of sudden improvements. When the card failed to mount, the board fell back to 9 stages in flash, and load times dropped from 693 to 21 vblanks. That looks like a 35x speedup, but it means the card is missing. Check for `microSD mounted` and `table_size 1152 -> 96 stages` first.
  • 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

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

    Strict aliasing deleted a rotation

  • Symptom: every character had arms detached from the torso, a knee bent backwards and feet pointing different ways. The world was fine.
  • Dead ends:
  • * A `MulRotMatrix` “fix” that made things far worse and was reverted. * The model stride, proved to be 88 bytes from the data in three independent ways. * Stale seams, measured at zero effect. * A frozen rest-pose dump put all 16 joints within a millimetre of the positions decoded from the disc, with `det = 4096` and `d2 == want` on every bone.
  • Why those checks missed it: in the rest pose every rotation is the identity, `d2 == want` is a tautology, and `det` checks the 3x3 block, which was correct.
  • What caught it: joint positions in two different frames. The parent-to-child offset stayed identical byte for byte while the parent rotated 19 degrees.
  • Root cause: `CompMatrix` wrote the translation through `(VECTOR)&tmp.t[0]` over a `long[3]`. GCC 14 at `-O2` concluded that nothing reads those stores and removed the call. The disassembly had no multiply instructions. It computed `m2->t = m0->t + m1->t`.
  • Fix: `long long` arithmetic with no type punning, plus `-fno-strict-aliasing` for the whole game. A second identical case exists in `libdg/display.c:318`, so the flag is the real protection. A `[rig]` audit now checks every skeleton using scale-invariant ratios against the longest bone: 14,416 joints audited, 0 bad.
  • The white screen: five wrong hypotheses, three real causes

  • Symptom: when an enemy spotted Snake, a white sheet covered the scene. The HUD still showed through, and there were colour flashes.
  • Hypotheses ruled out by measurement:
  • 1. A full-screen flash. The probe logged 0 lines. 2. `change camera -1` as an error. It is the “no fixed camera here” sentinel, and treating it as an error froze the camera behind the player. 3. A runaway camera. That was the misread `eye_inv.t`; the real position was stable. 4. Draw-queue overflow. A culling fix cut drawn models from 25,093 to 14,054 and the white stayed. 5. `setlen(pack, z_idx >> 8)` in `divide.c`. `setlen` casts to `u_char`, so it cannot produce lengths like `0x2E000000`. Cause 1: `SetDrawStp` did `((u_long)p)[1] = 0xE6000000 | stp`. In psyz, word 1 is `len`, and the command belongs in `code[0]`. The reader saw a packet billions of words long, dropped it and resumed mid-primitive, decoding vertices as commands. It fired in combat, because muzzle flashes and smoke are what turn on semi-transparency. psyz had already fixed the same mistake in its own `setDrawTPage`, but nobody had reviewed the game-side twin. After the fix: 0 dropped, 0 desync.
  • Cause 2: `DG_MakePrim` never initialized the length. It is fixed in one place by deriving `len = psize/4 - 2` and checking the result against the psyz macros (FT4 gives 9, GT4 gives 12, TILE gives 3).
  • Cause 3, the missing hardware rule: once textured primitives were included, the probe showed `[cover] code 3E abe 1 tex 1 box -1024,-1024..142,66`, which is the GTE saturation limit. Polygons crossing behind the camera get pinned there as screen-spanning quads. The PSX GPU refuses any polygon larger than 1023x511, and the game depends on that. Our rasterizer drew them: 27.8 M texels per 120 frames, about three screens per frame, and 142 ms of rasterization. The size test now runs in `soft_raster.inc.c` before clipping, because clipping would shrink the polygon to screen size and hide the very property under test.
  • 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:

  • Movement and collisions.
  • Enemy sight range.
  • Bullets.
  • The infrared sensor.
  • Light attenuation.
  • 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:

  • A step of `27,0` sustained.
  • Y = 250.
  • Smooth turning.
  • Textured pixels per frame up from 23,500 to 107,877.
  • The water came back too, because it is gated on the camera being inside the water volume.
  • 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:

  • All input streams are declined in `NewStreamControl` by running their callback, with a 600-frame watchdog as a safety net. That costs cutscenes and voices, and it gives a game that plays.
  • A cutscene’s recorded pad input started at once and Snake walked off on his own. That playback is now skipped the same way the game skips it on START.
  • FMV is declined as well, because psyz has no MDEC decoder.
  • Smaller bugs

  • `PSX_W` was 256, inherited from the code I reused. MGS renders 320 columns, so one fifth of the image was lost. Audit every inherited constant.
  • Boot race: the tick read USB serial before the driver was installed, causing `LoadProhibited` in 2 of 6 measured boots.
  • Konami’s frame dropper: two actors read the skip flag at different moments, so buffers were swapped undrawn and the screen strobed. Both flags are forced to 0 under `__psyz`. Heavy scenes now run slower instead of strobing.
  • “Broken collision”: wall pushes measured about 452 against a radius of 450, which is exactly right. At 8 to 10 fps, a 450-unit correction just looks like a jump.
  • 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).

  • A burst of 128 instead of 16 sectors gained only 7%.
  • The card is reliable at 20 MHz on the Waveshare board. At 40 MHz it gives CRC errors.
  • Skipping redundant `fseek` calls is in place but not yet measured.
  • 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:

  • A XIAO ESP32S3 Sense, whose expansion board provides the microSD slot.
  • An ILI9341 SPI panel.
  • A drone-controller gimbal.
  • 8 buttons: 6 on a resistor ladder on one ADC pin, plus fire and aim on their own pins, because a ladder only registers one button at a time.
  • 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:

  • The missing ground. The panel glowed white and changed brightness when data was sent, because it was powering itself through the protection diodes on its inputs. Before that, measurement ruled out the wiring, the reset pulse, the supply voltage and the SPI setup. A bug of mine also cost two rounds: a scripted edit meant to insert `lcd_bus_init()` silently failed, and the panel was re-soldered for nothing.
  • ILI9341, not ILI9488. The plan assumed a 480x320 ILI9488, which over SPI only accepts 3-byte RGB666. An earlier project of mine with the same panel showed it is an ILI9341 at 320x240. That was better than planned: native resolution, no scaling, 2 bytes per pixel. Colour order was checked with three labelled R/G/B stripes at once.
  • RST on a real pin. With RESET tied to 3V3 and only a software reset, the ILI9341 did not start reliably, so RST moved to D4. D4 was free because the card’s CS is GPIO21, internal to the Sense board. A web search had given wrong SD pins. The error was `ESP_ERR_INVALID_ARG`, a configuration error rather than a card error, which pointed to Seeed’s official documentation.
  • Buttons and pots. Wire 4-leg switches on diagonal legs, or one button reads as permanently pressed. For the pots:
  • * A reading pinned at 0 or 4095 means the wire is on an outer leg. * A reading stuck at mid-scale means the pot has no 3V3/GND. * A wide range means the axis is healthy.
  • Stick calibration. The gimbal rests at 2317, not 2048, and X travels from 141 to 2888.
  • * The centre is the resting reading at boot, and each half is scaled by the travel seen in that direction. * The dead zone is 60 counts (about 4x the measured +/-15 noise), subtracted on both sides so motion starts smoothly. * Each axis averages 8 readings, and both axes are inverted in software. * A min/max accumulated since boot is useless here, because a floating input touches 0 and 4095 at power-on. Use a moving window.
  • ADC reads in the vblank tick froze everything. 24 conversions per tick stalled the tick and every mts task. The warning was already written in `esp_input.c`. A task on core 1 now samples every 10 ms and publishes three integers.
  • `printf` over native USB blocks when no host drains it (UART does not). MGS prints a lot, so a blocked print stopped the cooperative scheduler. `port/mgs_printf.h` writes with a zero timeout.
  • Clock pairs. PSRAM at 120 MHz with flash at 80 MHz is not a supported MSPI pair, and the failure looks like a toolchain bug. The XIAO runs 80/80 for now, and 120/120 is the next thing to measure.
  • Panel SPI. With jumper wires the panel is stable at 40 MHz. At 80 MHz, bands of the image slip sideways.
  • 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

  • Codec calls. The 417,792-byte face group now loads from a static PSRAM reserve, because the game’s pool refuses the allocation. The `RADIO.DAT` index is still wrong: the computed start sector is 31,392, and the file has 867 sectors. The input value is bad; the arithmetic is fine. Enabling the real codec screen also panics inside a `printf`.
  • The intro. Booting through the title screen gave 11 panics. The last line before the first one was `this memcard is OK`. The swim-in and briefing need the memory card, CD streaming and the codec.
  • The elevator. Probes in both elevator constructors stay silent for a whole session, so the stage script never requests it. The likely reason, not yet proven, is a story variable that only a codec call advances.
  • CD streaming (cutscenes, voices), FMV (no MDEC), audio, and memory card saves.
  • 28 stages whose actor code is still MIPS assembly.
  • Performance beyond about 15 fps.
  • 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.

    ---

  • esp32
  • esp32-s3
  • game-port
  • retro-gaming
  • 3d-graphics
  • debugging
  • handheld
  • xiao
  • 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

    David Montero

    Written by

    David Montero

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

    GitHub velxio.dev

    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

velxio.dev · 16 images · 49 links

Comments (0)

No comments yet