# 05 — Debugging recipes ## Reading a crash report ``` [dm] CRASH sig=11 addr=0 pc=main+0x373e04 lr=main+0x373dd8 sp=0x7066dd6370 x0=… x28=… [dm] bt: main+0x373dd8 main+0x2ea2ac … frame-pointer backtrace [dm] fp=… 0x100 bytes below sp: … the 32 words below sp (the frame that was just popped) [dm] stack scan sp=…: [-72]main+0x57a14 [+24]main+0x57618 … every word near sp that resolves to a module or host symbol [dm] thread 0x1001 'main thread' … all guest threads, what they wait on, their backtraces ``` - `pc` and `lr` are module-relative (`main+…`, `sdk+…`) or host symbols. Module offsets are the same as in the decompressed NSO and the ELF (Ghidra adds 0x100000). - Look at the registers before the code: a pointer that is a valid pointer plus garbage high bits, an index of `0xfffffffe`, or `pc == lr == 0x100000000` each tell a different story. a fault at `0x74a740e114 = 0x70a740c570 + 0xfffffffe·4 + 0x1bac` was an index of −2 widened as unsigned; `pc == lr` on a nonsense value is a `ret` through an overwritten saved LR (look at the zeroed frame below `sp`). - The handler takes SIGSEGV, SIGBUS, SIGILL, SIGTRAP, SIGALRM (the run's time limit) and SIGABRT (`abort()`: an assertion or an uncaught exception in the host or a framework; before it was handled, Big Brain Academy: Brain vs. Brain died once with exit 134, nothing in the log and no report). A run that ends with exit 137 and no `CRASH` line was killed with SIGKILL: macOS does that after a trap in a system library (`_os_unfair_lock_corruption_abort` destroying a corrupted mutex, the movie decoder entry [Input indices returned by a flush while a client holds them](../reference/movies.md#input-indices-returned-by-a-flush-while-a-client-holds-them)), and its report is in `~/Library/Logs/DiagnosticReports/dmhost-*.ips`, a JSON document whose `faultingThread` names the thread whose `frames` to read. - When the frame-pointer backtrace is empty, the stack scan's negative offsets (below `sp`) show the return addresses of the calls the crashing function made last. - `[dm] *xN:` lines show the first six words each register points at (read safely, so a bad pointer is skipped). A word inside a module prints as `main+…`: the first word of an object is its vtable, which names its class, and comparing it with the class's own vtable shows whether the object was destroyed (a base-class vtable) or rebuilt. ## Pipeline compile waits Render-pipeline lookahead runs after the demanded pipeline is available and cached. It admits at most two predictions per scan and at most two scheduled or running predictions overall, so speculative attachment variants cannot create an unbounded compiler backlog ahead of real draws. Exact-key results are still recorded only when used; a full pending table may discard completed unused results, never running jobs. The independent startup warmer is not subject to this limit. A new pipeline can still stall on its intrinsic first-use compilation cost; compare pipeline-miss timings and live stacks with asset reads before attributing a hitch to loading. `dm/gfx/tests/t_pso_pending.m` exercises the queue and demand-before-prediction ordering with a gated fake compiler. ## Ghidra / objdump ```bash # decompile the functions containing these module offsets JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ /opt/homebrew/opt/ghidra/libexec/support/analyzeHeadless $W/ghidra/proj -process .elf \ -noanalysis -readOnly -scriptPath /dm/tools/ghidra -postScript Decompile.java 0x373e04,0x2ea2ac # raw disassembly (works where Ghidra defined no function) objdump -d --start-address=0x2ea1c0 --stop-address=0x2ea2b0 $W/elf/.elf ``` Prefix an offset with `@` (`... Decompile.java @0x49740`) to (re)create the function at exactly that address. Code reached only through vtables has no function, and Ghidra's analysis marked some `nn::os` imports (`LockMutex`) as non-returning, which cut many functions short right after the first lock. The script clears that flag and the call-return overrides on such calls before decompiling. Map GOT slots to NVN names with the survey table (`census/*_nvn_slots.tsv`): a call through `[x8, #0x3e8]` after `adrp x8, 0x35ad000` is GOT entry 0x35ad3e8. ## Probes without a debugger (`DM_PROBE`) `DM_PROBE=main:4290c0,main:9712c` (module:offset, comma-separated; `host/probe.c`, Mac only) replaces the instruction with `brk`, and on every hit prints the guest thread (`T=`), x0–x7 (and strings they point to), x19–x23, lr, x8 and a backtrace, then runs the original instruction from a trampoline. Put probes at function entries or where x16 is dead; the probed instruction must not be PC-relative (no `adrp`, `adr`, `b`, `bl`, literal `ldr`: probe the next instruction instead). Each hit prints as one block even when several threads hit at once. Probes slow the game, which can shift heap addresses between runs. Per-probe suffixes, in this order: | Suffix | Effect | |---|---| | `@off`, `@*off`, `@xN+off`, `@*xN+off`, `@*xN+off+add` | Print 16 bytes at x0+off (or xN+off); `*` dereferences that address first, and `+add` moves on that many bytes from the pointer read (`@*x0+48+8` on an `al::LiveActor` is its position, `al::getTrans`) | | `?xN=val`, `?[xN+off]=val` | Only report hits where the register (or the 64-bit word at xN+off) equals `val` (hex); others pass silently. E.g. `main:4743c0?[x0+30]=781548` = only allocations into the heap whose size field is 0x781548 | | `!xN` | Treat xN as a `sead::ExpHeap` and print its free and used blocks (offset+size, `p` + alignment pad) | The `!xN` heap walk takes the layout of the game's sead version from `DM_PROBE_HEAP=,,,,` (hex offsets in the heap object; default Mario's `0,28,30,b0,c8`, Breath of the Wild's `28,38,40,e0,f8`; find them in the heap's constructor and `tryAlloc`). `DM_PROBE_HEAPNAME=` reports only hits whose heap name contains the text (resource heaps are named after their file), `DM_PROBE_HEAPWORDS=1` adds the first two words of each used block (a vtable names the object), `DM_PROBE_HEAPFRAME=` prints heaps of that class as frame heaps (head, tail, free), and `DM_PROBE_HEAPRAW=1` dumps the heap object's first 0x130 bytes, to find another class's fields. `DM_PROBE_ALL=1` adds x9–x18; `DM_PROBE_X2=` dumps the memory x2 points to. **Who writes a word: `DM_PROBE_WATCH=
[+]`** (hex guest address, 8 bytes by default; the bytes must lie in one 16 KB host page). The host write-protects that page as soon as it is writable and keeps it protected: the fault handler carries out every store that lands on the page through a second, writable mapping of it (STR / STUR / STP of general and SIMD registers in their immediate, pre- and post-index forms, register-offset STR, DC ZVA), so the stores of a busy page all pass through it. A store that reaches the watched bytes prints `WATCH … store of N bytes at …: old → new`, the thread, pc, lr and a backtrace. Other instructions (atomics, exclusives) get a `not emulated` line once per pc and run on an unprotected page, protected again a millisecond later. A guest object sits at the same address in every run (the guest heap is laid out the same), so take the address from a probe's register dump first. STEINS;GATE RE:BOOT's render size setting (engine object + 0x6000): the static search for stores with that offset, conditional probes on 25 candidate stores and a wrapper around the host's `memcpy` / `memset` (the guest's go straight to the host's libc, `hle_table.c`, where guest probes cannot see them) all found nothing; a watch that unprotected the page for every fault missed the write among the bursts of stores to the engine object's page; this one found it on the first run (`main+0x389e90`, a setter its start-up code calls with 0). An exclusive or atomic store (`STXR`, `STLR`, `CAS`, `SWP`, `LD`) that reaches the watched bytes prints `WATCH … exclusive store` (or `atomic store`) with its pc and backtrace, every time, though it is not emulated. A page that takes many atomics stays unprotected most of the time, so even that can miss: Tears of the Kingdom's memory-shortage flag (a word its frame check compares with 1 and clears) shares its page with atomic counters, and the watch saw neither the write nor the check's own clear. **When the watch misses: `DM_PROBE_POLL=
[+]`** (hex guest address, 4 bytes by default, up to 8). A host thread spins on the bytes; on each change it stops every thread at once, reads each one's pc, lr and 8 frame-pointer frames, lets them run again, then prints `POLL thread …` for the threads with guest code in those frames, the change (`POLL … old → new`) and, for the first two changes, the full guest thread dump. The snapshot comes a few microseconds after the store; a writer that went on into a wait shows only its wait (for that flag the one thread in guest code was `PlacementMgr` in `main+0x5aaac0`, both times). It costs one core while it runs. A word that changes thousands of times before the bad store takes a value filter, `DM_PROBE_POLL=
+=`: every change is still logged, but the threads are snapshotted and dumped only at changes to that value (Mario vs. Donkey Kong: a heap block's list link, rewritten by every allocation beside it, until an output index landed in it, [AAC decoders offer at most 32 outputs](../reference/movies.md#aac-decoders-offer-at-most-32-outputs)). A probe's backtrace reads each frame with `vm_read_overwrite`: code built without frame pointers keeps data in x29, and the walk used to fault in the host on such a value (x29 = 0x430 at `main+0x38921c` in the same game). Use it when lldb is unavailable: after macOS revokes the debugger authorization (`DevToolsSecurity -status` says developer mode is disabled), `lldb` launches hang waiting for a GUI approval prompt. `sudo DevToolsSecurity -enable` (run by the user) restores it. ## lldb The host uses SIGSEGV for dirty-page tracking, so lldb must pass it through without stopping. Address randomization is off under lldb, so guest addresses repeat across runs. Template (`$W/dbg/*.py`, run with `perl -e 'alarm 240; exec @ARGV' lldb -b -o "command script import x.py"`): ```python import lldb HOST = '/path/to/dm/host/dmhost' def __lldb_init_module(dbg, _): dbg.SetAsync(False); ci = dbg.GetCommandInterpreter(); res = lldb.SBCommandReturnObject() ci.HandleCommand('settings set platform.plugin.darwin.ignored-exceptions EXC_BAD_ACCESS|EXC_BAD_INSTRUCTION', res) t = dbg.CreateTarget(HOST); t.BreakpointCreateByName('run_module_inits') li = lldb.SBLaunchInfo(['']); li.SetEnvironmentEntries(['DM_ROMFS=…', 'DM_SAVE=…', 'DM_TEMP=…'], True) p = t.Launch(li, lldb.SBError()) for sig in ('SIGSEGV', 'SIGBUS', 'SIGUSR1', 'SIGUSR2', 'SIGPIPE'): ci.HandleCommand('process handle %s -n false -p true -s false' % sig, res) base = None while p.GetState() == lldb.eStateStopped: fr = p.GetSelectedThread().GetFrameAtIndex(0) if base is None and fr.GetFunctionName() == 'run_module_inits': base = t.EvaluateExpression('(unsigned long)g_mod[0].base').GetValueAsUnsigned() # g_mod[0] = main t.BreakpointCreateByAddress(base + 0x2ea228) # module offset elif base is not None and fr.GetPC() == base + 0x2ea228: print('x0=%#x' % fr.FindRegister('x0').GetValueAsUnsigned(), flush=True) # flush: a timeout kill loses buffered output p.Continue() p.Kill() ``` Patterns that worked: print every call of one function with its arguments; stop when a return value is null and print the backtrace; trace the branch path through one function (one-shot breakpoints on every branch site). Hardware watchpoints on words touched by `ldxr`/`stxr` may stay silent. ## Scripted input and thread scheduling - `DM_KEYS=":