Files
nxnative-easy/docs/agents/03-bringup.md
T
seedds 476b1135b4 Octopath Traveler 0 boots on the Mac: Unreal pak and Oodle shader decode, the host's allocator for Unreal
Octopath Traveler 0 1.0.5 (Unreal Engine 4.26 / 4.27 mobile renderer, SDK 19.3.13) plays from boot through character
creation into the story on the Mac (15 minutes with the iPhone memory layout, no crash); not yet run on an iPhone.
- Shaders: the 10,324 are in the two shader code archives of the game's pak, each one Oodle-compressed. uepak.py reads
  the version 11 pak (AES-256 index; the key is the window of main that decrypts it, never printed or written; AES
  through CommonCrypto) and the archives; the Mac host decodes them with the game's own OodleLZ_Decompress
  (DM_OODLE_JOB, hle_oodle.c, build-gated) and shader_split.py reads the decoded LSLG records (profile key
  shader_ue_decode; the NSP script's decode_ue_shaders stage). Bindings measured: Engage's separate texture layout.
- Memory: Unreal's nninitStartup reserves a 62 GB address region and its FMallocBinned3 37 GB of it. A cut skips the
  allocator's constructor and the host puts its own FMalloc vtable on a malloc zone in the object (hle_uemalloc.c);
  the region is capped at 2 GB with the game's own -SwitchVirtualAddressSpaceSize (new profile key game_args, also
  for the iOS app) and SDK 19's node area is cut to 62 MB as Engage's, for a 3.75 GB guest block.
- Host: game arguments are set before nninitStartup, as on hardware; an undefined weak import nothing defines binds
  to 0, as rtld binds it (Tears of the Kingdom boots the same with the old and new hosts: its weak glslc* imports
  now resolve to 0); nvnCommandBufferFenceSync (OP_FENCE_SYNC), the texture / sampler pool getters,
  vi::GetLatestFrameNumber / ListFrameHistory; DM_ALIAS_WINDOWS for sizing fixed-window allocators.
- Rewriter: a byte jump table whose add sits 17 instructions before its br (jump_bytes_far, tried last).
- build_ipa.sh quotes FONT_PY (a python path with spaces failed the font step).
Open: 61 vertex shaders pick a texture slot at run time (dynamic handle), 16 compute shaders with 3D image atomics
do not build. The profile (dm/games/01005270232F2000) holds the shader tables, translation chain b-ba76ba138e78.
2026-10-20 23:01:20 +08:00

83 KiB
Raw Blame History

03 — Host bring-up

The loop

DM_RULESET=native python3 dm/tools/patch_nso.py $W/data/v0/ExeFS/main $W/build/patched_v0 main   # and sdk
make -C dm/host GAME=<title ID> dmhost
$W/run/boot.sh <log name> [seconds] [VAR=value ...]

boot.sh runs dmhost <patched dir> with DM_ROMFS, DM_SAVE, DM_TEMP, DM_MSL_DIR set, under a perl alarm (a run that hits the time limit ends with CRASH sig=14 — that is the alarm, not a crash). Logs go to run/<name>.log. Logs may contain non-UTF-8 bytes: use grep -a / LC_ALL=C when filtering.

Reading the log

Marker Meaning
>>> nnosInitialize, >>> nnMain Boot stages reached
[RED] … Something unimplemented or wrong, printed once per kind. Collect them with grep -aF "[RED]" log | sort | uniq -c after every run
UNIMPL <name> A forced NVN / SDK stub was called
[nvn] frame N: … | name=count … Per-frame NVN call counts (calls made on the frame thread; command buffers recorded elsewhere replay later)
[longframe] A long frame, with submit / GPU / pipeline / upload breakdown
memory up to frame N: resident … GPU allocated … Memory report (RSS, Metal allocated size). Printed when memory moves; steady growth here is a leak. A watchdog exits at 24 GB. Also: memory pools and how many are resident (pools join the residency set on first GPU use), finalized pool buffers still alive, the storage-sharing texture table, and on iOS headroom to the system memory limit (os_proc_available_memory, how far the app is from being killed for memory)
CRASH sig=… pc=… lr=… + registers + bt: + stack scan + thread dump Crash report: see 05
thread 0x10xx 'name' … waits:<state> addr=… Thread dump: what each guest thread waits on (address = address arbiter, mutex = mutex with holder, condvar, sync object, running/not waiting = running)

Useful switches: DM_FS=1 (file opens, and not-found probes), DM_SVC=1 (memory SVCs and other syscalls), DM_THR=1 (thread SVCs), DM_WATCH=<s> (dump all threads after s seconds), DM_WATCH_STALL=<s> (dump when the frame loop stops progressing).

Failure catalogue

Each entry: symptom → root cause → fix (where the fix lives). Check these first; most new-title failures are one of them in a new form.

