Guest threads, their synchronization and how the host schedules them. Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.
- Symptom: a parent returns from
StartThreadbefore a newly runnable higher-priority child pinned to its core finishes its work (deterministic regression, not attributed to a game failure). - Cause:
StartThreadsignalled the child's pthread but did not request the priority handoff already used when waking a waiter. Atmosphère'sKThread::Runmakes the child runnable; scheduler priority rules then apply. It does not promise that an equal-priority or other-core child has entered its function before the call returns. - Fix:
note_runnablerecords the same handoff for starts and wakes (hle_thread.c). No unconditional child-entry handshake or timeout. - Check:
make -C dm/host/tests GAME=010038B015560000 preemptdelays the child's host dispatch by 1 ms and 20 ms. WithDM_PREEMPT=0, both delayed starts failed before the fix. Afterward bothDM_PREEMPT=0and1pass; the broader mode already supplied the handoff before this correction. Equal-priority, other-core, migratable, host-helper, suspended and host-GPU-work children do not hold up the caller. - Found on: the threading audit of FINAL FANTASY TACTICS; its affected workers are lower priority and initially migratable, so this correction does not remove its local wake cut.
- Symptom: per-core job queues never drained.
- Cause: a
SetThreadCoreMaskthat pins a thread to other cores (ideal core −3 = keep) left the ideal core outside the new mask, soGetCurrentProcessorNumberreported a core the thread cannot run on. - Fix: the host moves the ideal core into the new mask, as Horizon does, so
GetCurrentProcessorNumberreports a core the thread can run on.
- Symptom: crash indexing a per-core table with a huge offset (index −2).
- Cause: threads created with ideal core −2 (use process default) were recorded as core −2, and
GetCurrentProcessorNumberreturned −2. - Fix: −2 resolves to the process default core (0), and a negative core is never returned (
hle_thread.c). - Found on: Super Mario Bros. Wonder, in its font engine (
main+0x107dbc0).
- Symptom: the main thread waits forever on an event right after
nnMainstarts, with only three or four threads created;DM_THR=1shows noCreateThreadfor the thread it waits on. - Cause: Unity's thread start (
main+0x3e1a50) creates a thread only whennn::os::GetFreeThreadCount()is at least 1. That issvcGetInfotype 24 (FreeThreadCount, Horizon 11 and later), which the host answered 0 like every type it did not know, so the thread was never asked for. The kernel answers the thread resource limit less the threads alive (Atmosphèrekern_svc_info.cpp);pmgives an application 96 threads (pm_spec.cpp, 256 with a boost). - Fix: the host answers 96 less the game's threads that have not exited (
thr_limit_used; guest threads the host spawns for itself are left out). - Found on: Monster Train 2 (Unity 2022.3.54f1 on SDK 18.3.5:
main+0x3e7288starts a thread and waits for it to signalmain+0x539cf80). With the fix the game creates 40 threads and reaches its title. - Note: of the 20 games extracted on this machine, only this
mainimports the function.
- Symptom: in garbage-collected titles (Unity / IL2CPP), live objects freed, with their finalizers releasing native resources.
- Cause: the Boehm collector pauses each thread, reads its registers for roots and scans its stack.
SetThreadActivity(0x32) andGetThreadContext3(0x33) were placeholders that only reported success: threads kept running and the context was empty, so roots held only in registers were missed. - Fix: both really pause and report registers (
hle_thread.c). A thread inside an SVC counts as paused at once, with the gate frame as its context (thr_svc_enter/thr_svc_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. - Found on: Unity / IL2CPP titles in general.
- Symptom: crashes around movie changes in the game's own player threads, about one run in two:
SignalEvent→MultiWaitImpl::NotifyAndWakeupThreadon a null MultiWait at address 0x20;WaitAnywalking freed holders; a worker reading a freed object atmain+0x225338. The other way round, a handoff that waits too widely: a black screen at boot that never loads a texture (packaged textures: first uploads 0), or a load that never ends behind an animated loading screen, with the thread dump'ssame-core handoffs N, total T mslarger than the run itself. - Cause: Horizon schedules the highest-priority runnable thread on a core (Mesosphere's scheduler). A lower-priority thread that wakes a higher-priority thread pinned to its core cannot continue until that work blocks. The host's pthreads run in parallel. The earlier approximation also confused host dispatch state with guest readiness:
wake()cleared the guest wait before the pthread resumed, butthread_infocould still report that pthread waiting. The caller then continued too soon. Its 5 ms timeout opened the same hole under a longer host delay; the recordedyield_pendingflag was unused unless the profile enabled checks after every thread SVC. The first unbounded handoff then waited for every runnable higher-priority thread pinned to the caller's core, not only the ones its wake made runnable. The host runs a lower-priority thread beside such threads at all other times, and a core's busy loaders, job workers and pollers are seldom all blocked at the same moment, so a wake could hold its caller for minutes. - Fix: every game's thread SVC honours
yield_pending. Before returning, it waits for the threads its wakes made runnable that are higher priority and pinned to the caller's core, and for those such a thread makes runnable in turn while the handoff lasts (Dread's dispatcher wakes the worker), until each blocks, sleeps, yields to any thread (SleepThread(-2), which takes it off its core so that lower-priority threads run there), exits, is suspended, or enters explicit host GPU work (svc_thread/dm_yield_to_core_peersinhle_thread.c). A wake records the thread in the waker's current handoff and in those the waker belongs to (note_runnable, at most four); blocking ends that (hand_leave), and a handoff ends when its caller's thread SVC returns (hand_epoch). Guest runnable state is authoritative, including the wake-to-pthread-resume interval; a host dispatch snapshot cannot release the handoff, and there is no timeout that lets unfinished work through. A higher-priority peer the wake did not make runnable does not hold the caller, as at every other point on the host. Host-created helpers and threads that can run on another core are excluded. - Check:
make -C dm/host/tests GAME=010093801237C000 preemptdelays a woken pthread's dispatch by 1 ms and 20 ms through real event, mutex and condition-variable SVCs. The worker's payload and exit must precede the signaller's return. The original implementation failed both delays; changing readiness alone fixed 1 ms but still failed 20 ms. Equal-priority, other-core, migratable, host-helper, suspended and host-GPU-work peers do not block the caller. Its handoff-scope cases add a busy higher-priority peer that nobody woke: the woken worker must still finish first and the signaller must return (the whole-core wait never returns), a worker woken by the woken dispatcher must finish before the signaller returns (fails without the recorded chain), and a woken thread that yields to any thread releases the signaller (never returns without that rule). The native-mutex case wakes a higher-priority pinned peer while holding the mutex it needs; the signal must return at that native block, and the peer finishes after unlock (hangs without the wait annotation). - Found on: Metroid Dread, whose per-build movie request-ring gates and
core_preempt onsetting are now removed; the whole-core wait on The Legend of Zelda: Tears of the Kingdom (issue #247) and Donkey Kong Country: Tropical Freeze (issue #259, section 6; no profile here). - Native helper joins:
dm_host_thread_joinalso releases the guest core during a native helper's lifetime, preserving an enclosing host-work scope. Xenoblade Chronicles 3 exposed an audio-unit setup join inAKEventManagerduring a later stall. That sample establishes the missing scheduler annotation, but does not establish an unbounded Apple audio-unit construction deadlock. The preemption regression gates a native helper, verifies that a same-core signaller returns while the peer joins it, then checks completion, the join result and nested host work. - Scope: these are cooperative thread-SVC handoffs, not a complete asynchronous guest scheduler. The optional
core_preemptprofile key (DM_PREEMPT=0/1overrides it) additionally checks already-runnable peers after every thread SVC; it defaults off. Historically that broader policy moved Super Mario Bros. Wonder's first frame from 0.6 s to 2.9 s by waiting for busy decompression threads. Wake-triggered handoffs apply with either setting.
- Symptom: the game freezes when a movie starts playing beside the game (a guide movie in a corner of the screen): the last frame stays, the process idles at a few percent of a core, and the thread dump shows a lower-priority job thread
running/not waitingwith its backtrace in the movie player (ReleaseOutputBufferIndex), the main thread waiting for it, and the movie's input threadrunning/not waitingtoo. Asampleof the process shows the job thread inmd_pump→md_event→dm_yield_to_core_peers, and the input thread inh_movie_SendIn→md_pumpwaiting for a pthread mutex. - Cause: the host's movie decoder holds its decode lock (
dmu) across guest code: the "input available" and "output ready" events are signalled with the SDK'sSignalEventfrom insidemd_pump. The signal woke the movie's higher-priority threads pinned to the signaller's core, so the signaller's same-core wake handoff waited for them to block before its SVC returned. One of them, the input thread, had calledSendInputBufferForDecodeand was waiting fordmuin host code. A thread waiting for a host lock still counted as runnable, so the handoff never ended and the lock was never released. On Horizon that lock is guest-visible (the SDK's decoder holds annn::osmutex there), and a thread waiting for it is blocked. - Fix:
dm_host_lock(hle_thread.c) takes a host lock that HLE code holds while it runs guest code. A guest thread that has to wait for it is in a native HLE wait (dm_thread_native_wait) while it waits: blocked forthr_runnable, and out of the handoff that made it runnable, so the handoff does not wait for it. The movie decoder takesdmuand the deferred-event lockg_dfmuwith it (hle_movie.c). Releasingdmubefore signalling instead would let a signal land afterStophas returned, on events the game is tearing down (the reasonmd_defer_bodysignals under its lock). - Check:
make -C dm/host/tests GAME=010093801237C000 preempt, handoff scope "woken thread waiting for the signaller's host lock": the signaller holds a host lock across its signal and the woken higher-priority worker waits for it withdm_host_lock. The signaller must return before the worker finishes; without the native wait around the lock the test never returns. - Found on: Kirby and the Forgotten Land: the first guide movie ("A Jump", 480×270 VP9) froze the game on the first walk from the beach, every time. The job worker (priority 46) and the movie threads (priority 44) are all pinned to core 0.
- Found on: Xenoblade Chronicles 3, whose 140-second opening VP9 movie stopped at its first output release. A live stack sample identified the input thread in
md_pumpwaiting ondmu, and the output thread holding it insideSignalEvent→dm_yield_to_core_peers.
- Symptom: several threads crash at once and the dumps interleave, then the process hangs instead of exiting.
- Cause: Unity's job threads tend to crash together.
- Fix:
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. - Found on: Unity titles in general.
- Symptom: none seen; found while investigating Fire Emblem Engage's demo crash, which it did not change (that was the address-space shortage, Size-aligned regions exhaust the alias address space).
- Cause: for
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. - Fix: the host writes
value − 2, the specified behaviour.
- Symptom: a 64-bit wait could return at once or block when it should not (not met in a game).
- Cause: Horizon 18 added a 64-bit "wait if equal" arbitration type, which SDK 19 exports as
nn::os::WaitForAddress(const long*, long); the host used to fall into its 32-bit "wait if less than" test. - Fix: type 3 compares the 64-bit value (
hle_thread.c). - Found on: Pokémon Legends: Z-A (SDK 19.3.11): Z-A does not call it, but the SDK has it.
- Symptom: a PhysX worker crashes in a task's
removeReferencewith a null task manager, right as the game switches to 30 fps (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. - Cause: the game publishes completion before task teardown ends, then reuses the same task. Its main thread rebuilds each world's completion task before
simulate; the worker sets done, notifies and unlocks insiderun, then callsrelease, whose destructor clearsmTm. Another core may rebuild it in between. This is a guest lifetime race: an unlock cannot protect subsequent destructor writes. PhysX's task contract places recycling inrelease, afterrun, and itsPxLightCpuTaskdestructor clearsmTm. There is no measured Switch timing guarantee that makes the ordering safe. The game's offsets, affinities and replay evidence are in its note. - Fix: a per-build cut makes that one
releasearet(itspatches.py); the destructor frees nothing and the step rewrites every field. - Check:
dm/tools/check_thread_cuts.pyreplays the original construction, release andremoveReferenceinstructions with a forced late release: the original faults at the recorded null-manager load; normal release-before-rebuild succeeds; the registered cut preserves the new task. This controlled replay has no host mutex or graphics implementation in its failure path. - Found on: The Legend of Zelda: Link's Awakening. How it was found is in 05.
The yield back-off (DM_YIELD_BACKOFF) and the rewriter's spin waits (patch_nso.py "Spin waits", dm_spin_wait in hle_thread.c), with their measurements, are in development.md ("Yield loops", "Spin loops").
- Symptom: a 30 Hz game thread runs at 27 Hz, and whatever it paces comes out slow (cutscenes at 36 ms per frame instead of 33.3; gameplay was fine at 60).
- Cause: 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. - Fix:
SleepThreadsleeps three quarters of the remaining time per step until within 20 µs (sleep_preciseinhle_thread.c). - Found on: Metroid Dread: cutscenes at 33.3 ms, the sound thread at 33.2 ms. Super Mario Bros. Wonder's frame-time p95 also went from 19.7 to 18.4 ms.
- Note: timed waits (
WaitSynchronization, condition variables) still use the host's own timeouts.
- Symptom: a guest thread keeps a core at 100% with billions of
SleepThreadcalls. - Cause:
sleep_precisereturns at once for anything under 20 µs (its nanosleep would overshoot), so a loop of such sleeps never sleeps. - Fix: sleeps under 20 µs take the yield path and its back-off (plain yields for the first 2 ms of a run, then short sleeps).
- Found on: Super Mario Galaxy: Hagi polls with
SleepThread(100 ns)(main+0x517c), 1.4 billion calls in 200 s, and the render thread nearly as busy. 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.
- Symptom: a loading screen sits for a minute with the CPU nearly idle; one thread yields thousands of times a second, every yield a back-off sleep (the power line's top yielder, with as many back-off sleeps).
- Cause: the thread was working, not waiting: its loop calls
nn::os::YieldThreadonce per item, and the back-off made each yield a 250 µs sleep. - Fix: a yield whose caller's x19–x28 changed since the last one is progress (
thr_yieldinhle_thread.c; the SDK reaches the SVC through tail branches, so those registers are the loop's own): it returns at once with no host call, and the run starts again. Polls keep those registers (Three Houses' limiter, Galaxy 2's Hagi sleep poll atmain+0x11ec); counters move them. - Found on: Animal Crossing: New Horizons: a black screen with the plane icon for 77 s after the opening movie,
SaveProcat about 2,600 yields a second, its grid walks over the new island (main+0x157d020) yielding once per cell; also about 40 s of the same at boot. The island now shows about 10 s after the movie, as withDM_YIELD_BACKOFF=0. Polls that keep their registers: Fire Emblem: Three Houses, Super Mario Galaxy 2. - Check: check such a change on The Legend of Zelda: Breath of the Wild outdoors: its
PlacementMgrandRadarMgrturned out to yield per item too, and asched_yieldon each of their 200,000 yields a second cost a third of a core more than the back-off had; with no host call, CPU energy fell instead (numbers in development.md "Yield loops"). - Note: a temporary log of which of x19–x28 move at each yield, per thread, is what told the two kinds apart.
- Symptom: one guest thread keeps a host core at 100% with about a billion yields a minute (
SVC 0xbinDM_HLECOUNT), none of them a back-off sleep, while the game itself plays normally. - Cause: the loop waits at two yields whose callee-saved registers differ, so each yield looked as if the loop had moved on since the last one, and returned at once. Hades'
NetworkPollThread(main+0x27aaa0) yields between its passes with w23 = 0, and in each pass yields while the nifm request is on hold with w23 = 2,000, its retry count. - Fix: a yield is progress only when x19–x28 differ from both of the last two yields (
thr_yieldinhle_thread.c): a loop that comes back to the registers of the yield before last is waiting, and the run goes on to the back-off. Counters and cursors (Animal Crossing's grid walk, Breath of the Wild's batches) never come back, so they still return at once. - Found on: Hades, with Network requests stay on hold before they settle. Neither change alone was enough: in 60 s of its smoke route the process used 117 s of CPU on
main, 119 s with only the nifm change (627 million submits, a pass now ended at the second query) and 122 s with only this one (1.38 billion queries, the 50 ms hold still timed out). With both it used 62 s, as with the poll thread's yields turned into sleeps: 216,000 yields, most of them back-off sleeps. - Check: a temporary per-thread count of the yields only this rule keeps in a run, and of the back-off sleeps those runs then took, on boots and play of other games: Tears of the Kingdom's main thread and ModuleSystem workers about 0.1% of their yields (761 of 926,116 on the main thread in 120 s) and no back-off sleep; Breath of the Wild outdoors at most 7 per thread in 150 s, no back-off sleep; Three Houses up to 95 per thread, Donkey Kong Country: Tropical Freeze about 1,000 on the main thread, neither with a back-off sleep from them; Kirby at most 1; Celeste, BALL x PIT, Bayonetta and Ghost Trick none. Their frame rates matched
mainin runs that kept the window's focus (a run whose window loses focus to another app drops to about 30 fps, onmainas well).
- Symptom: a host helper thread aborts when signalling a destroyed
SystemEvent; repeatedvi::GetDisplayVsyncEventcalls double the event's tick rate. Rapid replacement can abort withtoo many vsync events, or a retired vsync worker can signal another service's event at reused storage. - Cause: lazy binding calls the SDK's
CreateSystemEvent, which requires a guest thread. Checking only the guest address also mistakes a replacement binding for the original event, even if its kernel handle number is checked: both can be reused before the next tick. An eight-slot worker table counts destroyed events until their workers next run. - Fix:
hle_signal_system_eventretains lazy binding on guest threads; host threads skip an unbound event and log once. Each service-event binding gets a monotonically increasing identity.hle_signal_system_event_bindingmatches that identity and signals under the same lock asCloseHandle, so a stale producer cannot signal a replacement. The vi registry keeps one worker per binding identity in an allocated list; a worker whose binding closed removes only its own entry and exits. A new vi binding at reused storage gets its own worker, while repeated requests for a live binding share the existing one. - Found on: synthetic host regression cases in
t_system_event.c, using real binding and event SVCs with worker startup held until after destruction and handle reuse; no game failure is claimed. - Check: duplicate vi registration, nine rapid replacements before the first tick, storage and handle reuse by a different service, vi rebinding at reused storage, host signalling after destruction, and guest lazy binding. The capacity and unrelated-service cases fail on the original PR #202 and pass with lifetime identities and the allocated registry.
- Symptom: the main thread waits forever on a job's done event at boot while every worker sleeps, two of them on one event (
main+0x1426cwaiting onJob::m_doneEvent); 4 of 6 Mac runs. - Cause: the engine puts a job in worker k's private queue but signals one shared SDK auto-clear event. Its condition wake chooses by priority, then FIFO; it has no target-worker identity. The wrong worker checks only its own queue and sleeps again. This is a guest assumption about waiter order, exposed by host timing. Host
pick/wseqagrees with Atmosphère's condition-tree comparator andSignal, including the historic 0.19.0 implementation. This describes the SDK's condition-based auto-clear event; kernel readable events instead notify all synchronization waiters. Game offsets and observations belong in its note. - Fix: the game's own wait wrapper (
main+0x12500,{event, pending}) signals the event once more when it takes a nonzero pending word after waking, and nothing set it: a build cut makes the signal set it first (itspatches.py), so a worker woken in another's place passes the wake on; 6 of 6 runs boot. - Found on: FINAL FANTASY TACTICS - The Ivalice Chronicles.
- Check:
t_preemptchecks priority/FIFO selection and mutex reacquisition through real condition SVCs.dm/tools/check_thread_cuts.pyruns the game's actual signal and wait wrappers with both worker arrival orders: only the original wrong-worker cases strand a job. The game's note records the Mac boot comparison with the corrected host. - Note: an unconditional
StartThreadchild-entry handshake changed the timing but did not eliminate the hang and is not a kernel guarantee. The separate same-core start handoff applies only when priority and affinity require it.
- Symptom: abort in
os::WaitSystemEventunderoe::PopNotificationMessage(The Legend of Zelda: Link's Awakening); one guest thread at about 1000 ms/s on the iPhone only, the phone hot, CPU energy 5–7 W (Monster Hunter Rise'sMessageSuperviseThread, priority 32, in the[nvn] threadslines). - Cause: the host keeps the notifications in a queue of its own. The blocking pop waits on the system event
oe::Initializewould have created, and the host'sInitializeis a no-op. Monster Hunter Rise's 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. - Fix: the host now implements the blocking pop on its notification queue (a condition variable, then the
TryPoppath), and the event 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). - Found on: The Legend of Zelda: Link's Awakening (SDK 8.2.0, the blocking pop); Monster Hunter Rise (the event).
- Check: 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.
- Symptom: Paper Mario: The Thousand-Year Door 1.0.1 stays black before its first frame. The main thread stops at
main+0x32796c, after startingSYS_Notification; the priority-28 notifier is inoe::PopNotificationMessage, while the priority-44 main thread waits for its same-core wake handoff. - Cause: the notification HLE blocks in a host
pthread_cond_wait, outside the guest kernel's wait bookkeeping. The notifier remains classified as runnable and retains its inherited handoff, so its lower-priority waker cannot continue even though the notifier has no work. - Fix:
dm_thread_native_waitmarks the blocked native-wait interval under the scheduler lock and drops the inherited handoff.PopNotificationMessageenters that state while holding the queue mutex, waits, and restores runnable state after reacquiring the mutex. Ordinary native execution is still runnable. - Found on: Paper Mario: The Thousand-Year Door.
- Check: the original PR #291 reproducer on upstream
1402ded2blocks until its watchdog exits with code 99.make -C dm/host/tests GAME=0100ECD018EBE000 notify-schedulernow uses realCreateThread,StartThread, wait-for-exit and close SVCs; only the assembly transition to a guest stack is replaced by a C test entry. The original notification HLE strandsStartThreaduntil the watchdog exits with code 99. The fixed HLE passes 100 runs in each scheduling mode, including a wake without a queued message, runnable-state restoration and delivery of focus notification 15. The full host test suite (make -C dm/host/tests GAME=0100ECD018EBE000 run) also passes with CoreAudio access. The game's note records its separate Mac startup comparison and iPhone report.