Goal: a Jedi Knight (Dark Forces II) port running on the M5Stack Tab5, in a project shaped like esp-box-emu so game/emulator glue can move between the two.
- OpenJKDF2 is a ~96% complete C reimplementation of JK.EXE, permissively
licensed, single-threaded, 32-bit-native (the original was a 32-bit Win32
binary), and already has two 16 MB / no-SDL / no-GL ports that define the
platform contract a new target implements:
- Nintendo DSi (
src/Platform/TWL): ARM9 @ 133 MHz, 16 MB, fixed point, DS GPU for triangles. - Dreamcast (
src/Platform/Dreamcast): SH4 @ 200 MHz, 16 MB, float (single precision only), PowerVR for triangles.
- Nintendo DSi (
- Since 2026-07 it also has a CPU rasterizer (
RDRASTER_SOFTWARE_RENDERER,src/Raster/rdZRaster.c: perspective-correct, z-buffered, 8-bit paletted output through the classic light tables). Upstream gates it off on the retro targets for CPU reasons; the ESP32-P4 (2x RISC-V @ 360 MHz with FPU) is several times faster than either retro target, so the plan is:TARGET_RETRO_HOMEBREWmemory diet + software rasterizer, rendering at a low internal resolution and letting the P4's PPA scale/rotate to 1280x720. - Original 1997 requirements: Pentium 90, 16 MB RAM, all-software rendering at 320x200..640x480. Tab5: 32 MB PSRAM (~27 MB usable for the engine after display buffers).
- Game data streams from the SD card through plain
fopen/fread(the GOB container is read on demand; only the entry table is resident). Res1hi.gob + Res2.gob are ~57 MB; levels are a few MB each.
Modeled on src/Platform/TWL/* and src/Platform/Dreamcast/*:
| File (components/jk/platform) | Role |
|---|---|
Window_Esp.c |
main loop entry (Window_Main_Linux), message pump, mouse/keyboard events from the HAL |
stdDisplay_Esp.c |
8-bit tVBuffer implementation (alloc/lock/copy/fill), master palette, mode set (internal resolution), flip |
std3D_Esp.c |
"no hardware 3D" backend: texture cache stubs, std3D_DrawMenu presents the 8-bit world/menu buffer via the HAL |
stdSound_Esp.c |
software mixer feeding the ES8388 through the HAL audio task |
stdControl_Esp.c |
keys / axes from the HAL gamepad state (touch now, USB HID later) |
jkGUIDisplay_Esp.c |
the "Display" options page, trimmed |
stdPlatform hooks |
file I/O: fopen on /sdcard/jk/...; allocation: PSRAM heap |
engine 8bpp frame (W x H, e.g. 320x240) -- Video_pSwWorldBuffer / Video_menuBuffer
-> palette LUT -> RGB565 staging (internal SRAM) [video task, CPU]
-> PPA SRM: scale to display size + rotate 90 [hardware]
-> written directly into the MIPI-DSI frame buffer (no extra copy)
The GUI (LVGL) is not driven while a game runs, so the panel frame buffer is
the game's. Tab5Emu::display_size() picks original/fit/fill scaling.
| DPI frame buffer (720x1280 RGB565) | 1.8 MB (BSP) |
| LVGL draw buffers + PPA scratch | ~0.7 MB (BSP) |
| RGB565 staging (native res) | 0.15 MB (internal if possible) |
| Engine 8bpp buffers (world, menu, z-buffer) | ~1.5 MB at 640x480 menu / 320x240 world |
| Engine heap (world, models, materials LRU, COG) | remainder, ~25 MB |
- Scaffold (done): HAL, launcher, pause menu, carts, CI, PPA video path, test-pattern cart, OpenJKDF2 fork + submodule.
- Engine compiles and links for riscv32 as an ESP-IDF component
(
CONFIG_JK_ENGINE=y): Dreamcast-style flags (all float, single precision,-fno-fast-math -ffp-contract=off),TARGET_RETRO_HOMEBREW+RDRASTER_SOFTWARE_RENDERER, platform layer inOpenJKDF2/src/Platform/ESP32. Done (2026-09-13): links, 2.6 MB app, 1.27 MB engine .bss moved to PSRAM (tools/bss_to_psram.py). Next milestone: main menu renders on hardware (2D path is 100% decompiled and pure software). Open items for that: verifyMain_Startupfinds the game data (paths are made absolute under the game dir instdPlatform.c), stack size of the cart task (engine uses big stack buffers; run it on a task with a PSRAM stack),std3D_DrawMenupresent rate,stdSound_ESP32_Pump()still needs to be called from the frame loop. - World renders through
rdZRasterat 426x240 (16:9), 2D layer at 640x480, PPA scales to the panel. Done (2026-09-18): level 1 playable, ~19 fps. The HUD stays at its native 640x480: the engine hands it to the HAL as a keyed overlay, the video task builds an 852x480 staging image (world 2x nearest + HUD 1:1, 4:3-centered) in 64 KB internal-RAM strips and the PPA scales each strip 1.5x into the rotated panel buffer (video task ~26 ms/frame on core 1, ~18 fps). Pause menu save / load / reset go through the engine's own save files (absolute slot paths on the SD card; reset = level-start autosave). Quit + relaunch in-process works (2026-09-18): the platform shutdown runs jkMain_GameplayLeave, the engine clears jkGui_GdiMode and the COG parser pool on shutdown, the engine task's PSRAM stack is freed with vTaskDeleteWithCaps; per-cycle PSRAM leak is now ~2 KB (debug allocation tracker:alloc:/files:lines after shutdown). Remaining: profile the rasterizer, IRAM for hot loops, PSRAM cache tuning, cut the engine-side frame/HUD copy (~4 ms/frame). - Audio: software mixer (port of the DSi
stdSound.c) into the HAL audio path. Done (2026-09-18): mixes at the codec's 48 kHz on a 10 ms glue task (jk_audio, core 1) with back-pressure pacing and a mutex around the voice list; confirmed working on hardware. Music (2026-09-18): the soundtrack streams fromMUSIC/Track*.wav(IMA ADPCM, made once withtools/convert_music.py; ~5% of a core incl. SD reads) through a core-1 decode task and a PCM ring the mixer resamples from;.oggalso plays via stb_vorbis but costs ~75% of a core, so it is only a fallback. - Input: USB HID host on the Tab5's USB-A port. Done (2026-09-18):
keyboard + mouse, gamepads and hubs (2026-09-21/22). Since
2026-09-22 the host is
espp::UsbHostand every device is decoded from its report descriptor with espp'shid-rpruntime report map (hid-rp-report-map.hpp: ReportMap + Gamepad / Keyboard / Mouse decoders, VID/PID quirk table);usb_hid.cpponly classifies interfaces and merges states. Needed upstream (espp branches, one PR each once #807 lands):Task::stack_alloc_caps(PSRAM task stacks),UsbHost::full_speed_only(IDF has no transaction translator, so HID devices behind a high-speed hub are unreachable unless the root port runs at full speed),UsbHost::task_stack_alloc_caps(with the HID driver's event pump on an espp task, and the uninstall ordering the driver expects), transfer-error recovery (restart the interface, drop held state), and the hid-rp report map. HID usages feed JK's SDL-scancode table, mouse motion / right stick is look input, the left stick strafes. Not yet: in-game touch layout, non-HID pads (Xbox's own protocol). IMU aim as a stretch. Touch remains for menus. - Save / load through the engine's native
.jkssaves under/sdcard/jk/player, wired to the pause menu slots. - Cutscenes (
.SMKvia libsmacker) if memory allows; else skip. - Dark Forces cart shared with the esp-box-emu
dark-forces-portworktree: the HAL API mirrors BoxEmu (display_size,native_size,palette,push_frame,gamepad_state,play_audio, ...), so the glue ports by swapping the HAL type. Engine code is not shared (different GOB container, formats and engine).
- PSRAM latency for z-buffer/texture reads; mitigations: internal-SRAM staging, lower internal resolution, L2 cache config, IRAM for hot loops.
- Level memory on
TARGET_RETRO_HOMEBREWlimits is proven on 16 MB targets; we have ~25 MB. - ESP-IDF newlib vs the engine's POSIX expectations (
fopencase-insensitive paths are handled byfcaseopen; long file names needCONFIG_FATFS_LFN). - Touch-only input is not a good way to play JK; USB HID is the real target.
- Genesis / Mega Drive (gwenesis): shared with esp-box-emu (components
genesis+shared_memory,box-emu.hppaliases BoxEmu to Tab5Emu). Runs full speed: Sonic 1 at ~11 ms emulation per 60 Hz frame (88 fps capability). Internal RAM was the blocker (22 KB free at cart start): profiler tables, video tile and frame buffers are now PSRAM / per-cart, main stack 16 KB; ~150 KB free before a cart starts. Remaining levers if a core needs more: the engine's ~60 KB of GUI menu tables in .data, L2 cache 256 -> 128 KB. - SNES (snes9x2005) and GBA (gpSP interpreter) are in (2026-09-19):
components/snesdrives the core through a port of its libretro front end (snes_glue.c);components/gbacompiles gpSP's own libretro front end and hosts it with a minimal libretro environment (gba.cpp). Both cores' static state is moved to PSRAM withtools/bss_to_psram.py. Adaptive frameskip in both (skip after an over-budget frame). Verified on hardware 2026-09-20: SNES (Super Mario World, Zelda) runs at speed (~14 ms/frame avg incl. skipped frames, audio clean); the SNES ROM buffer comes from a 4 MB PSRAM arena the HAL reserves at boot (an 8 MB contiguous block is not available after a JK session; JK releases the arena while it runs). GBA plays but feels slow: ~15.5 ms/frame avg with rendered frames up to 70 ms, i.e. ~40-50 rendered fps with frameskip. GBA performance pass (2026-09-20): -O3, the CPU registers / IO / OAM / palette kept in internal RAM, never skipping two frames in a row (gpSP allowed 30), cumulative frame pacing, the whole 16 MB ROM resident (SD paging stalled the emulation). Result: smooth, ~80-90% of real time in heavy scenes (Fire Red: ~21 ms of emulation per frame vs 16.7 budget; light scenes run at speed and sleep). The sampling profile (jk_profhooked into the GBA cart in debug builds) shows the time spread evenly over the interpreter's opcode dispatch and per-instruction loop header, ~40% in the two big switches; rendering is ~5%, audio <1%, the idle-loop skip is active. No hot spot left to fix. Remaining levers, all substantial: execute_arm (74 KB) in internal RAM (the executable regionsram_lowis 175 KB and full of IDF's IRAM code; a smaller L2 cache only grows the data region, so this needs a custom placement plus disabling the PMP I/D split), or a RISC-V dynarec for gpSP (none exists).
Internal RAM (~440 KB DIRAM, ~150 KB free after link) is the constraint, not PSRAM. Rules that keep everything booting:
- Emulator cores keep no static state in internal RAM (
bss_to_psram.pyfor .bss; big initialized tables areconstso they live in flash). - Plain
mallocof >256 bytes goes to PSRAM (CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=256); anything that needs internal or DMA memory asks for it explicitly. JK's level load otherwise drained the DMA heap and SD reads failed. - The 64 KB video tile is allocated at boot and kept; allocating it at the first frame fell back to PSRAM once the GUI had fragmented the heap (JK dropped from 38 to 21 fps).
- The 128 KB DMA reserve pool at boot must fit: watch the "internal RAM after ..." log lines (currently ~76 KB free after the GUI). Remaining levers: the GUI's ~48 KB (not small mallocs; likely task stacks), JK's 60 KB of GUI tables in .data, L2 cache 256 -> 128 KB.
Settings > "USB drive": espp::UsbDevice (MSC, auto hand-over) over the
BSP's espp::SdCard on the USB-C port. The P4 has two full-speed PHYs; the
Tab5's USB-C is PHY 0 (GPIO24/25, the USB-Serial-JTAG console's by default)
and the OTG 1.1 controller sits on PHY 1 (GPIO26/27 = I2S). usb_msc.cpp
swaps the controllers with LP_SYS.usb_ctrl (console pads off, 300 ms
disconnect gap, then TinyUSB on port 0) and swaps back on switch-off; the
HAL restores the console mapping at boot since the register survives a
software reset. The USB host (HID) is stopped while the drive is on.
Verified: the drive appears on a Mac, files edited, card handed back.
UsbDevice::Config::port is in espp PR #802 (local override until
released). The switch-off "crash" seen during bring-up was a
CHIP_USB_UART_RESET: a terminal reopening the restored console port with
DTR low + RTS high resets the chip; keep both asserted.
Core dumps to flash (64 KB coredump partition, ELF); at the next boot
Tab5Emu::dump_core_dump_to_console() prints the image base64 and erases
it, so crashes during the USB drive hand-over (console on the other PHY)
are still decodable (esp-coredump info_corefile -t raw). Found with it:
the pause menu's 600 KB screenshot buffers must be reserved before a game
fills PSRAM (bad_alloc on pause in JK), and JK's small-object pool has to be
permanent (the engine frees the previous session's strings at the next
start).
Native port, not emulation: the game's decompiled C
(components/mgs/mgs_reversing, the S3 port's esp32-port branch) on psyz's
PlayStation SDK + software GPU (components/mgs/psyz), following
https://velxio.dev/blog/posts/metal-gear-solid-on-esp32-s3/. What differs
from the S3 and why it should do better here: RISC-V at 360 MHz with a 256 KB
L2 cache against the S3's 240 MHz Xtensa and 64 KB; 32 MB of PSRAM at 200 MHz;
SDMMC for the disc data (the S3 shared one SPI bus between card and panel and
read at ~95 KB/s); the HAL's PPA scaler for the 320x240 frame.
Phases:
- Build (done): the component mirrors the upstream CMake; the private
shim is reconstructed (
shim/); game statics to PSRAM; mts stacks in PSRAM; the game's main() on its own task. - Platform layer (done, on hardware): the scanout presents the rows the display environment declares (224 centred, 256 cropped) as RGB565 through the HAL's frame buffers; pads from the HAL's merged gamepad (face buttons by position, L1/R1 on the shoulders, L2/R2 on the triggers for the item and weapon menus); data root on the card; pause by parking the game threads at an idle point with the vblank held; reset, relaunch and quit in place (the game's statics are put back between launches, its big buffers live in a PSRAM arena acquired at launch).
- Bring-up (done): the retail boot chain from the US disc (title, opening, dock, elevator, topside), cutscenes with the frame-buffer effects, codec calls, sound effects, music and streamed speech; the virtual CD reads the card sector by sector with a read-ahead task.
- Performance (done to the game's cap): two rasterizer workers, one per
core, each drawing a band of rows of the same command stream, with FPU
gradients corrected to the exact integer quotient, DDA edges and a
modulation table; about 28 fps at the dock against the game's 30. Measured
with a replay benchmark (
CONFIG_MGS_REPLAY_BENCH): the pause menu records frames, the next start replays and compares them pixel for pixel. - Integration polish (done): metadata/boxart entry, save states from the
pause menu (version 4: the statics, the arena, every thread's stack and
context), the disc's own English menu text (
TEXT.DATfrom the executable), documentation of the data extraction. Not done: memory-card game saves, full-motion video (MDEC).
The two submodules are esp-cpp forks (esp32p4 branches).
Same shape as the MGS port (decompiled C on psyz, natively), but there is no
decompilation to start from: making one is the job. It lives in its own repo,
tpm-decomp (submodule
components/tpm/tpm-decomp), target
SLUS_008.84, the USA Rev 1 disc. Its docs/LAYOUT.md has what is known
about the executable and the disc, docs/PLAN.md the road map.
Where it stands:
- Decomp ground (done): splat split, byte-identical rebuild from
INCLUDE_ASMstubs through PSY-Q 4.3's GCC 2.8.1 + maspsx; ~105 PSY-Q library objects named by signature; 823 game functions waiting. - The game runs, recompiled (2026-10-11): tpm-decomp's static
recompiler translates the whole executable to C at build time and its
runtime emulates the machine around it (BIOS calls, interrupts, DMA,
CD-ROM over the
.bin, GPU ports on psyz's rasterizer, psyz's GTE and SPU). On the desktop it boots into the first level in 3D. Here it isCONFIG_TPM_ENGINE(default on): CMake recompiles the user'sSLUS_008.84at configure time; the game, runtime and psyz pieces are one relocatable object with only the runtime's API global (MGS links the same psyz names). Built (7.9 MB image; app partition grown to 12 MB); not run on the Tab5 yet. - Next: the first run on the board, then performance (the MGS lessons: the two-core rasterizer), MDEC movies, XA audio, memory card saves. The decompilation continues in tpm-decomp; decompiled functions can replace recompiled ones one at a time.
The load-screen viewer remains the fallback (no executable at build time,
or no .bin on the card).