mh3u (runtime/boot/main.cpp) runs the recompiled game (recomp/out, library mh3u_recomp_game) on HLE
implementations of the Cafe OS libraries (runtime/os). Run from the repo root: build/cmake/runtime/boot/Debug/Cha-Cha.exe .
(MH3U_TRACE=1 prints the first calls of every import and HLE details such as file opens and heap carving;
--headless runs without a window; MH3U_VK_VALIDATION=1 enables the Vulkan validation layer;
MH3U_EXIT_AFTER_FRAMES=N stops cleanly after N frames and prints frame/time/import statistics).
- Load + relocate
MH3G_Cafe_US.elfwith the recompiler's loader; copy.syscall/.text/.rodata/.data/.module_id, zero.bss/.ipc_smeminto guest memory. - Commit the other regions (below), create the MEM2 expanded heap and the MEM1 / foreground frame heaps, write the
data imports (function pointers for
MEMAllocFromDefaultHeap(Ex)/MEMFreeToDefaultHeap,__gh_FOPEN_MAX= 20 (assumed),environ= 0), register every HLE function by name (ppc::register_import). - Main thread: 1 MB stack at 0x60000000, guest
OSThreadin the HLE area,argv = {"MH3G_Cafe_US.rpx"}. - Open the window (
runtime/gfx/presenter: SDL3 + Vulkan device/swapchain) on the host main thread, which then only pumps window events; the guest boots on its own host thread. - Call the ELF entry
__rpx_start(0x02E84370). The guest crt itself runs the static constructor table (0x02E842ECwalks0x101632C4until a null entry) and thenmain;exit()unwinds back asGuestExit.
The whole 32-bit space is one 4 GB host reservation (guest address A = base + A, as the generated code assumes); regions are committed explicitly, so a stray access faults and is reported with its guest address.
| Range | Use |
|---|---|
| 0x02000000–0x031963A4 | .syscall + .text |
| 0x10000000–0x104D8FC0 | .rodata .data .module_id .bss .ipc_smem |
| 0x10600000–0x50000000 | MEM2 arena = default/base expanded heap (MEMGetBaseHeapHandle(1)) |
| 0x60000000–0x64000000 | guest thread stacks (main thread: first 1 MB) |
| 0x70000000–0x71000000 | runtime-owned guest objects (OSThread, argv, OSSystemInfo, scratch) |
| 0xC0000000–0xC0010000 | import area: data imports are read at their symbol addresses (e.g. 0xC0007F88) |
| 0xC1000000– | fake code addresses for host functions reachable through guest pointers (never mapped) |
| 0xE0000000 + 40 MB | foreground bucket (frame heap, MEMGetBaseHeapHandle(2); size assumed) |
| 0xF4000000 + 32 MB | MEM1 (frame heap, MEMGetBaseHeapHandle(0)) |
runtime/gfx/gx2.cpp: GX2Init and the setup the game performs – TV (1080p, RGBA8 0x1A, triple-buffered) and
GamePad (854x480) scan buffers (GX2CalcTVSize/DRCSize, GX2SetTVBuffer/DRCBuffer, scale/gamma/enable), swap
interval, context state, GX2CalcSurfaceSizeAndAlignment (our Latte surface layout, runtime/gfx/addrlib/, test gfx_addrlib:
exact level count, sizes, pitch, alignment, per-level tile mode and mip offsets – an earlier approximation padded every
mip level to 0x800 bytes, so the game's back-to-back .tex mip chains were read from wrong offsets), HiZ and
fetch-shader/ring-buffer sizes, GX2R surfaces from the default heap, timestamps, vsync (16.7 ms pacing),
GX2SwapScanBuffers → presents the TV scan-out image. Tiling apertures return the tiled image
as-is. Device used here: NVIDIA GeForce RTX 3060.
- Display lists. Command-emitting GX2 calls are registered through
recordable<F>: while a list is open on the calling thread (GX2BeginDisplayListEx) the call is appended (function + r3..r10 + f1..f8) instead of run;GX2EndDisplayListstores the list by buffer address and returns a nominal size (16 bytes/command, ≥ 0x20, capped to the buffer) – a zero size had madesLtRenderskipGX2DirectCallDisplayListentirely. DirectCall replays it. - CPU versions of clears (RGBA8, RGB10A2, RGBA16F, R32F; D24S8/D32F/D16),
GX2CopySurface(detile → retile withruntime/gfx/tiling.*, addrlib R6xx thin1, checked againstscripts/mt_tex.pyin thegfx_tilingtest) and TV scan-out (detile + convert to RGBA8) remain for surfaces the renderer cannot take (unsupported formats, no device).
The window and the device come from Presenter; --headless creates the same device without a window, so frame
dumps render too. Required: Vulkan 1.3 (dynamic rendering, demote-to-helper, synchronization2) and
VK_KHR_push_descriptor-capable hardware; VK_EXT_depth_clip_control is used for GX2's -1..1 clip-space depth.
- State. gx2.cpp collects a
DrawState: shaders (VS/PS, GS whenGX2SetShaderModeExmode 2), fetch-shader streams, attribute buffers, per-stage uniform blocks, pixel textures + samplers, colour/depth buffers, viewport, scissor, blend (GX2SetColorControl/BlendControl/TargetChannelMasks/constant), depth, cull, alpha test, point size, clip control, stencil (GX2SetDepthStencilControl– arguments 9-13 come from the caller's stack parameter area, which display lists now record too – andGX2SetStencilMask; GX2 compare/stencil-op enums equal Vulkan's; masks and reference are dynamic state). - Shaders are looked up per guest shader struct by the translator's key (FNV-1a 64 over register block + program,
tools/shaderc-latte), loaded frombuild/shaders/<key>.<stage>.spvwith its.meta(uniform blocks, samplers, attribute semantics), which defines the descriptor set layout. A missing file skips the draw with a warning. - Fetch shaders.
GX2InitFetchShaderExgenerates no microcode: it copies theGX2AttribStreamarray into the program buffer. At draw time each VS input semantic is matched to a stream (location), and the CPU converts the referenced vertices (big-endian attribute data; UNORM/SNORM/INT/SCALED/FLOAT formats; the stream's component selection) intouvec4GPR values. Indexed draws are rebased to the index range; QUADS/QUAD_STRIP/LINE_LOOP become index lists; RECTS (3 corners per rectangle) get their fourth corner as v1 + v2 - v0 from the decoded attributes – exact for vertex shaders affine in their inputs – and become two triangles. - Uniform blocks are already little-endian in guest memory (the game swaps them for the GPU, unlike vertex data): they are copied as is into a per-frame host-visible upload buffer.
- Surfaces. Render targets live in a cache keyed by guest image address. Clears,
GX2CopySurfaceand TV scan-out of cached targets run on the GPU (copy/blit into a TV image the presenter blits to the swapchain); nothing is written back to guest memory. Textures are detiled with addrlib addressing (every mip level and slice; a macro-tiled mip chain takes its swizzle from bits 8-10 of the mipmaps pointer) and uploaded, re-uploaded when a hash of level 0 + mip chain changes; samplers use the guest min/max LOD and mip filter; a texture whose image is a cached target samples that image (an R32F target sampled with depth compare goes through a D32F copy). A draw that samples its own colour target samples a copy taken just before it. - GamePad screen.
GX2CopyColorBufferToScanBufferto the DRC goes to a second scan-out image; the presented frame shows it not at all (default – the port plays on the TV with a Pro Controller), as a 30%-width inset in the bottom-right corner, or instead of the TV image: F2 in the window cycles,MH3U_GAMEPAD_VIEW=off|pip|onlysets the start. Frame dumps include the inset. - Pipelines are cached by shaders, vertex layout, topology, target formats, blend/write masks, depth and cull state; viewport (flipped: negative height, GX2 viewports are y-up), scissor and blend constant are dynamic.
- Debugging.
MH3U_DUMP_FRAME=N[,N...]writesbuild/frame_N.bmp(read back from the GPU),build/frame_N_draws.txtwith the frame's ordered events (clears, draws, copies, scan-out) and a per-draw trace (shader keys, decoded first vertices per attribute, targets, textures, uniform values);MH3U_DUMP_TARGET=<hex image address>also writesbuild/target_<addr>.bmp.MH3U_RENDER_DEBUGflags: 1 no alpha test, 2 no blending, 8 only geometry-shader draws, 16 log the write-watched texture memory.MH3U_VK_VALIDATION=1enables the validation layer (clean over 600 frames, headless and windowed).
At boot the game shows its first screen correctly: the "Wii U GamePad has been detected" notice (clear, a
full-screen pass, a translucent panel, 8 gold frame sprites and 2 text runs – all point sprites expanded by the
1e2db7fd… geometry shader – then a copy to the GamePad buffer), and waits there for a button. Debug build: ~44 fps
headless.
runtime/audio/ax.cpp: AX (snd_core) with a software mixer modelled on Cemu's ax_mix.cpp. 96 guest AXVoices.
Every 3 ms AX frame (96 samples at 32 kHz) each playing voice is decoded (DSP ADPCM with its coefficients, predictor
and loop context; PCM16 big-endian; PCM8) through linear sample-rate conversion (16.16 ratio), scaled by its VE
envelope (volume + per-sample delta), low-passed if enabled, and mixed into the TV main bus by its device mix
(AXSetVoiceDeviceMix: 6 channels L R SL SR C LFE x 4 buses of {u16 volume 1.15, s16 delta}); the game sends each
voice to a single channel. Voices with no TV mix but a GamePad (DRC 0) mix play through their DRC L/R volumes. The
bus is downmixed to stereo (C and surrounds at -3 dB, LFE dropped) and queued on an SDL3 audio stream; frames are
produced whenever fewer than ~40 ms are queued, so AX time follows the audio clock. State and offsets are mirrored
into the guest structs, then the game's frame callback runs on its own guest stack.
- Not done: AUX buses / AXFX reverb (callbacks registered, never run), biquad, final-mix callbacks, 5.1 output.
--headlessorMH3U_AUDIO=0: no device, a steady 3 ms clock.MH3U_AUDIO_WAV=<file>records the stereo output.- Checked by recording the boot (music and effects at the expected times, tonal rather than noise) and by ear: intro, title music and menu effects sound right.
The game runs on a variable timestep: sMain::framePacing? (0x0294B8DC) sets sMain+0x7C to the real time since
the last frame in 1/60 s units (+0x4C = 60, +0x76 fixed-step flag = 0, its own minimum interval 60/+0x50 =
1/3000 s). Its frame rate is only bounded by the swap interval it requests (GX2SetSwapInterval(2): 30 FPS in game),
so uncapping does not speed the game up (at ~147 FPS the delta reads 0.41).
GX2SwapScanBuffers submits and presents the frame, then sleeps to the next flip time (the GPU works meanwhile):
MH3U_FPS– default in a window: the display's refresh rate; headless:game(60 Hz / swap interval, as the hardware flip).MH3U_FPS=<n>paces at n FPS,max(or 0) is unlimited.- Presenting never waits for vsync on top of that: MAILBOX, else IMMEDIATE;
MH3U_VSYNC=1forces FIFO. Two presents in flight. - The window title shows the FPS (game swaps per second), updated twice a second.
Moga Village, headless, RTX 3060 (MH3U_FPS=max): 12–16 FPS → ~155 FPS after the October 2026 work (research log);
GPU ~2 ms per frame; the game thread's remaining cost is the game's own update (~6 ms at 30 FPS) plus our draw
translation and Vulkan driver work.
MH3U_PROFILE=1samples every thread's stack at ~1 kHz and prints at exit, per thread (named: guest threads byOSThreadaddress/entry/priority): top functions (self), the functions on the stack most often (inclusive), and the blocking sites of samples outside Cha-Cha.exe. Also logs the FPS every 5 s (headless too). BuildRelWithDebInfofor names (build/clang/runtime/boot/RelWithDebInfo/Cha-Cha.exe).- The renderer's exit summary includes GPU ms/frame (timestamp queries) and MB streamed per frame over the last 600
frames, write-watch passes, texture hashes and vertex/index conversions.
MH3U_RENDER_DEBUG=16logs the write-watched texture memory. MH3U_INPUT_CLOCK=timemakesMH3U_INPUT_SCRIPTnumbers 1/60 s of wall time instead of swap counts, so scripted routes work at anyMH3U_FPS. Village benchmark on a save with a character:MH3U_INPUT_SCRIPT="1000:A;1200:A;1450:A;1650:A;1850:A;2100:A;2350:A;2600:A;2850:A"(Continue → slot 1 → Yes), in the village after ~40 s.
- Vertex data (expanded to vec4 per attribute) and rebased index lists are cached by stream layout / index buffer and reused while a hash of the raw guest bytes is unchanged; static entries live in a device-local arena (256 MB, then retired whole), entries that change on consecutive frames are streamed.
- Textures are re-hashed only when their pages were written: guest memory is reserved with
MEM_WRITE_WATCHand a background thread stamps written pages (GuestMemory::set_watched/written_since). A texture the CPU writes within ~1 ms before it is drawn shows up a frame later. - A colour target sampled with depth compare (the shadow map) is copied to its D32F alias only when written since.
- Render targets: other format/size views of the same address are kept (
spare_targets_), not reallocated. - Display-list command vectors are recycled; replay takes the GX2 state lock once per list; indirect calls use a direct-mapped table over the code range.
Each guest OSThread is a host std::thread with its own ppc::Context on the stack the game passed to
OSCreateThread (created suspended, started by OSResumeThread, joined by OSJoinThread, OSExitThread unwinds).
OSMutex (recursive), OSEvent (manual/auto reset), OSCond, OSSemaphore and OSMessageQueue block on host
primitives keyed by the guest object address. Priorities/affinity are accepted but scheduling is the host's.
- coreinit: threads, sync, time (
OSGetTimeetc., 62,156,250 ticks/s),OSGetSystemInfo,OSReport/OSPanic(guest printf), MEM expanded/frame heaps + allocators + base heaps,OSGetMemBound, foreground bucket, FS* (synchronous API onruntime/fsOverlayFs:/vol/contentupdate-over-base,/vol/save=<repo>/save), UC sys-config (English, USA), MCP product settings (USA), GHS crt locks, memcpy/memset/memmove, exit. - nn_act: one local offline account (slot 1, persistent id 0x80000001, no NNID; server calls fail).
- nn_save: save paths over FS (
/vol/save/common,/vol/save/80000001). proc_ui: always foreground. - Library static initialisers imported from system RPLs (
__sti___*) are no-ops. - vpad: GamePad connected, nothing pressed; padscore (WPAD/KPAD): no controllers.
- OSDynLoad (swkbd, erreula) → applets created but never shown (all 53 exports).
- Offline/absent: nsyshid, mic, usb_mic (no devices), nsysnet/nlibcurl/NSSL (no network), nn_ac (not connected), nn_uds (unavailable), nn_ndm (accepted), nn_acp network time, sysapp launches, h264 (unavailable for now).
- dmae copies (with endian swap), IM dimming (disabled), IPC buffer pools, OSAlarm (host alarm thread calling the guest callback on its own stack), hardware registers (read 0, every access logged once).
The game reaches its main loop and runs it. After the constructors and main it carves its heaps, starts its
threads, sets up GX2, input, applets and audio, loads its start-up data through FS (shader packages
LtUserShaderPackage.lfx and shaderArc.arc, system textures sysfont_AM_NOMIP.tex / DefaultCube_CM.tex /
HbmIcon_GSM_HQ_NOMIP.tex, the iWnn IME dictionaries, sound banks initial.scsr / initial_d.sdsr, the resident
arc/ID/ID_resi_eng.arc and arc/shell/shell.arc, jingles, quest.stqr), then every frame records display lists,
issues draws, copies the colour buffer to the scan buffers and calls GX2SwapScanBuffers. Its sound engine plays
voices. 900 frames run without a fault or a missing import; 228 distinct imports are exercised and ~400,000 GX2
calls accepted per 900 frames. Display lists are recorded and replayed, clears/copies/scan-out execute on the CPU and the window shows the TV
scan-out image. Draws run on Vulkan with the offline-translated shaders: the game's first screen (the GamePad
notice) renders correctly; with input (docs/input.md) the boot continues through the extended-data save prompt and
the intro movie (h264, below) to the title screen ("PRESS A").
The game's uMovie::Player demuxes the MP4 itself and feeds one Annex B access unit per H264DECExecute (with
OUTPUT_PER_FRAME = 1), expecting that unit's picture back through the output callback before it feeds the next.
The HLE decodes with the Windows Media Foundation H.264 decoder:
- Work memory:
H264DECMemoryRequirementreports 20 MB up to level 4.1. The game allocates it from the heap that also holds its 28 NV12 frame buffers (2048x1088x1.5 each); reporting the largest level's 180 MB exhausted that heap and the player failed in setup (FUN_02a4370c). - One picture per Execute, in decode order (what the Wii U decoder does; the game orders pictures for display by
timestamp): the SPS VUI is rewritten to
max_num_reorder_frames = 0, an access unit delimiter is appended, the decoder runs in low-latency single-thread mode, and a held B picture is pushed out with a drain. Pictures are matched to the oldest pending access unit (MF re-stamps sample times in display order). - Output: NV12 at pitch = width rounded up to 256, UV after
pitch * height, coded height (1088) with crop, in anH264DECFrameOutputrecord (result 100, timestamp, size, pitch, crop, image pointer) – layout as in Cemu. - No Media Foundation → pictures are delivered black so movies still run their course.
Method (repeatable): Cemu 2.6 in portable mode (tools/bin/Cemu/portable/, not in git) runs game/mh3u.wua; a
keyboard GamePad profile (portable/controllerProfiles/controller0.xml: Return = A, Backspace = B, arrows = d-pad)
drives it, and F12 saves the TV image at its native 1920x1080 to portable/screenshots/ (keys must be held
~0.3 s to be seen by the game). The port dumps the same screens with MH3U_DUMP_FRAME (with MH3U_GAMEPAD_VIEW=off
so the inset does not cover the image; boot prompts need an empty save/). scripts/compare-frames.ps1 prints
mean difference / PSNR / share of pixels off by > 24 and writes a difference map and a side-by-side image.
Results (2026-10-24, outputs in build/compare, not in git):
| Screen | Mean abs diff | PSNR | Pixels > 24 | Remaining difference |
|---|---|---|---|---|
| "Wii U GamePad has been detected" | 0.00 | identical | 0 % | none (bit-exact) |
| "Save data is required" Yes/No | 0.01 | 58.7 dB | 0 % | none visible |
| "Set extended data save settings" | 0.01 | 62.3 dB | 0 % | none visible |
| Title ("PRESS A") | 0.46 | 41.4 dB | 0.14 % | logo shimmer / pulsing A (animation phase) |
| Main menu | 0.10 | 43.3 dB | 0.52 % | blinking highlight of the selected item |
| Character creation | 1.24 | 30.4 dB | 1.8 % | player idle pose; UI identical, textures/shading match at 3x zoom |
| Character creation + name notice | 1.01 | 31.0 dB | 1.4 % | same |
No rendering differences beyond animation timing were found on these screens. Not compared yet: the GamePad screen (Cemu's view swap did not respond to Ctrl+Tab), the intro movie, and gameplay scenes.
- With
MH3U_TRACE=1the exit summary lists every import's call count and every guest thread: entry point, creation frame, import count, last import, and – for blocking HLE waits (mutex, cond, event, semaphore, message queue, join) – the object waited on, the guest call site and since which frame. MH3U_EXIT_AFTER_SECONDS=Nexits (with the summary) by wall clock, for runs whose frames stop.MH3U_WATCH_TARGETS=1makes the guest pages of every cached render target no-access after each frame and logs the first CPU access to each (watch: CPU read from …), to find code that would need render targets read back to guest memory (none found up to character creation).MH3U_PEEK="@<addr>:<off>,..."prints u32 fields of a guest object at exit ("<vtable>:<off>,..."scans for objects by vtable; slow in Debug).ghidra/scripts/Decompile.java <out file> <addr>...decompiles functions (output stays in build/).
__gh_FOPEN_MAX = 20; foreground bucket 40 MB; MCP product_area/game_region offsets; FSStat flag values;
FSSetUserData offset in the command block; nn_act / nn_uds failure Result value; bsp hardware version
0x26100010; MEM_BASE_HEAP_FG = 8 (confirmed by the game's own use); OSCancelThread ignored; _iob has no
backing storage (stdio HLE must treat FILE* as identity).