Order same-core wake handoffs and remove Dread movie gates

Use guest runnable state after a higher-priority same-core wake instead of Mach dispatch state or a 5 ms cutoff. Honour wake handoffs for every game and remove Metroid Dread request-ring gates and its core_preempt setting.

Validate event, mutex and condition-variable handoffs with delayed host dispatch; 13 valid Dread movie-transition runs passed. Host tests, docs and shader-chain checks passed, along with an iOS syntax check. Record Wonder boot comparison and pending iOS runtime validation.
This commit is contained in:
seedds committed 2026-10-26 02:27:03 +08:00
1 parent 9feac2f1e7
commit 9a56ecf0c1
13 files changed
+155 -85

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ No game-specific executable cuts. No `dm/tools/sdk_patches.py` cut for its `sdk`
- **Current:** 60 fps with a dynamic present interval. The game times every frame (`main+0xcfd24`); after a frame that ran past one vertical sync it sets present interval 2 for the next frame and sets its own time step to the interval (a float at `main+0x36ceff0`), so that frame catches up 2/60 s (Mario 1.2.1 on the iPhone). A user's 2-minute iPhone session switched 123 times, mostly for one frame.
- **Resolved:** in `display` mode those frames were shown for 16.7 ms and the game visibly jumped ahead ("sometimes the speed increases when jumping"). In `game` mode (the default since 2026-10-15) the display link keeps a frame whose period is over 25 ms on screen for its whole period (`g_link_hold_until` in `nvn_mtl.m`), and the 30 fps target waits until interval 2 has held for a full second (`nvn_game_fps` in `hle_nvn.c`), so one-frame switches no longer flip the display-link mode. `DM_TEST_PRESENT_LAG=90,30` (30 ms before every 90th present) makes Mario do this on the Mac; run it with `DM_WINDOW=1` to get the display link.
- `core_preempt` stays off: with it Mario's loader threads waited for busy decompression threads and its first frame moved from 0.6 to 2.9 s.
- `core_preempt` stays off: with it Mario's loader threads waited for busy decompression threads and its first frame moved from 0.6 to 2.9 s. The shared [wake-triggered handoff](../../../docs/reference/threads.md#same-core-wake-handoffs) applies with it off: a 2026-10-26 boot smoke check reached the first frame in 1.36–1.56 s versus 1.31–1.50 s with the old host (three fresh saves each, no current shader libraries; this checks startup scheduling, not rendering performance). Logs are in the NSP work folder's `wake-handoff-check/`.
- The precise `SleepThread` (`sleep_precise`, found on Metroid Dread) took Mario's frame-time p95 from 19.7 to 18.4 ms.
- The spin-wait rewrite finds 175 loops in `main`; the store rule added one more in each build.
+3 -6
View File
@@ -6,8 +6,6 @@ Update 2.1.0 (v327680) over the base game, SDK 11.4.3. MercurySteam's own engine
| Setting | Where | Why |
|---|---|---|
| `core_preempt on` | [`game.tsv`](game.tsv) | the movie player relies on Horizon's per-core preemption; without it about half the runs crashed at the intro / title movie changes. See [Movies](#movies) |
| Observation gates OBS 19 / 20 at `main+0x2247a4` / `main+0x2248e4` | [`patches.py`](patches.py), `main` build `646761f6…` | wait for the movie dispatcher's request ring to empty after each posted request (`movie_ring_wait`) |
| 2.25 GB block: `mem_reserve 0x90000000`, `mem_alias 0x10000000`, `mem_stack 0x10000000`, `app_pool 0xCD500000` | `game.tsv` | no system resource; 256 MB in front + 256 MB alias region + 256 MB stack region + 1.5 GB heap region. See [Memory](#memory) |
| `modules main subsdk0 sdk` | `game.tsv` | `subsdk0` is the movie library, which the game's movies go through |
| `shader_groups /system/shd/=shd` | `game.tsv` | every shader is in `system/shd/*.bshdat`; see [Files and shaders](#files-and-shaders) |
@@ -33,9 +31,8 @@ The game plays VP9 WebM movies through the SDK's own demuxer and a decoder in na
- **Resolved:** the game calls the complete-object destructor `movie::Decoder::~Decoder` (`D1`), which the host now takes over too; the SDK's own one destroyed the host object as a `DecoderImpl`.
- **Current:** the game's `RegisterClientEvents` struct is overwritten by its second movie. The host first copied the four event pointers; it now keeps the pointer and reads the entry each time it signals, as the SDK does (`md_event` in `hle_movie.c`), and when the client's entry is not a readable, 8-byte aligned pointer it falls back to the entry's value at registration (this game's case).
- The game ends a movie with `Flush`, `Flush`, `Stop`, destroy, and calls `Start` after a `Flush` when it loops. As with MediaCodec, `Flush` announces nothing, and no input or output index is handed out while the decoder is stopped. The "input available" after `Start` is signalled about 2 ms later from a guest thread, as the SDK's codec thread would; pending signals are dropped at `Stop`, `Flush` and destruction.
- **Resolved: crashes around movie changes in the game's own player threads, about one run in two** (`SignalEvent` → `MultiWaitImpl::NotifyAndWakeupThread` on a null MultiWait at address 0x20; `WaitAny` walking freed holders; a worker reading a freed object at `main+0x225338`). The movie player's main thread (priority 44, core 0) posts "link / unlink these holders" requests into the dispatcher thread's request ring (`main+0x222cc8`) 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):
- observation gates after each posted request (OBS 19 after the link request, loop at `main+0x224728`; OBS 20 after the unlink request, loop at `main+0x224868`; both on the `add x22, x22, #8` after the `bl`, `x19` = the request ring), which wait until the ring is empty (`movie_ring_wait` in `hle_movie.c`). Gates may now sit right after a `bl` as well as at function entries;
- the same-core preemption approximation, `core_preempt` (on only for this game: Mario's loader threads would wait for busy decompression threads, and its first frame moved from 0.6 s to 2.9 s).
- **Resolved: crashes around movie changes in the game's own player threads.** The main thread (priority 44, core 0) posts link / unlink requests into the dispatcher's ring (`main+0x222cc8`) and leaves work for a second thread (both priority 30, core 0 only), then frees and rebuilds the movie object without an explicit acknowledgement. The shared [same-core wake handoff](../../../docs/reference/threads.md#same-core-wake-handoffs) now supplies the ordering. The build-specific ring waits (OBS 19 / 20 at `main+0x2247a4` / `main+0x2248e4`, `main` build `646761f6…`) and the profile's `core_preempt on` are removed.
- **Mac confirmation (2026-10-26):** with both former workarounds disabled, the old host crashed in 2 of 3 fresh-save runs, at `sdk+0x347eb4` called from `main+0x225338` (17.2 / 18.7 s). The shared handoff passed the isolated dispatch-delay regression and 13 fresh-save movie-transition runs with the current Mac shader libraries (45–65 s each), with both former workarounds disabled. This includes the regenerated main module with zero observation gates. Temporary read-only probes saw all 33 sampled request posts already drained without waiting. Game files, logs and isolated saves are in the NSP work folder's `run/movie-sync/`. iOS validation of this replacement remains open.
## Frame rate
@@ -76,7 +73,7 @@ General host changes first needed here; each applies to every game:
- **Patch areas before the module base** for modules with a giant bss (negative `PATCH_OFF`, `FILE_IMAGE_LEN`, `IMAGE_SIZE`): [Patch area before the module base for a large bss](../../../docs/reference/rewriter.md#patch-area-before-the-module-base-for-a-large-bss).
- **Used memory counts the bss of front-patch modules** (`big_bss_bytes`): [Heap sized from the application pool less used memory](../../../docs/reference/memory.md#heap-sized-from-the-application-pool-less-used-memory).
- **`sleep_precise`** against macOS timer slack, **same-core preemption** (`core_preempt`, `svc_thread` / `dm_yield_to_core_peers`, `DM_PREEMPT`) and **observation gates right after a `bl`**: [Timer slack in guest sleeps (`sleep_precise`)](../../../docs/reference/threads.md#timer-slack-in-guest-sleeps-sleep_precise), [Approximating same-core preemption (`core_preempt`)](../../../docs/reference/threads.md#approximating-same-core-preemption-core_preempt).
- **`sleep_precise`** against macOS timer slack, **same-core wake handoffs** (`svc_thread` / `dm_yield_to_core_peers`): [Timer slack in guest sleeps (`sleep_precise`)](../../../docs/reference/threads.md#timer-slack-in-guest-sleeps-sleep_precise), [Same-core wake handoffs](../../../docs/reference/threads.md#same-core-wake-handoffs).
- **Native-texture movies** (`nvn_host_write_texture`, `nvn_host_signal_sync`), the `D1` destructor, `md_event`, the SDK 11 names, `FinishStartupLogo`, `SendVibrationValues` and lexical `..` (`norm_rest`): [Decoding into the client's NVN texture](../../../docs/reference/movies.md#decoding-into-the-clients-nvn-texture), [The complete-object destructor](../../../docs/reference/movies.md#the-complete-object-destructor), [Client events read through the registered pointer](../../../docs/reference/movies.md#client-events-read-through-the-registered-pointer), [Older SDK names for the same calls](../../../docs/reference/services.md#older-sdk-names-for-the-same-calls), [The startup logo](../../../docs/reference/services.md#the-startup-logo), [Vibration values flushed by the SDK's vibration graph](../../../docs/reference/services.md#vibration-values-flushed-by-the-sdks-vibration-graph), [Paths with `.` and `..` components](../../../docs/reference/services.md#paths-with--and--components).
- **Per-stage program binding** (`bind_stages`, composite programs, `Z` lines in pass dumps), **in-place `WriteTexels`** (`write_region_bl`) and the **MSHD reader**: [Programs bound per stage](../../../docs/reference/rendering.md#programs-bound-per-stage), [`WriteTexels` regions written in place by block-linear address](../../../docs/reference/nvn-objects.md#writetexels-regions-written-in-place-by-block-linear-address) and [MSHD in MercurySteam's bshdat files](../../../docs/reference/shaders.md#mshd-in-mercurysteams-bshdat-files).
- **The submit-thread autorelease leak** (`g_sub_pool`): [05, Memory growth](../../../docs/agents/05-debugging.md#memory-growth).
-5
View File
@@ -33,11 +33,6 @@ mem_alias 0x10000000
mem_stack 0x10000000
app_pool 0xCD500000
# ---- Threads ----
# Same-core preemption approximation (hle_thread.c): the movie player's main thread (priority 44, core 0) posts "unlink the holders" to the dispatcher thread and leaves messages for the worker thread (both priority 30, core 0 only), then frees the object and builds a new one in place without waiting;
# on hardware they preempt the main thread at once and finish first. Without the approximation about half of the runs crashed at the intro / title movie changes (SignalEvent on a null MultiWait, main+0x225338 reading a freed object); with it 12 runs in a row were clean
core_preempt on
# ---- Shader split (shader_split.py; MSHD containers) ----
shader_groups /system/shd/=shd
shader_note Metroid Dread shaders
Can't render this file because it contains an unexpected character in line 37 and column 113.
-10
View File
@@ -1,10 +0,0 @@
# Metroid Dread (010093801237C000): the rewriter's patches of this game's own builds. dm/tools/patch_nso.py loads every profile's patches.py and applies those of a module's NSO build ID;
# CUTS, OBS and FRAME_STEP as described there ("Registered patches"). Patches of Nintendo SDK module builds are in dm/tools/sdk_patches.py
OBS = {
# Metroid Dread 2.1.0 main: the movie player posts "link / unlink holders" requests into the dispatcher thread's request ring (main+0x222cc8) and goes on without waiting for them (after an unlink it frees the wrapper object and builds a new one in place).
# On hardware the dispatcher thread (priority 30, core 0 only) preempts the main thread (priority 44, core 0) at once and finishes first; the host waits for the ring to empty after each post (hle_movie.c movie_ring_wait). The gates sit on the add x22, x22, #8 after the bl
'646761f643afebb379edd5e6a5151af2': [
(0x2247a4, 19, '910022d6', 'link request posted (loop at main+0x224728, x19 = request ring): wait for the dispatcher; orig add x22, x22, #8'),
(0x2248e4, 20, '910022d6', 'unlink request posted (loop at main+0x224868, x19 = request ring): wait for the dispatcher before the object is freed; orig add x22, x22, #8'),
],
}
+2 -2
View File
@@ -126,7 +126,7 @@ One row per profile; the notes are each profile's `README.md`.
| `present_interval` | `game` (the default): frames are paced by the present interval the game sets, as on the Switch (a frame presented with interval 2 stays on screen for two display ticks; a game that keeps asking for 2 for a second gets the 30 fps target, and on iOS the display link then runs at 30 Hz; Mario 1.2.1 switches to 2 for single frames after a slow one; Engage asks for 2 on the Switch, and a rewriter cut on its 2.0.0 `main` makes it ask for 1, see `0100A6301214E000/game.tsv`). `display`: one frame per display tick whatever the game asks, which is only right for games whose logic does not count frames (D2R's runs on its own 25 Hz clock; Monster Train 2 asks for 2 and steps by Unity's frame time, so it runs at 60 fps: screenshots at the same moments match at 30 and 60 fps). `DM_PRESENT_INTERVAL=game` / `display` overrides it, also from `Documents/env.txt` |
| `fps_target` | Frame rate cap of the rewriter's frame step, for builds that have one (`FRAME_STEP` in the profile's `patches.py`: Tears of the Kingdom 1.4.3, Breath of the Wild 1.9.0, Hyrule Warriors: Age of Calamity 1.3.0, Paper Mario: The Thousand-Year Door 1.0.1, ASTRAL CHAIN 1.0.1), or the engine's own frame limit the host writes into game memory (`hle_fpslock.c`: Monster Hunter Rise 16.0.2, which also needs `present_interval display`; Octopath Traveler 0 1.0.5, Unreal's `t.MaxFPS` and `rhi.SyncInterval`, so the game asks for present interval 1 itself; Luigi's Mansion 3 1.4.0, its frame limiter's milliseconds per frame, with `present_interval display`). `0` (the default): the game's own step. `30` / `60`: the host decides each frame's step in whole vsyncs and keeps the frame on screen that long (`hle_nvn.c` `fstep_tick`, `nvn_fstep.h`): 1 at a 60 cap, 2 when 60 does not hold or at a 30 cap (and while a prerendered movie plays), plus one vsync while the game is behind real time after a late frame. Presents follow that hold instead of the interval the game sets. `DM_FPS_TARGET` overrides it, also from `Documents/env.txt`; the log line `frame step: game time … of real time` checks the game's speed |
| `fps_hold` | `on` (the default): with `fps_target`, three late frames in a row raise the hold above the cap's vsyncs (30 or 20 fps), and steady frames that fit try the lower hold again. `off`: frames are never held longer than the cap's vsyncs; each is shown as soon as it is ready. For a game whose updates do not wait for the presents and step by the cap whatever the hold (Age of Calamity): there the hold only paces the screen. `DM_FPS_HOLD=on` / `off` overrides it, also from `Documents/env.txt` Paper Mario: The Thousand-Year Door sets it off too: its frame waits for three render worker threads, and holding frames left them idle, so a frame cost more the longer the hold (the hold stuck at 3 vsyncs, 20 fps, on a loaded Mac; 32–58 fps without holds) |
| `core_preempt` | `on` / `off` (the default): approximate Horizon's per-core preemption. When an SVC wakes a higher-priority thread pinned to the caller's core, the caller waits (at most 5 ms) until such threads block again (`hle_thread.c`). Metroid Dread's movie player relies on it; Mario's loading gets slower with it (first frame 0.6 → 2.9 s), so it is off unless a game needs it. `DM_PREEMPT=0` / `1` overrides |
| `core_preempt` | `on` / `off` (the default): additionally check higher-priority runnable threads pinned to the caller's core after every thread SVC (`hle_thread.c`). `DM_PREEMPT=0` / `1` overrides it. Wake-triggered same-core handoffs apply to every game with either setting and wait until the peer blocks, with no wall-clock cap. Metroid Dread no longer needs this key or its movie request-ring gates; see [Same-core wake handoffs](../../docs/reference/threads.md#same-core-wake-handoffs) |
| `alloc_big_align` | `on` / `off` (the default): `nn::mem::StandardAllocator::Allocate` aligns blocks of 64 KB or more to 64 KB (`hle_os.c`). Vanillaware's engine (13 Sentinels: Aegis Rim, Unicorn Overlord) takes a memory pool's storage from malloc and cuts ring heaps out of it at 64 KB alignment relative to the pool's GPU address. On the Switch that address is big-page aligned; the host's is the CPU address, only 4 KB aligned, so the last ring heap does not fit and keeps base 0, and the game writes through a null pointer (13 Sentinels entering its first battle, Unicorn Overlord past the title screen) |
| `shared_font` | Optional, decided by the game files without it (the SDK version the host reads from the `sdk` module, `loader.c` `dm_sdk_major`, and `main`'s imports, `dm_main_imports`): what the shared-font address points at. `bfttf` (SDK 11 and later, when `main` imports `ScalableFontEngineHelper::Decode`) = the 8-byte BFTTF header (with the "already decoded" marker) followed by the TTF, which Mario passes to `Decode`; `ttf` = the TTF itself, which SDK 10 and older games hand straight to `ScalableFontEngine::LoadFont` (Super Mario 3D World + Bowser's Fury: with the header the engine fails with error 403 and the game crashes looking up a glyph; Odyssey, Link's Awakening, Age of Calamity and Three Houses alike) and Unity's player to FreeType (Patrick's Parabox: with the header its Japanese, Korean and Chinese text drew nothing). No profile sets it |
| `device_api` | Optional, decided by the SDK version without it: the NVN API version `major.minor` that `nvnDeviceGetInteger` reports, 55.15 for SDK 11 and later (what Mario requires) and 53.313 for SDK 10 and older (`hle_nvn.c` `nvn_api`). Games check it and skip creating their device on a mismatch: Super Mario 3D World + Bowser's Fury (SDK 10) requires major 53 and minor ≥ 313. No profile sets it. `DM_NVN_API` overrides. Not part of the shader chain version (only `shader_*` keys are) |
@@ -170,7 +170,7 @@ These are not profile keys: the tools read them from the game files each time, b
After the rewrite, `mem_used` is set when the rewritten modules exceed the default 64 MB (a game without a system resource); keep it when the profile moves into `dm/games` (Cuphead without it: a 0.03 GB heap and an abort at the first allocation). A game with a system resource that sets a heap then gets a **heap check**: it boots in the Mac host (headless, no shader libraries) until its first heap request, or 5 s into `nnMain` without one, at most 30 s (`HEAP_CHECK_SECS`). A request the heap region cannot hold (`SetHeapSize OOM`) sets the alias / heap split (Tears of the Kingdom asks for 0x86000000, which gives its hand-made 0x98000000 alias region; Wonder's 1.44 GB gives 0xc0000000), and a pool check that fails for good sets `mem_used`. A run with no heap set whose alias region is used up to within 0.25 GB of its end sets a 1 GB pool: `nninitStartup` handed pool − used to StandardAllocator as one address region and the game reserves regions of its own beside it (Unity 2020 and 2022: AI: THE SOMNIUM FILES - nirvanA Initiative reached +4.26 GB of 4.38 in 5 s with the hardware pool; Unity 2018.3's Raging Loop maps only inside StandardAllocator's region and keeps it). The 1 GB is a starting point, not a measurement: Advance Wars 1+2: Re-Boot Camp (Unity 2020.2) ran its malloc dry at its first loading screen with it and needs 2 GB. The profile gets a `# heap check:` line, and later runs keep its keys (delete the line to check again; `--no-heap-check` / `HEAP_CHECK = False` skips it). Other games skip the check, so it costs nothing for them and a few seconds plus a Mac host build for the rest.
No short run decides `render_cond`, `tex_flush_upload`, `tex_dirty_upload`, `core_preempt`, `gpu_timestamps`, `feedback_snap`, `alpha_from_red`, `vconv_range` or `present_interval`, nor `op_mode` / `pad_layout` outside the presets: they keep their defaults for a developer. A minute's boot without input counted Wonder's clears under render conditions (65,536 or more; it needs no `render_cond`) as readily as Tears of the Kingdom's, saw Breath of the Wild flush 32 CPU-filled textures with data (it needs no `tex_flush_upload`) and Age of Calamity none (its movie frames come later), and Metroid Dread's movie crash shows in about half the runs. On the iPhone, `Documents/env.txt` can still set `DM_OPMODE`, `DM_NVN_GPU_TS` and `DM_NVN_FB_SNAP`, and the pad layout switches in game.
No short run decides `render_cond`, `tex_flush_upload`, `tex_dirty_upload`, `core_preempt`, `gpu_timestamps`, `feedback_snap`, `alpha_from_red`, `vconv_range` or `present_interval`, nor `op_mode` / `pad_layout` outside the presets: they keep their defaults for a developer. A minute's boot without input counted Wonder's clears under render conditions (65,536 or more; it needs no `render_cond`) as readily as Tears of the Kingdom's, saw Breath of the Wild flush 32 CPU-filled textures with data (it needs no `tex_flush_upload`) and Age of Calamity none (its movie frames come later), and the old host's Metroid Dread movie crash showed in about half the runs (now resolved by [shared wake handoffs](../../docs/reference/threads.md#same-core-wake-handoffs)). On the iPhone, `Documents/env.txt` can still set `DM_OPMODE`, `DM_NVN_GPU_TS` and `DM_NVN_FB_SNAP`, and the pad layout switches in game.
The shader tables, `c1_full.tsv` and `cb_extent.tsv` are written into the same folder. A second run reuses that `game.tsv` (delete it to survey again), so it can be edited by hand. `--provisional` builds from a provisional profile even for a game that has one in `dm/games`, to test the guesses: `DM_GAMES_FIRST=1` makes every tool search `DM_GAMES_DIR` first, and the IPA gets `-provisional` in its name. Tools find profiles outside `dm/games` through `DM_GAMES_DIR` (folders separated by `:`, searched after `dm/games`); `make -C dm/host` reaches such a profile through the symlink `dm/host/gen/<id>/profile`. To keep a game, move the folder into `dm/games/` and bring it up as below.
2. **Profile.** Copy `010015100B514000/game.tsv` (or the provisional profile) to `dm/games/<title ID>/game.tsv` (the base game's title ID, as the folder name) and fill in the identity, `build` rows and `modules`. Leave the memory values as Mario's until the bring-up measures them ([docs/agents](../../docs/agents/README.md)).
+2 -2
View File
@@ -71,8 +71,8 @@ module_t *module_of(const void *p);
// Gate dispatch: rewritten svc / timer reads come in through the common entry, ret_addr = the return point of that gate block, regs points to the saved x0..x30
uint64_t gate_dispatch(uint64_t ret_addr, uint64_t *regs);
// Observation gates (rewriter OBS tables in the profiles' patches.py, applied by patch_nso.py; numbering matches): read-only callbacks, called on a gate hit once attached
enum { OBS_MOVIE_RING_LINK = 19, OBS_MOVIE_RING_UNLINK = 20, // Metroid Dread 2.1.0's movie request ring (hle_movie.c)
OBS_UE_MALLOC = 21, // DRAGON QUEST VII Reimagined 1.2.0: Unreal's allocator object just made (hle_uemalloc.c)
// IDs 19 / 20 belonged to the removed movie request-ring workaround. Old rewritten modules may still contain them; without callbacks those gates are harmless passes through.
enum { OBS_UE_MALLOC = 21, // DRAGON QUEST VII Reimagined 1.2.0: Unreal's allocator object just made (hle_uemalloc.c)
OBS_MAX = 22 };
void gate_obs_set(int id, void (*fn)(const uint64_t *regs));
int gate_obs_present(int id);
+1 -17
View File
@@ -134,7 +134,7 @@ static void md_event(mdec_t *d, int i) { // 0 input 1 output 2 format 3 error
// Deferred events: "input available" after Start / Flush. The SDK's decoder signals it asynchronously from its own codec thread; Metroid Dread calls InitializeMultiWaitHolder before RegisterClientEvents
// and only later links the holders into its MultiWait. Signalled synchronously inside Start, the holders were not linked yet and SignalEvent crashed in MultiWaitImpl::NotifyAndWakeupThread on a null MultiWait (0x20)
// (it showed only when a decoder was built a second time in the same memory). A guest thread now signals it about 2 ms later (SignalEvent is guest code and must be called on a guest thread); the destructor drops that decoder's pending signals under the same lock.
// The root of that crash and of the worker crash at main+0x225338 was missing same-core preemption (the game's dispatcher and worker preempt the main thread on hardware); hle_thread.c now approximates it (svc_thread, dm_yield_to_core_peers)
// The movie player's dispatcher and worker must also finish their requests before the posting thread continues; the shared SVC wake handoff supplies that ordering (hle_thread.c).
void *dm_guest_thread_spawn(void (*fn)(void *), void *arg, int prio, size_t stack_size);
static pthread_mutex_t g_dfmu = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t g_dfcv = PTHREAD_COND_INITIALIZER;
static struct { void *d; int ev; uint64_t due; } g_dfq[64]; static int g_ndfq, g_dfthr;
@@ -170,22 +170,6 @@ static void md_defer_body(void *arg) {
else { uint64_t w = next - now; struct timespec ts = { (time_t)(w / 1000000000u), (long)(w % 1000000000u) }; pthread_cond_timedwait_relative_np(&g_dfcv, &g_dfmu, &ts); }
}
}
// Metroid Dread 2.1.0's movie player: the main thread posts "link / unlink holders" requests into the dispatcher thread's request ring (main+0x222cc8: ring +0x368 write pointer, +0x370 pending count;
// the dispatcher links or unlinks each entry in main+0x222d94, finalizes the holders and events, and counts the pending number down to 0) and goes on without waiting: after an unlink it frees the wrapper object and builds a new one in place, after a link it calls Start at once.
// On hardware the dispatcher thread (priority 30, core 0 only) preempts the main thread (priority 44, core 0) and the requests are done first; on the host the threads run in parallel, the memory was reused while the old holders were still linked into the dispatcher's MultiWait,
// and WaitAny / SignalEvent walked into freed holders (the core_preempt approximation does not cover it: with the dispatcher busy, posting wakes nobody, and the main thread makes no SVC on the way).
// The rewriter puts observation gates on the instruction after each "one request posted" (OBS 19 / 20 in Metroid Dread's patches.py, for this build only); here we wait for the pending count to reach zero, for at most 3 s.
// 100 ms was too short once the Mac was saturated (an iOS shader build in parallel): the wait ran out with one request pending and WaitAny crashed right after. Waits over 50 ms are logged (the first eight)
static void movie_ring_wait(const uint64_t *regs) {
const volatile uint64_t *pending = (const volatile uint64_t *)(regs[19] + 0x370);
uint64_t t0 = now_ns();
while (*pending) {
if (now_ns() - t0 > 3000000000ull) { static int r; if (!r++) LOG("[RED] movie: player request ring not drained in 3 s (%llu pending), letting it through anyway", (unsigned long long)*pending); return; }
struct timespec ts = {0, 50000}; nanosleep(&ts, 0);
}
uint64_t dt = now_ns() - t0; static _Atomic int nlong; if (dt > 50000000ull && nlong++ < 8) LOG("[movie] waited %.1f ms on the player request ring", dt / 1e6);
}
__attribute__((constructor)) static void movie_ring_gates(void) { gate_obs_set(OBS_MOVIE_RING_LINK, movie_ring_wait); gate_obs_set(OBS_MOVIE_RING_UNLINK, movie_ring_wait); }
// log the first 12 calls one by one, then every 2^k-th (DM_MOVIE_TRACE=1: every call, with the calling thread and the decoder: Unity drives a movie's audio and video decoders from one thread)
static int md_trace_all(void) { static int on = -1; if (on < 0) on = getenv("DM_MOVIE_TRACE") != NULL; return on; }
#define MD_TRACE(d, slot, fmt, ...) do { unsigned long n_ = ++(d)->ncall[slot]; if (md_trace_all()) LOG("[movie] [%p] [%p] " fmt, (void *)pthread_self(), (void *)(d), ##__VA_ARGS__); \
+26 -33
View File
@@ -55,7 +55,7 @@ typedef struct thr {
// Current wait: kind 0=none 1=lock 2=condition 3=address 4=synchronization object
int wkind, woke; uint64_t waddr, wkey; uint32_t wtag; int64_t wres;
_Atomic int sleeping; // inside SleepThread's timed sleep (not runnable; used by dm_yield_to_core_peers)
int yield_pending; // this SVC woke a higher-priority thread pinned to this core: let it run before the SVC returns (svc_thread)
int yield_pending; // this caller woke a higher-priority thread pinned to its core: hand off before returning to guest code
_Atomic int *host_work; // points at the thread's dm_host_work (registered when the thread starts)
int host_owned; // a guest thread the host created itself (dm_guest_thread_spawn): it waits in host code and is not one of the game's threads
uint64_t wseq; // Sequence number of wait registration: at equal priority first come, first woken (same order as the Horizon kernel's KThread wait tree)
@@ -85,7 +85,8 @@ typedef struct thr {
} thr_t;
static thr_t *g_all;
extern __thread _Atomic int dm_host_work; // declared in hle.h too; defined in hle_nvn.c
static _Atomic uint64_t g_preempt_n, g_preempt_ns, g_preempt_cap; // same-core preemption yields: count, total wait, cap hits (svc_thread)
static _Atomic uint64_t g_preempt_n, g_preempt_ns; // same-core handoffs: count and total wait (svc_thread)
static int g_core_preempt; // also check already-runnable peers after every thread SVC (profile / DM_PREEMPT)
static pthread_mutex_t G = PTHREAD_MUTEX_INITIALIZER;
#define MAX_H 4096
@@ -114,34 +115,26 @@ static thr_t *cur(void) {
if (!t) die("thread SVC called from a non-guest thread");
return t;
}
// Let higher-priority threads of the same core run first: Horizon schedules per core, and when a signal from one thread wakes a higher-priority thread pinned to the same core, that thread preempts at once and runs until it blocks again before the signalling thread continues.
// Some games rely on this as synchronization: in Metroid Dread's movie player the main thread (priority 44, core 0) posts "tear down the decoder" to the dispatcher and worker threads (priority 30, core 0 only), then frees the object without waiting
// and builds the next one in the same memory; on the host the threads truly run in parallel, the dispatcher still had the old holders linked and the worker still had the old object queued, so SignalEvent hit a null MultiWait and the worker read a freed object.
// svc_thread calls it after an SVC that woke such a thread: it waits until every started, unexited, not host-created thread allowed only on this core with a higher priority is waiting or sleeping, for at most max_us; returns −1 on timeout
// Horizon's per-core priority ordering: a woken higher-priority thread pinned to
// the caller's core runs until it blocks before the signalling thread continues.
// See docs/reference/threads.md, "Same-core wake handoffs", for the movie lifetime failure this prevents.
// svc_thread calls it after waking such a thread. Wait for guest blocking, not a host dispatch snapshot or a wall-clock deadline: neither establishes that the work has finished.
void dm_thread_mark_host(void) { thr_t *t = pthread_getspecific(g_keythr); if (t) t->host_owned = 1; }
// Whether a thread is really running: besides guest waits / timed sleeps it may be blocked in host code (an HLE call waiting on a host condition variable, the GPU, a file), which has no guest wait kind; only the kernel's thread state tells
static int thr_running(const thr_t *t) {
if (t->wkind || t->sleeping || !t->mport || (t->host_work && *t->host_work)) return 0;
thread_basic_info_data_t bi; mach_msg_type_number_t n = THREAD_BASIC_INFO_COUNT;
if (thread_info(t->mport, THREAD_BASIC_INFO, (thread_info_t)&bi, &n) != KERN_SUCCESS) return 0;
return bi.run_state == TH_STATE_RUNNING;
// Holding G. wake() clears wkind before the pthread resumes; it must already count as runnable in that window, even if Mach still reports WAITING / STOPPED.
// Explicit host GPU work does not occupy a guest core. Ordinary host dispatch delays and HLE calls do, until a guest wait / sleep / exit says otherwise.
static int thr_runnable(const thr_t *t) {
return !t->wkind && !t->sleeping && !t->pause_req && !(t->host_work && *t->host_work);
}
int dm_yield_to_core_peers(unsigned max_us) {
int dm_yield_to_core_peers(void) {
thr_t *self = pthread_getspecific(g_keythr); if (!self) return 0;
uint64_t bit = 1ull << (self->core & 63), t0 = now_ns(); int waited = 0;
uint64_t bit = 1ull << (self->core & 63); int waited = 0;
for (;;) {
thr_t *busy = NULL;
int busy = 0;
pthread_mutex_lock(&G);
for (thr_t *t = g_all; t && !busy; t = t->all)
if (t != self && t->started && !t->exited && !t->host_owned && t->mask == bit && t->prio < self->prio && thr_running(t)) busy = t;
uint32_t bh = busy ? busy->handle : 0; int bp = busy ? busy->prio : 0; uint64_t blr = busy ? busy->last_lr : 0; int bsvc = busy ? busy->last_svc : 0;
if (t != self && t->started && !t->exited && !t->host_owned && t->mask == bit && t->prio < self->prio && thr_runnable(t)) busy = 1;
pthread_mutex_unlock(&G);
if (!busy) return waited;
if (now_ns() - t0 > (uint64_t)max_us * 1000) {
static _Atomic int nlog; if (nlog++ < 8) { module_t *m = module_of((void *)blr); // the first few cap hits: who waits, who keeps running
LOG("same-core preemption yield hit its cap: thread %#x (priority %d core %d) waits for %#x (priority %d, last svc %#x lr=%s+%#llx)", self->handle, self->prio, self->core, bh, bp, bsvc, m ? m->name : "?", (unsigned long long)(m ? blr - (uint64_t)m->base : blr)); }
return -1;
}
{ struct timespec ts = {0, 50000}; nanosleep(&ts, 0); } waited = 1;
}
}
@@ -418,6 +411,7 @@ static void hitch_init(void) {
uint32_t svc_thread_main_init(void) {
pthread_key_create(&g_keythr, NULL);
qos_map_init();
{ const char *e = getenv("DM_PREEMPT"); g_core_preempt = e ? atoi(e) != 0 : DM_GAME_CORE_PREEMPT; }
pause_install();
hitch_init();
thr_t *t = calloc(1, sizeof *t);
@@ -566,7 +560,7 @@ void svc_thread_dump(void) {
static const char *kinds[] = {"running/not waiting", "mutex", "condvar", "address", "sync object"};
int nh = 0; for (int i = 1; i < MAX_H; i++) nh += g_h[i].type != O_FREE;
fprintf(stderr, "[dm] handle table use %d / %d, threads created so far %llu\n", nh, MAX_H, g_next_id - 0x51);
fprintf(stderr, "[dm] same-core preemption yields %llu, total %.1f ms, hit the cap %llu times\n", (unsigned long long)g_preempt_n, g_preempt_ns / 1e6, (unsigned long long)g_preempt_cap);
fprintf(stderr, "[dm] same-core handoffs %llu, total %.1f ms\n", (unsigned long long)g_preempt_n, g_preempt_ns / 1e6);
for (thr_t *t = g_all; t; t = t->all) {
module_t *m = module_of((void *)t->last_lr);
// Thread name: SDK ThreadType+0x1a0 is the name pointer (ThreadManager::SetInitialThreadNameUnsafe 0x34a2a4); x2 of CreateThread = ThreadType*
@@ -620,10 +614,9 @@ void svc_thread_dump(void) {
}
}
// An approximation of same-core preemption: when an SVC woke a higher-priority thread pinned to this core, wait before the SVC returns until those threads (and every other higher-priority thread of this core) block, for at most 5 ms.
// Metroid Dread's movie player relies on it: the main thread posts "unlink the holders" to the dispatcher thread (priority 30, core 0 only) and leaves messages for the worker thread, then frees the object and builds a new one in its place;
// on hardware the dispatcher / worker preempt the main thread as soon as it signals and finish first, while on the host, without the wait, old holders got re-initialized and the worker read freed objects (crashes in SignalEvent on a null MultiWait and at main+0x225338).
// On per the game profile's core_preempt (off by default: Mario's loader threads would wait for busy high-priority decompression threads, pushing its first frame from 0.6 s to 2.9 s); DM_PREEMPT=0 / 1 overrides the profile. Counts are in the thread state dump
// Same-core wake handoffs apply to every game. core_preempt additionally checks
// already-runnable peers after every thread SVC; DM_PREEMPT overrides that option.
// These are cooperative boundaries, not an asynchronous guest-core scheduler.
static int svc_thread_impl(int num, uint64_t *regs);
// Yield back-off: on the Switch a yield with nothing else runnable costs nothing, but a guest loop that polls the tick and yields (Three Houses' frame limiter main+0x5be430 waited out each 33 ms frame this way:
// 228,105 yields per frame on an iPhone, a P-core at full clock; a per-build cut in its patches.py now makes it sleep) becomes a host busy spin. A run of yields with no other SVC between them (and no progress, thr_yield) is let through as plain sched_yield for its first 2 ms;
@@ -754,12 +747,12 @@ void dm_guest_sleep_ns(int64_t ns) {
int svc_thread(int num, uint64_t *regs) {
int r = svc_thread_impl(num, regs);
thr_t *me = pthread_getspecific(g_keythr);
static int on = -1; if (on < 0) { const char *e = getenv("DM_PREEMPT"); on = e ? atoi(e) != 0 : DM_GAME_CORE_PREEMPT; }
// Not only "whom did this SVC wake": when a higher-priority thread of the same core is already running (busy, not waiting, at the time of the signal), on hardware the calling thread does not get the CPU at all.
// Metroid Dread: with the dispatcher thread busy, the main thread's "unlink the holders" request woke nobody, the object was freed right away, and the dispatcher later walked the freed holders in WaitAny and crashed
if (me && on) {
uint64_t t0 = now_ns(); int w = dm_yield_to_core_peers(5000);
if (w) { if (w < 0) g_preempt_cap++; g_preempt_n++; g_preempt_ns += now_ns() - t0; }
// The default path hands off only after a wake. The optional broader mode
// also checks peers that were already runnable, without serializing every
// game's SVCs behind unrelated busy workers by default.
if (me && (g_core_preempt || me->yield_pending)) {
uint64_t t0 = now_ns(); int w = dm_yield_to_core_peers();
if (w) { g_preempt_n++; g_preempt_ns += now_ns() - t0; }
}
if (me) me->yield_pending = 0;
return r;
+9
View File
@@ -20,6 +20,8 @@ run: $(B)/t_heap_size $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_
$(B)/t_altstack
$(B)/t_condvar
$(B)/t_condvar_old
$(B)/t_preempt
$(B)/t_preempt --all-peers
$(B)/t_pctl
$(B)/t_time
$(B)/t_shared_font
@@ -82,6 +84,13 @@ $(B)/t_condvar: t_condvar.c t_condvar_stub.c ../hle_thread.c ../dm.h $(GAME_H) |
clang -O1 -Wno-unused-function -DDM_TEST_HOOKS -I.. $(GINC) -o $@ t_condvar.c t_condvar_stub.c ../hle_thread.c
$(B)/t_condvar_old: t_condvar.c t_condvar_stub.c ../hle_thread.c ../dm.h $(GAME_H) | $(B)
clang -O1 -Wno-unused-function -DDM_TEST_HOOKS -DDM_TEST_CV_OLD_ORDER -I.. $(GINC) -o $@ t_condvar.c t_condvar_stub.c ../hle_thread.c
$(B)/t_preempt: t_preempt.c t_condvar_stub.c ../hle_thread.c ../dm.h $(GAME_H) | $(B)
clang -O1 -g -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_preempt.c t_condvar_stub.c
.PHONY: preempt
preempt: $(B)/t_preempt
$(B)/t_preempt
$(B)/t_preempt --all-peers
run: $(B)/t_preempt
# the Vorbis tests build their own packets and need no game profile (they build without GAME)
$(B)/t_vorbis: t_vorbis.c ../../third_party/stb_vorbis/stb_vorbis.c | $(B)
clang -O1 -g -Wno-unused-function -Wno-tautological-compare -o $@ t_vorbis.c
+103
View File
@@ -0,0 +1,103 @@
// Wake-to-run ordering through real event SVCs. Suspend the waiting pthread to
// model a host dispatch delay: it is guest-runnable as soon as SignalEvent wakes
// it, even though Mach still reports it stopped. Its payload must precede the
// lower-priority signaller's return to guest code, including delays over 5 ms.
#include "../hle_thread.c"
#include <assert.h>
#include <unistd.h>
static thr_t worker;
static uint32_t event_read, event_write;
static _Atomic int payload;
static unsigned dispatch_us;
static _Atomic uint32_t mutex_word, condition_key;
static uint32_t main_handle;
static int wait_kind;
static _Atomic int worker_host_work;
static void *work(void *arg) {
(void)arg;
pthread_setspecific(g_keythr, &worker);
worker.mport = pthread_mach_thread_np(pthread_self());
uint32_t handle = event_read; uint64_t r[32] = {0};
if (wait_kind == 4) {
r[1] = (uint64_t)&handle; r[2] = 1; r[3] = (uint64_t)-1;
svc_thread(0x18, r);
} else if (wait_kind == 2) {
atomic_store(&mutex_word, worker.handle);
r[0] = (uint64_t)&mutex_word; r[1] = (uint64_t)&condition_key;
r[2] = worker.handle; r[3] = (uint64_t)-1;
svc_thread(0x1C, r);
} else {
r[0] = main_handle; r[1] = (uint64_t)&mutex_word; r[2] = worker.handle;
svc_thread(0x1A, r);
}
assert(r[0] == 0);
atomic_store(&payload, 1);
usleep(1000); // dispatching the work is insufficient: its teardown must finish too
atomic_store(&payload, 2);
thread_exit(&worker);
return NULL;
}
static void *dispatch(void *arg) {
(void)arg;
usleep(dispatch_us);
assert(thread_resume(worker.mport) == KERN_SUCCESS);
return NULL;
}
static int delayed_wake(unsigned delay, int kind, int exclusion) {
uint64_t r[32] = {0};
svc_thread(0x45, r); assert(r[0] == 0);
event_write = r[1]; event_read = r[2];
memset(&worker, 0, sizeof worker);
pthread_cond_init(&worker.cv, NULL); pthread_cond_init(&worker.pcv, NULL);
worker.started = 1; worker.prio = 30; worker.core = 0; worker.mask = 1;
if (exclusion == 1) worker.prio = 44; // equal priority
if (exclusion == 2) worker.core = 1, worker.mask = 2; // another guest core
if (exclusion == 3) worker.mask = 7; // can run on another guest core
if (exclusion == 4) worker.host_owned = 1; // host helper, no guest core
if (exclusion == 5) worker.pause_req = 1; // guest suspension
if (exclusion == 6) { worker_host_work = 1; worker.host_work = &worker_host_work; } // host GPU work
pthread_mutex_lock(&G);
worker.handle = h_new(O_THREAD, &worker); worker.all = g_all; g_all = &worker;
pthread_mutex_unlock(&G);
atomic_store(&payload, 0);
atomic_store(&mutex_word, main_handle | WAITERS_BIT); atomic_store(&condition_key, 0);
wait_kind = kind;
pthread_t w, d; assert(!pthread_create(&w, NULL, work, NULL));
for (;;) {
pthread_mutex_lock(&G); int waiting = worker.wkind == kind; pthread_mutex_unlock(&G);
if (waiting) break;
usleep(100);
}
assert(thread_suspend(worker.mport) == KERN_SUCCESS);
dispatch_us = delay; assert(!pthread_create(&d, NULL, dispatch, NULL));
if (kind == 4) { r[0] = event_write; svc_thread(0x11, r); }
else if (kind == 2) { r[0] = (uint64_t)&condition_key; r[1] = 1; svc_thread(0x1D, r); }
else { r[0] = (uint64_t)&mutex_word; svc_thread(0x1B, r); }
assert(r[0] == 0);
pthread_mutex_lock(&G); int ordered = atomic_load(&payload) == 2 && worker.exited; pthread_mutex_unlock(&G);
pthread_join(d, NULL); pthread_join(w, NULL);
pthread_mutex_lock(&G); g_all = worker.all;
unsigned slot = worker.handle - 0x1000; g_h[slot].type = O_FREE; g_h[slot].obj = NULL;
pthread_mutex_unlock(&G);
pthread_cond_destroy(&worker.cv); pthread_cond_destroy(&worker.pcv);
r[0] = event_read; svc_thread(0x16, r);
r[0] = event_write; svc_thread(0x16, r);
int ok = exclusion ? !ordered : ordered;
printf("%s: wait kind %d, dispatch delay %u us, exclusion %d, payload before signaller returns %d\n", ok ? "PASS" : "FAIL", kind, delay, exclusion, ordered);
return ok;
}
int main(int argc, char **argv) {
(void)argv;
alarm(10);
setenv("DM_PREEMPT", argc > 1 ? "1" : "0", 1);
main_handle = svc_thread_main_init();
int ok = 1;
for (int kind = 1; kind <= 4; kind++) if (kind != 3) {
ok &= delayed_wake(1000, kind, 0);
ok &= delayed_wake(20000, kind, 0);
}
for (int exclusion = 1; exclusion <= 6; exclusion++) ok &= delayed_wake(20000, 4, exclusion);
return ok ? 0 : 1;
}
+1 -1
View File
@@ -102,7 +102,7 @@ Patterns that worked: print every call of one function with its arguments; stop
- **Logs:** `sites.tsv` lists each loop as `<head> S <back-edges>`. In the power line's top yielders, a thread's spin passes show as `spin passes N, last at main+0x…`; the site is the loop head.
- **Checking a loop:** to tell whether one is a real wait, disassemble from its head to its last back-edge. It must reload what it compares on every pass.
- **Who runs during a hitch.** Guest threads carry their guest names as host thread names (`0x1001 main thread`, `0x1008 Worker`), so `sample <pid>` and `xcrun xctrace record --template 'Time Profiler' --attach <pid>` (no root needed) name them; the export (`xctrace export --xpath '/trace-toc/run[@number="1"]/data/table[@schema="time-profile"]'`) gives each sample's time, thread and backtrace, and guest frames map to `main+…` with the module bases at the top of the log. The trace starts at the TOC's `start-date`; the TSV's `@call_ms` counts from the process start (`kinfo_proc` `p_starttime`), which lines the two up. `DM_HITCH_DUMP=<ms>[:<s>]` logs every guest thread once whenever no frame has been presented for `<ms>` (from `<s>` seconds after the start on, at most 24 times): what it waits on, its last SVC, and where it is, with x0–x2 and x19–x25 when it runs guest code. Breath of the Wild's CPU hitches showed up there as the main thread and two workers in one guest loop with no SVC, and the registers told which thread the loop waited for (its item index in x0; [docs/development.md](../development.md), Spin loops).
- `DM_PREEMPT=0` / `1` turns the same-core preemption approximation off / on whatever the profile's `core_preempt` says (`hle_thread.c`). The thread dump at exit reports `same-core preemption yields N, total T ms, hit the cap K times` (yields, total wait, 5 ms cap hits). A higher-priority thread that keeps a core busy shows up in the first eight `same-core preemption yield hit its cap` lines, with its last SVC and call site.
- `DM_PREEMPT=0` / `1` disables / enables the additional check of already-runnable same-core peers after every thread SVC, overriding the profile's `core_preempt` (`hle_thread.c`). Wake-triggered handoffs apply with either setting. The thread dump reports `same-core handoffs N, total T ms`; these waits have no timeout that releases unfinished guest work. See [Same-core wake handoffs](../reference/threads.md#same-core-wake-handoffs) for the runnable-state rule and regression test.
## Racy crashes: load, trace gates
+1 -1
View File
@@ -69,7 +69,7 @@ One line per symptom in the [subsystem references](../reference/): what you see
- crash indexing a per-core table with a huge offset (index −2). → [Ideal core −2 resolves to the process default core](../reference/threads.md#ideal-core-2-resolves-to-the-process-default-core)
- the main thread waits forever on an event right after `nnMain` starts, with only three or four threads created; `DM_THR=1` shows no `CreateThread` for the thread it waits on. → [Free thread count (`svcGetInfo` type 24)](../reference/threads.md#free-thread-count-svcgetinfo-type-24)
- in garbage-collected titles (Unity / IL2CPP), live objects freed, with their finalizers releasing native resources. → [Pausing threads and reading their context for garbage collectors](../reference/threads.md#pausing-threads-and-reading-their-context-for-garbage-collectors)
- crashes around movie changes in the game's own player threads, about one run in two: `SignalEvent` → `MultiWaitImpl::NotifyAndWakeupThread` on a null MultiWait at address 0x20; `WaitAny` walking freed holders; a worker reading a freed object at `main+0x225338`. → [Approximating same-core preemption (`core_preempt`)](../reference/threads.md#approximating-same-core-preemption-core_preempt)
- crashes around movie changes in the game's own player threads, about one run in two: `SignalEvent` → `MultiWaitImpl::NotifyAndWakeupThread` on a null MultiWait at address 0x20; `WaitAny` walking freed holders; a worker reading a freed object at `main+0x225338`. → [Same-core wake handoffs](../reference/threads.md#same-core-wake-handoffs)
- several threads crash at once and the dumps interleave, then the process hangs instead of exiting. → [Simultaneous crashes of several threads](../reference/threads.md#simultaneous-crashes-of-several-threads)
- 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). → [`SignalToAddress` type 2 with count ≤ 0](../reference/threads.md#signaltoaddress-type-2-with-count--0)
- a 64-bit wait could return at once or block when it should not (not met in a game). → [`WaitForAddress` type 3: 64-bit wait if equal](../reference/threads.md#waitforaddress-type-3-64-bit-wait-if-equal)
+6 -7
View File
@@ -32,15 +32,14 @@ Guest threads, their synchronization and how the host schedules them. Each entry
- **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_leave` around every gate SVC; it waits at the SVC exit while paused). A thread in guest code gets SIGUSR2, and the handler saves the ucontext and waits there. A thread in host code is retried every 100 µs. The context is Horizon's 0x320-byte `ThreadContext`.
- **Found on:** Unity / IL2CPP titles in general.
### Approximating same-core preemption (`core_preempt`)
### Same-core wake handoffs
- **Symptom:** crashes around movie changes in the game's own player threads, about one run in two: `SignalEvent` → `MultiWaitImpl::NotifyAndWakeupThread` on a null MultiWait at address 0x20; `WaitAny` walking freed holders; a worker reading a freed object at `main+0x225338`.
- **Cause:** 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.
- **Fix:** 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 (OBS 19 / 20 in its `patches.py`, this build only) waits until the dispatcher's request ring is empty (`movie_ring_wait` in `hle_movie.c`). Gates may now sit right after a `bl` as well as at function entries;
- an approximation of same-core preemption (`svc_thread` / `dm_yield_to_core_peers` in `hle_thread.c`): after a thread SVC, the caller waits (at most 5 ms) while a higher-priority thread pinned to its core is running guest code. "Running" means the Mach thread state is running, the thread is not in a guest wait or timed sleep, and it is not doing host NVN submit / present work (`dm_host_work`).
- **Found on:** [Metroid Dread](../../dm/games/010093801237C000/README.md).
- **Note:** the preemption approximation is a profile key, `core_preempt`, on only for Dread: in [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md) the loader threads would wait for busy decompression threads, and its first frame moved from 0.6 s to 2.9 s.
- **Cause:** Horizon schedules the highest-priority runnable thread on a core ([Mesosphere's scheduler](https://github.com/Atmosphere-NX/Atmosphere/blob/master/libraries/libmesosphere/source/kern_k_scheduler.cpp)). 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, but `thread_info` could 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 recorded `yield_pending` flag was unused unless the profile enabled checks after every thread SVC.
- **Fix:** every game's thread SVC now honours `yield_pending`. Before returning, it waits for the higher-priority peers pinned to the caller's core to block, sleep, exit, be suspended, or enter explicit host GPU work (`svc_thread` / `dm_yield_to_core_peers` in `hle_thread.c`). 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. Host-created helpers and threads that can run on another core are excluded.
- **Check:** `make -C dm/host/tests GAME=010093801237C000 preempt` delays 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.
- **Found on:** [Metroid Dread](../../dm/games/010093801237C000/README.md), whose per-build movie request-ring gates and `core_preempt on` setting are now removed.
- **Scope:** these are cooperative thread-SVC handoffs, not a complete asynchronous guest scheduler. The optional `core_preempt` profile key (`DM_PREEMPT=0` / `1` overrides it) additionally checks already-runnable peers after every thread SVC; it defaults off. Historically that broader policy moved [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md)'s first frame from 0.6 s to 2.9 s by waiting for busy decompression threads. Wake-triggered handoffs apply with either setting.
### Simultaneous crashes of several threads