Rewriter

  • jump table <addr> assertion in patch_nso.py (Unity / IL2CPP main) → the compiler used x18 for other jump-table shapes: an offset table (ldrsw x18, [xB, x18, lsl #2]; add x18, x18, xB; br x18), an indirect call (blr x18) and an alloca (mov sp, x18). The rewriter reads the offset table's targets at rewrite time (site_br_tab), calls through x16 with the original return address so C++ unwinding still finds its frames (site_blr), and restores its scratch registers relative to the old sp (site_sp). Engage main: 7 jump tables, 1 call, 1 sp write; Mario's output stays byte-identical.

  • jump table <addr> assertion in a subsdk (Link's Awakening's GLSLC and movie modules) → the offset-table form ldrsw x18, [xB, xI, lsl #2]; add x18, x18, xB; br x18 with the bounds check on another register: the index in w0 (sub w0, w18, #0xd1; cmp w0, #0x31), a copy (cmp w21, #7; …; mov w18, w21) or a masked copy (and w16, w18, #0x3f; cmp w16, #0xe; …; and x18, x18, #0x3f). jump_offsets follows mov copies back to the cmp and accepts a check on the same and of the same, unchanged source. Engage's output stayed byte-identical. The entry may also be loaded into another register, with only the add writing x18 (Cuphead, Unity 2017.4: cmp w17, #7; ldrsw x17, [x15, x17, lsl #2]; add x18, x17, x15).

  • jump table <addr> assertion on a br x18 with no table in front of it (Ace Attorney Investigations Collection, Unity 2022.3 IL2CPP: main+0x1d271bc and +0x204ebec) → not a jump table but an indirect tail call: an IL2CPP thunk loads the method pointer with ldr x18, [x0, #0x18], moves its stack arguments with every other scratch register (ldr x16, [sp], ldp x17, x15, [sp, #8], …) and ends with br x18. When neither table form is found, the last write of x18 is a plain load and the br ends the function, it becomes x16 = guest x18; br x16 (tail_call / site_br_call; at a call x16 / x17 are free, as for blr x18). It is tried only where the rewriter used to stop, so every module it accepted before comes out byte-identical.

  • Job system hangs; a queue claims thousands of tasks but every slot is empty → in an ldxr … cmp … b.ne out … stxr … cbnz window, the rewritten stub emitted its exits in the wrong order, so successful stores took the failure path → emit the fall-through exit first (patch_nso.py). D2R had no such windows; Mario had 82.

  • branch into window <addr> assertion (The Legend of Zelda: Tears of the Kingdom 1.4.3 main, 8 sites) → the compiler moved an atomic update's retry path out of line: the failed stxr branches to a block ldxr; cmp; b.eq <the stxr> that re-enters the window at its store. That block cannot jump into the window's patch copy (the x18 stand-in would be loaded between its exclusive load and the store), so it gets a copy of its own: the retry block and the window's tail from the store on, under one prologue (retry_block / site_retry_window). A window also never spans a second exclusive load: before this rule, a retry block's ldxr opened a window that ran on into the next, unrelated ldxr … stxr loop (main+0x5a4674), and branches to that loop's head landed mid-window.

  • jump table <addr> assertion, entries relative to an adr (Tears of the Kingdom main+0x393bb0 and others) → cmp wI, #N; b.hi; …; adr x18, #L; ldrsw xE, [xT, xI, lsl #2]; add x18, x18, xE; br x18: target = L + entry, not table + entry (jump_adr_offsets, tried only where the table-relative form is not found). The bounds check can be on a register copied into the index (mov w16, w15), and the table base can be built far from the dispatch; then every adrp + add pair in the function that builds that register is tried, and a table is accepted only if exactly one makes every target an instruction of the function.

  • jump table <addr> assertion on a byte table indexed by x30 (Hyrule Warriors: Age of Calamity main+0x138216c: adr x18, #L; ldrb w30, [x14, x12]; add x18, x18, x30, lsl #2; br x18) → capstone names x30 lr (and x29 fp), so the index register looked like no register and the ldrb never matched. The byte / halfword table check maps those names back to 30 / 29.

  • jump table <addr> assertion on a byte table whose add is far from the br (Octopath Traveler 0 main+0x37f9bc8: adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves before br x18) → the byte / halfword search looked 16 instructions back. jump_bytes_far looks 32 back, requires that nothing writes x18 between the add and the br, and runs only when every other form has failed, so sites that rewrote before come out the same. One of the 12 br x18 sites in this main; a census of all of them first (each shape within 16 / 32 / 48 instructions) saves a 12-minute rewrite per site.

  • Checking a rewriter change: rewrite every extracted module of every title with the old and the new patch_nso.py and compare all outputs; only build_src.sha256 (the rewriter's own hash) may differ for titles the change is not meant for.

  • SIGILL / trap on a system register read → newer SDK reads a register macOS traps (CTR_EL0 in SDK 16's FlushDataCache) → rewrite to a constant.

  • B() range assertion in patch_nso.py (site_plain), only for one module (Metroid Dread main) → its bss is 1.8 GB, the engine's static heap, so a patch area appended after the image starts 1.9 GB from the code and no B (±128 MB) reaches it. When an appended patch area would start beyond 112 MB, the rewriter puts it 32 MB before the module base (negative PATCH_OFF). patched.bin then holds only text + ro + data (FILE_IMAGE_LEN) followed by the patch bytes, and the loader maps the bss and the key page as zero pages (IMAGE_SIZE). The Mac loader, the iOS embedding (gen_embed.py: no bss in the executable) and QueryMemory handle both layouts; Mario's and Engage's output is byte-identical to before.

  • B() range assertion, then patch area of … bytes does not fit in the … before the base (Monster Hunter Rise main: 125.3 MB of text) → with the patch area 32 MB before the base, sites near the end of the text are more than 128 MB away. When the text ends within 32 MB of the reach (BIG_TEXT), the patch area goes right before the base: the stubs follow the sites in address order, so each site's stub is about as far from it as the text is long. The first pass tries the gap that keeps every site in reach (2.6 MB here); when the patch area is larger (3.6 MB, 34,322 sites), the rewriter runs itself again with exactly that size (DM_PATCH_FRONT, set only by the rerun) and B() checks every branch.

  • KeyError at site_br for a br x18 that is an ordinary byte jump table (Monster Hunter Rise main+0x6b346d0) → capstone stops at the first word it cannot decode, and the function held a trap word (0xe7ffdefe, main+0x6b34540) before the jump table, so the table was never analysed. Function disassembly now goes on after undecodable words (disasm_fn).

  • Every pointer in .data / the GOT is a module offset, crashes everywhere (SDK 20: The Legend of Zelda: Breath of the Wild 1.9.0) → the modules pack their relative relocations as DT_RELR (tag 36, size tag 35): main has 223,994 of them and its DT_RELA only symbol relocations. Each 8-byte entry is either an even offset (relocate it, move to the next slot) or an odd bitmap whose bits 1–63 stand for the following 63 slots; the addend is the slot's own value (relr_apply in loader.c; DM_LOADHASH and nvn_slots_got.py read it too). The loader's log line counts them as relr=.

Memory and address space

  • StandardAllocator::Initialize aborts in nninitStartup → the NPDM declares a system resource (offset 0x14 of META), so IsVirtualAddressMemoryEnabled is true and the allocator reserves all free memory (about 3.2 GB for Engage) as one region in the alias region, after the ~1 GB node area → the profile's mem_alias / mem_reserve must hold it (Engage: 5.5 / 8 GB on the Mac).
  • C++ exception aborts in nn::ro::detail::GetExceptionInfo → the SDK's unwinder finds a module from svcQueryMemory: it walks back over Code+RX regions to the module start, forward over Code then CodeData, and reads MOD0 for .eh_frame_hdr. The host must report each loaded module's real layout and report other host memory as free (hle_svc.c). IL2CPP throws a C++ exception for every managed exception, so Unity games need this; Mario never threw.
  • First AllocateAddressRegion fails with 0x1803 → SDK 16's AddressRegionManager takes 0x3fe00000 at the start of the alias region as a node area → the alias region must be larger than 1 GB.
  • The game calls exit(1) in nninitStartup (Octopath Traveler 0, Unreal Engine: main+0x12e74) → Unreal's nninitStartup needs virtual address memory and reserves one address region for the engine with AllocateAddressRegion, 62 GB by default (-SwitchVirtualAddressSpaceSize= in MB, or in bytes above 4095; the other options it reads are -SwitchMaxMemorySize=, -NVNCommandBufferGranularity=, -NVNControlBufferSize=, -SwitchMainThreadStackSize=, -NVNTempMemoryPageSize=, -ResX= / -ResY= and -SwitchDisableVirtualAllocator, which also exits). It reads them from the game arguments, which the host used to set only after nninitStartup; they are set before it now, as on hardware, and the profile key game_args supplies them on iOS. Its allocator, an FMallocBinned3, then reserves 37 GB of that region (74 pools of 512 MB, pool index = offset >> 29; the null read in the out-of-memory report at main+0x1005824 is the symptom when the region is smaller). The pools are uneven (at character creation the largest holds 68 MB, the 74 together 732 MB; measured with DM_ALIAS_WINDOWS=512), so no smaller window fits a phone's ~5 GB. The constructor call is cut (patch_nso.py) and the host puts its own allocator in the object (hle_uemalloc.c: the FMalloc vtable on a host malloc zone, the trivial slots kept). With it the region holds only what the engine maps itself (709 MB at character creation) and is capped at 2 GB; SDK 19's AddressRegionManager node area is cut to 62 MB as Engage's (sdk+0x1423fc / +0x142468), so the guest block is 3.75 GB.
  • A null-based write minutes into play, at the pool size minus 0x20 (Engage: addr=0x1fffe0 at main+0x9a4664) → Unity's TLSF heap added a pool, and its low-level virtual allocator (vtable at main+0x61ffeb0; reserve main+0x9aa0f4 → nn::os::AllocateAddressRegion) got no address region and returned 0. The alias region ran out of address space, not memory: the AddressRegionManager aligns each region by its size (64 KB under 2 MB, 2 MB under 4 MB, 4 MB under 32 MB, 32 MB under 1 GB, else 1 GB) and Unity asks for its block plus alignment slack, so a 2.25 MB block takes a 4 MB slot and a 16.25 MB block a 20 MB one. At the crash Engage held 4.17 of the 4.375 GB left after the node area (StandardAllocator 3.14 GB, Unity about 1.05 GB, the rest fragments). On hardware the alias region is 64 GB. → shrink the node area with a rewriter cut for that SDK build (patch_nso.py, Engage: the two mov w8/w9, #0x3fe00000 in the constructor → 62 MB). To measure, probe the SDK's VammManager::AllocateAddressRegion at its success path (ReleaseWriteLock return: x21 = size, x22 = address) and FreeAddressRegion after its prologue (x1 = address), and sum the live regions by caller.
  • Sizing the reservation for a title that uses virtual address memory (Engage) → such a title may never call SetHeapSize: everything comes from MapPhysicalMemory in the alias region. The [mem] log lines report the alias high-water mark (one line per 256 MB), the total actually mapped (page bitmap) and every heap growth. What fills the alias region first is address space, not memory: StandardAllocator reserves the whole free pool (pool − 64 MB, 1 GB aligned) but Engage commits only 0.58 GB of it, and Unity's own regions commit 0.97 GB. So Engage's profile reports a 2.5 GB pool (app_pool) and uses a 4.375 GB alias region in a 5 GB block (reserved address space peaks at 3.47 GB of 4.31 GB). iOS without the extended-virtual-addressing entitlement allows a single 6 GB reservation, but Metal needs address space too; 5 GB is the total proven on a phone. To measure commits, probe VammManager::AllocateMemoryPages / FreeMemoryPages (x1 address, x2 size) and split them by region.
  • StandardAllocator::Initialize aborts in nninitStartup on SDK 17, then the alias region fills in minutes (Ace Attorney Investigations Collection, Unity 2022.3, SDK 17.5.3) → the same node area as Engage's SDK: CentralHeap::Init(nullptr, pool − used) got no 1 GB-aligned region for 3.14 GB after the 1 GB node area. The constructor is at sdk+0x139458 on that build and takes the same cut. With Engage's 2.5 GB pool the game then ran, but probes on VammManager::AllocateAddressRegion's success path (sdk+0x1395a8: x21 size, x22 address) and FreeAddressRegion (sdk+0x139df0) showed StandardAllocator's 2.44 GB reservation beside Unity's pools (1.06 GB live, from main+0x40f1d4) ending 4.37 GB into the 4.375 GB alias region after 140 s, while the MapPhysicalMemory log showed StandardAllocator committing 0.19 GB. The profile reports a 1 GB pool, a little less than the 1.02 GB the game maps at its peak (the host does not enforce the pool, and only nninitStartup reads it); ten minutes then peak at +2.85 GB of address space, against +3.91 GB with a 1.5 GB pool.
  • Large resources silently skipped, later a null deref where the resource is used → the game reserves "virtual address memory" (Mario: 1.75 GB in HeapDef.byml) and maps it into the alias region with MapPhysicalMemory on demand; the alias region left after the node area was too small. Also visible as SetHeapSize asking for much less than the pool. → repartition the guest block (hle_svc.c mem_init): Mario uses alias 2.875 GB, stacks 256 MB, heap 1.625 GB (it asks for 1.47 GB), 5 GB in total. Measure before sizing (DM_SVC=1: the SetHeapSize request and the highest MapPhysicalMemory address). On iOS without the extended-virtual-addressing entitlement a single 6 GB reservation succeeds and 7 GB fails, but Metal and the rest of the process need address space too; 5 GB is the total proven on a phone (D2R).
  • MapPhysicalMemory faults on part of a page → round to 16 KB host pages.
  • nnMain returns right after nv::InitializeGraphics, with no error (Hyrule Warriors: Age of Calamity) → the game's nninitStartup sizes its heap as total − used memory (QueryMemoryInfo), calls SetMemoryHeapSize and hands the block to its own malloc. The host reports 64 MB used beside the heap, but SetHeapSize checks a request against the real size of the loaded modules (66.6 MB here), so SetHeapSize(pool − 64 MB) failed with ResultLimitReached, AllocateMemoryBlock returned 0x1003, malloc was never set up, the 866 MB graphics heap allocation came back null and nnMain returned. Probe DM_SVC=1's svc 0x1 request and the AllocateMemoryBlock result. The profile key mem_used sets the used-memory figure (72 MB for this game: the image plus stacks). Monster Hunter Rise's modules take 320 MB (main alone 314 MB), so its SetHeapSize(pool − 64 MB − bss) failed the same way ([mem] SetHeapSize … + modules … > app pool); 328 MB gives a 2.88 GB heap.
  • SetHeapSize asks for almost the whole application pool and fails at boot (Metroid Dread: 0xc9400000, larger than the heap region) → the SDK sizes the heap as total − used memory, and the host reported "used" as heap + 64 MB. That 64 MB covers ordinary modules (Mario's two are about 74 MB, and its heap request matches hardware), but Dread's main carries a 1.8 GB bss that the Switch counts as used. Used memory now adds the bss of modules in the front-patch layout (big_bss_bytes in hle_svc.c); Dread then asks for 1.38 GB.
  • Some textures are re-uploaded every frame (several MB per frame) → the dirty-page tracker registers each memory pool rounded to 16 KB host pages, but NVN pools are only 4 KB aligned, so neighbouring pools share their first / last host page and one address can lie in several registered ranges. Taking the first range that contains an address judged textures in the second pool untracked (always dirty), released only one range on a write fault, and could unregister the neighbour when a pool was destroyed. nvn_dirty.c now uses the range that holds the whole span, releases every range covering a faulting page, and removes a range by the pool's exact start (t_dirty_slots covers it).
  • A null write as soon as the main thread starts recording a command buffer (13 Sentinels: a constructor at main+0x2fa9a0 runs on this = 0 when the prologue enters its first battle) → the object came from one of the engine's ring heaps (main+0x2d83ec), and that heap had base 0. At startup the engine takes one GPU memory pool, sized as exactly the sum of three ring heaps, and cuts the heaps out at 64 KB alignment relative to the pool's GPU address (main+0x30c118). On the Switch a pool's GPU address is big-page aligned; the host's GPU address is the CPU address, and the storage came from the game's malloc rounded only to 4 KB. Up to 60 KB was lost at the front, the third heap (16 MB) did not fit, and the sub-allocator returned 0. The game's malloc is nn::mem::StandardAllocator::Allocate(size); on this build the host routes allocations of 64 KB or more to Allocate(size, 0x10000) (hle_os.c). Probing the heap object at the allocation (DM_PROBE=main:2bec60@x0+8) showed the zero base directly.
  • SIGBUS inside a host hook (snprintf under mount_ex / h_fs_MountSave), then the process hangs (Super Mario 3D World + Bowser's Fury) → the game's save thread has an 8 KB stack (CreateThread stack top 0x…9fe0, fault 0x21a0 below it). Host hooks run on the calling guest thread's stack, and the file system hooks size their path buffers by PATH_MAX: one file open takes 6–7 KB (-fstack-usage: OpenFile 1.3 KB → host_path_ex 1.9 KB → path_component 3.2 KB); on hardware the same call is one IPC. Every hook in hle_fs.c now registers through HLE_FORCE_BIG (hle.h): a compile-time stub loads the implementation into x16 and jumps to dm_bigstack_enter (hle_bigstack.c), which moves the call to a per-thread 512 KB host stack (with a guard page) when sp lies in the guest address block, passing x0–x8 and d0–d7 through. Only for functions with register arguments only (no variadic or stack arguments). iOS forbids generating code at run time, so the stubs are static.
  • SIGBUS inside Metal (AGCDeserializedReply, an AGX compiler block) at an address in the guest stack region (Breath of the Wild) → a host NVN call did Metal work on a game thread with a small stack: nvnQueueSubmitCommands replays the frame, and ending a render pass made the driver compile its fast-clear blit program on the calling thread. NVN functions are not registered through HLE_FORCE, so they got their own big-stack entry: every implemented NVN function except the nvnCommandBuffer* recording calls goes through nvn_big_entry (nvn_entry.S), which finds the implementation by slot and hands it to dm_bigstack_enter.
  • Write through a null pointer at +0x1c0 during startup → GetInfo(UserExceptionContextAddress) must return a real buffer.
  • Heap allocations fail late in a busy scene → the reported application pool (3285 MB, APP_POOL) and used memory decide the game's heap size; report Switch-accurate numbers.

Threads

  • Per-core job queues never drained → a SetThreadCoreMask that pins a thread to other cores (ideal core −3 = keep) must move the ideal core into the new mask, as Horizon does, so GetCurrentProcessorNumber reports a core the thread can run on.

  • Garbage-collected titles (Unity / IL2CPP): SetThreadActivity (0x32) and GetThreadContext3 (0x33) must really pause and report registers → the Boehm collector pauses each thread, reads its registers for roots and scans its stack. As placeholders that only reported success, threads kept running and the context was empty: roots held only in registers were missed and live objects were freed, with their finalizers releasing native resources. In hle_thread.c a thread inside an SVC counts as paused at once, with the gate frame as its context (thr_svc_enter / thr_svc_leave around every gate SVC; it waits at the SVC exit while paused). A thread in guest code gets SIGUSR2, and the handler saves the ucontext and waits there. A thread in host code is retried every 100 µs. The context is Horizon's 0x320-byte ThreadContext.

  • SignalToAddress type 2 (SignalAndModifyByWaitingCountIfEqual) with count ≤ 0 and waiters present → the new value is value − 2, not value − 1 (mesosphere KAddressArbiter; Ryujinx and yuzu agree). The host had −1. Fixing it did not change Engage's demo crash (that was the address-space shortage above), but it is the specified behaviour.

  • Several threads crash at once and the dumps interleave, then the process hangs instead of exiting → Unity's job threads tend to crash together. crash.c lets only the first crashing thread print (crash_gate); the others stop in pause(), and a 15 s alarm ends the process if the dump itself stalls.

  • Crash indexing a per-core table with a huge offset (index −2) → threads created with ideal core −2 (use process default) were recorded as core −2 and GetCurrentProcessorNumber returned −2 → resolve −2 to the process default core (0) and never return a negative core (hle_thread.c). Seen in Mario's font engine (main+0x107dbc0).

  • A 30 Hz game thread runs at 27 Hz, and whatever it paces comes out slow (Metroid Dread: cutscenes at 36 ms per frame instead of 33.3; gameplay was fine at 60) → macOS timer slack. nanosleep and mach_wait_until overshoot by about 10% at any QoS (measured: 33 ms → 36.4 ms, 5 ms → 6.1 ms, 1 ms → 1.26 ms). Dread's sound thread (main+0xf95b0) reads a millisecond clock, updates, then sleeps "33 ms minus this round's work", so on the host its period was 36.9 ms. The cutscene loop renders a frame whenever the audio play position, which that thread refreshes, moves (main+0x119a3d0; the position is played samples ÷ sample rate of the cutscene's stream), so cutscenes ran at about 27 fps. SleepThread now sleeps three quarters of the remaining time per step until within 20 µs (sleep_precise in hle_thread.c): cutscenes at 33.3 ms, the sound thread at 33.2 ms. Mario's frame-time p95 also went from 19.7 to 18.4 ms. Timed waits (WaitSynchronization, condition variables) still use the host's own timeouts.

  • Crashes around movie changes in the game's own player threads, about one run in two (Metroid Dread: SignalEvent → MultiWaitImpl::NotifyAndWakeupThread on a null MultiWait at address 0x20; WaitAny walking freed holders; a worker reading a freed object at main+0x225338) → the game relies on Horizon's per-core preemption. Its main thread (priority 44, core 0) posts "unlink these holders" to a dispatcher thread and leaves messages for a worker thread (both priority 30, only core 0), then frees the object and builds the next one in the same memory without waiting. On hardware the two threads preempt the main thread as soon as it signals them, so their work is done first; on the host all threads run in parallel. Two fixes, both needed (with only the second, 3 of 6 runs still crashed; with both, 12 of 12 were clean):

    • an observation gate after each request the game posts (patch_nso.py OBS 19 / 20, this build only) waits until the dispatcher's request ring is empty (movie_ring_wait in hle_movie.c). Gates may now sit right after a bl as well as at function entries;
    • an approximation of same-core preemption (svc_thread / dm_yield_to_core_peers in hle_thread.c): after a thread SVC, the caller waits (at most 5 ms) while a higher-priority thread pinned to its core is running guest code. "Running" means the Mach thread state is running, the thread is not in a guest wait or timed sleep, and it is not doing host NVN submit / present work (dm_host_work). It is a profile key, core_preempt, on only for Dread: Mario's loader threads would wait for busy decompression threads, and its first frame moved from 0.6 s to 2.9 s.
  • PhysX worker crashes in a task's removeReference with a null task manager, right as the game switches to 30 fps (Link's Awakening: pc=main+0x10ccf28, lr=main+0x13c236c; on the iPhone and in 2 of 5 Mac runs, in every Mac run with 12 busy loops loading the CPU) → a destructor that runs a moment too late. The game rebuilds each physics world's completion task in place before every simulate (main+0x15730, main thread). A worker runs the task (fetchResults, sets the world's done flag, notifies, unlocks) and then calls its release, which calls the task's own destructor (vtable back to the PxLightCpuTask base, mTm = 0). At 30 fps the game steps twice per frame, so the unlock wakes a main thread that steps again at once. On the Switch the release follows the unlock within nanoseconds; on Apple devices the worker can be descheduled between them, the main thread rebuilds the task, and the late destructor wipes the new one. The next simulation's NpSceneCompletion::release then dereferences mTm. A per-build cut makes that one release a ret (patch_nso.py); the destructor frees nothing and the step rewrites every field. How it was found is in 05.

  • A guest thread keeps a core at 100% with billions of SleepThread calls (Super Mario Galaxy: Hagi polls with SleepThread(100 ns), main+0x517c; 1.4 billion calls in 200 s, and the render thread nearly as busy) → sleep_precise returns at once for anything under 20 µs (its nanosleep would overshoot), so such a loop never sleeps. Sleeps under 20 µs now take the yield path and its back-off (plain yields for the first 2 ms of a run, then short sleeps): the two threads went from 199 s and 171 s of CPU per 200 s to 5 s and 19 s per 120 s, at the same frame rate.

Services and SDK

  • sm::Initialize aborts right after ConnectToNamedPort (SVC 0x1F) and SendSyncRequest (0x21) (Super Mario Galaxy, SDK 18) → an SDK call the host does not hook opened its service: here nn::hid::InitializeTouchScreen(int, int), the SDK 18 overload of the hooked InitializeTouchScreen(). Symbolize the abort backtrace against the SDK's exports to find the caller, and hook that name.

  • A library's free rejects a pointer (CentralHeap::Free → NotNMallocPtrError) → the game's main defines its own operator new / delete (Unity's memory manager) and sdk has a copy forwarding to malloc / free. Symbols are resolved globally in load order (the first module's definition wins, also for a module's references to its own exports); only local / non-default-visibility symbols bind to the module itself (loader.c). The loader logs how many references bind to an earlier module.

  • IL2CPP "Could not open …/global-metadata.dat" (then a crash in its logger) → IL2CPP checks nn::fs::IsMounted("rom") before opening; the SDK's own IsMounted reads a mount table the host never fills → answer from the host's mount table (hle_fs.c).

  • Crash in nn::ro::InitializeWithPortName → nn::ro connects to ldr:ro; take over Initialize and answer LookupSymbol from the loaded modules (hle_ro.c). Check the mangled names against the new title's imports: SDK 19's LoadModule takes one more bool (_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmib, Hades II), so the hook for the older form did not catch it and the SDK's own code would have run.

  • SDK 12 names → older SDKs export other names for the same calls (ListAudioDeviceName / GetActiveAudioDeviceName, singular hid::GetNpadState, OpenDefaultAudioOut); hook both generations.

  • SDK 11 names (Metroid Dread) → fs::CommitSaveData(mount) is the older name of fs::Commit; fs::DeleteDirectory (an empty directory) was missing too.

  • More SDK 11 calls that reach a service (13 Sentinels) → hid::GetMouseState (the first boot aborted in it: the SDK's mouse resource was never initialized), fs::CleanDirectoryRecursively, fs::EnableGlobalFileDataCache, oe::SetUserInactivityDetectionTimeExtendedUnsafe, time::ToPosixTime (host mktime) and audio::GetAudioOutName. Finding them before the first boot: list main's nn:: imports that no host hook names (the survey's import table, filtered against HLE_FORCE names); most nn::os / nn::mem imports run natively in sdk and need nothing.

  • SDK 11 calls of Monster Hunter Rise (one boot each; the import list against the hooks and the other SDK 11 games found most of them before the first boot): oe::IsCpuOverclockEnabled (a null read in sdk+0x35258, apm's session, the first call in nnMain), album::Initialize (abort in sm::Initialize under capsrv::InitializeForApplication; saving to the album now fails, hle_offline.c), oe::GetDefaultDisplayResolution and its change event, oe::SendServerMaintenanceOverlayNotification, time::LoadTimeZoneRule and the ToCalendarTime overload with a rule (the host's zone is used), time::StandardNetworkSystemClock::IsAccuracySufficient (offline: no), hid::GetKeyboardStates, fs::GetSaveDataSize / ExtendSaveData and the one-argument fs::MountCacheStorage. nvnDeviceGetTexelFetchHandle returns the texture ID with sampler 0.

  • Texture creation returns null right after the inline keyboard's Initialize, then a null read at main+0x7713044 (Monster Hunter Rise) → the game makes three textures of swkbd::InlineKeyboard::GetWindowSize, and the host answered 0 × 0 (its creation function returns null for a size of 0, main+0x77112b8). It is 1280 × 720 as on hardware now. SDK 11 adds keyboard setters (window mode, key top alpha / scale / position, touch, hardware keyboard, user words, dictionaries; ignored) and two callbacks the applet makes from Calc: finished-initialize after Initialize and released-user-words after UnsetUserWordInfo. The host makes them on the next Calc.

  • A null-pointer crash early in nnMain, right after NVN setup should have happened (Super Mario 3D World + Bowser's Fury, main+0x759e08: the device singleton main+0x1a28ea0 is still null) → the game checks nvnDeviceGetInteger(API_MAJOR / API_MINOR) for major == 53 and minor ≥ 313 (main+0x711c88) and quietly skips creating its device otherwise. The reported version is the profile key device_api (default 55.15, Mario's requirement). NVN 53 also brings nvnQueueAcquireTexture (the older window acquire: the queue waits instead of a sync object), nvnSamplerCompare and the debug group calls (no-ops).

  • Crash after the shared fonts load, looking up the "?" glyph (main+0x6c73c0, a font cache lookup returning null) (3D World) → SDK 10's ScalableFontEngine::LoadFont failed with error 403 and SetFont with 301 on both stand-in fonts. This game records shared fonts as type 0 (main+0x975658), calls no ScalableFontEngineHelper::Decode and hands the GetSharedFontAddress pointer straight to LoadFont, so the pointer must be the plain TTF; Mario decodes the same pointer and needs the 8-byte BFTTF header in front. The profile key shared_font (bfttf default, ttf for 3D World) chooses what the address points at (hle_sdk16.c). Found by probing the engine's error code after each call (DM_PROBE on the instruction after GetError).

  • More SDK 10 calls that reach a service (3D World): settings::LanguageCode::Make and the reversed operator==(LanguageCode, Language) (the first comparison in nnMain aborted in sm), account::ListAllUsers, oe::GetPerformanceConfiguration, oe::SetAlbumImageOrientation, the nifm IP queries with SDK 10's socket::InAddr / in_addr mangling, prepo::AddSessionId / AddInternetConnectionStatus (entering the save select), and vi::GetIndirectImageMemoryRequirement.

  • Null read in applet::CreateLibraryApplet under swkbd::InlineKeyboard::Initialize (3D World, entering the save select after the character select) → the inline keyboard is a resident library applet whose results arrive through Calc. hle_swkbd.c takes over every entry that touches the applet: Appear arms a request, and the next Calc asks for the text the way ShowKeyboard does (DM_KBD, else the front end's input box) and hands it to the game's changed-string and enter callbacks, or calls its cancel callback; GetImage reports no picture. GetImageMemoryRequirement is not a pure computation: it asks vi, hence the vi hook above.

  • The hunter's name box stays empty, then stays open with the name in it (Monster Hunter Rise, character creation) → the inline keyboard used to count every Appear as a cancel. Now the text arrives through the enter callback, whose argument is the text inline (char16_t[502], the length at +0x3EC; the changed-string argument adds three cursor positions at +0x3F0..+0x3F8: ikb_str_arg, read off the game's callbacks at main+0x6fb3910 / main+0x6fb3afc). The game then finishes the entry only when Calc returns 1 (ready / hidden) after a non-zero value (main+0x6fb3e6c); the hook returned nothing. Calc now returns 0 until initialization finishes, 3 while shown and 1 otherwise. SetFinishedKeyboardCallback is called on the Calc after Disappear. AppearArg's textMaxLength (+0x20) limits the text.

  • CRASH sig=10 at a heap address, called from main+0x6bca160 right after the name was entered (Monster Hunter Rise) → nn::ngc::ProfanityFilter is polymorphic and the game masks the name through its vtable (slot 8, MaskProfanityWordsInText(int *, char16_t *, uint32_t)). The constructor hook did nothing, so the object kept what the heap held. It now gets a host vtable (ngc_vtable in hle_misc.c): each slot of the SDK's _ZTVN2nn3ngc15ProfanityFilterE is matched by address with the class's methods, and slots the loader already bound to forced take-overs keep them. Nothing is masked or reported.

  • Abort in nn::sm::Initialize under nn::oe::FinishStartupLogo (Metroid Dread, right after the first frames) → it reaches the applet proxy through am::GetApplicationFunctions. The host has no startup logo: a no-op hook (hle_oe.c).

  • Abort in nn::sm::Initialize under nn::hid::VibrationNode::Update (Metroid Dread) → the SDK's vibration graph flushes queued values with hid::detail::SendVibrationValues, which opens the hid service. The game sets handles on its VibrationTargets even when the host reports no vibration device. The flush calls it through the PLT, so an HLE_FORCE on that symbol catches it and returns success (hle_hid.c).

  • An Atk task aborts in a switch default (UnexpectedDefaultImpl, then AbortImpl) while a stream sound loads (Metroid Dread, main+0x1225a8c) → the stream path was rom:/system/snd/../../sounds/streams/…, and the host answered "not found" for any .. component. Horizon's path normalizer resolves . and .. lexically and fails only above the mount root; host_path_ex now does the same (norm_rest in hle_fs.c).

  • Abort in NVIDIA's video path (NvRmChannelOpen → NvOsDrvOpen) → the SDK movie library's hardware decoder opens nvdrv directly. The host takes over movie::Decoder (hle_movie.c, next entry); nvdrv opens fail (hle_nvdrv.c).

  • The game waits forever at a movie (Engage's 94 s opening S01F.webm; frames keep presenting, nothing happens) → Unity demuxes the WebM itself and feeds VP9 packets to movie::Decoder; with no working decoder the movie never ends. movie::Decoder is a thin wrapper over Android's MediaCodec, and its vtable entries are symbol relocations, so HLE_FORCE on each method also catches Unity's virtual calls. The host implements the protocol: four nn::os events (DecoderEvents = input available, output ready, format changed, error), input indices (AcquireInputBufferIndex, none = 0x1011; GetInputBuffer fills the client's movie::Buffer; SendInputBufferForDecode(index, offset, size, µs, flags)), output indices (AcquireOutputBufferIndex, none = 0x100e; one NV12 frame per packet copied into the buffer Unity allocated, w × h × 1.5; ReleaseOutputBufferIndex). While every output slot is taken, packets wait and their input indices are not returned, as with MediaCodec; dropping them instead lost frames. Buffer, MediaData and events are handled through the SDK's own exported functions (SetDataAndCapacity, SetRange, SetInt32, SetRect, SignalEvent). Unity reads width, height, crop, nv12-y-stride, nv12-y-offset, nv12-uv-offset and slice-height. VP9 packets are decoded with libvpx (dm/third_party/libvpx) on one host thread per decoder: guest threads run on the stacks the SDK gives them, too small for libvpx. The guest thread that sends a packet or returns an output index waits for the decode and signals the events itself, because SignalEvent is guest code and cannot run on a host thread. Packets are decoded in arrival order from one queue. When the movie ends the game continues to its title screen.

  • Abort with no message at start-up in a service call the host has no hook for (Super Mario Odyssey, SDK 7: account::OpenPreselectedUser, then bcat::Initialize, then audio::AcquireAudioDeviceSwitchNotification; a null read in account::GetLastOpenedUser) → the SDK's IPC to the service fails and it calls AbortImpl (Break from sdk+… with an nn::diag backtrace). Resolve the frames with sdis.py --sym on the decompressed module, list the game's imports of that service the host does not hook (the survey's import table against the HLE_FORCE names), and add the ones that talk to a service; library helpers that only fill the game's own buffers need nothing. Look at how the game uses the result before choosing it (bcat mount failure is the game's own "nothing delivered" path).

  • Buttons and menus work, the sticks do nothing in play (Super Mario Odyssey: its first hint, "Look Around", waited forever) → the game reads all 16 entries of GetNpadStates and ignores the returned count; the host filled only the newest. It polls every controller slot each frame, which moves the shared sampling number on by about 16, so the stick samples it combined were mostly empty, while buttons and the stick's direction bits (which menus use) still came through. The host now fills every requested entry with the current state under consecutive sampling numbers. DM_KEYS takes RLeft / RUp / RRight / RDown for the right stick.

  • Movies through the SDK's own demuxer and a decoder in native-texture mode (Metroid Dread):

    • movie::Extractor (libwebm in subsdk0) runs natively. The host used to force SetDataSource to fail because Engage's Unity demuxes by itself, so the game crashed on a null right after the failed open.
    • The decoder is created with output format 3 and mode 2 (DecoderMode_NativeTexture). The client's movie::Buffer then carries the NVNtexture object itself as its data, 8 bytes of capacity (an RGBX8_SRGB texture embedded in the game's texture pool entry, main+0x226c98), plus SetNvnDev / SetNvnSync. The host converts the VP9 picture to RGBA (BT.709 / BT.601 and range from the stream) and writes it into the texture's storage like WriteTexels, then marks the sync as signalled (nvn_host_write_texture / nvn_host_signal_sync in hle_nvn.c).
    • The game calls the complete-object destructor movie::Decoder::~Decoder (D1), which the host now takes over too; the SDK's own one destroyed the host object as a DecoderImpl.
    • RegisterClientEvents copies the four event pointers: the client's struct does not stay alive.
    • As with MediaCodec, Flush announces nothing (the game ends a movie with Flush, Flush, Stop, destroy, and calls Start after a Flush when it loops), and no input or output index is handed out while the decoder is stopped.
    • The "input available" after Start is signalled about 2 ms later from a guest thread, as the SDK's codec thread would. Pending signals are dropped at Stop, Flush and destruction.
  • Null read in sdk under nn::applet::CreateLibraryApplet (Engage: title menu → DLC → nn::ec::ShowShopAddOnContentList) → every Show… call that opens a system applet (eShop, friend lists, web pages, error screens) goes through the applet proxy that oe::Initialize would have set up, and the host has none. Hook each Show… overload the game imports: list main's undefined symbols containing Show / Applet and grep the host sources for each mangled name; one overload missing is enough to crash. The shop calls return void and act as if the player closed the shop (hle_misc.c); the friends and web ones return failure (hle_friends.c, hle_offline.c).

  • A long black screen at boot with nothing in the log (Engage: about 17 s on the Mac, 32 s on an iPhone) → sample the process (sample <pid> 3). If the top of stack is getxattr under path_component, the game is walking RomFS, and every path component scanned its whole parent directory. That scan maps the game's case-sensitive names onto APFS, so it calls getxattr on each entry, and Engage has directories of 38,000 files. Read-only mounts now use a per-directory name index built at the first lookup (dir_index_get in hle_fs.c), which also speeds up every file Wwise opens during play. The first frame then came at 5.7 s on the Mac. The rest was the listing itself: ReadDirectory called stat, getxattr and a failing fopen("<file>.holes") for every entry. Now only .dm-case-* names (the only ones that carry the xattr) read it, directories take their type from d_type, and files use fstatat / openat relative to the directory. The first frame comes at 1.8 s on the Mac (9.9 s → expected about 3 s on an iPhone), and the opening movie starts at 7 s instead of 49 s.

  • DLC the player owns is not unlocked → the host reports add-on content from DM_AOC_DIR, one numbered folder per DLC index (title ID − base − 0x1000) holding that DLC's RomFS. build_ipa_from_nsp.py extracts the DLC NSPs in the folder with nx_extract --aoc, build_ipa.sh copies them to <App>/aoc/, and the app sets DM_AOC_DIR. aoc::CountAddOnContent / ListAddOnContent report those indices (hle_misc.c), and fs::MountAddOnContent mounts the folder read-only (hle_fs.c). Engage's two DLC packages each hold one empty marker file (e0, h0), and the content is already in update 2.0.0. DLCManager.RefreshHasList marks a content owned when its mount succeeded (probe main+0x29f3c88). D2R keeps its fixed {1} without DM_AOC_DIR; DM_AOC_OWNED=0 reports none.

  • Abort in nn::sm::Initialize under nn::pctl::IsFreeCommunicationAvailable (Engage, entering its first battle map) → the SDK connects to the parental-control service through sm. The host answers it like the other free-communication calls: not available (hle_misc.c).

  • Abort inside nn::sm::Initialize → an SDK path reached a system service the host doesn't replace → hook the public SDK entry point named in the backtrace (HLE_FORCE in the relevant hle_*.c). The log shows it as SVC 0x1f (ConnectToNamedPort, port name sm:) and SVC 0x21 answered by placeholders, then nn::diag abort.

  • A weakly imported function the game calls although nothing defines it (Octopath Traveler 0: the thread_local init function _ZTHN13LowLevelTasks5FTask10ActiveTaskE, an UNIMPL line, then an abort) → the loader bound every undefined import to a named stub, so the address test the compiler emits for a weak symbol saw a function. An undefined weak symbol that no module defines and the host does not implement is now 0, as Horizon's rtld binds it (loader.c, logged as undefined weak … bound to 0).

  • SIGSEGV in android::Surface::getQueuedFrameNumber under nn::vi::GetLatestFrameNumber (Octopath Traveler 0: Unreal paces frames by the layer's frame number, main+0x10140e8) → the SDK reads the vi service's surface, which the host never made. hle_vi.c answers with the frames presented so far; ListFrameHistory returns no entries.

  • NVN calls new with Unreal (Octopath Traveler 0): nvnTexturePoolGetSize / nvnSamplerPoolGetSize and their memory pool / offset getters (an abort in device setup when they returned 0), and nvnCommandBufferFenceSync, which Unreal's RHI records in its command buffers (OP_FENCE_SYNC: the replay fences the sync with the submission that reaches it).

  • A game update aborts at boot where the base game ran → the update imports SDK functions the base game didn't. Diff the undefined dynamic symbols of the two main modules before the first run. Mario 1.2.1 added 28, among them nn::nfp (amiibo, initialized at boot), pl::IsRunningOnOunce (is this a Switch 2), ec::ShowShopApplicationInformationForUpgrade, account::PopOpenUsers and aoc::CountAddOnContent. Read the call sites before choosing answers: Mario only reads the count from PopOpenUsers and falls back to TryOpenPreselectedUser on 0, and only counts add-on content when running on a Switch 2. The host answers as a Switch 1 with no amiibo and no add-on content (hle_sdk16.c, hle_account.c, hle_misc.c).

  • SDK 20 calls that reach a service (Breath of the Wild 1.9.0, SDK 20.5.22): applet::GetAppletResourceUserId (SDK 20's OpenAudioRenderer aborts on an ARUID of 0, which the SDK's own getter returns without am; the host reports 0x80), pl::RequestSharedFontLoad, the VR-mode calls (pl::IsVrMode / BeginVrMode / EndVrMode), pctl::CheckStereoVisionPermission (bool, permitted), oe::TryPopLaunchParameter (none), time::StandardSteadyClock::GetCurrentTimePoint (host monotonic seconds, fixed source ID; GetSpanBetween runs in sdk), the console six-axis sensor (hid::*SevenSixAxisSensor*: zero states) and more nfp calls (AttachActivateEvent / AttachDeactivateEvent crashed in the SDK at sdk+0x3f2a78; also two-argument Mount, Unmount, GetCommonInfo, GetRegisterInfo, GetState, Finalize). Two new imports need nothing: nvnPrivate0 is a flag in sdk's bss that enables an inlined command-buffer fast path when the NVN driver sets it (it stays 0), and nn::ro::ProtectRelro (called from main's DSO init) runs natively on the host's no-op SetMemoryPermission / SetMemoryAttribute.

  • More SDK 20 calls that reach a service (Tears of the Kingdom 1.4.3, the same sdk build as Breath of the Wild 1.9.0; each aborted in sm::Initialize or read a null applet proxy, one per boot): oe::GetPreviousProgramIndex (−1: launched from the home menu); the hardware Opus decoder's C interface (GetHardwareOpusDecoderWorkBufferSize, InitializeHardwareOpusDecoder, DecodeOpusInterleavedWithHardware, …), forwarded to the sdk's software OpusDecoder C interface with a host-held decoder state per game object; cache storage (OpenCacheStorageList / ReadCacheStorageList report none, CreateCacheStorage / MountCacheStorage use a host folder beside the temporary storage); fs::IsSaveDataExisting(ApplicationId, Uid) for another title (it asks for Breath of the Wild's save: none) and MountSaveDataReadOnly; time::ClockSnapshot::CreateWithStandardSystemClock (0xD0 bytes, filled on the host from the steady clock, the user clock and the calendar; the network fields stay 0). The import list itself says where to look: diff the undefined symbols against the closest title the host already runs, drop what the host hooks, and the rest are the candidates. To find which sdk function aborted, map the backtrace offsets to the nearest exported symbol.

  • Unicorn Overlord (SDK 16.2.4, Vanillaware's engine as in 13 Sentinels), one boot each:

    • Abort in sm::Initialize under nn::codec::GetHardwareOpusMultiStreamDecoderWorkBufferSize(int, int, int, int, int) (at nnMain, CRI's Opus streams) → the C interface of the hardware multi-stream decoder, including the SDK 12+ overload with an option argument. Age of Calamity only needed the class and the four-argument size. Each call goes to the sdk's software OpusMultiStreamDecoder C interface, with a host-held state per game object as for the single-stream decoder (hle_codec.c). GetHardwareOpusMultiStreamDecoderProcessingTime reports 0.
    • Abort in sm::Initialize under nn::crypto::GenerateCryptographicallyRandomBytes (std::random_device → the sdk's own getentropy → InitializeCsrng) → the sdk defines getentropy, so the host's getentropy hook never sees the call, but the call into nn::crypto goes through the sdk's PLT. An HLE_FORCE on it fills from the host's entropy stream (hle_os.c).
    • Write to 0x268 right after the title screen (main+0x5d6708, a constructor whose this is 0x200) → operator new (main+0x5a55d4) takes the thread's ring heap, a bump allocator whose cursor starts at its base. One ring heap kept base 0, as in 13 Sentinels: the engine cuts ring heaps out of a memory pool at 64 KB GPU alignment, and the host's pool storage from malloc is only 4 KB aligned. The first allocation returned 0, which operator new's null check skipped, and the next returned 0x200. The profile key alloc_big_align aligns allocations of 64 KB or more to 64 KB (dm/games/README.md); the game then plays from the title screen into its opening story scene.
  • SDK 17 C library (Ace Attorney Investigations Collection, 17.5.3, Unity 2022.3 IL2CPP):

    • Addressables never start (no StreamingAssets/aa/settings.json in the DM_FS=1 output at all), then a null asset crash (main+0x31100ec) → Unity 2022's C# file API reaches the SDK's C library, not nn::fs: File.Exists → SystemNative_Stat → stat = open + fstat + close (sdk+0x948b0). nnmusl's path check (sdk+0x904e0, then the converter sdk+0x8ef50) asks nn::fs::IsPathMounted, GetPathLengthMax, GetUtf16PathLengthMax, GetEntryNameLengthMax and GetUtf16EntryNameLengthMax before it calls nn::fs::GetEntryType / OpenFile, and the SDK's own versions read the empty mount table (IsPathMounted false; the next one aborted with Result 0x35f202). The host answers them from its mount table, with Horizon's 0x300-byte limits and no UTF-16 limit (hle_fs.c). The path .NET builds is /rom:/…; nnmusl's converter turns it back into rom:/…. armyja's fork (GitHub issue #18) patched the managed stat wrappers by address instead; these hooks need no per-build table.
    • std::terminate (Break from sdk+0x5bfa48) at the first real-time read (right after the first-boot dialog, main+0x3f3f68 → system_clock::now()) → clock_gettime(CLOCK_REALTIME) calls through a source pointer (sdk+0xb05010) that nntimeStartup sets once time::Initialize succeeds: 1 = nn::time::GetStandardUserSystemClockCurrentTimespec, 2 = the adjustable clock's (sdk+0x8e414). Both asked the time service; they are host take-overs now (hle_time.c), and system_clock::now() works on any build.
    • Not needed in the end: the process heap of pull request #16 (nninitStartup calls SetMemoryHeapSize / AllocateMemoryBlock only without virtual address memory, and nothing else in main calls them) and the tolerant common string table of pull request #15 (1.0.0.2's main has the table intact at main+0x46169ac, 109 entries ending at Hash128).
  • SDK 6 (Fire Emblem: Three Houses, 6.5.0):

    • The first loading screen never ends; DM_FS=1 shows save:/system "not found" and no fs mount save → in SDK 6 account::OpenPreselectedUser returns a Result, and the game skips its save mount when it is not 0 (main+0x53b7e0). The host's hook returned void and left the handle pointer in w0. It returns 0 now; SDK 7 and later callers ignore it.
    • No sound; red audren: command 1 in the command list is malformed (magic 0 size 0) → SDK 6's DSP command header is +0x04 size (s16), +0x06 command ID (s16), +0x10 enabled, where SDK 8–20 have +0x04 enabled, +0x05 ID, +0x06 size (CommandListProcessor::Process, sdk+0xaaa00); its DeviceSink is ID 0x10. capture in hle_audren.c takes the layout whose walk finds the 0xcafebabe magic on every command of the first full list.
    • Shaders missing from the map that no game file holds → KTGL compiles 9 built-in GLSL shaders at boot with the game's own GLSLC (glslcCompile from main+0x9c89f8; the sources are strings in main). DM_GLSLC_CAPTURE=<dir> saves every compile's output (hle_glslc.c; the compile object's first word points at {status, output}), and with the profile key shader_glslc_capture on shader_split.py reads <version dir>/glslc_capture. The outputs are identical on every run.
  • SDK 8 (Link's Awakening, 8.2.0):

    • Abort in os::WaitSystemEvent under oe::PopNotificationMessage → the blocking pop waits on the system event oe::Initialize would have created, and the host's Initialize is a no-op. The host now implements it on its notification queue (a condition variable, then the TryPop path).
    • SIGSEGV in vi::SetLayerCrop → the SDK's version dereferences vi service objects the host never made; SetLayerCrop, DestroyLayer, CloseDisplay and vi::Finalize are host no-ops.
    • Abort with Result 0x499 in audio::GetAudioRendererWorkBufferSize → SDK 8's AudioRendererParameter is one word longer than SDK 16's: its revision sits at +0x38. The host copies the game's parameter before switching it to CPU rendering with manual execution, and copied only 0x38 bytes, so CheckValidRevision read garbage. It copies 0x40 now.
    • No music and no sound effects, only the movies' sound (the log repeats audren: first-frame commands … every frame and never prints DeviceSink inputs) → SDK 8 numbers the DSP commands differently: its command list goes 4×6, 7×24, 11×2, 16×1, 21×1 where SDK 20's goes 7×6, 11×24, 15×2, 20×1, 25×1, and the output command (DeviceSink, the same 0x148-byte layout with device name MainAudioOut, six inputs) is 0x10, not SDK 16's 0x13. The host never found it and played silence. When the expected ID is missing from the first full command list, capture in hle_audren.c now takes the one 0x148-byte command with 1 to 6 inputs as the sink and logs the ID it adopted.
    • The game never reads the controller (the title's "Press L+R" never sees a press; no GetNpadState call at all) → it waits for the event bound by hid::BindNpadStyleSetUpdateEvent before reading. The host now signals it once when it is bound for the connected controller.
    • nvnWindowGetPresentInterval, nvnCommandBufferSetCopyImageStride → implemented (the latter is the layer stride of texture ↔ buffer copies).
    • Movie sound → the decoder is asked for type 5 (Vorbis) with output format 1 (PCM16); see the movie entries above and hle_movie.c.
  • Monster Hunter Rise's movies → H.264 (type 1) into the client's NVN texture (output 3 = RGBA, mode 2), with AAC 5.1 sound (type 4, PCM16). VideoToolbox decodes to BGRA for that mode (mavc_set_rgba), and AAC goes through AudioToolbox's converter with an MPEG-4 ES descriptor built from the AudioSpecificConfig in csd-0 (as FFmpeg's AudioToolbox decoder does). The game ends a stream with time −1 and the whole 4 MB input buffer as its length: that packet carries no data (decoding it gave VideoToolbox −12909 and AudioToolbox −50). It also deletes its decoders (D0), so the host frees the object with the game's operator delete after its own state.

  • One guest thread at about 1000 ms/s on the iPhone only, the phone hot, CPU energy 5–7 W (Monster Hunter Rise's MessageSuperviseThread, priority 32, in the [nvn] threads lines) → the thread waits on oe::GetNotificationMessageEvent() with os::WaitAny, which does not clear an event, then pops notifications until the queue is empty. The host only ever signalled its notification event, so after the first focus change (iOS changes focus at launch; the Mac does not) it stayed set and the loop never blocked again. The event now follows the queue as the am service's does: set while a notification is queued, cleared when the last one is popped (hle_oe.c mq_ev_sync, hle_clear_system_event). Mac repro: DM_OE_SCRIPT=focus=2@5,focus=1@6 (the script also starts at the game's first GetNotificationMessageEvent, since such a game pops only once the event fires): before 995 ms/s, CPU 109%, 6.97 W; after, the thread is idle and CPU energy is 0.05 W.

  • SDK 9.6 (Hyrule Warriors: Age of Calamity):

    • Null read in oe::InitializeCopyrightFrameBuffer (right after the window setup) → the copyright frame buffer is an overlay the system draws only into screenshots and recordings; the SDK hands it to am as transfer memory. InitializeCopyrightFrameBuffer, SetCopyrightImage and SetCopyrightVisibility are host no-ops (hle_oe.c).
    • HardwareOpusMultiStreamDecoder → the game builds it with the imported constructor and then calls it through its vtable. Its 12 slots match the SDK's software OpusMultiStreamDecoder one by one, so the constructor hook builds the software decoder in place (0x38 of the 0x48 bytes), as for the single-stream HardwareOpusDecoder; GetHardwareOpusMultiStreamDecoderWorkBufferSize returns the software size (hle_codec.c). Both destructors are a bare ret.
  • Code inherited from another title calls fixed addresses → harmless on the build it was written for, silent memory corruption on any other. d2r-mobile's 3D render scale read a renderer pointer at main+0x31e0848 and called main+0x8582f0; in Mario v0 that is the middle of a static initializer and only did nothing because the pointer happened to read 0. Such code checks module_t.build_id now (written into fixups.tsv by the rewriter) and stays off on other builds.

  • Device / subsystem setup returns quietly and the next stage never starts → a version or capability check failed (NVN API version, a feature query) → compare what the game queries with what the host answers.

  • Font engine runs on garbage → the system shared font must look like BFTTF: the SDK calls ScalableFontEngineHelper::Decode(ptr, size), which XOR-decodes in place unless the first word is the "already decoded" marker (LE 0x18029a7f) → serve marker + bswap(size ^ 0x49621806) + TTF (hle_sdk16.c).

  • File-not-found noise → games probe many optional paths (debug parameters, other-language voice files); with DM_FS=1 these show as fs … not found. Only a missing file followed by a failure matters.

  • A buffer is created with a null pool right after a scene change → NVN virtual memory pools (flag 0x200) have no storage until nvnMemoryPoolMapVirtual maps physical pool pages in; if the host returns failure, the game treats the pool as unusable. Mario maps its single 128 MB virtual pool onto one physical pool in one request, so the host aliases it (hle_nvn.c). True sparse mappings are still unimplemented.

  • [RED] MapVirtual: non-contiguous mapping (true sparse) not implemented (Monster Hunter Rise) → RE Engine's GPU heap is one 64 GB virtual pool built from separately allocated 4 MB physical pools, each mapped at a virtual offset of its own (45 at boot, over the first 180 MB). A virtual pool larger than the application pool cannot be one alias of a physical pool, so it gets an address range of its own at MemoryPoolInitialize (reserved, inaccessible: 64 GB on the Mac, at most 1 GB on iOS), and each mapping request vm_remaps the physical pages over its part of the range, so virtual addresses read and write the same memory, as the GPU's page table does. Each run of mapped pages is registered with the replay like a pool of its own and rebuilt when a request joins or splits runs (vpool_* in hle_nvn.c). Mario's 128 MB pool, mapped whole onto one physical pool, keeps the alias. The log reports the mapped high-water mark per 128 MB.

  • CRASH sig=10 in memmove from pkg_upload → nvn_deswizzle, at an address 32 GB into a sparse virtual pool (Monster Hunter Rise) → the game's streamed textures (flag 0x20, packaged layout) live 32 GB into its virtual pool; it writes their tiles on the CPU into 4 MB physical pools and then maps them, with no GPU copy. NVN maps at 4 KB and the host aliases at its 16 KB page, so a request whose offsets differ modulo 16 KB cannot be aliased: the ASTC 6x6 textures' mip tail starts 0x81000 into the texture. Every request also goes into a 4 KB page table (vpt_* in hle_nvn.c: the physical page behind each virtual page and the generation of the request that set it). The replay reads a sparse pool's textures through it (nvn_sparse_copy, unmapped pages as 0) and uploads one again only when a mapping in its range changed or the CPU wrote a physical page behind it (nvn_sparse_since; the physical pools are dirty-tracked). Before that the untracked alias counted as written on every look: 32 textures, 18 MB, were uploaded again every frame, and those with unaliased tails came out magenta. Requests beyond the reserved range (iOS reserves 1 GB: the buffers stay under 0x20100000 through the opening) are kept in the table only, like unaligned ones: textures can read them, the GPU cannot address them.

  • A null-pool buffer even with virtual pools working → the game's own heap ran out. Trace it with probes (05): the gsys GPU allocator at main+0x4290c0 and the heap's tryAlloc (vtable slot +0x30) show which heap, its size and every allocation into it; the !xN heap walk shows the free and used blocks at the moment of failure.

    • Mario: each resource gets its own sead::ExpHeap sized resource-size-table value + 0x1008 (main+0x1e603c → ExpHeap::create main+0x4aac38). The table values are Nintendo's measured peaks with almost no margin (Yoshi's animation file uses 99 % of its budget). Model/NpcCommonerA.bfres in the opening course came up about 0x70 bytes short: its last GPU chunk (0x9000 bytes, 4 KB aligned) fit, but the chunk's 0x598-byte descriptor did not. Every allocation size before it is fixed by game code and data, so the difference from hardware is still unknown.
    • Fix: a registered code cut gives every resource heap +16 KB (CUTS in dm/tools/patch_nso.py, keyed by the module build ID, original bytes verified). Per-title code patches go there, with the reason.
    • Read the probe log carefully: tryAlloc probes show requests, not successes, so a running total that already exceeds the heap means an earlier request failed.
  • FATAL NVN command memory: the game's pool is empty… → our command records are larger than real NVN commands, so command buffers the game sizes for hardware overflow; when the game's out-of-memory callback declines to add memory, the host lends a segment. Many small command buffers each overflowing once per frame exhausted a fixed pool of 64 × 1 MB. The pool now grows on demand (256 KB segments, abort only beyond 1 GB; hostseg_take in hle_nvn.c). A segment is released only when the game re-grants memory overlapping the guest bytes of the recording that jumped into it (hostseg_release in AddCommandMemory): the game records setup blocks once and keeps CallCommands-ing them for many frames, so recycling segments after a fixed number of frames would overwrite live recordings.

  • Frames stop after about 15 s; a game thread spins in its command-memory callback (Fire Emblem: Three Houses: a ring allocator at main+0x9cc120 waiting for fences that have all signalled) → KTGL gives its main command buffer one 16 MB chunk of a ring and, in its callback (main+0x9ca960), takes chunk start + GetCommandMemoryUsed as the position the GPU has reached. NVN counts used bytes from the last AddCommandMemory; the host counted from BeginRecording, so the position stayed at one frame's size and the ring never freed. The count now resets at AddCommandMemory (Super Mario Bros. Wonder and Super Mario Odyssey checked unchanged).

  • FATAL submit re-entered in the first frames (Fire Emblem: Three Houses) → the game initializes two NVN queues and submits on both from two threads, while the Metal replay keeps one command buffer for the submission in progress. nvnQueueSubmitCommands now holds a lock across the replay (g_sub_mu in hle_nvn.c).

  • GetCommandMemoryUsed must count everything the recording used, including the parts that went into host segments (plus 16 bytes for the jump). Mario marks a recorded block as empty when the count is 0 and then skips calling it (see the next entry for how that happened).

  • Frames stop, no crash, the main thread sits in nvnSyncWait(sync, -1), and the watchdog's wait-state line shows the main fence one short (main fence submitted N / completed N-1) → a GPU command buffer never completes. Check GPU load from outside: ioreg -r -d 1 -c IOAccelerator | grep 'Device Utilization' at 100 % means a shader is looping forever. Find the shaders first used in the last frames (DM_NVN_PASSDUMP, compare the K keys) and look at their .metal for an empty do {} while (true); with no return (see 04 section 1: the compute program start).

  • Metal aborts on a texture descriptor with an absurd size (height (1869367126) greater than … 16384) → a command record kept a pointer to the game's NVNtexture and read it at replay. NVN commands do not depend on the API object once recorded, so the host now copies the texture object into the record (REC_SNAP / tex_snap in hle_nvn.c: render targets, texture copies and clears). Replay rejects objects without the texture magic and sizes beyond Metal's limits, with a red line instead of an abort. The first red lines name the call site and the object's first bytes; in Engage those bytes were 32-bit triangle indices, i.e. another write had already overrun into the texture object; that stopped once the alias region had address space left (see the Engage entry in 04).

  • Shaders reported missing at random, and their dumped control sections are garbage (Unity) → NVN lets the caller free a program's control section once nvnProgramSetShaders returns; Unity does, and the host used to hash it at the first draw. The keys are now computed inside SetShaders (hle_nvn.c prog_key). Mario calls SetShaders for some programs with a code address that is not mapped yet, so a stage whose code is not in a registered memory pool at that moment is hashed at its first draw, as before.

  • Video frames, or a game's built-in default textures, are all zero → the texture is block-linear, has no packaged layout and is written from the CPU with nvnTextureWriteTexels (Engage's video frames: Y plane R8 + UV plane RG8, every frame; Mario's and Unity's 4×4 fallback textures, which hold black, white, transparent and red texels). The host uploaded only packaged textures from guest memory and made everything else a GPU-only texture. WriteTexels now records the texture, and it resolves like a packaged one: uploaded from guest memory, re-uploaded when its pages are dirtied (mtl_tex_cpu_written). Mario's frames got slightly brighter where materials sample the default white texture.

  • Menus and dialogue boxes draw but contain no text; images and pictogram digits are fine → the game rasterizes glyphs on the CPU into a linear texture in a CPU-visible pool (Mario: 2048×8192 R8, flags IMAGE|LINEAR). A private Metal texture never sees those writes. Linear textures in CPU-visible pools are now created on the pool buffer (tex_linear in nvn_mtl.m). DM_NVN_TEXCPU=1 lists non-packaged textures in CPU-visible pools.

  • Characters (skinned models) invisible or stretched into streaks while the level renders → integer vertex inputs (bone indices) translated as float; see 04 section 6. To find the draws: log model file ranges with DM_PROBE=main:480f0 (x1 = file data, x2 = size), match the vertex/index buffer addresses in DM_NVN_PASSDUMP_ALL against them, then DM_NVN_HILITE their fragment shader.

  • Sky black → CPU-written linear colour ramps (see the linear-texture entry above).

  • Controller-button icons in text show as ? → those glyphs come from the system shared font's Nintendo-extension set (private-use code points; Mario's GameMsg/Key.msbt uses U+E0E0–E0F0 for A/B/X/Y/L/R/ZL/ZR/SL/SR/D-pad/+/−, U+E0AB–E0AE for D-pad directions, U+E090–E097 for stick arrows, U+E100/E101 for sticks). dm/tools/make_ext_font.py generates a stand-in nintendo_ext.ttf with those icons; the host serves it for shared-font type 5 (DM_FONT_DIR or DM_SHARED_FONT_EXT) and a desktop font for the other types. To find which code points a title uses, scan the TXT2 sections of its MSBT files for U+E000–F8FF.

  • Shared fonts on iOS: the macOS stand-in path (/System/Library/Fonts/Supplemental/Arial Unicode.ttf) does not exist on a phone, and GetSharedFontLoadState would report "not loaded". The iOS app ships dm/font/standard.ttf and dm/font/nintendo_ext.ttf (copied / generated by build_ipa.sh) and sets DM_FONT_DIR.

Frame rate

  • Running a 30 fps game at 60 (Breath of the Wild): find where the present interval comes from and how the logic time step follows it before patching anything. BotW's sead::GameFrameworkNx main loop (main+0x112660c) runs a dynamic interval: from the last frame's time it computes how many vertical syncs were needed, clamps that to [2, 3] (the maximum from the interval controller built at main+0x1126c04), steps up after three overrunning frames and down one level every six frames, and sets the result through setWaitVsyncInterval (vtable +0x130, main+0x11269b8 → nvnWindowSetPresentInterval). The game logic follows on its own: ksys::VFR is initialized with a constant base interval of 2 (main+0x127e740) and every frame VFR::updateInterval (main+0x151d3d0) takes the framework's current interval, giving a frame ratio of interval / 2. Five rewriter cuts lower the floor from 2 to 1 (patch_nso.py, build cd57b23f…): the candidate floor (two places) and the hysteresis targets. Probes confirmed the result: the framework presents at interval 1, VFR reports interval 1 with ratio 0.5, and when a frame runs long the game steps back to 2 with ratio 1, so the game speed does not change. Then check what the controller measures: BotW's measured only the main thread's CPU time (about 3 ms a frame), so a GPU-bound device stayed at interval 1 and played in slow motion (GitHub issue #9). Two more cuts make it always take its GPU-time path, and the host's timestamp reports had to carry real GPU times for that path to see anything (profile key gpu_timestamps, 04). The zeldaret/botw decompilation (VFR.cpp) gave the semantics; 1.9.0's addresses were found by the constants (60 / interval, the fmov s11, #2.0 floor).

Audio

  • Check whether the SDK carries the renderer. List the sdk module's exports for nn::audio::server:: and nn::audio::dsp::CommandListProcessor. If they are there (Mario, SDK 16), do not write a mixer: switch AudioRendererParameter to rendering device = CPU (+0x2c = 1) and execution mode = manual (+0x30 = 1) in GetAudioRendererWorkBufferSize and both OpenAudioRenderer overloads, call the SDK originals (guest_sym returns the module's own code even for a forced symbol), and drive ExecuteAudioRendererRendering from a guest thread (dm_guest_thread_spawn). The open then goes through CreateAudioRendererManagerByDfc; no IPC.
  • SDK 20's AudioRenderSystem has another layout (Breath of the Wild): the getters the host used (GetExecutionMode, GetRenderingDevice, GetCommandBufferWorkBufferSize) are inlined away, GenerateCommand takes and returns a Span<std::byte> (the generated list comes back in x0 / x1), the device and mode are words at +0x48 / +0x4c, the command buffer is at +0xf8 / +0x100, and UpdateImpl locks the mutex at +0x2d0 instead of +0x1f0. The DeviceSink command became ID 0x14 (SDK 20 inserted a command before it) with the same layout. render_once picks the layout by whether the Span version of GenerateCommand exists (hle_audren.c). The render thread's call also takes another route: the public ExecuteAudioRendererRendering (sdk+0x221e90) reaches AudioRendererImpl::ExecuteAudioRendererRendering (sdk+0x22ca88, x0 += 0x60 and a jump through the PLT) through the vtable, so the host's takeover of the Impl function was never called. The SDK's own AudioRenderSystem::ExecuteAudioRendererRendering ran instead (no update lock), every RequestUpdate rendered a catch-up frame because the host's render counter never moved, and the two threads wrote the same command buffer: CommandListProcessor::Process crashed now and then (SIGSEGV under render_main, once at 60 s and once at 134 s in the shrine). The host now also takes over AudioRenderSystem::ExecuteAudioRendererRendering, the PLT target; catch-ups fell from one per update to 3 per run.
  • The CPU processor drops the final mix. dsp::SendCommandBuffer, dsp::Wait and dsp::Signal are stubs in the application build, and DeviceSink (command 0x13) is a no-op in CommandListProcessor::Process. Take the output after Process from the mix buffers the DeviceSink command names (layout in hle_audren.c).
  • Generation needs the renderer's lock. AudioRenderSystem::Update holds the SDK mutex at AudioRenderSystem + 0x1f0; GenerateCommand does not take it, so the host does (the auto-mode system thread does the same in SendCommandToDsp).
  • At least one rendered frame between two updates. An effect's "needs init" parameter state is set only in the update where the game flags it new; the next update copies the game's value over it. Mario updates twice in a row at start-up; with a real audio device the first frame came after both, and the light limiter later crashed on a null state pointer (ApplyLightLimiterEffect, sdk+0x228494). The AudioRenderSystem::Update hook renders a catch-up frame first when nothing was rendered since the last update. DM_AUDREN_TRACE=1 logs every update with the number of frames rendered since the previous one.
  • Crackling on iOS only → RemoteIO asks for as many frames per callback as the audio session's I/O buffer duration, about 21 ms by default (1024 frames at 48 kHz), while the renderer keeps 4 blocks × 5 ms = 20 ms in flight. Every callback came up 1.3 ms short and was padded with zeros, about 47 gaps per second. Reproduced on the Mac by forcing 1024-frame callbacks (DM_DBG_AU_FRAMES=1024 with DM_AUDIO=1: 1999 underruns in 2000 callbacks). Fix: AVAudioSession setPreferredIOBufferDuration:0.005 (and 48 kHz) before the game opens audio and again on return to the foreground (front_ios_audio_session in front_ios.m); the device then calls back with 240 frames and nothing underruns. When checking a new device, read the host's audio #N device callbacks line, logged every 2000 callbacks: frames per callback, queued blocks, underruns.
  • Several outputs at once. Engage opens one AudioOut from Wwise (main+0x177d924, no release event, the game polls). On the phone it opened a second one later; the Mac runs never did. Unity's FMOD also has an output (main+0x4aec0c / 0x4aec4c). The host logs each output as audio #N: the open with its call site, Start / Stop / Close (the first 32 per output), and the callback line every 2000 of that output's callbacks. Combined numbers mislead: one fully fed output plus one fed by nobody look like "every other callback underruns, 2.0 blocks queued".
  • The background round trip on the Mac: DM_AUDIO=1 DM_AUDIO_MUTE=1 takes the real Core Audio path with the output silenced, and DM_OE_SCRIPT="bg=1@60,bg=0@66" runs the same sequence as the iOS lifecycle callbacks (focus, audio take-over, guest clock freeze, graphics suspend). Send dmhost SIGSTOP / SIGCONT between the two to imitate iOS suspending the process. The silent sink that stands in while in the background does not make up the time the process was suspended (it used to pull seconds' worth at once and log hundreds of underruns).
  • Sound stays silent after Siri, an alarm, a call or another app's audio → iOS stops calling the RemoteIO unit back without stopping it, so the host still thinks the output plays; a game whose audio thread waits for its buffers back (Wwise's poll-only output, FMOD) then waits forever. Three layers handle it: front_ios.m observes AVAudioSessionInterruptionNotification (began → the silent sink stands in, ended → session up again and resume; becoming active also ends an interruption whose "ended" never came) and AVAudioSessionMediaServicesWereResetNotification (dm_audio_reset recreates every AudioUnit); and a watchdog in hle_audio.c switches any started output whose device has not called back for 200 ms in focus to the silent sink, retrying the AudioUnit after 1, 2, 4, 8 s. A start that fails (session still held by a call) falls back the same way instead of aborting. The log shows audio session: interrupted, the device has not called back for N ms and AudioUnit restarted. tests/t_audio.c scenario 3 stops a playing AudioUnit behind the host's back.
  • The main thread spins at start-up in a resource wait (a YieldThread loop, no crash), right after OpenAudioRenderer (Hyrule Warriors: Age of Calamity) → Koei Tecmo's sound engine (KTSL) failed its setup quietly. The init hands it three heaps cut from one block (15 MB engine memory, 2 MB, 142 MB sound data; the sizes are constants at main+0x8d9610). In the 15 MB heap the setup allocates the renderer config and work buffer (0x760000: AudioRenderSystem::GetWorkBufferSize, 0x40a9c8 of it command buffer) and one block per player (up to 0x1b5860; three of the four mix 5.1). The fourth block came back null, the setup released its memory pools (RequestAttachMemoryPool then Detach with no update between) and returned −51, the first sound archive (ASRS) never loaded, and the resource the main thread waited for depended on it. How it was found: probe the pump the wait calls (main+0x8e1c0: first missing stage bit → the type's stage handler), the handler's dependency list (an incomplete entry of type 0xbbd39f2d, ASRS), then the memory pool states (MemoryPoolInfo +0x10: 1 new, 3 detached, 4 request attach, 5 attached, 6 unused) and the engine allocator's arguments and results. A per-build cut moves 4 MB from the sound data heap to the engine heap (patch_nso.py); why hardware fits in 15 MB is not known.
  • Checking audio without listening: DM_AUDIO_DUMP=file writes the PCM the host plays (16-bit, 48 kHz, stereo). RMS per second, clipping share, spectral flatness (music ≈ 0.1–0.5, white noise ≈ 0.85) and the strongest FFT peaks (they should land on musical notes) tell real music from noise or silence.

NVN object sizes (memory corruption)

The game allocates NVN objects with the sizes of its SDK's headers, which shrank in newer SDKs. A host record larger than the game's allocation overwrites neighbouring memory:

  • NVNtexture is 0x80 bytes in SDK 16 (heap corruption: the old 0x88 record overwrote the next heap block's links).
  • NVNtextureView is 16 bytes in SDK 16 (stack smash: a 16-byte local next to the saved frame pointer; the function returned to 0x100000000).
  • NVNprogram is smaller in Mario than the 0xC0 bytes in nvn.h: growing the host record from 112 to 168 bytes corrupted the next object (a crash in SHA-256 on the code address 0x16300). Keep prog_t at 112 bytes; per-program host data goes in a side table (prog_key).

Check every host object against the new SDK: _Static_assert(sizeof(host) <= N) next to the struct, and when a crash returns to a nonsense address, look at the 0x100 bytes below sp in the crash report for a zeroed frame.

The game may also write into an NVN object directly, without an NVN call, through code inlined from the SDK headers. Host records stored in place must leave those bytes alone:

  • NVNcommandBuffer +0x10: Mario writes the byte 1 there (main+0x4da5c, main+0xb8dec). In the host record it was the low byte of end. Each write shrank the game's 512-byte material setup blocks, so their recordings overflowed into host segments; GetCommandMemoryUsed (then cur − begin) returned 0, the game marked the setups empty and skipped calling them, and the draws ran with the previous draw's vertex buffers: out-of-range vertices, NaN, a black band and flicker. It was seen on the iPhone; Mac runs of the same scene looked clean. cmd_t now reserves the byte (nvn_flag10 in nvn_obj.h). To find such writes: put the field you suspect behind a guard value and log when it changes outside the host's own code, or force overflows on the Mac by shrinking the command memory the game gets.

Game state

  • In a scripted run, a screen goes back by itself right after a confirm (Engage's appearance choice) → the confirm opened the system keyboard (swkbd, name entry). Without a window the host answers "cancel" by design; pass DM_KBD=<text> to answer it. The log shows swkbd: … → no window, treated as cancel.
  • A player join screen that ignores A (3D World: a character select with four "L + R to join" slots after the title) → the game calls hid::StartLrAssignmentMode and waits for L and R pressed together on the join screen itself, then a character choice with A. In DM_KEYS that is L+R~0.5 while the screen is up (presses on the title before it are lost). On the game select, holding the stick right for a second (7:LRight~1) switches to Bowser's Fury, which also uses nvnTextureGetSwizzle and nvnTextureGetStorageClass. Map and level movement read the left stick only: DM_KEYS takes LLeft / LUp / LRight / LDown. A working route into World 1-1 on a fresh save: DM_KEYS=6:A,10:A,14:L+R~0.5,17:A*3/2,24:Plus*6/3,45:A*6/3,66:LUp~1,70:A,74:LRight~1,78:A,82:LLeft~1,86:A (World 1-1 by frame ~6000).
  • "Stuck" at a screen with one draw per frame → usually not stuck: most draws are in recorded command buffers replayed later; dump the passes (04) before assuming the game logic is blocked.