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.
83 KiB
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 inpatch_nso.py(Unity / IL2CPPmain) → the compiler usedx18for 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 analloca(mov sp, x18). The rewriter reads the offset table's targets at rewrite time (site_br_tab), calls throughx16with the original return address so C++ unwinding still finds its frames (site_blr), and restores its scratch registers relative to the oldsp(site_sp). Engagemain: 7 jump tables, 1 call, 1spwrite; Mario's output stays byte-identical. -
jump table <addr>assertion in asubsdk(Link's Awakening's GLSLC and movie modules) → the offset-table formldrsw x18, [xB, xI, lsl #2]; add x18, x18, xB; br x18with the bounds check on another register: the index inw0(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_offsetsfollowsmovcopies back to thecmpand accepts a check on the sameandof the same, unchanged source. Engage's output stayed byte-identical. The entry may also be loaded into another register, with only theaddwritingx18(Cuphead, Unity 2017.4:cmp w17, #7; ldrsw x17, [x15, x17, lsl #2]; add x18, x17, x15). -
jump table <addr>assertion on abr x18with no table in front of it (Ace Attorney Investigations Collection, Unity 2022.3 IL2CPP:main+0x1d271bcand+0x204ebec) → not a jump table but an indirect tail call: an IL2CPP thunk loads the method pointer withldr x18, [x0, #0x18], moves its stack arguments with every other scratch register (ldr x16, [sp],ldp x17, x15, [sp, #8], …) and ends withbr x18. When neither table form is found, the last write ofx18is a plain load and thebrends the function, it becomesx16 = guest x18; br x16(tail_call/site_br_call; at a callx16/x17are free, as forblr 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 … cbnzwindow, 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.3main, 8 sites) → the compiler moved an atomic update's retry path out of line: the failedstxrbranches to a blockldxr; cmp; b.eq <the stxr>that re-enters the window at its store. That block cannot jump into the window's patch copy (thex18stand-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'sldxropened a window that ran on into the next, unrelatedldxr … stxrloop (main+0x5a4674), and branches to that loop's head landed mid-window. -
jump table <addr>assertion, entries relative to anadr(Tears of the Kingdommain+0x393bb0and 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 everyadrp+addpair 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 Calamitymain+0x138216c:adr x18, #L; ldrb w30, [x14, x12]; add x18, x18, x30, lsl #2; br x18) → capstone names x30lr(and x29fp), so the index register looked like no register and theldrbnever matched. The byte / halfword table check maps those names back to 30 / 29. -
jump table <addr>assertion on a byte table whoseaddis far from thebr(Octopath Traveler 0main+0x37f9bc8:adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2, then 17 register moves beforebr x18) → the byte / halfword search looked 16 instructions back.jump_bytes_farlooks 32 back, requires that nothing writes x18 between theaddand thebr, and runs only when every other form has failed, so sites that rewrote before come out the same. One of the 12br x18sites in thismain; 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.pyand compare all outputs; onlybuild_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_EL0in SDK 16'sFlushDataCache) → rewrite to a constant. -
B()range assertion inpatch_nso.py(site_plain), only for one module (Metroid Dreadmain) → 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 noB(±128 MB) reaches it. When an appended patch area would start beyond 112 MB, the rewriter puts it 32 MB before the module base (negativePATCH_OFF).patched.binthen 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) andQueryMemoryhandle both layouts; Mario's and Engage's output is byte-identical to before. -
B()range assertion, thenpatch area of … bytes does not fit in the … before the base(Monster Hunter Risemain: 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) andB()checks every branch. -
KeyErroratsite_brfor abr x18that is an ordinary byte jump table (Monster Hunter Risemain+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 asDT_RELR(tag 36, size tag 35):mainhas 223,994 of them and itsDT_RELAonly 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_applyinloader.c;DM_LOADHASHandnvn_slots_got.pyread it too). The loader's log line counts them asrelr=.
Memory and address space
StandardAllocator::Initializeaborts innninitStartup→ the NPDM declares a system resource (offset 0x14 ofMETA), soIsVirtualAddressMemoryEnabledis 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'smem_alias/mem_reservemust 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 fromsvcQueryMemory: it walks back over Code+RX regions to the module start, forward over Code then CodeData, and readsMOD0for.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
AllocateAddressRegionfails 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)innninitStartup(Octopath Traveler 0, Unreal Engine:main+0x12e74) → Unreal'snninitStartupneeds virtual address memory and reserves one address region for the engine withAllocateAddressRegion, 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 afternninitStartup; they are set before it now, as on hardware, and the profile keygame_argssupplies 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 atmain+0x1005824is 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 withDM_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=0x1fffe0atmain+0x9a4664) → Unity's TLSF heap added a pool, and its low-level virtual allocator (vtable atmain+0x61ffeb0; reservemain+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 twomov w8/w9, #0x3fe00000in the constructor → 62 MB). To measure, probe the SDK'sVammManager::AllocateAddressRegionat its success path (ReleaseWriteLockreturn: x21 = size, x22 = address) andFreeAddressRegionafter 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 fromMapPhysicalMemoryin 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, probeVammManager::AllocateMemoryPages/FreeMemoryPages(x1 address, x2 size) and split them by region. StandardAllocator::Initializeaborts innninitStartupon 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 atsdk+0x139458on that build and takes the same cut. With Engage's 2.5 GB pool the game then ran, but probes onVammManager::AllocateAddressRegion's success path (sdk+0x1395a8: x21 size, x22 address) andFreeAddressRegion(sdk+0x139df0) showed StandardAllocator's 2.44 GB reservation beside Unity's pools (1.06 GB live, frommain+0x40f1d4) ending 4.37 GB into the 4.375 GB alias region after 140 s, while theMapPhysicalMemorylog 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 onlynninitStartupreads 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 withMapPhysicalMemoryon demand; the alias region left after the node area was too small. Also visible asSetHeapSizeasking for much less than the pool. → repartition the guest block (hle_svc.cmem_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: theSetHeapSizerequest and the highestMapPhysicalMemoryaddress). 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). MapPhysicalMemoryfaults on part of a page → round to 16 KB host pages.nnMainreturns right afternv::InitializeGraphics, with no error (Hyrule Warriors: Age of Calamity) → the game'snninitStartupsizes its heap as total − used memory (QueryMemoryInfo), callsSetMemoryHeapSizeand hands the block to its own malloc. The host reports 64 MB used beside the heap, butSetHeapSizechecks a request against the real size of the loaded modules (66.6 MB here), soSetHeapSize(pool − 64 MB)failed with ResultLimitReached,AllocateMemoryBlockreturned 0x1003, malloc was never set up, the 866 MB graphics heap allocation came back null andnnMainreturned. ProbeDM_SVC=1'ssvc 0x1request and theAllocateMemoryBlockresult. The profile keymem_usedsets the used-memory figure (72 MB for this game: the image plus stacks). Monster Hunter Rise's modules take 320 MB (mainalone 314 MB), so itsSetHeapSize(pool − 64 MB − bss)failed the same way ([mem] SetHeapSize … + modules … > app pool); 328 MB gives a 2.88 GB heap.SetHeapSizeasks 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'smaincarries 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_bytesinhle_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.cnow 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_slotscovers it). - A null write as soon as the main thread starts recording a command buffer (13 Sentinels: a constructor at
main+0x2fa9a0runs onthis = 0when 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'smallocrounded 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'smallocisnn::mem::StandardAllocator::Allocate(size); on this build the host routes allocations of 64 KB or more toAllocate(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 (
snprintfundermount_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 top0x…9fe0, fault0x21a0below 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:OpenFile1.3 KB →host_path_ex1.9 KB →path_component3.2 KB); on hardware the same call is one IPC. Every hook inhle_fs.cnow registers throughHLE_FORCE_BIG(hle.h): a compile-time stub loads the implementation into x16 and jumps todm_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:nvnQueueSubmitCommandsreplays 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 throughHLE_FORCE, so they got their own big-stack entry: every implemented NVN function except thenvnCommandBuffer*recording calls goes throughnvn_big_entry(nvn_entry.S), which finds the implementation by slot and hands it todm_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
SetThreadCoreMaskthat pins a thread to other cores (ideal core −3 = keep) must move the ideal core into the new mask, as Horizon does, soGetCurrentProcessorNumberreports a core the thread can run on. -
Garbage-collected titles (Unity / IL2CPP):
SetThreadActivity(0x32) andGetThreadContext3(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. Inhle_thread.ca thread inside an SVC counts as paused at once, with the gate frame as its context (thr_svc_enter/thr_svc_leavearound 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-byteThreadContext. -
SignalToAddresstype 2 (SignalAndModifyByWaitingCountIfEqual) with count ≤ 0 and waiters present → the new value isvalue − 2, notvalue − 1(mesosphereKAddressArbiter; 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.clets only the first crashing thread print (crash_gate); the others stop inpause(), 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
GetCurrentProcessorNumberreturned −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.
nanosleepandmach_wait_untilovershoot 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.SleepThreadnow sleeps three quarters of the remaining time per step until within 20 µs (sleep_preciseinhle_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::NotifyAndWakeupThreadon a null MultiWait at address 0x20;WaitAnywalking freed holders; a worker reading a freed object atmain+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.pyOBS 19 / 20, this build only) waits until the dispatcher's request ring is empty (movie_ring_waitinhle_movie.c). Gates may now sit right after ablas well as at function entries; - an approximation of same-core preemption (
svc_thread/dm_yield_to_core_peersinhle_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.
- an observation gate after each request the game posts (
-
PhysX worker crashes in a task's
removeReferencewith 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 everysimulate(main+0x15730, main thread). A worker runs the task (fetchResults, sets the world's done flag, notifies, unlocks) and then calls itsrelease, which calls the task's own destructor (vtable back to thePxLightCpuTaskbase,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 thereleasefollows 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'sNpSceneCompletion::releasethen dereferencesmTm. A per-build cut makes that onereleasearet(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
SleepThreadcalls (Super Mario Galaxy: Hagi polls withSleepThread(100 ns),main+0x517c; 1.4 billion calls in 200 s, and the render thread nearly as busy) →sleep_precisereturns 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::Initializeaborts right afterConnectToNamedPort(SVC 0x1F) andSendSyncRequest(0x21) (Super Mario Galaxy, SDK 18) → an SDK call the host does not hook opened its service: herenn::hid::InitializeTouchScreen(int, int), the SDK 18 overload of the hookedInitializeTouchScreen(). Symbolize the abort backtrace against the SDK's exports to find the caller, and hook that name. -
A library's
freerejects a pointer (CentralHeap::Free→NotNMallocPtrError) → the game'smaindefines its ownoperator new/delete(Unity's memory manager) andsdkhas a copy forwarding tomalloc/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 ownIsMountedreads a mount table the host never fills → answer from the host's mount table (hle_fs.c). -
Crash in
nn::ro::InitializeWithPortName→nn::roconnects toldr:ro; take overInitializeand answerLookupSymbolfrom the loaded modules (hle_ro.c). Check the mangled names against the new title's imports: SDK 19'sLoadModuletakes 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, singularhid::GetNpadState,OpenDefaultAudioOut); hook both generations. -
SDK 11 names (Metroid Dread) →
fs::CommitSaveData(mount)is the older name offs::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(hostmktime) andaudio::GetAudioOutName. Finding them before the first boot: listmain'snn::imports that no host hook names (the survey's import table, filtered againstHLE_FORCEnames); mostnn::os/nn::memimports run natively insdkand 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 insdk+0x35258, apm's session, the first call innnMain),album::Initialize(abort insm::Initializeundercapsrv::InitializeForApplication; saving to the album now fails,hle_offline.c),oe::GetDefaultDisplayResolutionand its change event,oe::SendServerMaintenanceOverlayNotification,time::LoadTimeZoneRuleand theToCalendarTimeoverload with a rule (the host's zone is used),time::StandardNetworkSystemClock::IsAccuracySufficient(offline: no),hid::GetKeyboardStates,fs::GetSaveDataSize/ExtendSaveDataand the one-argumentfs::MountCacheStorage.nvnDeviceGetTexelFetchHandlereturns the texture ID with sampler 0. -
Texture creation returns null right after the inline keyboard's
Initialize, then a null read atmain+0x7713044(Monster Hunter Rise) → the game makes three textures ofswkbd::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 fromCalc: finished-initialize afterInitializeand released-user-words afterUnsetUserWordInfo. The host makes them on the nextCalc. -
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 singletonmain+0x1a28ea0is still null) → the game checksnvnDeviceGetInteger(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 keydevice_api(default 55.15, Mario's requirement). NVN 53 also bringsnvnQueueAcquireTexture(the older window acquire: the queue waits instead of a sync object),nvnSamplerCompareand 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'sScalableFontEngine::LoadFontfailed with error 403 andSetFontwith 301 on both stand-in fonts. This game records shared fonts as type 0 (main+0x975658), calls noScalableFontEngineHelper::Decodeand hands theGetSharedFontAddresspointer straight toLoadFont, so the pointer must be the plain TTF; Mario decodes the same pointer and needs the 8-byte BFTTF header in front. The profile keyshared_font(bfttfdefault,ttffor 3D World) chooses what the address points at (hle_sdk16.c). Found by probing the engine's error code after each call (DM_PROBEon the instruction afterGetError). -
More SDK 10 calls that reach a service (3D World):
settings::LanguageCode::Makeand the reversedoperator==(LanguageCode, Language)(the first comparison innnMainaborted insm),account::ListAllUsers,oe::GetPerformanceConfiguration,oe::SetAlbumImageOrientation, thenifmIP queries with SDK 10'ssocket::InAddr/in_addrmangling,prepo::AddSessionId/AddInternetConnectionStatus(entering the save select), andvi::GetIndirectImageMemoryRequirement. -
Null read in
applet::CreateLibraryAppletunderswkbd::InlineKeyboard::Initialize(3D World, entering the save select after the character select) → the inline keyboard is a resident library applet whose results arrive throughCalc.hle_swkbd.ctakes over every entry that touches the applet:Appeararms a request, and the nextCalcasks for the text the wayShowKeyboarddoes (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;GetImagereports no picture.GetImageMemoryRequirementis 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
Appearas 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 atmain+0x6fb3910/main+0x6fb3afc). The game then finishes the entry only whenCalcreturns 1 (ready / hidden) after a non-zero value (main+0x6fb3e6c); the hook returned nothing.Calcnow returns 0 until initialization finishes, 3 while shown and 1 otherwise.SetFinishedKeyboardCallbackis called on theCalcafterDisappear. AppearArg'stextMaxLength(+0x20) limits the text. -
CRASH sig=10at a heap address, called frommain+0x6bca160right after the name was entered (Monster Hunter Rise) →nn::ngc::ProfanityFilteris 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_vtableinhle_misc.c): each slot of the SDK's_ZTVN2nn3ngc15ProfanityFilterEis 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::Initializeundernn::oe::FinishStartupLogo(Metroid Dread, right after the first frames) → it reaches the applet proxy througham::GetApplicationFunctions. The host has no startup logo: a no-op hook (hle_oe.c). -
Abort in
nn::sm::Initializeundernn::hid::VibrationNode::Update(Metroid Dread) → the SDK's vibration graph flushes queued values withhid::detail::SendVibrationValues, which opens the hid service. The game sets handles on itsVibrationTargets even when the host reports no vibration device. The flush calls it through the PLT, so anHLE_FORCEon that symbol catches it and returns success (hle_hid.c). -
An Atk task aborts in a
switchdefault (UnexpectedDefaultImpl, thenAbortImpl) while a stream sound loads (Metroid Dread,main+0x1225a8c) → the stream path wasrom:/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_exnow does the same (norm_restinhle_fs.c). -
Abort in NVIDIA's video path (
NvRmChannelOpen→NvOsDrvOpen) → the SDK movie library's hardware decoder opens nvdrv directly. The host takes overmovie::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 tomovie::Decoder; with no working decoder the movie never ends.movie::Decoderis a thin wrapper over Android's MediaCodec, and its vtable entries are symbol relocations, soHLE_FORCEon each method also catches Unity's virtual calls. The host implements the protocol: fournn::osevents (DecoderEvents= input available, output ready, format changed, error), input indices (AcquireInputBufferIndex, none = 0x1011;GetInputBufferfills the client'smovie::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,MediaDataand events are handled through the SDK's own exported functions (SetDataAndCapacity,SetRange,SetInt32,SetRect,SignalEvent). Unity readswidth,height,crop,nv12-y-stride,nv12-y-offset,nv12-uv-offsetandslice-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, becauseSignalEventis 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, thenbcat::Initialize, thenaudio::AcquireAudioDeviceSwitchNotification; a null read inaccount::GetLastOpenedUser) → the SDK's IPC to the service fails and it callsAbortImpl(Breakfromsdk+…with annn::diagbacktrace). Resolve the frames withsdis.py --symon the decompressed module, list the game's imports of that service the host does not hook (the survey's import table against theHLE_FORCEnames), 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 (bcatmount 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
GetNpadStatesand 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_KEYStakesRLeft/RUp/RRight/RDownfor the right stick. -
Movies through the SDK's own demuxer and a decoder in native-texture mode (Metroid Dread):
movie::Extractor(libwebm insubsdk0) runs natively. The host used to forceSetDataSourceto 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'smovie::Bufferthen carries theNVNtextureobject itself as its data, 8 bytes of capacity (anRGBX8_SRGBtexture embedded in the game's texture pool entry,main+0x226c98), plusSetNvnDev/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 likeWriteTexels, then marks the sync as signalled (nvn_host_write_texture/nvn_host_signal_syncinhle_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 aDecoderImpl. RegisterClientEventscopies the four event pointers: the client's struct does not stay alive.- As with MediaCodec,
Flushannounces nothing (the game ends a movie withFlush,Flush,Stop, destroy, and callsStartafter aFlushwhen it loops), and no input or output index is handed out while the decoder is stopped. - The "input available" after
Startis signalled about 2 ms later from a guest thread, as the SDK's codec thread would. Pending signals are dropped atStop,Flushand destruction.
-
Null read in
sdkundernn::applet::CreateLibraryApplet(Engage: title menu → DLC →nn::ec::ShowShopAddOnContentList) → everyShow…call that opens a system applet (eShop, friend lists, web pages, error screens) goes through the applet proxy thatoe::Initializewould have set up, and the host has none. Hook eachShow…overload the game imports: listmain's undefined symbols containingShow/Appletand grep the host sources for each mangled name; one overload missing is enough to crash. The shop calls returnvoidand 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 isgetxattrunderpath_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 callsgetxattron 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_getinhle_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:ReadDirectorycalledstat,getxattrand a failingfopen("<file>.holes")for every entry. Now only.dm-case-*names (the only ones that carry the xattr) read it, directories take their type fromd_type, and files usefstatat/openatrelative 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.pyextracts the DLC NSPs in the folder withnx_extract --aoc,build_ipa.shcopies them to<App>/aoc/, and the app setsDM_AOC_DIR.aoc::CountAddOnContent/ListAddOnContentreport those indices (hle_misc.c), andfs::MountAddOnContentmounts 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.RefreshHasListmarks a content owned when its mount succeeded (probemain+0x29f3c88). D2R keeps its fixed{1}withoutDM_AOC_DIR;DM_AOC_OWNED=0reports none. -
Abort in
nn::sm::Initializeundernn::pctl::IsFreeCommunicationAvailable(Engage, entering its first battle map) → the SDK connects to the parental-control service throughsm. 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_FORCEin the relevanthle_*.c). The log shows it asSVC 0x1f(ConnectToNamedPort, port namesm:) andSVC 0x21answered by placeholders, thennn::diagabort. -
A weakly imported function the game calls although nothing defines it (Octopath Traveler 0: the
thread_localinit function_ZTHN13LowLevelTasks5FTask10ActiveTaskE, anUNIMPLline, 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 asundefined weak … bound to 0). -
SIGSEGV in
android::Surface::getQueuedFrameNumberundernn::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.canswers with the frames presented so far;ListFrameHistoryreturns no entries. -
NVN calls new with Unreal (Octopath Traveler 0):
nvnTexturePoolGetSize/nvnSamplerPoolGetSizeand their memory pool / offset getters (an abort in device setup when they returned 0), andnvnCommandBufferFenceSync, 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
mainmodules before the first run. Mario 1.2.1 added 28, among themnn::nfp(amiibo, initialized at boot),pl::IsRunningOnOunce(is this a Switch 2),ec::ShowShopApplicationInformationForUpgrade,account::PopOpenUsersandaoc::CountAddOnContent. Read the call sites before choosing answers: Mario only reads the count fromPopOpenUsersand falls back toTryOpenPreselectedUseron 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'sOpenAudioRendereraborts 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;GetSpanBetweenruns insdk), the console six-axis sensor (hid::*SevenSixAxisSensor*: zero states) and morenfpcalls (AttachActivateEvent/AttachDeactivateEventcrashed in the SDK atsdk+0x3f2a78; also two-argumentMount,Unmount,GetCommonInfo,GetRegisterInfo,GetState,Finalize). Two new imports need nothing:nvnPrivate0is a flag insdk's bss that enables an inlined command-buffer fast path when the NVN driver sets it (it stays 0), andnn::ro::ProtectRelro(called frommain's DSO init) runs natively on the host's no-opSetMemoryPermission/SetMemoryAttribute. -
More SDK 20 calls that reach a service (Tears of the Kingdom 1.4.3, the same
sdkbuild as Breath of the Wild 1.9.0; each aborted insm::Initializeor 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 thesdk's softwareOpusDecoderC interface with a host-held decoder state per game object; cache storage (OpenCacheStorageList/ReadCacheStorageListreport none,CreateCacheStorage/MountCacheStorageuse a host folder beside the temporary storage);fs::IsSaveDataExisting(ApplicationId, Uid)for another title (it asks for Breath of the Wild's save: none) andMountSaveDataReadOnly;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 whichsdkfunction 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::Initializeundernn::codec::GetHardwareOpusMultiStreamDecoderWorkBufferSize(int, int, int, int, int)(atnnMain, 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 thesdk's softwareOpusMultiStreamDecoderC interface, with a host-held state per game object as for the single-stream decoder (hle_codec.c).GetHardwareOpusMultiStreamDecoderProcessingTimereports 0. - Abort in
sm::Initializeundernn::crypto::GenerateCryptographicallyRandomBytes(std::random_device→ thesdk's owngetentropy→InitializeCsrng) → thesdkdefinesgetentropy, so the host'sgetentropyhook never sees the call, but the call intonn::cryptogoes through thesdk's PLT. AnHLE_FORCEon it fills from the host's entropy stream (hle_os.c). - Write to
0x268right after the title screen (main+0x5d6708, a constructor whosethisis0x200) →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, whichoperator new's null check skipped, and the next returned0x200. The profile keyalloc_big_alignaligns 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.
- Abort in
-
SDK 17 C library (Ace Attorney Investigations Collection, 17.5.3, Unity 2022.3 IL2CPP):
- Addressables never start (no
StreamingAssets/aa/settings.jsonin theDM_FS=1output at all), then a null asset crash (main+0x31100ec) → Unity 2022's C# file API reaches the SDK's C library, notnn::fs:File.Exists→SystemNative_Stat→stat=open+fstat+close(sdk+0x948b0). nnmusl's path check (sdk+0x904e0, then the convertersdk+0x8ef50) asksnn::fs::IsPathMounted,GetPathLengthMax,GetUtf16PathLengthMax,GetEntryNameLengthMaxandGetUtf16EntryNameLengthMaxbefore it callsnn::fs::GetEntryType/OpenFile, and the SDK's own versions read the empty mount table (IsPathMountedfalse; 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 intorom:/…. armyja's fork (GitHub issue #18) patched the managed stat wrappers by address instead; these hooks need no per-build table. std::terminate(Breakfromsdk+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) thatnntimeStartupsets oncetime::Initializesucceeds: 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), andsystem_clock::now()works on any build.- Not needed in the end: the process heap of pull request #16 (
nninitStartupcallsSetMemoryHeapSize/AllocateMemoryBlockonly without virtual address memory, and nothing else inmaincalls them) and the tolerant common string table of pull request #15 (1.0.0.2'smainhas the table intact atmain+0x46169ac, 109 entries ending atHash128).
- Addressables never start (no
-
SDK 6 (Fire Emblem: Three Houses, 6.5.0):
- The first loading screen never ends;
DM_FS=1showssave:/system"not found" and nofs mount save→ in SDK 6account::OpenPreselectedUserreturns 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.captureinhle_audren.ctakes 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 (
glslcCompilefrommain+0x9c89f8; the sources are strings inmain).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 keyshader_glslc_capture onshader_split.pyreads<version dir>/glslc_capture. The outputs are identical on every run.
- The first loading screen never ends;
-
SDK 8 (Link's Awakening, 8.2.0):
- Abort in
os::WaitSystemEventunderoe::PopNotificationMessage→ the blocking pop waits on the system eventoe::Initializewould have created, and the host'sInitializeis a no-op. The host now implements it on its notification queue (a condition variable, then theTryPoppath). - SIGSEGV in
vi::SetLayerCrop→ the SDK's version dereferences vi service objects the host never made;SetLayerCrop,DestroyLayer,CloseDisplayandvi::Finalizeare host no-ops. - Abort with Result 0x499 in
audio::GetAudioRendererWorkBufferSize→ SDK 8'sAudioRendererParameteris 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, soCheckValidRevisionread 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 printsDeviceSink 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 nameMainAudioOut, 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,captureinhle_audren.cnow 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
GetNpadStatecall at all) → it waits for the event bound byhid::BindNpadStyleSetUpdateEventbefore 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.
- Abort in
-
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 incsd-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'soperator deleteafter 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] threadslines) → the thread waits onoe::GetNotificationMessageEvent()withos::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.cmq_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 firstGetNotificationMessageEvent, 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,SetCopyrightImageandSetCopyrightVisibilityare 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 softwareOpusMultiStreamDecoderone by one, so the constructor hook builds the software decoder in place (0x38 of the 0x48 bytes), as for the single-streamHardwareOpusDecoder;GetHardwareOpusMultiStreamDecoderWorkBufferSizereturns the software size (hle_codec.c). Both destructors are a bareret.
- Null read in
-
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+0x31e0848and calledmain+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 checksmodule_t.build_idnow (written intofixups.tsvby 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 (LE0x18029a7f) → servemarker + 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=1these show asfs … 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
nvnMemoryPoolMapVirtualmaps 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 atMemoryPoolInitialize(reserved, inaccessible: 64 GB on the Mac, at most 1 GB on iOS), and each mapping requestvm_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_*inhle_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=10inmemmovefrompkg_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_*inhle_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+0x4290c0and the heap'stryAlloc(vtable slot +0x30) show which heap, its size and every allocation into it; the!xNheap walk shows the free and used blocks at the moment of failure.- Mario: each resource gets its own
sead::ExpHeapsized resource-size-table value + 0x1008 (main+0x1e603c→ExpHeap::createmain+0x4aac38). The table values are Nintendo's measured peaks with almost no margin (Yoshi's animation file uses 99 % of its budget).Model/NpcCommonerA.bfresin 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 (
CUTSindm/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:
tryAllocprobes show requests, not successes, so a running total that already exceeds the heap means an earlier request failed.
- Mario: each resource gets its own
-
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_takeinhle_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_releaseinAddCommandMemory): the game records setup blocks once and keepsCallCommands-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+0x9cc120waiting 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 +GetCommandMemoryUsedas the position the GPU has reached. NVN counts used bytes from the lastAddCommandMemory; the host counted fromBeginRecording, so the position stayed at one frame's size and the ring never freed. The count now resets atAddCommandMemory(Super Mario Bros. Wonder and Super Mario Odyssey checked unchanged). -
FATAL submit re-enteredin 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.nvnQueueSubmitCommandsnow holds a lock across the replay (g_sub_muinhle_nvn.c). -
GetCommandMemoryUsedmust 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 theKkeys) and look at their.metalfor an emptydo {} while (true);with noreturn(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'sNVNtextureand 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_snapinhle_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
nvnProgramSetShadersreturns; Unity does, and the host used to hash it at the first draw. The keys are now computed insideSetShaders(hle_nvn.cprog_key). Mario callsSetShadersfor 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 planeR8+ UV planeRG8, 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.WriteTexelsnow 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_linearinnvn_mtl.m).DM_NVN_TEXCPU=1lists 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 inDM_NVN_PASSDUMP_ALLagainst them, thenDM_NVN_HILITEtheir 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'sGameMsg/Key.msbtuses 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.pygenerates a stand-innintendo_ext.ttfwith those icons; the host serves it for shared-font type 5 (DM_FONT_DIRorDM_SHARED_FONT_EXT) and a desktop font for the other types. To find which code points a title uses, scan theTXT2sections 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, andGetSharedFontLoadStatewould report "not loaded". The iOS app shipsdm/font/standard.ttfanddm/font/nintendo_ext.ttf(copied / generated bybuild_ipa.sh) and setsDM_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::GameFrameworkNxmain 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 atmain+0x1126c04), steps up after three overrunning frames and down one level every six frames, and sets the result throughsetWaitVsyncInterval(vtable +0x130,main+0x11269b8→nvnWindowSetPresentInterval). The game logic follows on its own:ksys::VFRis initialized with a constant base interval of 2 (main+0x127e740) and every frameVFR::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, buildcd57b23f…): 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 keygpu_timestamps, 04). The zeldaret/botw decompilation (VFR.cpp) gave the semantics; 1.9.0's addresses were found by the constants (60 / interval, thefmov s11, #2.0floor).
Audio
- Check whether the SDK carries the renderer. List the
sdkmodule's exports fornn::audio::server::andnn::audio::dsp::CommandListProcessor. If they are there (Mario, SDK 16), do not write a mixer: switchAudioRendererParameterto rendering device = CPU (+0x2c = 1) and execution mode = manual (+0x30 = 1) inGetAudioRendererWorkBufferSizeand bothOpenAudioRendereroverloads, call the SDK originals (guest_symreturns the module's own code even for a forced symbol), and driveExecuteAudioRendererRenderingfrom a guest thread (dm_guest_thread_spawn). The open then goes throughCreateAudioRendererManagerByDfc; no IPC. - SDK 20's
AudioRenderSystemhas another layout (Breath of the Wild): the getters the host used (GetExecutionMode,GetRenderingDevice,GetCommandBufferWorkBufferSize) are inlined away,GenerateCommandtakes and returns aSpan<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, andUpdateImpllocks 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_oncepicks the layout by whether the Span version ofGenerateCommandexists (hle_audren.c). The render thread's call also takes another route: the publicExecuteAudioRendererRendering(sdk+0x221e90) reachesAudioRendererImpl::ExecuteAudioRendererRendering(sdk+0x22ca88,x0 += 0x60and a jump through the PLT) through the vtable, so the host's takeover of theImplfunction was never called. The SDK's ownAudioRenderSystem::ExecuteAudioRendererRenderingran instead (no update lock), everyRequestUpdaterendered a catch-up frame because the host's render counter never moved, and the two threads wrote the same command buffer:CommandListProcessor::Processcrashed now and then (SIGSEGV underrender_main, once at 60 s and once at 134 s in the shrine). The host now also takes overAudioRenderSystem::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::Waitanddsp::Signalare stubs in the application build, and DeviceSink (command 0x13) is a no-op inCommandListProcessor::Process. Take the output afterProcessfrom the mix buffers the DeviceSink command names (layout inhle_audren.c). - Generation needs the renderer's lock.
AudioRenderSystem::Updateholds the SDK mutex atAudioRenderSystem + 0x1f0;GenerateCommanddoes not take it, so the host does (the auto-mode system thread does the same inSendCommandToDsp). - 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). TheAudioRenderSystem::Updatehook renders a catch-up frame first when nothing was rendered since the last update.DM_AUDREN_TRACE=1logs 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=1024withDM_AUDIO=1: 1999 underruns in 2000 callbacks). Fix:AVAudioSessionsetPreferredIOBufferDuration:0.005(and 48 kHz) before the game opens audio and again on return to the foreground (front_ios_audio_sessioninfront_ios.m); the device then calls back with 240 frames and nothing underruns. When checking a new device, read the host'saudio #N device callbacksline, 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 asaudio #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=1takes the real Core Audio path with the output silenced, andDM_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). SenddmhostSIGSTOP/SIGCONTbetween 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.mobservesAVAudioSessionInterruptionNotification(began → the silent sink stands in, ended → session up again and resume; becoming active also ends an interruption whose "ended" never came) andAVAudioSessionMediaServicesWereResetNotification(dm_audio_resetrecreates every AudioUnit); and a watchdog inhle_audio.cswitches 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 showsaudio session: interrupted,the device has not called back for N msandAudioUnit restarted.tests/t_audio.cscenario 3 stops a playing AudioUnit behind the host's back. - The main thread spins at start-up in a resource wait (a
YieldThreadloop, no crash), right afterOpenAudioRenderer(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 atmain+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 (RequestAttachMemoryPoolthenDetachwith 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 type0xbbd39f2d, 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=filewrites 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:
NVNtextureis 0x80 bytes in SDK 16 (heap corruption: the old 0x88 record overwrote the next heap block's links).NVNtextureViewis 16 bytes in SDK 16 (stack smash: a 16-byte local next to the saved frame pointer; the function returned to0x100000000).NVNprogramis smaller in Mario than the 0xC0 bytes innvn.h: growing the host record from 112 to 168 bytes corrupted the next object (a crash in SHA-256 on the code address 0x16300). Keepprog_tat 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 ofend. Each write shrank the game's 512-byte material setup blocks, so their recordings overflowed into host segments;GetCommandMemoryUsed(thencur − 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_tnow reserves the byte (nvn_flag10innvn_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; passDM_KBD=<text>to answer it. The log showsswkbd: … → 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::StartLrAssignmentModeand waits for L and R pressed together on the join screen itself, then a character choice with A. InDM_KEYSthat isL+R~0.5while 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 usesnvnTextureGetSwizzleandnvnTextureGetStorageClass. Map and level movement read the left stick only:DM_KEYStakesLLeft/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.