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

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-gcc14.2 against the psyz headers: 57 of 58 files. - None of them contain absolute
0x800xxxxxaddresses. - All graphics output goes through 11 libgpu calls (
DrawOTag,LoadImage,ClearOTagRand 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=gnu17and-mlongcallsare both mandatory. GCC 14’s default gnu23 rejects this code, and Xtensacall8only 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
printfwith its own stub. I renamed itmts_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 becameSCRPAD_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 layers, from the game down to the hardware:
- MGS engine: decompiled C, compiled to Xtensa
- psyz: PlayStation SDK and GTE in C, software rasterizer
- Platform layer: mts scheduler on FreeRTOS, vblank tick, virtual CD, LCD, input
- 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:
divide.cbuckets by the low byte and parks the high byte in the tag’s length.sort.cre-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_PrimInfosput the first vertex at +8 instead of +12.LinkModelToParentprecomputed parent-vertex offsets as*52 + 8instead 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.
xTaskCreatefollowed byvTaskSuspendis 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
ExitCriticalSectionwithout a matchingEnter. - 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 afterGV_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_SwapFramehas changed, with a 250 ms fallback.
Memory placement
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 becauseRESIDENT_BOTTOMis used as a static initializer, which rules outmalloc. - 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.
- 32 KB stacks from internal SRAM made the fourth
xTaskCreatefail silently (see “A priority deadlock that was a failed malloc”). - 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. - Stacks are now 20 KB in internal SRAM. High-water marks show five tasks using about 2 KB and one using 14,484 B.
- 32 KB stacks from internal SRAM made the fourth
- A stub with the wrong type ends up in read-only flash.
_spu_transferCallbackis a function pointer. Declaring it as a function put it in.text, so the first= 0crashed. I found it by runningnmon a working psyz-based ELF and on this one, comparing them withcomm, and listing symbols that areB/Dthere andThere.
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:
- Each file gets a synthetic range of 1,000,000 sectors, so a sector number maps back to (file, offset) with a division.
CDBIOS_ReadRequestdelivers data sector by sector and calls a callback after each sector. The callback may rewrite the remaining size.FS_CdStageFileInitdepends on that to learn theSTAGE.DIRtable size from its first 4 bytes. Treating the request as a plain byte copy and ignoring the callback made every stage lookup returnNOT 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.
ReadRequestnow only records the request, andReadSyncpumps 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 farnearly 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.tlooks 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 = 0x7fff0000blacked out the screen, I guessed wrong twice. Tagging the four writers with"file:line"namedgame/gamed.c:476at 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.replacehad silently failed.grepthe 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 fromDG_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 mountedandtable_size 1152 -> 96 stagesfirst.
The bugs

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 = 4096andd2 == wanton every bone.
- A
- Why those checks missed it: in the rest pose every rotation is the identity,
d2 == wantis a tautology, anddetchecks 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:
CompMatrixwrote the translation through(VECTOR*)&tmp.t[0]over along[3]. GCC 14 at-O2concluded that nothing reads those stores and removed the call. The disassembly had no multiply instructions. It computedm2->t = m0->t + m1->t. - Fix:
long longarithmetic with no type punning, plus-fno-strict-aliasingfor the whole game. A second identical case exists inlibdg/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:
- A full-screen flash. The probe logged 0 lines.
change camera -1as an error. It is the “no fixed camera here” sentinel, and treating it as an error froze the camera behind the player.- A runaway camera. That was the misread
eye_inv.t; the real position was stable. - Draw-queue overflow. A culling fix cut drawn models from 25,093 to 14,054 and the white stayed.
setlen(pack, z_idx >> 8)individe.c.setlencasts tou_char, so it cannot produce lengths like0x2E000000.
- Cause 1:
SetDrawStpdid((u_long*)p)[1] = 0xE6000000 | stp. In psyz, word 1 islen, and the command belongs incode[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 ownsetDrawTPage, but nobody had reviewed the game-side twin. After the fix: 0 dropped, 0 desync. - Cause 2:
DG_MakePrimnever initialized the length. It is fixed in one place by derivinglen = psize/4 - 2and 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 insoft_raster.inc.cbefore clipping, because clipping would shrink the polygon to screen size and hide the very property under test.
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,0sustained. - 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
NewStreamControlby 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_Wwas 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
LoadProhibitedin 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:
SOFT_RASTER_IRAMwas empty in the MGS build, so the per-pixel loops ran from flash. It should beIRAM_ATTR. Fixing it gave -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=yis missing. CheckCONFIG_SPIRAM_SPEED 120in the generatedsdkconfig.h. - 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).
- 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
fseekcalls is in place but not yet measured.
Bringing up the handheld

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.

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. printfover 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.hwrites 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.DATindex 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 aprintf. - 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
- Right-size the mts stacks and move hot texture pages into internal SRAM.
- Measure 120/120 MHz memory clocks on the XIAO.
- Fix the
RADIO.DATindex, then check whether the elevator follows. - Implement streaming in the virtual CD, to get cutscenes and voices back.
- Add audio through a software SPU.
- 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.