Skip to content

Latest commit

 

History

History
1285 lines (938 loc) · 242 KB

services.md

File metadata and controls

1285 lines (938 loc) · 242 KB

Services and SDK calls

SDK calls and system services the host answers (dm/host/hle_*.c). Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.

Service connections

SDK calls that open a system service through sm

  • Symptom: sm::Initialize aborts right after ConnectToNamedPort (SVC 0x1F) and SendSyncRequest (0x21); abort inside nn::sm::Initialize, the log showing SVC 0x1f (ConnectToNamedPort, port name sm:) and SVC 0x21 answered by placeholders, then an nn::diag abort; abort with no message at start-up in a service call the host has no hook for (Break from sdk+… with an nn::diag backtrace).
  • Cause: an SDK path reached a system service the host doesn't replace: the SDK's IPC to the service fails and it calls AbortImpl. Often it is an SDK call the host does not hook, for example a newer overload of a hooked call.
  • Fix: hook the public SDK entry point named in the backtrace (HLE_FORCE in the relevant hle_*.c). Symbolize the abort backtrace against the SDK's exports to find the caller (resolve the frames with sdis.py --sym on the decompressed module) and hook that name. List the game's imports of that service the host does not hook (Listing a game's unhooked imports before they are met) and add the ones that talk to a service; library helpers that only fill the game's own buffers need nothing. Look at how the game uses the result before choosing it (a bcat mount failure is the game's own "nothing delivered" path).
  • Found on: Super Mario Galaxy (SDK 18): nn::hid::InitializeTouchScreen(int, int), the SDK 18 overload of the hooked InitializeTouchScreen(); Super Mario Odyssey (SDK 7): account::OpenPreselectedUser, then bcat::Initialize, then audio::AcquireAudioDeviceSwitchNotification, and a null read in account::GetLastOpenedUser.
  • Note: the same abort from other services: ec::InitializeForShopServiceAccessors (Shop service accessors), pctl::IsFreeCommunicationAvailable (Parental controls and free communication), oe::FinishStartupLogo (The startup logo), hid::VibrationNode::Update (Vibration values flushed by the SDK's vibration graph), audio::GetAudioDeviceOutputVolume (Audio device output volume and names), capsrv::InitializeForApplication (Album calls fail with no album), settings::LanguageCode (Language codes), and the rest of this file.

SDK 3.6.0 answered as SDK 4

Listing a game's unhooked imports before they are met

Most service calls can be found before they crash, before the first boot or the next one: the calls that would abort in sm::Initialize (or read a null service object) are in the import list.

  • Procedure: list main's nn:: imports (its undefined imports) that no host hook names, by namespace: the survey's import table (nso_syms.py), then main's undefined nn:: symbols that no hook (HLE / HLE_FORCE) names (02), and read each one's SDK code. This gives the calls a game will make before they are met. The import list itself says where to look: diff the undefined symbols against the closest title the host already runs (and every other game's imports), drop what the host hooks, and the rest are the candidates. To find which sdk function aborted, map the backtrace offsets to the nearest exported symbol (The Legend of Zelda: Tears of the Kingdom 1.4.3).
  • What needs nothing: most nn::os / nn::mem imports run natively in sdk. Calls that are pure computations need nothing, nor do socket calls behind a Socket() that already fails offline, nor library helpers that only fill the game's own structures.
  • Hook names: hooks are HLE_FORCE, SSF, SKF, IKB_IGNORE across dm/host and dm/gfx. strings misses names the symbol table stores as the tail of a longer one, so list them with nso_syms.dynsyms.
  • The survey table's blind spots: it counts only names given to HLE / HLE_FORCE directly: hooks made through macros (SKF and SSF in hle_net.c, TOUCH(N) in hle_hid.c) show as missing. The touch-screen states (GetTouchScreenState<16>) were already hooked by such a macro, which the import table does not see. It once listed every vi call as unhooked: nso_syms.py host_hooks read only dm/host, and the vi hooks are in dm/gfx (it reads dm/gfx and dm/gfx/nvn now; found on Absolum).
  • What it found (the calls themselves are in the entries of their service below):
    • Pokémon Sword (SDK 7, one boot each): the import list against the hooks and every other game's imports named all of them beforehand: 33 nn:: imports no host hook names and no other game imports.
    • 13 Sentinels: Aegis Rim (SDK 11).
    • Monster Hunter Rise (SDK 11, one boot each): the import list against the hooks and the other SDK 11 games found most of them before the first boot.
    • DRAGON QUEST XI S.
    • Octopath Traveler 0: the pure computations were the swkbd presets, the web argument constructors, time::GetDayOfWeek and the audio resampler.
    • DRAGON QUEST VII Reimagined (SDK 19.3.1): the import list against the hooks gave the rest before they were met; the rest fill the game's own structures or sit behind a socket that fails offline.
    • Pokémon Legends: Z-A (19.3.11): the import list against the hooks (the survey's nso_syms.py table, then main's undefined nn:: symbols that no HLE_FORCE names) left 86 candidates; most only fill the game's own structures (error and web argument builders, prepo report buffers, swkbd configuration, the audio resampler, nn::async). The ones that reach a service were hooked before they were met where the answer is clear: time::ToPosixTime with an explicit rule, album::SaveCurrentScreenshot, err::ShowApplicationError and the ShowShopAddOnContentList(UserHandle) overload.
    • STEINS;GATE RE:BOOT (SDK 20.5.17): nn::audio::SetAudioOutVolume (AudioOut volume kept per output) was among main's imports that no host hook names; the others there fill the game's own structures or run in sdk (the CPU audio renderer, the swkbd and hid argument builders).
    • Paper Mario: The Thousand-Year Door (SDK 15.3.2): nn::settings::GetAvailableLanguageCodes was the one import of main that no host hook names and that reaches a service; fs::MountRamDisk and the swkbd and hid helpers it also imports run in sdk or were never called.
    • Catherine: Full Body (SDK 8.2.1): besides GetAvailableLanguageCodeCount, the other imports no hook names run in sdk (the LanguageCode comparison, ClockSnapshot's constructor and getters, fibers, NumberLineAllocator).
    • Animal Crossing: New Horizons (SDK 20.5.4): the clock, region and album calls under their own entries below were found in main's imports before the first boot.
    • The Great Ace Attorney Chronicles (SDK 10.4.1): listed after its first boot crashed in oe::DisableAlbumImageTakenNotification; the calls found are in the entries under ## Display, window and operation mode, ## File system and save data and ## Accounts, network, shop and parental controls.
    • Big Brain Academy: Brain vs. Brain (SDK 11.4.3).

A game update imports SDK calls the base game did not

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

Game-linked HIPC clients of system services

  • Symptom: abort in sm::Initialize under sf::hipc::InitializeHipcServiceResolution, right after the first frames (from main+0x5b8198).
  • Cause: main links its own clients of olsc:u (cloud save data backup settings) and npns (push notifications), which talk HIPC through sm and svcSendSyncRequest. For each user the game asks whether backup is on and switches it off where it is (main+0x2809c8c); a failed connect aborts as well.
  • Fix: hle_sf.c takes over the SDK's HIPC client entry points (InitializeHipcServiceResolution, ConnectToHipcService, SendSyncRequest, CloseClientSessionHandle and the control requests QueryPointerBufferSize, ConvertSessionToDomain, DuplicateSession(Ex)): a service it serves (olsc:u, the capture services below) gets a host session, and every request on it succeeds with zeroed output, which reads as backup off for every user. Any other name is refused with a red line, as sm refuses a service nobody registered (npns was never connected). DM_SF_TRACE=1 dumps each request.
  • Capture services: Super Smash Bros. Ultimate links clients of caps:su and caps:u (screenshots and clips) and aborts with Result=0xe15 when sm refuses them at the start of its single-player mode. The host has no album, so caps:su, caps:u, caps:a, caps:c, caps:sc and caps:ss get host sessions too: a list reads as empty and a save does nothing.
  • Found on: Animal Crossing: New Horizons (SDK 20.5.4); Super Smash Bros. Ultimate (capture services).

SDK names that changed between versions

Older SDK names for the same calls

  • Symptom: a call the host hooks under its newer name reaches the SDK's own code on an older game.
  • Cause: older SDKs export other names for the same calls.
  • Fix: hook both generations. SDK 12 names: ListAudioDeviceName / GetActiveAudioDeviceName, singular hid::GetNpadState, OpenDefaultAudioOut. SDK 11 names: fs::CommitSaveData(mount) is the older name of fs::Commit.
  • Found on: Metroid Dread (SDK 11 names).
  • Note: overloads change too: SDK 18's nn::hid::InitializeTouchScreen(int, int) (Super Mario Galaxy, SDK calls that open a system service through sm), SDK 19's LoadModule with one more bool (nn::ro and its port), and vi::CreateLayer with the layer's size (Absolum, vi::CreateLayer with the layer's size). SDK 19's fs::MountRom(const char *, int, void *, size_t) and fs::QueryMountRomCacheSize(size_t *, int) take a program index (Metroid Prime 4: Beyond's boot, [RED] sf: the game connects to service fsp-srv itself): the host has one RomFS, so index 0 mounts it and any other index logs a red line.

SDK 4's friends::GetBlockedUserList without a Uid

  • Symptom: abort in sm::Initialize under nn::friends::GetBlockedUserList after the opening movie.
  • Cause: SDK 4's overload without the Uid (EPiPNS_7account23NetworkServiceAccountIdEii), which the host did not take over.
  • Fix: it reports an empty list with success: the game turns a failure into an error for the error applet (main+0x8ff9f0). The overload with a Uid keeps reporting a failure, which DRAGON QUEST XI S takes as it is (Account, friends, play data and Nintendo Account calls).
  • Found on: DARK SOULS: REMASTERED (SDK 4.5.0, the oldest so far; one boot).

SDK 6's account::OpenPreselectedUser returns a Result

  • Symptom: the first loading screen never ends; DM_FS=1 shows save:/system "not found" and no fs mount save.
  • Cause: in SDK 6 account::OpenPreselectedUser returns a Result, and the game skips its save mount when it is not 0 (main+0x53b7e0). The host's hook returned void and left the handle pointer in w0.
  • Fix: the hook returns 0 now; SDK 7 and later callers ignore it.
  • Found on: Fire Emblem: Three Houses (SDK 6.5.0).

File system and save data

Directory handle allocation failure

  • Symptom: the host crashes in h_fs_OpenDirectory when its folder-backed directory handle allocation fails.
  • Cause: opendir succeeded, but the following calloc of dirh_t was dereferenced without a null check. Returning early without cleanup would also leak the already-open DIR.
  • Fix: close the directory on allocation failure, preserve errno across cleanup, and return the existing filesystem failure through err_res before publishing a handle. The caller's output stays untouched, as on the other failed-open paths. t_fs_directory.c injects allocation failure in the real handler, checks the descriptor is closed and the output unchanged, then exercises successful open, count, read and close.
  • Found on: the GCC audit in issue #313, reproduced with a synthetic directory and allocation fault injection; no game-specific failure was reported.

The SDK's own fs::IsMounted reads an empty mount table

  • Symptom: IL2CPP "Could not open …/global-metadata.dat" (then a crash in its logger).
  • Cause: IL2CPP checks nn::fs::IsMounted("rom") before opening; the SDK's own IsMounted reads a mount table the host never fills.
  • Fix: answer from the host's mount table (hle_fs.c).
  • Note: the C library's path checks read the same empty table through IsPathMounted (C library path checks ask the file system's limits).

A mount in the SDK's own table stays until the SDK releases it

  • Symptom: game called nn::diag abort: Result 0x7802 (MountNameAlreadyExists) when a game mounts a RAM disk under a name it has just unmounted.
  • Cause: the host's mount table is permanent and nn::fs::Unmount did nothing. nn::fs::MountRamDisk has no host version, so it runs in sdk and registers in the SDK's own table, and the no-op left that entry there while the game freed the disk's buffer.
  • Fix: h_fs_Unmount leaves a host mount alone and sends any other name to the SDK: its IsMounted, then its Unmount if it holds the name (the SDK's Unmount aborts on a name it does not hold). guest_sym gives the module's own definitions, not the host's take-overs. A name in neither table stays a no-op. t_fs.c covers each case.
  • Note: this makes such a mount releasable, not usable. The host's file calls and IsMounted answer from the host's table only, so a file path on a RAM disk is "path not found". A game that needs the files back would need those calls routed to the SDK as well; none is known.
  • Found on: Paper Mario: The Thousand-Year Door (GitHub issue #1012).

File handles with low bits set, and backslash paths

  • Symptom: a 10-byte read of the wrong RomFS file, then a null pointer at boot; files named rom:\StartupUI.pak are not found.
  • Cause: on Horizon a FileHandle is a pointer to the SDK's FileAccessor, a heap object, so its low bits are clear, and a game's file device may mask them off (handle & ~7) before ReadFile. The host's RomFS-image handle kept the entry index in its low bits, so entry 7 was read as entry 0. Separately, Horizon's file system accessor turns every backslash in a path into a slash before it normalizes the path (FileSystemAccessor::SetUpPath, on every mount except the PC host's); the host treated a backslash as part of a name.
  • Fix: the guest gets the host's handle shifted left by 4, 16-byte aligned like an AArch64 heap block (FH_OUT / FH_IN). Each guest path resolver (img_path, host_path_ex, set_guest_name, dir_snapshot) turns backslashes into slashes first, so overlays, .. and guest names see the same path (hle_fs.c; dm/host/tests/t_fs.c).
  • Found on: Metroid Prime 4: Beyond (SDK 19; bring-up in issue #229, from PR #513); not in the games table yet.

Saves named before backslash paths

  • Symptom: after a rebuild, a game shows every file of its file select as new, while its save folder holds names that start with a backslash (\savedata0.bin, \screenshot0.bin).
  • Cause: a host from before backslashes separated paths took the backslash in save:\savedata0.bin as part of the file's name. Today's host looks for savedata0.bin, and the old file cannot be reached by any path a game can ask for.
  • Fix: each mount of a committed save, before its work directory is cloned, renames every entry whose name has a backslash to the name the game asks for today (save_old_names, hle_fs.c): \dir\f becomes dir/f, the folder made on the way, and \dir and dir merge. It goes by guest names, so \Foo beside foo stays a file of its own. Each rename is atomic and keeps the contents, and a kill partway is finished by the next mount, so it needs no transaction and no marker, and a file the game erases later stays erased. It holds the locks the cloud sender's clone takes, and comes after the cloud's save came in, so that one is renamed too.
  • Limits: an entry whose new name is taken stays under its old name, with a log line at each mount (save: … stays as it is): the game wrote a new save since, and that is the one it reads. To go back to the old save, delete the new file and start again. A build from before the change can no longer read a renamed save. With cloud saves, a rename counts as a change to this device's save and goes out at the next mount or Commit; a device still on such a build then sees an empty save.
  • Check: dm/host/tests/t_fs.c section 9. On Link's Awakening on the Mac, one save renamed to the old form: every file shows New Game without the fix, and File 1 with it, the file unchanged. Through a cloud folder, old names that come in are renamed, the renamed save goes out, and the next mount is in step.
  • Found on: The Legend of Zelda: Link's Awakening (GitHub issue #905, diagnosed in LNPANDY/NX2Apple#461).

Both saves changed is the player's choice, not the newest file's

  • Symptom: after playing on two devices, a save started fresh on one of them a minute after a real save on the other replaces the real one in the cloud; it survives only in that device's save-backups/<date> cloud.
  • Cause: when both saves changed since the last sync (or a device that has never synced has a save of its own), the host keeps the newer one, and "newer" is the newest file date in the save. A new game's first save written 45 s after a 54-minute save is the newer one. The host could ask instead (ss_decide returns SS_ASK when a front end sets DM_SAVE_SYNC_CHOICE, documented in The cloud saves folder), but neither launcher set it, so the player was never asked.
  • Fix: save_sync_report() (dm/host/save_sync.c) answers, before the start, one line about the two saves as the game's first mount will see them: their hashes, dates, file counts and sizes, the cloud's device, and whether both changed. It finds the cloud's file with the mount's own rules (cloud_path: DM_SAVE_SYNC, icloud, a folder named after the game) and fetches it through the same front-end call. Both launchers call it when Play is pressed (SaveSyncReport.swift, shared): on a conflict they show both saves with their dates and sizes (the size tells a new game from one with progress; no button is the default on the Mac) and pass local:<hash> or cloud:<hash>; with nothing to choose they pass ask. The Mac launcher sets DM_SAVE before its window so the report can read the save, and checks with the window off too.
  • Limits: with no launcher (DM_LAUNCHER=0, or an iPhone app without launcher.json) the newer save still wins. A front end that sets ask without comparing keeps a save that changed on both sides on neither side's until a start that asks.
  • Check: dm/host/tests/t_save_sync.c section 15: nothing anywhere, a save only here, in step, the game's own folder chosen, only in the cloud, both changed with the new save the newer and the smaller, the cloud unreadable, sync off, DM_SAVE_SYNC read when no folder is given, nothing left behind. The Mac launcher and the iPhone sources build; neither dialog has been run here.
  • Found on: Cadence of Hyrule (01000B900D8B0000, 1.5.0) on an iPhone (iPhone19,4) and a Mac mini M2 Pro (PR #2180).

The cloud's save is listed but not downloaded

  • Symptom: the first start after the other device saved plays this device's save; start it, quit, start again, and the cloud's save comes in. With no connection, the game's first save mount waits out its whole time limit.
  • Cause: iCloud lists a file it has not downloaded as .save.dmsync.icloud, and the mount gives the download FETCH_MS (10 s) before it plays on with this device's save; a later start finds the file there. The report says the coordinated read could also come back without the file before the download landed; that is not checked here.
  • Fix: the download happens in the launcher, before the game: its report waits up to a minute (SaveSyncReport.waitMs) with "Checking the cloud save…" and Play Without It on screen. front_save_sync_fetch (dm/ios/Sources/SaveSync.m) asks for the download and waits for the file until it is there or iCloud reports a download error (NSURLUbiquitousItemDownloadingErrorKey), so with no connection it ends at once. A cloud save that did not come is said so, with this device's save, another try or Cancel; playing this device's passes offline, so the mount does not wait again, and that save is not sent until it has been compared with the cloud's.
  • Limits: with no launcher the mount's 10 s apply as before. Whether iCloud sets the download error at once when offline is from Apple's documentation, not a run.
  • Check: not run on an iPhone here: neither a listed file nor an offline start. The PR author's runs (its own 25 s wait): a planted .save.dmsync.icloud in a scratch folder timed out with nothing written, and a real iCloud fetch logged downloaded in N s.
  • Found on: Cadence of Hyrule on an iPhone (iPhone19,4), after a save from a Mac mini M2 Pro, macOS 26.6 (PR #2180).

A failed pull deleted this device's save

  • Symptom: [RED] save sync: bringing the cloud's save in failed (errno=…): playing on with this device's, and the game starts with the cloud's save; this device's is in neither its save folder nor <save>-backups.
  • Cause: pull (dm/host/save_sync.c) unpacked the cloud's save into <save>.sync-tmp/in, swapped each folder with the live one, and then moved the swapped-out folder, this device's, from the staging folder into the backups. Between the swap and the move the staging folder held the only copy of this device's save. When the move failed, mount_once logged the line above and cleared <save>.sync-tmp, deleting it; an app killed between the two steps lost it the same way, since every start clears that folder. A failure at the second folder (save_device) after the first had swapped left a save that was half the cloud's.
  • Fix: each of the cloud's folders is moved into this device's backup folder first, and swapped from there with the live one, so one atomic step puts the cloud's folder in place and leaves this device's in the backups; a folder the cloud's save does not have is moved to the backups. The staging folder never holds this device's save. pull records what it did to each folder, and on a failure puts them back in reverse order: the save is this device's again, whole, and the empty backup folder goes. If a step of putting back fails, nothing is deleted and the red line names the backup folder that holds what is not in place.
  • Check: dm/host/tests/t_save_sync_metadata.c, the pull cases: nothing fails, with and without the cloud's device save; the move into the backups fails; the device save's swap fails and the save is swapped back; the device save's move to the backups fails; the swap back fails too. After every rename and swap the staging folder is checked for this device's save. On the old pull, 22 of these checks fail, among them the reported loss. t_save_sync 101 checks.
  • Found on: no game; a report with an injected EIO on the move into the backups (a pull of the cloud's save chosen by the player).

Automated runs synced their scratch save with the player's cloud save

  • Symptom: none in a log. With DM_SAVE_SYNC exported in the shell, a scripts/regress.py route with save=new starts with the player's cloud save for that game, and the run's own progress then replaces it in the cloud.
  • Cause: regress.py (run_env), bench.py (run_one) and the NSP script's host runs (tool_env: the captures, checks and the pipeline prebuild) copied the caller's environment and replaced only DM_SAVE with a scratch folder. The host syncs whenever DM_SAVE_SYNC names a folder, and names the cloud file by title ID (dm/host/save_sync.c, cloud_path), so the scratch save was synced with the player's: with no DM_SAVE_SYNC_CHOICE the newer save wins, a save only in the cloud is pulled in, and the first commit after it is sent over it. The cloud save it replaced was kept in <DM_SAVE>-backups, inside the run folder, which the next run deletes.
  • Fix: every automated run sets DM_SAVE_SYNC=0, after the overrides it takes; regress.py --env or a bench.py variant or --env that names a folder is refused. regress.py leaves DM_SAVE_SYNC* out of a baseline's context, since no run reads them. The command the NSP script prints for the player's own run is unchanged. Cloud saves are checked by dm/host/tests/t_save_sync.c and t_save_sync_metadata.c on temporary folders.
  • Found on: no game; a report with a temporary cloud folder holding a synthetic save.

An unhooked fs::ReadFile overload falls through to the SDK's own

  • Symptom: a fault inside sdk at boot, right after the save data is mounted: ldr w8, [x0, #0x24] with x0 holding a host file handle (SD GUNDAM G GENERATION CROSS RAYS: sdk+0x62a78, x0 = 0x4000000000000390), while GetFileSize on the same handle answered just before.
  • Cause: the game imports nn::fs::ReadFile(FileHandle, s64, void*, size_t, const ReadOption&), the overload with read options and no bytes-read output, which the host did not hook, so the call bound to the SDK's own. That one reads the handle as a FileAccessor*, and the host's handle is an image-entry id (as above). The general case: SDK calls that open a system service through sm; the import list finds such overloads beforehand (Listing a game's unhooked imports before they are met).
  • Fix: a hook with the contract of the overload without read options (h_fs_ReadFileExactOpt calls h_fs_ReadFileExact): the options are ignored and a short read is a failure, as on Horizon, since the caller has no byte count to see it by (hle_fs.c; dm/host/tests/t_fs.c).
  • Found on: SD GUNDAM G GENERATION CROSS RAYS (010055700CEA8000, SDK 9.4.1, from PR #947): the fault was all that stood between the boot and its frame loop; not in the games table yet.

C library path checks ask the file system's limits

  • Symptom: Addressables never start (no StreamingAssets/aa/settings.json in the DM_FS=1 output at all), then a null asset crash (main+0x31100ec).
  • Cause: Unity 2022's C# file API reaches the SDK's C library, not nn::fs: File.Exists → SystemNative_Stat → stat = open + fstat + close (sdk+0x948b0). nnmusl's path check (sdk+0x904e0, then the converter sdk+0x8ef50) asks nn::fs::IsPathMounted, GetPathLengthMax, GetUtf16PathLengthMax, GetEntryNameLengthMax and GetUtf16EntryNameLengthMax before it calls nn::fs::GetEntryType / OpenFile, and the SDK's own versions read the empty mount table (IsPathMounted false; the next one aborted with Result 0x35f202).
  • Fix: the host answers them from its mount table, with Horizon's 0x300-byte limits and no UTF-16 limit (hle_fs.c). The path .NET builds is /rom:/…; nnmusl's converter turns it back into rom:/….
  • Found on: Ace Attorney Investigations Collection (1.0.0.2, SDK 17.5.3, Unity 2022.3 IL2CPP).
  • Note: armyja's fork (GitHub issue #18) patched the managed stat wrappers by address instead; these hooks need no per-build table.

Paths with . and .. components

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

RomFS lookups on large directories

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

ReadFile into write-protected texture pool pages

  • Symptom: [RED] fs ReadFile into … failed at +0 (EFAULT): VM region … protection 1, now and then.
  • Cause: the destination was texture pool memory, which the dirty-page tracker write-protects after each upload (nvn_dirty.c). ReadFile lifts the protection first (dm_guest_write_prepare), but the graphics layer can protect the pages again before pread writes them, and a system call gets EFAULT where a plain write would fault into the tracker's handler; at 60 fps uploads come twice as often.
  • Fix: on EFAULT the host now prepares the rest of the buffer again (unprotect, mark dirty, so the texture is uploaded again) and reads on, up to eight times (h_fs_ReadFile; the first case is logged, [fs] ReadFile … prepared again).
  • Found on: Monster Train 2 at 60 fps: two runs in three as the route map loads, never at 30 fps.
  • Check: four runs at 60 fps: one retry, no failed read.

File-not-found noise from optional paths

  • Symptom: fs … not found lines with DM_FS=1.
  • Cause: games probe many optional paths (debug parameters, other-language voice files).
  • Note: only a missing file followed by a failure matters.
  • Found on: every game (general).

Directory, file data cache and save-thumbnail calls

  • Symptom: a missing fs::DeleteDirectory; undefined nn::fs:: / nn::account:: imports with no hook, found from the import list (Listing a game's unhooked imports before they are met) before they were met.
  • Fix: hooked: fs::DeleteDirectory (an empty directory), fs::CleanDirectoryRecursively, fs::RenameDirectory (done as RenameFile); fs::EnableGlobalFileDataCache, fs::DisableGlobalFileDataCache, fs::EnableIndividualFileDataCache / DisableIndividualFileDataCache; account::StoreSaveDataThumbnailImage / DeleteSaveDataThumbnailImage (nothing kept) (hle_fs.c, hle_account.c).
  • Found on: Metroid Dread (SDK 11: fs::DeleteDirectory was missing); 13 Sentinels: Aegis Rim (SDK 11: CleanDirectoryRecursively, EnableGlobalFileDataCache); DRAGON QUEST XI S (SDK 7.3.2: DisableGlobalFileDataCache); The Great Ace Attorney Chronicles (SDK 10.4.1: RenameDirectory, the thumbnail calls), Big Brain Academy: Brain vs. Brain (SDK 11.4.3: the individual file data cache).

fs::Commit with a list of mounts

  • Symptom: the SDK's own code opened fsp-srv and aborted, leaving the save writes uncommitted.
  • Cause: fs::Commit(const char* const*, int), the mount-list overload, had no hook.
  • Fix: the host commits the list as one transaction when every name is the same host save; cache and temporary storage in the list are skipped (their writes are already on disk, as for the single-name Commit), and a list of two separate saves is refused without committing either.
  • Found on: Animal Crossing: New Horizons (PR #25).
  • Note: SDK 11's fs::CommitSaveData(mount) is the older name of fs::Commit (Older SDK names for the same calls); fs::Commit with a CommitOption is under Device save data on the account save's directory.

Save-write failures stay with their backing transaction

  • Symptom: a failed write in one independent save can make another save's next Commit fail and discard its successful changes; the damaged save may then commit its truncated working file.
  • Cause: the host used one process-wide failure marker, consumed by whichever save committed first.
  • Fix: each canonical backing save owns its failure state. Open file handles retain that transaction identity, aliases share it, and each commit waits for in-flight writes or size changes to its backing save before consuming or restoring its failure marker (hle_fs.c). Concurrent first writes initialize the shared failure-injection settings with pthread_once.
  • Check: t_fs_commit_list injects write failures after truncation in either of two independent saves, verifies that the other save commits its changes, and checks failure propagation through aliases, rename failure handling, and a failed recovery clone. It blocks writes and size changes through an alias while another save commits, then verifies that both Commit ABIs wait for the affected I/O and refuse its failure. make -C dm/host/tests GAME=<title ID> fs-tsan also checks concurrent first writes and failed file I/O concurrent with mounting another alias.
  • Found on: the two-save host regression. Mario Kart 8 Deluxe uses independent device and account saves.

A save folder that cannot clone directories

  • Symptom: [RED] save transaction: cloning the work directory failed (…, errno=45) when the save folder (DM_SAVE) is on an external drive, then a read-only save mount and every save write failing (South Park: profile.sys); now the line reads the save folder's filesystem cannot clone directories (save transactions need APFS …).
  • Cause: a save transaction clones the committed folder into its work folder with clonefile and publishes a commit with renamex_np(RENAME_SWAP). Both need APFS: on HFS+ and exFAT disk images both fail with ENOTSUP (45), and neither format advertises VOL_CAP_INT_CLONE or VOL_CAP_INT_RENAME_SWAP, so copying the files instead of cloning them would still leave no atomic commit. The host already failed safely (the committed save mounted read-only, the iOS front end told); the line did not say why. The apps' own save folders are on APFS (the Mac app's ~/Library/Application Support/nxnative/<title ID>/save, the iOS app's Documents/save); only a DM_SAVE set by hand or a development run's work folder can be elsewhere.
  • Fix: hle_fs.c names the cause when clonefile returns ENOTSUP: set DM_SAVE to a folder on an APFS volume (the RomFS and shader libraries may stay on the other drive). Recursive deletes (rm_tree, CleanDirectoryRecursively) now take each entry's type from lstat, not d_type (which a filesystem may report as unknown), unlink a symlink without entering it, and refuse a symlink given as the tree itself; a commit whose leftover .new cannot be cleared is refused with its own red line instead of failing in the clone.
  • Check: dm/host/tests/t_fs.c section 8 makes clonefile fail with ENOTSUP (read-only mount, one front-end notice, committed save unchanged, writable again once cloning works) and deletes save folders holding a symlink to a folder outside the save (the target keeps its files).
  • Found on: South Park: The Fractured But Whole 1.05, whose port is PR #662 (not yet in the games table): a development Mac run with its save folder on a Journaled HFS+ external drive.

Save data size queries

  • Symptom: SIGSEGV at address 0 in nn::fs::GetSaveDataSizeMax, about 13 minutes into a new game.
  • Cause: Unreal's save system asks before its first save; the SDK's version reads the fsp-srv object the host never made.
  • Fix: it reports 256 MB of data and journal, as GetSaveDataSize does (hle_fs.c). fs::GetCacheStorageSize and fs::GetFreeSpaceSize give the same answers. Also hooked: fs::GetSaveDataSize / ExtendSaveData and the one-argument fs::MountCacheStorage.
  • Found on: Octopath Traveler 0 (GetSaveDataSizeMax, GetCacheStorageSize, GetFreeSpaceSize, found by the import list); Monster Hunter Rise (SDK 11: GetSaveDataSize, ExtendSaveData, MountCacheStorage).

Cache storage and other titles' save data

  • Symptom: each aborted in sm::Initialize or read a null applet proxy, one per boot.
  • Cause: SDK 20 file system calls that reach a service.
  • Fix: cache storage: OpenCacheStorageList / ReadCacheStorageList report none, CreateCacheStorage / MountCacheStorage use a host folder beside the temporary storage. fs::IsSaveDataExisting(ApplicationId, Uid) for another title (it asks for Breath of the Wild's save: none) and MountSaveDataReadOnly.
  • Found on: The Legend of Zelda: Tears of the Kingdom (1.4.3, the same sdk build as Breath of the Wild 1.9.0).

Device save data on the account save's directory

  • Symptom: the island lives in device save data, which the host did not mount.
  • Cause: 3.0.3's main mounts no account save data; the island is in device save data (MountDeviceSaveData, GetDeviceSaveDataSize, ExtendDeviceSaveData).
  • Fix: the host gives device save data the account save's host directory and transaction (one player). A second mount name over a save already mounted now shares that mount's transaction (mount_ex); it used to clone the committed directory again, over the first name's pending writes. Also fs::Commit with a CommitOption (its one flag clears the restore flag) and GetSaveDataRestoreFlag (never restored from a backup).
  • Found on: Animal Crossing: New Horizons (3.0.3, SDK 20.5.4).
  • Note: a game whose main imports MountSaveData too gets its device save as a save of its own (Device and account save data as two saves).

Device and account save data as two saves

  • Symptom: every boot asks to pick a Mii again (DM_FS=1: UserSave:/userdata.dat not found although the host save has it).
  • Cause: the game mounts CommonSave with MountDeviceSaveData and UserSave with MountSaveData(uid) (main+0x8b0c84), two saves on Horizon; the host made device save data the account save (for Animal Crossing: New Horizons, which mounts only device save data), so both names shared one directory. At boot the save manager deletes CommonSave:/userdata.dat (main+0x8b1910, through its file device's DeleteFile, main+0x633afc; a probe there with @*x1+8 shows the name) and commits, and in the shared directory that was the account save's own file.
  • Fix: a game whose main imports MountSaveData too gets its device save as <DM_SAVE>_device, a transaction of its own (hle_fs.c); of the games on hand only this one imports both, so Animal Crossing's islands stay where they are.
  • Found on: Mario Kart 8 Deluxe (4.0.0, SDK 4.4.0; one boot).

System data stand-in for the MiiModel archive

  • Symptom: abort in fs::detail::GetFileSystemProxyServiceObject under fs::QueryMountSystemDataCacheSize (main+0x8f23bc).
  • Cause: the game mounts system data 0100000000000802, MiiModel, as MiiResource: and reads NXTexture{Low,Mid}{Linear,SRGB}.dat and Shape{Mid,High}.dat whole; the SDK's Mii rendering is linked into main (main+0x8f1400–+0x8fc000).
  • Fix: firmware content is not the host's to ship, so the host writes a stand-in of the same layout with every part empty (dm_mii_standin in hle_mii.c, mounted by hle_fs.c MountSystemData; any other system data answers "target not found"). Heads build and draw nothing; bodies and favourite colours show.
  • Found on: Mario Kart 8 Deluxe (4.0.0, SDK 4.4.0; one boot).
  • Note: the layout as the code reads it: texture file +0xc the largest decompressed size of each of 11 part kinds, +0x38 their alignments, from +0x64 one 0x1c-byte entry per part (offset, size, stored size, 4 zlib parameters, then width, height, format, mips); shape file +0xc / +0x3c the same for 12 kinds, from +0x6c one 0x58-byte entry per part (offset, size, stored size, parameters, five vertex attribute offsets and sizes, index offset and byte size, bounding box), then hair (132 × 0x48, +0x12704) and faceline (12 × 0x24, +0x14c24) transforms. A texture of size 0 is skipped (main+0x8faaf4); shapes cannot all be absent, because the faceline is drawn without a check (a null buffer read at main+0xa690d0 under main+0x8286c4), so every shape entry points at one shared mesh of one vertex and the triangle (0, 0, 0), 32 bytes of zeros in a stored zlib block. The largest sizes must not be 0: they size the model's memory pool (main+0x8f2e6c), whose alignment is the first one. The Mii database itself: Mii database answered by the host.

Save-sync metadata errors close the temporary file

  • Symptom: a failed write of <DM_SAVE>.sync.tmp leaks its stream, and the diagnostic can report a cleanup error instead of the write error.
  • Cause: write_base short-circuited fprintf, fclose and rename in one condition. A failed write skipped the close; unlink could then replace errno before logging.
  • Fix: always close an opened stream, preserve the first write/close/rename error, and rename the temporary file only after both writing and closing succeed. Cleanup leaves the previous sync metadata in place.
  • Found on: static analysis reported in issue #313; reproduced without game files.
  • Check: t_save_sync_metadata injects open, write, close and rename failures into the production writer, including simultaneous write/close failures and cleanup changing errno. It checks descriptor closure, preservation of existing metadata, diagnostics and a successful replacement. No cloud service or gameplay validation is claimed.

Add-on content

Add-on content from DM_AOC_DIR

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

Owned add-on content lists beyond 64 entries

  • Symptom: some owned DLC is missing, and aoc: reporting stops at 64 add-on contents.
  • Cause: hle_misc.c stored only 64 indices and stopped reading DM_AOC_DIR when that array filled, before sorting. The omitted indices depended on directory order; its 256-byte log buffer also truncated longer lists.
  • Fix: the shared array holds 2000 indices, covering the scanner's existing accepted range (1..2000), and a static buffer holds the complete index log without increasing guest-stack use. Folder and .img discovery, sorting, deduplication, pagination and DM_AOC_OWNED=0 retain their behavior. This applies to every game using the add-on content hooks.
  • Open: the existing 1..2000 acceptance range is retained; it is not established here as Horizon's full legal range. More than 2000 accepted folder/image entries can still fill the array before deduplication, even when their unique indices fit.
  • Found on: Super Smash Bros. Ultimate (13.0.5, 99 DLC licences; PR #441).

Add-on content mount cache sized as RomFS working memory

  • Symptom: SIGSEGV in crypto::detail::CbcModeAes128Helper::DecryptBlocks with a null destination (main+0x8737e8, right after aoc: is mounted).
  • Cause: the add-on content (0100152000023001: contents.dat and version.txt, the courses are in the update) is decrypted in AocHeap, a 0x3130-byte heap that also holds the mount cache. The host answered QueryMountAddOnContentCacheSize with 4 KB, and the 0x1400-byte decryption buffer then did not fit.
  • Fix: on Horizon the cache is the RomFS's file table as RomFsFileSystem::GetRequiredWorkingMemorySize sizes it: both hash tables (4 bytes a bucket, the builder's bucket count) and all directory and file entries (0x18 / 0x20 bytes plus the name, padded to 4), 0x88 bytes for these two files. The host computes that from the folder it mounts (romfs_work_size in hle_fs.c); rom keeps its minimum answer, which is too small rather than too large.
  • Found on: Mario Kart 8 Deluxe (4.0.0, SDK 4.4.0; one boot).

Add-on content queries on a small guest stack

  • Symptom: SIGBUS in __vfprintf under aoc_load, from h_aoc_Count (a store to 0x…67ff0, right below a page of the guest thread's stack).
  • Cause: the first add-on content query lists DM_AOC_DIR and logs, a few KB of stack, and the game asks from a thread with little left.
  • Fix: aoc::CountAddOnContent and ListAddOnContent run on the big host stack (HLE_FORCE_BIG, hle_misc.c).
  • Found on: Digimon Story Cyber Sleuth: Complete Edition (SDK 6.4.0; one boot).

An add-on content mount name used again after Unmount

  • Symptom: the game shows the add-on content version error (game asks to show an error: add-on content version mismatch) after its title screen, with every add-on in DM_AOC_DIR; with DM_FS=1 the first add-on's file opens and the second's is not found under the same mount name (Pokémon Friends: AVCtitle:/AocVersion/AocVersion_DLC01.json opens, AocVersion_DLC02.json is fs stat not found).
  • Cause: the game checks each add-on in turn under one mount name: MountAddOnContent("AVCtitle", index), read its version file, Unmount. On Horizon Unmount releases the name. The host's mount table is permanent and its Unmount a no-op, and a mount under a name in use answers success with the old target, so the second add-on's mount still showed the first one's files.
  • Fix: an add-on content mount releases its name at Unmount (hle_fs.c aoc_release): the entry is marked released, which no name lookup matches (mnt_is). The entry and its image stay, as an open image file names its entry by index, and a later mount of the same image under any name takes that entry back (mount_aoc), so cycling through the add-ons does not grow the table. Save, RomFS and other mounts keep the permanent table.
  • Found on: Pokémon Friends (010008C01E742000, update 1.1.5, two add-ons; no profile in the repository yet). Its expected versions are one string, "3", compared for equality (AocVersionCheker.Check), which both add-ons hold.

Input

Controller disconnect between style and player lookup

  • Symptom: clang reports a negative shift in fill or vib_targets (hle_hid.c).
  • Cause: a style check looked up the connected player, then input or vibration routing looked it up again. A disconnect between the reads made the second result -1, used in 1u << player.
  • Fix: look up the player once and derive the style from that same result before constructing the player mask.
  • Found on: clang static analysis (issue #424); no gameplay failure reported.

History reads fill every requested entry

  • Symptom: buttons and menus work, the sticks do nothing in play (Super Mario Odyssey: its first hint, "Look Around", waited forever); an nn::diag abort with Result 0x2aca in the first frames of nnMain (Hollow Knight: Silksong, Unity 6000.0.50f1: bt: sdk+0x514580 main+0x1296c64 …).
  • Cause: games read whole histories and ignore the returned count. Odyssey reads all 16 entries of GetNpadStates; the host filled only the newest. It polls every controller slot each frame, which moves the shared sampling number on by about 16, so the stick samples it combined were mostly empty, while buttons and the stick's direction bits (which menus use) still came through. Silksong's main+0x1296c60 calls hid::GetDebugPadStates(states, 16) every frame, and the SDK's own reads a debug pad resource nothing initialized (module 202, hid); the host had only the single-state GetDebugPadState.
  • Fix: the host now fills every requested GetNpadStates entry with the current state under consecutive sampling numbers; the GetDebugPadStates history form answers every requested entry as not connected, newest first with consecutive sampling numbers (hle_hid.c). DM_KEYS takes RLeft / RUp / RRight / RDown for the right stick.
  • Found on: Super Mario Odyssey; Hollow Knight: Silksong.
  • Note: Odyssey's six-axis history is read the same way (Six-axis sensors).

Npad style set update event

  • Symptom: the game never reads the controller (the title's "Press L+R" never sees a press; no GetNpadState call at all).
  • Cause: it waits for the event bound by hid::BindNpadStyleSetUpdateEvent before reading.
  • Fix: the host now signals it once when it is bound for a connected controller, and again when a player's controller connects or goes away.
  • Found on: The Legend of Zelda: Link's Awakening (SDK 8.2.0).

No input after the console mode changes at run time

  • Symptom: the game runs on but takes no input, from the touch gamepad or a controller, after the console mode is switched at run time (to handheld and back to docked), until it is restarted.
  • Cause: a mode change moves player 1 between the Handheld npad and No1, as taking the Joy-Cons off the console's rails or putting them back does, and the hid service signals the style update event of every npad whose controller came or went. The host pushed notification 30 and signalled none, and it kept no event bound to Handheld (0x20) at all. A game that waits for those events stays on the npad it has: Hades II, sent to Handheld by the controller applet in handheld mode, kept reading it once docked, where it is empty.
  • Fix: one function, styles_sync (hle_hid.c), signals the bound event of each npad (No1..No8 and Handheld) whose style is no longer the one the game was last told, and everything a style depends on calls it after its change: the console mode (dm_set_opmode, before the notification), the players and their Joy-Cons, the ids and styles the game supports, its Joy-Con assignment. A split pair's second npad, which handheld mode takes back, is signalled with the rest. t_hid.c (t_opmode) and t_oe.c cover it.
  • Found on: Hades II (issue #2143); on the Mac, DM_OE_SCRIPT="opmode=0@15,opmode=1@21.5" on the note's route reproduces it (the applet answers at the press between the two).

Six-axis sensors

  • Symptom: motion moves never happen; shaking does nothing (Super Mario Odyssey: cap throws by shaking).
  • Cause: the host handed out six-axis handles but reported no sensor.
  • Fix: it now reports one per connected controller: two for handheld or dual Joy-Cons, one otherwise, with the style, npad id and device in the handle. At rest the sensor lies flat: acceleration (0, 0, −1) G, no rotation, identity direction. Samples are 5 ms apart on the guest clock and numbered by time, so a history read returns the samples at those times. DM_BTN_SHAKE (dm_input.h: the iPhone shaken, Space on the Mac, DM_KEYS Shake) plays a side-to-side shake in whole cycles.
  • Found on: Super Mario Odyssey: it reads all 16 history entries per sensor and ignores the count, like its npad history (History reads fill every requested entry).
  • Note: find a game's swing detector by probing the accelerometer value it reads (DM_PROBE=main:626248@x8+10 in Odyssey 1.4.1).

Switch 2 controllers over Bluetooth LE

  • Symptom: a Joy-Con 2 or Pro Controller 2 does not appear in the Mac's Bluetooth menu, or as a GameController or HID device; the game sees no controller.
  • Cause: these controllers are Bluetooth LE devices with Nintendo's own GATT service, not HID gamepads (Apple's controller driver list on macOS 27.2 has no Switch 2 products), and a Joy-Con 2 forgets a connection the moment it drops: one that is let go stays silent until Sync is held again, unless it is bonded to the host.
  • Fix: switch2_proto.h (pure C: the advertisement and what the host does with it, command framing, bonding, the input report, stick calibration, the motion maps) and pad_switch2_apple.m (a CoreBluetooth central the Mac and iOS front ends share, behind the off-by-default DM_SW2). A controller held in Sync advertises no host: on the Mac the host bonds it with the console's 0x15 commands (this Mac's Bluetooth address, then two keys, which may be fresh random ones), and from then on a button press makes it advertise toward this Mac and the host connects. A controller advertising another host is never written to. After connecting, the host runs the handshake (identity read, stick calibration, player lights, features) and takes the 63-byte reports; a player light write once a second keeps the link up.
  • Report layout (a left and a right Joy-Con 2, macOS 27): the controller's own clock in milliseconds at offset 0 (four bytes; +30 a report on a Mac, +60 or +90 when one was lost), buttons at offset 4 (the same bits as the Pro Controller 2; the right Joy-Con's rail buttons are 0x10 / 0x20, the left's 0x100000 / 0x200000), the left Joy-Con's stick in the first stick field and the right one's in the second, both calibrated from the first slot (0x130A8); accelerometer at 0x30, gyro at 0x36. A Joy-Con held upright reports raw x right, y forward, z up, and its gyro channels are right-handed rotations about the same axes, which is the Switch frame, so its map is the identity for rotation and the negated reading for gravity. The gyro scale is 0.0607 degrees per second per count, measured: 14 tilts from lying on an edge to standing against a wall (90 degrees by the accelerometer) integrated to 1.153 times as much at the Switch family's nominal 0.070, on both Joy-Cons. Each sample is timed by the controller's clock, not by when its notification arrived (they come in bursts, and a lost report leaves a gap only the clock shows). At rest the gyro reads up to about 1.5 degrees per second off, a fixed offset per controller: it is there from the first sample, the one learned for that controller before (kept under its serial number) or, for a right Joy-Con 2, its factory offset (16 bytes at 0x13040: a temperature and the offset in radians per second, which matches its rest offset); about a second of nearly constant samples refines it.
  • Rumble: each controller lists its own vibration channel (the right Joy-Con 2's FA19B0FB-CD1F-46A7-84A1-BBB09E00C149, the left one's 289326CB-A471-485D-A8F4-240C14F18241). The report is a zero byte and a 16-byte block per actuator: 0x40 | samples << 4 | sequence, then up to three five-byte samples, played in turn. A sample is two bands, low then high, each a 10-bit frequency code and a 10-bit amplitude. The frequency code is logarithmic, 96 steps an octave, with 333 at 110 Hz (about 9.9 Hz to 16 kHz); the amplitude is linear, 1023 at full; both bands together are limited to 0.98. Hertz and amplitude are rounded as the console rounds them: truncate, then the nearer of that code and the next. Checked against the Switch 2's own encoder on 200,000 random values: no byte differed. An earlier reading (hertz as the code, a single sample per block) played effects 3 to 5 times too low in pitch and dropped two samples of three. A game sets its vibration about every 5 ms, and each value plays once in its own 5 ms sample, three to a packet every 15 ms (switch2_proto.h, Rumble timing), as the console plays them: a one-step pulse stays one sample long. Before that, a value played until the next packet, so short pulses lasted two to three times as long; replayed against a recording of Nintendo Switch 2 Welcome Tour's rumble exhibits, their energy came to 1.8 to 2.2 times the console's, and now matches it. The game's two full-key motors reach the pad through haptics_apple.m (dm_haptics_apple_set_sink, with Rumble strength and cutoff applied): each Joy-Con of a pair plays its side, a Joy-Con alone the stronger of the two, a Pro Controller 2 one per grip (its channel from the notes, untested). The console also corrects the amplitude by frequency with tables of its own per controller model; that is not done here.
  • Bonding (a left and a right Joy-Con 2): each bonded with fresh random keys, then woke toward the Mac on a button press and reconnected with no Sync. The answer to the first key was the same bytes on both Joy-Cons and with any key; the answer to the second changes with the key.
  • Permission: macOS asks once for each game app, at the first scan. A rebuilt app with the same bundle ID asked again (a fresh ad-hoc signature); a second start of the same app did not.
  • Found on: a left and a right Joy-Con 2 on an M1 Pro Mac, macOS 27.2: Metroid Dread (both woke and played as one pair, pairing by Sync during the game, the Home quit prompt; a Joy-Con paired back to a Switch 2 woke for it and was left alone, woke for another host, until Sync paired it with the Mac again) and Super Mario Odyssey (motion: the cap throw by shaking, from the pair's right Joy-Con; rumble on both Joy-Cons).
  • Note: protocol credit: the community's reverse engineering of these controllers (Finally the Controller Works, ndeadly/switch2_controller_research, trevlars/switch2-controllers-linux, Nadeflore/switch2-controllers); the code was written from those notes. A Pro Controller 2 (an M4 Pro Mac, Super Mario Bros. Wonder 1.2.0, issue #1267) paired by Sync on the first try and sends the Joy-Con 2's report layout (GR 0x1000000, GL 0x2000000, C 0x4000), with full stick travel from its factory calibration and Home's quit prompt; its rumble channel is from the notes and untested, and its motion map and gyro scale are borrowed (the Pro Controller's map, the Joy-Con 2's scale), not measured on it. This Mac's own address comes from IOBluetoothHostController, read once the app may use Bluetooth. On iOS there is no API for the device's own address, so a controller in pairing mode is used there without bonding. The Joy-Con 2's mouse sensor: The Joy-Con 2 mouse sensor and pointer.

Joy-Cons as dual or single npads

  • Symptom: a pair of Joy-Cons is one Pro-style controller: the game cannot see a dual controller with two sensors, one Joy-Con cannot be a sideways player with its rail buttons, and a game's single / dual assignment calls (SetNpadJoyAssignmentModeSingle / Dual) change nothing; a game that wants two players from one pair (its controller screen) gets one.

  • Cause: every controller was a full-key (or handheld) npad: style_for_player knew only those two styles, the styles the game supports were recorded but never consulted, fill kept the low 16 button bits (no SL / SR), the vibration handles knew types 3 and 4 only, and the assignment calls were ignored.

  • Fix: a front end that reads Joy-Cons one by one reports them to the npad layer (dm_joy_attach, dm_joy_set, dm_motion_set_joy in dm_input.h, no controller-specific names); nothing changes until one does. Docked, a pad that holds both Joy-Cons is one JoyDual npad (both Joy-Cons' buttons, rail buttons included, each stick on its side, a six-axis handle per Joy-Con) when the game supports JoyDual; when the game asks for single assignment, the left Joy-Con stays on the npad (JoyLeft, or the one the game named) and the other takes the lowest free supported npad (JoyRight); one Joy-Con alone is JoyLeft or JoyRight. The controller applet splits a pair when the game wants more players than there are controllers. A game that supports no Joy-Con style gets the pad's merged state as a full-key controller, as before. Vibration handles exist for the Joy-Con styles (type 5, 6, 7) and reach the motor of their Joy-Con, on whichever npad it is. Handheld mode: player 1's Joy-Cons count as attached to the console (the Handheld npad) and are not split; a player 2 Joy-Con pair is as docked.

  • Choosing on the controllers: the Switch 2 transport pairs a left and a right Joy-Con that connect; holding SL + SR on one of a pair for three seconds gives it a player of its own, and holding L on a left one alone and R on a right one alone for three seconds pairs them (s2_gesture), as a Switch's Change Grip/Order screen does, without its screen.

  • Found on: a left and a right Joy-Con 2 on a Mac:

    Game Before (every controller full-key) After
    Super Mario Odyssey 1.3.0 a pair was one Pro-style pad with one sensor; two-player mode could not take a Joy-Con each a JoyDual npad with a sensor per Joy-Con: the two-handed throws (both flicked up, both shaken sideways); two-player mode splits the pair through the controller applet (JoyLeft for Mario, JoyRight for Cappy); rumble on both, also through the full-key handles the game keeps sending to
    Super Mario Bros. Wonder 1.2.1 a pair was one controller: its two-player screen found one and was canceled choosing two players splits the pair (single assignment): one sideways Joy-Con each
    Super Smash Bros. Ultimate 13.0.5 a pair was one Pro-style pad; one Joy-Con could never be a fighter of its own holding SL + SR splits the pair into two sideways players; a 1v1 with a Joy-Con each, the rail buttons as shoulders, rumble on each

Live controller motion

  • Symptom: motion controls do nothing with a controller that has a gyroscope (Breath of the Wild: aiming with its Motion Controls option on); the six-axis sensor always lies flat.
  • Cause: the host's six-axis sensor had no source of motion: flat at rest, apart from the DM_BTN_SHAKE shake (Six-axis sensors).
  • Fix: a controller with a gyroscope drives its player's sensor on the Mac and iOS (GCMotion, motion_apple.h → dm_motion_set, hle_hid.c); on iOS the phone's own device motion drives player 1's while no controller supplies fresh motion (DmGamepad.m). The orientation is integrated from the gyro with tilt correction toward the measured gravity (motion_fusion.h), in SixAxisSensorState's units (G, revolutions/s, revolutions). After 300 ms without a sample the source expires; without another source the sensor lies flat again. A shake still adds on top. The switches are in development.md, How it works (Input).
  • Source routing (resolved, review of PR #351): sharing one integrator let phone and controller callbacks mix orientations, including with DM_PHONE_MOTION=1; checking only hasRotationRate also let zero-only controller reports suppress the phone. Each source now has its own orientation and timestamp: auto prefers fresh controller data, forced mode selects only the phone, and the Apple adapter waits for actual data before publishing. One-player games merge motion from the same controller slots as buttons. Motion settings initialize once with pthread_once; per-source locks also protect diagnostic timestamps. t_hid covers routing, expiry and concurrent startup under ThreadSanitizer; t_motion_apple covers activation, zero-only reports and detachment.
  • Upright initialization (resolved, review of PR #351): the heading reference was parallel to gravity when the phone started upright, so projection produced a zero vector and silently left the orientation flat. A nonparallel reference now primes the orientation on the first sample; t_motion covers all six axis-aligned starting poses.
  • Back from the background (iOS): iOS turns a manual-activation controller's sensors (DualSense, DualShock 4) off while the app is in the background and leaves them off, so the gyro stayed dead until the controller reconnected (reported by MacNativeGames on the iPad). Becoming active again sets sensorsActive once more for every attached controller (dm_motion_rearm, front_ios.m); not tested on a device here.
  • Found on: The Legend of Zelda: Breath of the Wild (1.9.0): motion aiming on a Mac with a DualSense. The iOS paths have not been tested on a device.
  • Note: history entries repeat the newest sample; yaw drifts as on the console, with no recenter. The console's own sensor still reports no states (SDK 20 six-axis sensor and amiibo (nfp) calls), so motion follows the controller in docked mode.
  • Open: PR #286 has iPad aiming measurements and calibration controls to compare on a device before choosing shared defaults. Related reports: #153, #389.

Non-finite motion samples

  • Symptom: a non-finite accelerometer or gyro sample corrupts the six-axis fields; later valid samples do not restore the previous fusion state.
  • Cause: motion_update published raw NaN/Infinity and integrated it into persistent angle/orientation. Finite but extreme rates can also overflow the float rotation norm or accumulated angle.
  • Fix: reject a non-finite sensor sample before priming or changing any state. A non-finite time step skips integration, and a non-finite gain disables tilt correction. Do not commit an overflowing rotation step or angle; normal finite samples keep the same arithmetic and coordinate conventions.
  • Found on: synthetic t_motion samples, not a reproduced game or controller fault. Tests cover each sensor axis before/after priming, subsequent valid input, invalid timing/gain, finite overflow, all 48 signed frame maps and small-angle accumulation. No device or gameplay result is claimed for this change.

The phone's motion in the picture's frame

  • Symptom: on iPhone Duo's inner display the phone's gyro is turned a quarter turn: tilting up and down moves the pointer left and right, and the other way round (GitHub issue #569); the outer display is right.
  • Cause: Core Motion delivered the phone's samples in the hardware frame, and DmGamepad.m turned them into the Switch frame by the interface orientation (motion_phone_to_switch, motion_fusion.h). That holds only where the hardware frame is the screen's portrait frame, as on a phone with one display. The Duo's inner display is a quarter turn from it, and it ignores the app's supported orientations.
  • Fix: on iOS 27 and later the pad view, full screen over the picture, is the motion manager's deviceMotionBody: Core Motion then delivers rotation rate, gravity and user acceleration in that view's frame, for whichever display shows it and however it is turned, which is the Switch frame as it is (x to the right of the picture, y to its top, z toward the player), and the orientation map is skipped. iOS 26 keeps the map. No device model or fold state is checked.
  • Found on: Super Mario Galaxy: the gyro pointer on the inner display, reported by klave7. Not yet tested on a device: on the Duo's inner and outer displays and on a phone with one display running iOS 27, DM_MOTION_LOG=1 should read acc 0 0 -1 with the phone flat and still.
  • Open: while the scene is not full screen (Split View on the inner display) Core Motion falls back to the hardware frame without a flag that says so, and the samples are still taken as the view's frame.

Switch Pro Controller advertises motion but sends zeroes

  • Symptom: a Pro Controller's buttons work on the Mac, but its gyro pointer never moves; GCMotion callbacks arrive with acceleration and rotation all zero.
  • Cause: the macOS driver can expose empty motion through GameController while its underlying Nintendo HID 0x30 reports contain IMU data (PR #296). The OS driver lies outside this repository; the passive HID adapter is a workaround for that platform defect.
  • Fix: motion_pro_mac.h listens nonexclusively to vendor 057E, product 2009, without sending commands or changing the driver's configuration. motion_pro.h converts the three signed little-endian samples to G and rad/s in the Switch frame; their nominal 5 ms spacing reaches the shared integrator through dm_motion_set_hid. Each HID device owns its buffer and decoder. HID, GameController and phone orientations stay separate; usable GameController data wins, otherwise fresh HID data supplies that player's controller motion. Removal, focus loss or ambiguous routing clears only the HID source; 300 ms of silence expires it. DM_MOTION_HID=0 disables the reader; DM_MOTION=0 disables all motion.
  • Routing limit: GameController exposes no public per-controller HID identity. Raw motion is enabled only with one physical Pro and one GameController Pro, using that controller's existing player slot (including DM_KBD_PLAYER=own / DM_PAD_MERGE). Multiple Pros keep their GameController paths; raw data is never assigned to the first controller by guesswork. Synthetic GameController HID devices are excluded with Apple's GCSyntheticDevice property.
  • Open: passive reading cannot obtain factory calibration without issuing SPI commands alongside the OS driver (identical pads read from their reports do issue that read, and it works: Identical controllers that GameController lists as one). Nominal scales are 4096 counts/G and 70 mdps/count (protocol research); no automatic bias learning removes slow intentional turns. Sample order follows that research and Linux hid-nintendo; SDL reverses it. Confirm order, sensitivity, drift and reconnects on hardware before calling the reworked reader device-tested. t_motion_pro, t_motion_pro_mac and t_hid cover decoding, timestamps, ownership, removal, ambiguity and source fallback with synthetic inputs.
  • Found on: Super Mario Galaxy, docked pointer on a Pro Controller.

Identical controllers that GameController lists as one

  • Symptom: two Switch Pro Controllers connected over USB to a Mac play as one player: GameController lists one controller and reads one of the two pads, the other does nothing, and a two-player game refuses to start (issue #1218). Over Bluetooth both are listed.
  • Cause: GameController lists one controller for both. Their USB serial numbers are the same, which may be why; over Bluetooth each has its own. IOKit has both devices, and each sends its report 0x30. GameController gives a controller no HID identity, so its one controller cannot be tied to one of the two devices.
  • Fix: one mechanism in pad_hid_mac.h, on its own HID manager. It keeps the devices GameController reads (g_hidpads_gc) instead of dropping them, to count IOKit's pads of each model (vendor and product ID) against GameController's controllers of it: a Pro Controller's by their product category, another model's by name (vendorName is the HID product string). Other Microsoft, Sony and Nintendo pads cannot be matched by name (a DualShock 4's HID product string is Wireless Controller), so they are never read this way, which would read them twice. When IOKit has at least two pads of a model and more than GameController lists (only entries that send reports count: A HID entry that sends nothing counted as a second pad), after a 3 s wait for a pad that is not listed yet (pad_dupes_want), every pad of that model is read there, a player each, by USB location: a Pro Controller from its report 0x30 (pad_pro.h) with its gyro (motion_pro.h), any other model from its HID elements (pad_hid.h). front_mac.m leaves GameController's controllers of that model unread (hidpad_gc_unread). When GameController lists them all again, they go back to it. DM_PAD_IDENTICAL=0 turns it off, as does DM_HID_PADS=0; DM_MOTION=0 and DM_MOTION_HID=0 turn off only the gyro. motion_pro_mac.h is not involved: it still pairs one Pro Controller's raw motion with GameController's one controller.
  • Sticks: a Pro Controller read this way asks for its stick calibration in SPI flash, subcommand 0x10 in output report 0x01: the user's at 0x8010 when its magic B2 A1 is there, else the factory's at 0x603D (spi_flash_notes.md). Alongside the macOS driver both pads answered over USB, and a pad over Bluetooth, on an M4 Pro with macOS 27. Without an answer each axis measures its own rest and reach.
  • Limits: such pads have no rumble or player lights, and Home does not quit from them. Another model's buttons follow 8BitDo's numbering unless DM_HID_MAP says otherwise; only Pro Controllers were run, before the mechanism was folded into pad_hid_mac.h (PR #1595), and the folded version awaits a run with two Pro Controllers over USB.
  • Found on: Mario Kart World 1.8.0, two Pro Controllers over USB, 33 minutes of two-player split-screen races.

One Pro Controller read as two players

  • Symptom: with one Pro Controller over Bluetooth, the log shows GameController lists 1 of 2 pads of this model twice for the same USB location, then players 1 and 2 for that pad; a one-player game opens the controller applet every few frames (hid controller applet: 1 to 1 players wanted, 2 connected, focus state 2 then 1, thousands of times). A game that suspends its music while an applet has focus plays none: sound effects and positional music still play, while area and cutscene music never start.
  • Cause: IOHIDManager hands a device over once per matching dictionary it meets, each time as a new IOHIDDeviceRef for the same I/O Registry entry. The Pro Controller meets both the GamePad usage dictionary and its own vendor / product one (added so identical pads are counted), and hidpad_found knew devices by reference only, so one pad counted as two pads of its model against GameController's one controller (Identical controllers that GameController lists as one).
  • Fix: pad_hid_mac.h knows a device by its registry entry ID (hidpad_entry): a second reference to a known entry is ignored in every list.
  • Found on: The Legend of Zelda: Ocarina of Time, one Pro Controller over Bluetooth on the Mac: no music in Kokiri Forest gameplay or its cutscenes, back once the pad was read once.

A HID entry that sends nothing counted as a second pad

  • Symptom: one Pro Controller over Bluetooth comes back from a long disconnect (20 minutes or more) as two players: GameController lists 1 of 2 pads of this model: read here for two USB locations, one of them 0x0, then controller 'Pro Controller' (HID) connected as players 1 and 2. The location-0 entry logs cannot ask its stick calibration (IOReturn 0x10000003). The connected then disconnected ... is free lines of the GameController controller just before are the host releasing it to the twin path, not a Bluetooth drop. A short drop of about 20 s reconnects as one player.
  • Cause: the twin path (Identical controllers that GameController lists as one) counted every IOKit entry of the model as a pad. After the long disconnect IOKit had a second entry of the one pad: 0x10000003 is MACH_SEND_INVALID_DEST, so nothing receives what is sent to it, and it survived the next reconnect while the live entry was replaced. Two entries against GameController's one controller read as two identical pads that GameController merged. The raw motion pairing (Switch Pro Controller advertises motion but sends zeroes) counted the same entries, so the dead one would also have turned the gyro off. What macOS keeps the entry for is not known: the report has no ioreg listing of that state.
  • Fix: an entry counts only while a pad is behind it (hidpad_live, pad_hid_mac.h; pro_live, motion_pro.h). macOS keeps a Pro Controller in full mode, which sends report 0x30 every 15 ms whether or not anything moves, so a Pro Controller entry counts while its last 0x30 is less than a second old; another model's entry counts once it has sent any report (it may send only on change). Each entry's reports are listened to for this (hidpad_listen; another model's only until its first). An entry starts counting at its first report, and again at its first after a silence; one that falls silent without being removed is left out at the next change of the model's pads. Both twins and the raw motion pairing use it. When a model has two or more entries, the log names each one that starts or stops counting, with its transport and registry entry ID, and the twin line names them too, so a report shows which entries a pad has. A twin now takes its player only once it sends.
  • Open: not run with a controller (none here), so that the reporter's location-0 entry sends nothing is inferred from its MACH_SEND_INVALID_DEST, not measured; if it does send report 0x30, it still counts. The issue stays open for the reporter's retest. Two Pro Controllers over USB are read as twins as before, about 15 ms later (their first reports), checked with fake devices only. A silence of a second or more (a pad whose reports stop while it stays connected) leaves it out at the next change of its model's pads.
  • Found on: The Legend of Zelda: Ocarina of Time (v131072), one Pro Controller over Bluetooth on a MacBook Air M4 with macOS 27.2, in two runs after long disconnects (issue #2237).
  • Check: t_pad_hid_mac (dm/host/tests, fake IOKit entries and GameController controllers): one pad with a silent second entry stays with GameController, as does a reconnected pad whose old entry fell silent; two pads over USB become twins only once both send, go back when one falls silent and come back when it sends again. With every entry counted, it fails with the report's two read here lines. t_motion_pro_mac: a silent entry does not stop the pairing, and a pad whose old entry fell silent is paired on its new one. t_motion_pro: pro_live.

A keyboard receiver that declares a gamepad takes player 1

  • Symptom: on the Mac, with a keyboard and mouse receiver plugged in, the log shows controller '<receiver>' (HID) connected: player 1 and the real controller as player 2; a one-player game takes no input from it and opens the controller applet again and again (hid controller applet: 1 to 1 players wanted, 2 connected).
  • Cause: the receiver declares a Generic Desktop GamePad or Joystick collection, so it matches the raw HID pad filter (hidpad_start), and a raw HID pad took the lowest free player at its connection. A HID descriptor only claims a controller: nothing in it tells a receiver that never sends a report through that collection from a pad at rest. +[GCController supportsHIDDevice:] answers YES for this receiver and GameController never lists it, so since the two-second wait for such devices (463d21f7) it was read as HID; before that it was skipped.
  • Fix: a raw HID pad is a player from its first button press, not from its connection (hidpad_first_press, front_mac.m), through the path of Press a button to join but with the slot a connecting controller takes (pad_slot_take): the log says press a button to play, then a button pressed: player N. A device that never presses takes no player. Identical pads read here (twins) are models GameController lists and stay players at their connection.
  • Open: not run with such a receiver (none here): the issue stays open for the reporter's retest. The applet opening in a loop when a one-player game sees two players was a cause of its own, resolved: The controller applet leaves out the players past the maximum.
  • Found on: The Legend of Zelda: Ocarina of Time, a Keychron Link receiver (3434:d030) over USB and a Pro Controller over Bluetooth, macOS 27.0.1 (issue #1657).

SDK 20 six-axis sensor and amiibo (nfp) calls

  • Symptom: SDK 20 calls that reach a service; nfp's AttachActivateEvent / AttachDeactivateEvent crashed in the SDK at sdk+0x3f2a78.
  • Fix: the console six-axis sensor (hid::*SevenSixAxisSensor*) reports zero states; more nfp calls are hooked: AttachActivateEvent / AttachDeactivateEvent, the two-argument Mount, Unmount, GetCommonInfo, GetRegisterInfo, GetState, Finalize. The host answers as a console with no amiibo.
  • Found on: The Legend of Zelda: Breath of the Wild (1.9.0, SDK 20.5.22). Super Mario Bros. Wonder 1.2.1 already initialized nn::nfp at boot (A game update imports SDK calls the base game did not).

nfp's extended application area

  • Symptom: [RED] sf: the game connects to service nfp:user itself, then game called nn::diag abort: Result 0xe15 under the game's own initialization (main+0x115deb0).
  • Cause: nn::nfp::InitializeWithExtendedApplicationArea is another entry to the library the host takes over, and was not hooked, nor were GetExtendedApplicationArea, SetExtendedApplicationArea, GetExtendedApplicationAreaInfo and Restore.
  • Fix: hooked as the rest: the same device that never finds a tag (hle_sdk16.c, checked in t_nfc.c).
  • Found on: Kirby Air Riders.

NFC initialization beside the amiibo library

  • Symptom: selecting amiibo initializes nfp, then the game connects to the unserved nfc:user service and aborts in nn::nfc::Initialize.
  • Cause: a game can call the general NFC library (nn::nfc) itself, beside the amiibo library (nn::nfp). The host takes over the whole nfp family, but nfc::Initialize stayed in the SDK and connected to nfc:user.
  • Fix: hle_sdk16.c takes over nn::nfc::Initialize, Finalize, GetState and IsNfcEnabled. Initialize and Finalize only track the library's state (no service session), and IsNfcEnabled answers false: a console with NFC switched off, so the game shows its own "NFC is disabled" message and returns to its menu. The nfp answers are unchanged. dm/host/tests/t_nfc.c checks the hooks. Of the games here, only Hyrule Warriors: Definitive Edition calls nn::nfc from its own code; the others have it only inside the sdk module.
  • Open: nfp answers as a console with NFC on and no amiibo (SDK 20 six-axis sensor and amiibo (nfp) calls), so a game that asks both libraries gets two different consoles, and the game's message points to a System Settings switch the host does not have. Answering IsNfcEnabled true would match nfp, but the game's amiibo path after it has not been run.
  • Found on: Hyrule Warriors: Definitive Edition 1.0.1 (SDK 3.5.1), entering amiibo from the title screen (GitHub issue #708). With the fix, the message opens, closes and opens again.

The amiibo library's state before Initialize

  • Symptom: game called nn::diag abort: Result 0, call site sdk+0x13ce58 in the middle of play, from nn::os::TryWaitSystemEvent on an event that was never set up (backtrace main+0x1fb9920 in Tears of the Kingdom 1.4.3).
  • Cause: nn::nfp::GetState answered 1 (initialized) from the start. Breath of the Wild (main+0x128ec7c) and Tears of the Kingdom (main+0xca5200) call nfp::Initialize and AttachAvailabilityChangeEvent only when GetState is not 1, so they skipped the attach; Tears of the Kingdom's amiibo poll then waits on that event and the SDK aborts on its uninitialized state. Breath of the Wild also checks for 0 after Finalize (main+0x128f964).
  • Fix: hle_sdk16.c keeps the library state as on Horizon: 0 until Initialize, 1 until Finalize, like the nfc hooks beside it. dm/host/tests/t_nfc.c checks it.
  • Found on: The Legend of Zelda: Tears of the Kingdom 1.4.3 (GitHub issue #613; when the poll first runs was not found; the abort came after about 15 minutes of play, and was not reproduced here).

Vibration values flushed by the SDK's vibration graph

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

Whether a vibration device is mounted

  • Symptom: with rumble on, [RED] sf: the game connects to service hid itself, then CRASH sig=11 addr=0 in hid::detail::IsVibrationDeviceMounted (sdk+0x24254c in SDK 7), under hid::IsVibrationDeviceMounted. With rumble off in the host's settings the game never asks.
  • Cause: the SDK's own call opens the hid service, which sm refuses (0xe15), and reads the null service object. Games ask only for handles GetVibrationDeviceHandles returned, which it returns none of with rumble off.
  • Fix: hle_hid.c answers it. On Horizon a device is mounted when the npad's connected controller has the handle's style and an actuator on that side; the host reports it mounted when the channel has an output, as GetVibrationDeviceInfo reports the device.
  • Found on: Super Mario Odyssey 1.4.1, entering a Sand Kingdom pipe (main+0x5d3150 asks for each handle of a controller whether any is mounted; GitHub issue #715). No other local game imports it. Kirby Air Riders calls the Switch 2's form, IsOunceVibrationDeviceMounted, for its handles soon after boot, answered the same way. It crashed a run with a window, and no headless run; why only that one was not checked.

Vibration handles before a controller is reported

  • Symptom: DM_RUMBLE_LOG=1 shows the game's startup handle request answered with none, then every vibration value sent to handle 0 and reaching no output for the whole session, although a controller with haptics (a DualSense on the Mac) is connected. A game that rumbles would stay silent.
  • Cause: hid::GetVibrationDeviceHandles answered 0 handles until a controller's haptics were routed. Games ask once at startup, before GameController reports the pad, and keep the answer. On Horizon the handles are computed from the npad id and style alone (libnx _hidGetVibrationDeviceHandles).
  • Fix (resolved): the handles no longer wait for a routed output: with rumble on and an output sink installed (dm_haptics_enabled), a connected npad style gets its two handles; vib_send checks for an output at each send (hle_hid.c). No sink, rumble off or a disconnected npad still get none, and IsVibrationPermitted still follows the routed outputs. DM_RUMBLE_LOG=1 logs the handle requests and, once a second, the values sent, those with no routed output and the peak amplitude. Each input value is counted separately, including mixed batches; a mutex protects the diagnostic totals across game threads. t_hid covers cached startup handles delivering a nonzero value after routing, mixed-batch diagnostics and concurrent sends; t_hid_tsan checks those sends for data races.
  • Found on: Unicorn Overlord (the Asian release, 010054B01AD92000), in the log only: the game has no rumble of its own (it sends amplitude 0 throughout), so nothing could be felt before or after. Not yet checked on a game that rumbles.

Controller haptic engines without advanced players

  • Symptom: a controller (an Xbox Wireless Controller, a GameSir Super Nova, a GameSir Cyclone) never vibrates in a game on the Mac or the iPhone, though iOS Settings' Identify buzzes it; the log repeats [RED] haptics: controller left handle create player: Couldn’t communicate with a helper application. (NSCocoaErrorDomain 4097). The engine itself starts.
  • Cause: the engines that GCDeviceHaptics makes are served by gamecontrollerd, which tells CoreHaptics whether it offers advanced pattern players (HapticServerConfig.supportsAdvancedPatternPlayers; its log: Server returned: supports audio: 0 adv players: 0). -[CHHapticEngine createAdvancedPlayerWithPattern:error:] does not check that; the request reaches the server, which refuses it, and the XPC reply comes back as NSCocoaErrorDomain 4097, on every locality (All, Default, Handles, Left Handle, Right Handle). The output made only advanced players (a looping 30 s event driven by dynamic parameters), and a player failure is retried on the same handle, so the fallback to the default locality could not help either. On the Mac the refusal also leaves that engine unusable: a plain player or a restart on it fails with -4810. A plain CHHapticPatternPlayer on a new engine plays and takes dynamic intensity and sharpness controls.
  • Fix (resolved): when the advanced player cannot be made, the channel drops that engine, makes a new one and keeps using plain players (haptics_apple.m, log line has no advanced players … plain players). A plain player cannot loop, and one stopped in the middle of an event left the Xbox controller's motors running at its level after the engine and the process were gone, until the controller was switched off. So plain players play pulses of 0.5 s that end on their own: the next one is scheduled where the last ends while the effect lasts, each starts at the current level (its pattern's initial control) and takes later levels as controls, and a stop sends level 0 to the playing pulses and stops the engine only after the last one has ended. Checked on an Xbox controller through the host's own haptics_apple.m: low and high levels are told apart, full level feels like a plain pattern at intensity 1, and the motors stop after the effect. t_haptics_apple covers a server without advanced players: the new engine, the pulses following each other, no pulse cut by a stop.
  • Left and right handle engines override each other (current). Without advanced players, the engines of LeftHandle and RightHandle playing at once were not felt on the Xbox controller, while one engine on Handles (or on All) at the same intensity was; a trigger engine playing beside them likewise drowned them out. So a split route whose first advanced player is refused becomes one engine on Handles (Default when the controller does not list it) that plays both motors mixed (merge, log line plays both motors on one engine): the rumble is the same on both sides. A DualSense (GCProductCategoryDualSense) is not merged: with plain players on its left and right handle engines, each side alone, both at different levels and silence were felt as sent (PR #986: a standalone probe and Super Mario Odyssey's buried Power Moons, over Bluetooth on macOS 26.7.1, with plain players set at connection). So its handles keep their engines, each moves to plain players when its advanced player is refused (log line has no advanced players … plain players, once per handle), and left and right keep their own levels. Why the Xbox controller's handle engines override each other is not known (its rotating motors, or its driver in gamecontrollerd), so other controllers with both handle localities stay merged until one is tried. Not run: a DualSense over USB or on iOS, a DualSense Edge, and the refusal on both handles of a real DualSense (the left handle's is what main already met). t_haptics_apple covers the merge and the DualSense's handles, with and without advanced players.
  • Low levels on rotating motors (open). Controller motors and the Switch's resonant actuators disagree at low amplitudes, in two directions. The Xbox controller's motors showed nothing at intensity 0.05, a faint buzz from about 0.1 and a soft one at 0.3, so Breath of the Wild's charged attack (0.07 for about 3 s, DM_RUMBLE_LOG=1) was not felt even at DM_RUMBLE_GAIN=2.5. Mario Kart 8 Deluxe holds its low band at 0.1–0.4 all race long (about 30 changes a second, 0.84 peaks every few seconds): a texture on a Joy-Con, rumble that never lets up on a DualShock 4's motors. A floor with a square-root rise (0.15 + 0.85·√x) fixed the first and would make the second much worse, so the mapping stays linear. DM_RUMBLE_MIN=0..0.9 (default 0) silences controller levels below it and stretches the rest over the full range, on every controller route; 0.45 leaves Mario Kart 8 Deluxe's crashes and drifts, as an emulator gives them on that controller. t_haptics_apple covers the cutoff.
  • Found on: The Legend of Zelda: Breath of the Wild (1.9.0), on macOS 27.0.1 with an Xbox Wireless Controller over Bluetooth and an Xbox 360 USB receiver; NieR:Automata The End of YoRHa Edition (the Asian release, 0100B8E016F76000) on an iPhone with a GameSir Super Nova in DualShock 4 mode, which plain players fixed; Mario Kart 8 Deluxe (4.0.1) on macOS 27.0.1 with a GameSir Cyclone; the same error in issue #219 (Sifu, provisional profile), controller unnamed.

A haptic engine that stops under a call raises, and ended the app

  • Symptom: the app dies with *** Terminating app due to uncaught exception 'com.apple.CoreHaptics', reason: '-[CHHapticEngine(CHHapticEngineInternal) checkEngineRunning:]: self.running', then [dm] CRASH sig=6 in __pthread_kill; no [RED] haptics line comes first. Seen after about 85 s of a Mario Kart World race on an iPhone, a few seconds after the log's oe focus state changed twice (2, then 1).
  • Cause: Core Haptics reports a player or parameter call on an engine that is not running by raising an NSException, not by returning an NSError, and offers no running state to ask first. The engine stops on its own at any time (an audio interruption, the haptics helper's exit) while its stopped handler is only queued onto the adapter's serial queue, and the adapter's own stop is asynchronous, so any queued work that calls a player (a level change, a pulse renewal, the idle release, a stop on focus loss or reconfiguration) can reach an engine that is no longer running. Every error: path in haptics_apple.m was already covered; this one is not an error return. The log has no backtrace, so which call raised in the crash is not known; it came a few seconds after a focus loss and its return, which stop and restart the engines.
  • Fix (resolved): every entry into a rumble channel (apply:, stop, the queued pulse renewal and delayed engine stop) runs its Core Haptics calls inside guarded:work:. A caught exception is handled as a failed engine (stoppedEngine:stage:) that is never called again: the channel drops its engine and players without stopping or silencing them (a stop sends a plain player zero, which raises once more; nothing plays from an engine that is not running), backs off like a failed start, clears the queued output and logs [RED] haptics: <target> <stage> on a stopped engine; the next effect builds a new engine. t_haptics_apple raises from a player's sendParameters on the advanced-player route and on the plain-player route, from a level change and from the idle release, and checks the channel clears and recovers; without the guard the first case ends in the same uncaught NSException, and with a recovery that stops the channel the plain-player case does.
  • Found on: Mario Kart World (1.8.0, a physical iPhone, run of 2026-11-07).

DualShock 4 rumble through its effects report

  • Symptom: on the Mac, a DualShock 4 or a controller that presents itself as one (a GameSir Cyclone over Bluetooth: HID vendor 054C, product 05C4, productCategory "DualShock 4") does not rumble; Core Haptics failed on every locality with NSCocoaErrorDomain 4097 (issue #363, also in a minimal Swift app), while the same controller rumbles in an SDL-based emulator.
  • Cause: the Core Haptics route was the only one. SDL's hidapi driver does not use it for a DualShock 4: it writes the controller's effects report, which needs no haptics server and keeps the strong and weak motors apart, where a server without advanced players merges them (above).
  • Fix (resolved): on the Mac, a controller GameController calls a DualShock 4 is driven through that report when a free HID device of vendor 054C, product 05C4 / 09CC / 0BA0 is there (asked of the registry at each connect; ds4_route, log line rumble through the DualShock 4 effects report): report 0x11 over Bluetooth (78 bytes, flags 0xC4, rumble-only flag 0x01 so the light bar is left alone, CRC32 over 0xA2 and the first 74 bytes), 0x05 over USB (32 bytes), sent only when a motor byte changes. As the emulators feed SDL, the strong motor follows the low band and the weak one the high band (0.85 / 0.15 left / right), linearly, with DM_RUMBLE_GAIN and DM_RUMBLE_MIN. Without the HID device the controller keeps the Core Haptics route. GameController does not name a controller's HID device, so two DualShock 4s each get one, possibly swapped. t_haptics_apple covers the CRC, the fallback without the device and the report's bytes on fake devices. iOS is unchanged.
  • Found on: Mario Kart 8 Deluxe (4.0.1) and The Legend of Zelda: Breath of the Wild (1.9.0) with a GameSir Cyclone over Bluetooth on an M5 Pro, macOS 27.0.1: rumble on crashes and drifts (with DM_RUMBLE_MIN=0.45), on hits and chopping, stopping after each. Not checked: a genuine Sony DualShock 4, the USB report.

A DualShock 4 that connects after the first stays on Core Haptics

  • Symptom: with two controllers that present themselves as DualShock 4s on the Mac, the one turned on first rumbles normally and the one turned on later is barely felt. The first logs rumble through the DualShock 4 effects report; the later one logs haptics: player 2 controller …, localities …, then has no advanced players (NSCocoaErrorDomain 4097): plain players and plays both motors on one engine (Handles).
  • Cause: ds4_route kept one IOHIDManager for the session and never scheduled it. A manager adds devices once when its matching is set; later arrivals and removals reach it only through a notification port, which needs a run loop or a dispatch queue (Apple's IOHIDManager.c in IOKitUser). So IOHIDManagerCopyDevices went on answering with the devices of the first DualShock 4's connect: a later controller found none free and fell back to Core Haptics, whose plain pulses on Handles the reporter barely felt. From the same code, a controller that disconnected and came back would get its dead device back and no rumble; that was not reported.
  • Fix (resolved): ds4_take asks the registry at each connect (IOServiceGetMatchingServices on vendor 054C) and each player owns the device it opens, closed when the controller goes. A device in use is told by its registry entry ID, since two handles on one device are not CFEqual. Scheduling the manager was not chosen: the lookup would then depend on the arrival callback running before GameController's connect, and a missed one would leave the controller on Core Haptics for good. t_haptics_apple covers a controller connecting later and one coming back, on fake devices; the same lookup finds the same devices as a manager on this Mac's own HID devices. How weak Core Haptics is on this controller was not measured: a controller with no effects report stays as it was.
  • Found on: Mario Kart World (1.0), issue #1165, two GameSir Cyclones over Bluetooth on an M5 Pro, macOS 27.2: from the reporter's log. Not yet run on two real controllers.

Scripted presses on a path of their own

  • Symptom: with a controller connected (a DualShock 4 here), DM_KEYS and DM_CTL presses are logged (DM_KEYS: … press …) but the game does not react to them, or only now and then; without the controller the same script works.
  • Cause: scripted presses went through player 1's controller path (dm_pad_set). A connected controller publishes its whole state there on every change, replacing scripted holds and sticks; the press latch preserves only the first read. Script release also cleared the physical controller's state.
  • Fix (resolved): DM_KEYS and DM_CTL set buttons, a press latch and sticks of their own (script_set in hle_hid.c), ORed with the keyboard / touch path and player 1's controller when the game reads player 1; for the sticks, the one deflected more wins. Shake includes the scripted path too.
  • Check: dm/host/tests/t_hid.c (t_script) runs the scripted publisher with a controller update during the hold: buttons survive repeated reads after the latch is consumed, the scripted stick wins over drift, shake continues, and release preserves physical input. Short presses are latched once, all four axes reach the handheld npad, and player 2 stays separate.
  • Found on: Batman: Arkham Knight (0100ACD0163D0000), reported in PR #326 while driving the opening route with DM_KEYS and a DualShock 4. Reproduced independently in the host HID test without game files; its game profile is not in this checkout.

Touch gestures recognized from the touch points

  • Symptom: taps in handheld mode do nothing, though the game reads the touch screen (the title's PRESS ANY BUTTON, the menus' cards).
  • Cause: the game's touch handler (main+0x42f1ac) uses GetTouchScreenState only to track the finger and takes its taps, pans and swipes from hid::GetGestureStates (it acts on types 5–9), which the host answered with no entries.
  • Fix: the host now recognizes gestures from the same touch points, by yuzu's rules plus an 8 px slop before a touch counts as moving (hle_touch.c gesture_sample, unit test t_gesture; only in handheld mode, as touches): Touch, Press after 0.5 s, Pan, Pinch / Rotate with two fingers, Cancel on a change in the finger count, then Tap (IsDoubleTap within 0.35 s), Swipe above 400 px/s or Complete, and Idle. For every game.
  • Check: every menu, the cards, the map's drag and flick worked by touch with it. DM_TOUCH_LOG=1 logs each gesture entry, and a DM_CTL line drag <x0> <y0> <x1> <y1> [seconds] moves a finger for testing pans and swipes on the Mac.
  • Found on: Taiko Risshiden V DX (SDK 12.3.3, Koei Tecmo's KTGL).
  • Two-argument InitializeGesture: SDK 20.5.15 has an overload nn::hid::InitializeGesture(int, int), whose SDK body opens the hid service and aborts in sm::Initialize (Result 0xe15) on the host; it is taken over as the other one is (PRAGMATA).

Touch sampling numbers across docked and handheld

  • Symptom: a game started docked takes no touch after it is switched to handheld (the in-game menu on the iPad, DM_OE_SCRIPT=opmode=0@… on the Mac), though DM_TOUCH_LOG=1 logs its reads with the touch points. Started handheld, the same game takes taps.
  • Cause: docked reads of GetTouchScreenState(s) were numbered from the npad sampling counter (from 65536) and handheld reads from hle_touch.c's own count (from 1). Unity's touch reader takes a state only if its sampling number is above the last one it took, and it takes the docked reads' empty states too (Aokana main+0xd2bde0; the last number is cleared at init, main+0xd2b588, and stored at main+0xd2bdec). After the switch every handheld state fell below the docked numbers and was dropped. Started handheld and then docked and back, the same happened.
  • Fix: hle_touch.c numbers the states of both modes from its one count: touch_read (hle_hid.c) always calls touch_fill_n, with points 0 in console mode, which gives an empty state with the next number and leaves the fingers (their Start and End) for the next handheld read. DM_TOUCH_LOG prints the number the game sees. For every game.
  • Check: dm/host/tests/t_touch.c reads in console mode between handheld reads (an empty state with the next number, the Start of a finger pressed meanwhile kept), and dm/host/tests/t_hid.c (t_touch_sampling) reads docked, handheld, docked and handheld again through both GetTouchScreenState and GetTouchScreenStates (every read reaches touch_fill_n, points asked for in handheld mode only). In Aokana on the Mac (the PR author's runs, headless, a tap every 1.5 s from 20 s, with the first form of the fix, which numbered both modes from the npad counter): a tap during the opening movie stops it and the title comes up, before the fix only when started handheld; with it, also started docked and switched with DM_OE_SCRIPT=opmode=0@12, and started handheld, docked at 12 s and back at 24 s. Not run on the iPad.
  • Found on: Aokana - Four Rhythms Across the Blue (0100990011866000, Unity 2018.4.22f1 IL2CPP; profile in PR #1372), on the iPad: started docked, then handheld and Tap in the in-game menu, and no tap reached the game.

Mac pointer stays over the game

  • Symptom: on the Mac, the mouse pointer stays over the game picture while playing with a controller or the keyboard, in a window and in fullscreen (issue #675).
  • Cause: AppKit hides the pointer only for an app that asks, and front_mac.m never asked.
  • Fix: a key the game takes, a controller button or a stick past 15% (GameController and raw HID pads alike, pad_set) calls [NSCursor setHiddenUntilMouseMoves:YES] (cursor_hide_for_input), only while the game window is key, the app is active and the pointer is over the content view. AppKit shows the pointer again on the next mouse movement, and input_release shows it when the window loses focus. The mouse is the touch screen, so touching shows the pointer.
  • Check: with the pointer over the game, a controller button or the keyboard hides it; moving the mouse shows it; over the title bar, or with another app active, it stays.
  • Found on: every game on the Mac (general); PR #584.

Mac display sleep during controller play

  • Symptom: the Mac dims or turns off its display and can reach the lock screen while the player is using a controller, in a window or in fullscreen.
  • Cause: macOS counts keyboard and mouse input as user activity, not a controller's, so its display-idle timer keeps running. The host's NSActivityUserInitiated | NSActivityLatencyCritical activity (front_run) prevents idle system sleep, not display sleep.
  • Fix: while the game window is key, front_mac.m holds a second activity with NSActivityIdleDisplaySleepDisabled (display_awake; pmset -g assertions shows PreventUserIdleDisplaySleep "NxNative active game window"). input_release lets go of it when the window loses focus or is minimized, input_resume takes it again, and startup takes it if the window became key before the focus observers were installed. Windowless runs never hold it. An explicit lock or sleep still works, and no input is synthesized.
  • Found on: Mario Kart 8 Deluxe on the Mac, controller play; every game on the Mac (general); PR #741.

Mouse, keyboard and key code maps

  • Symptom: the first boot aborted in hid::GetMouseState (the SDK's mouse resource was never initialized).
  • Fix: hooked: hid::GetMouseState, hid::GetKeyboardStates, and kpr::GetKeyCodeMap, which asks the settings service (settings::detail::GetKeyCodeMap) for the keyboard layout's map; the host leaves the map zero.
  • Found on: 13 Sentinels: Aegis Rim (SDK 11, GetMouseState); Monster Hunter Rise (SDK 11, GetKeyboardStates); DRAGON QUEST VII Reimagined (kpr::GetKeyCodeMap: the game zeroes a 0x1000-byte map and fetches it only once a keyboard reports keys, main+0x1f0eb4).

Poké Ball Plus (Palma)

  • Symptom: the first boot crashed in hid::detail::SetPalmaBoostMode, a null read at sdk+0x238eb0 (the hid service never opened).
  • Fix: the Poké Ball Plus calls (hid::*Palma*, 26 functions) answer that none is connected: no npad has the Palma style, so the game never gets a connection handle, and every call on a handle fails (hle_hid.c).
  • Found on: Pokémon Sword (SDK 7).

IR camera reported unconnected

Software keyboard and applets

System applets opened by Show… calls

  • Symptom: null read in sdk under nn::applet::CreateLibraryApplet (Engage: title menu → DLC → nn::ec::ShowShopAddOnContentList); Show… overloads found in the imports before they were met.
  • Cause: every Show… call that opens a system applet (eShop, friend lists, web pages, error screens) goes through the applet proxy that oe::Initialize would have set up, and the host has none.
  • Fix: hook each Show… overload the game imports: list main's undefined symbols containing Show / Applet and grep the host sources for each mangled name; one overload missing is enough to crash. The shop calls return void and act as if the player closed the shop (hle_misc.c); the friends and web ones return failure (hle_friends.c, hle_offline.c). web::ShowOfflineHtmlPage is cut, as ShowWebPage. err::ShowNetworkServiceError and err::ShowApplicationError are logged. Also hooked, found in the imports before they were met: the ShowShopAddOnContentList(UserHandle) overload, and the ec::ShowShop… overloads without an ApplicationId (they show the calling application's own pages). ec::ShowShopSubscriptionDetails is under Shop service accessors.
  • Found on: Fire Emblem Engage (the shop calls); Octopath Traveler 0 (web::ShowOfflineHtmlPage); Pokémon Sword (SDK 7, err::ShowNetworkServiceError); Pokémon Legends: Z-A (19.3.11: ShowShopAddOnContentList(UserHandle), err::ShowApplicationError); The Great Ace Attorney Chronicles (SDK 10.4.1: the overloads without an ApplicationId); Fire Emblem Fortune's Weave (SDK 22.2.0: hid::ShowControllerStrapGuide(const StrapGuideArg&), met at the title about 100 s in).

Library applets take the application's focus

  • Symptom: entering 2P stops the game on its 2P screen: the same frame repeats, the music plays, no controller does anything, until the window loses and regains focus (Mario Kart 8 Deluxe, every launch). With one controller, once that was fixed: the 2P character select ignores input while the log shows the controller applet opened again every few frames.
  • Cause: on Horizon a library applet takes the application's focus. am sends message 15 with OutOfFocus when it opens and 15 with InFocus when it closes, and the Show… call returns once it has closed. The host's stand-ins answered at once with no focus change. Mario Kart applies the controller applet's answer only after that InFocus notification: its notification thread (main+0x8ea560, blocking PopNotificationMessage) sets a flag on 15 with state 1, and the per-frame main+0x87b5d0 takes the flag before it applies the answer (main+0x87b300). Second: with fewer controllers than the game's minimum, the host answered success with the connected count. A Switch never gives that answer (its applet waits for controllers until the player closes it), and the game, which asks for two in 2P, opened the applet again.
  • Fix: hle_oe.c counts open applets apart from the front end's focus. The game sees the front end's state, or 2 while an applet is open over a game in focus; each change of what it sees is one 15 (under focus modes 1 and 2, Focus handling mode decides whether focus changes are notified). DM_APPLET("name"); (hle.h) at the top of a stand-in holds the game out of focus until the stand-in returns: the controller applet (both overloads), strap guide (both overloads: SDK 22 adds one with a StrapGuideArg) and firmware update, swkbd::ShowKeyboard, the Mii editor, the user selector and creator, the error applets and the shop pages. Stand-ins that report the applet could not start (friends, web, the license notice, the album, amiibo) open none. The inline keyboard is left as it was: its applet draws into the game's own picture and is not believed to take focus (not checked). An instant open and close merges into one pending 15 that reads 1, which is all Mario Kart needs; a game that must see the 2 would need more. ShowControllerSupport with fewer players than the minimum (ControllerSupportArg +0) returns ResultControllerSupportCanceled (2202-3101), what the SDK's ShowControllerSupportCore (SDK 4.4.0 sdk+0x24eeb0) returns when the player closes the applet; Mario Kart goes back to its engine class menu, as on a Switch. DM_OE_SCRIPT now also starts for a game that only uses the blocking PopNotificationMessage.
  • Found on: Mario Kart 8 Deluxe (4.0.0 and 4.0.1; GitHub issue #550).
  • Check: t_oe and t_hid (dm/host/tests). On the Mac, from a save past the Mii pick: L+R at the title, Down, A (2P), A (Grand Prix), A (50cc). The log shows oe library applet: controller support, then oe focus state → 2 (library applet open) and → 1 (library applet closed), and the character select takes input. With one controller and no window (or under DM_CTL) the applet logs too few, closed as the player would (canceled) once and the engine class menu comes back; with a window its prompt waits for a second player (The controller applet waits for players).

The controller applet waits for players

  • Symptom: with Press a button to join on, a game's player screen closes when the game asks for more players than have pressed a button. The log shows hid controller applet: 2 to 2 players wanted, 1 connected: too few, closed as the player would (canceled) (Mario Kart World 1.8.0, pull request #1596).
  • Cause: the controller applet's stand-in (ctrl_support, hle_hid.c) answered at once, and below the game's minimum (ControllerSupportArg +0) it answered as the player closing the applet. A Switch's applet stays open until enough controllers are there or the player closes it. Before Press a button to join the host had nothing to wait for; with it, connected controllers that are no player yet are what the applet waits for.
  • Fix: with a window and no scripted input, ctrl_support asks the front end (front_ctrl_support, front.h). Its prompt is up over the game, holding the game's thread, while controllers join: a first button press with Press a button to join, a connection without it. The rules are in ctrl_prompt.h: B or Escape closes it (canceled); with at least the minimum in, it is done when a player presses A or Return, when the game's maximum has joined, or when no connected controller is left to join; the press that makes a controller join counts for neither; no time limit and no close on a focus loss, as on a Switch. The game gets no controller or keyboard input while it is up, nor until the A or B that closed it is let go. The host counts the players again once it closes (dm_players_reported) and answers canceled below the minimum. The Mac draws it on the Home prompt's card (front_mac.m, home_quit_mac.h hq_card_view), iOS as a card of its own with Done and Cancel buttons for touch (DmGamepad.m). A front end passes the texts in the player's language as DM_CTRL_PROMPT_TEXT (Starting a game). Without a window, or with DM_KEYS or DM_CTL (nobody at the controllers), the host answers at once as before.
  • Found on: Mario Kart World (1.8.0, pull request #1596; GitHub issue #1640).
  • Check: t_ctrl_prompt and t_hid (dm/host/tests). On the Mac with a window, two controllers and Press a button to join on, Mario Kart 8 Deluxe from a save past the Mii pick: press a button on one controller, then L+R at the title, Down, A (2P), A (Grand Prix), A (50cc). The log shows front end: controller prompt open: 2 to 2 players wanted, 1 in, 1 controllers waiting to join; a press on the second controller closes it (controller prompt closed: 2 players (done), then the prompt waited, 2 now (OK)) and the character select has two players. B instead closes it as canceled and the engine class menu comes back.

The controller applet leaves out the players past the maximum

  • Symptom: with more controllers than a game takes, the game gets no input and the log repeats oe library applet: controller support, hid controller applet: 1 to 1 players wanted, 2 connected and focus state → 1 (library applet closed) every few frames (1,671 times in one run). A game that suspends its music while an applet has focus plays none.
  • Cause: above the game's maximum (ControllerSupportArg +1) the stand-in (ctrl_support, hle_hid.c) answered with the count capped and left every npad connected. On a Switch the applet leaves no more controllers connected than the game takes, so the game saw the second npad again on its next frame and opened the applet again. Any second player caused it: a second controller, a receiver read as a pad (A keyboard receiver that declares a gamepad takes player 1), or one controller listed twice (One Pro Controller read as two players).
  • Fix: the players past the maximum, the highest first, are left out (g_benched): their npads read as not connected and their style events are signalled, as for a player on an npad the game does not support. With a maximum of one their controllers feed player 1 (merged_players), so whichever controller the player holds plays, and a device that never sends input does no harm. With a higher maximum they are not used. They count again when the applet opens for more players (the lowest first), when the game sets its supported npads again (SetSupportedNpadIdType), or when their controller goes away; a controller that connects later is a player until the applet opens again. The log names each one: hid: player 2's controller feeds player 1: the controller applet left it out. No prompt asks which controllers stay, as a Switch's applet does: with a maximum above one the lowest players stay.
  • Open: not run on a game (no second controller here): the tests cover the host's side, and that Ocarina of Time stops asking once No2 reads as not connected follows from its request for one player, not from a run. A game that opens the applet for one player and later expects a second controller without opening it again would not see that controller until it reconnects; none is known. The second HID entry behind the report no longer counts as a pad: A HID entry that sends nothing counted as a second pad.
  • Found on: The Legend of Zelda: Ocarina of Time (v131072, one Pro Controller over Bluetooth on the Mac, read as players 1 and 2 after it reconnected; GitHub issue #2237).
  • Check: t_hid (dm/host/tests): one wanted with two and three players, the lowest players kept with a maximum of two, the applet opened for more, a controller that goes and comes back, the supported npads set again.

The software keyboard blocks only its caller

  • Symptom: the sound stutters and the input box lags while the player types a name (Octopath Traveler 0 on an iPhone: one 4.7 s frame, 5,035 wakeups a second instead of about 1,800, and no audio underruns).
  • Cause: the host froze the guest clock while swkbd::ShowKeyboard was up (d2r-mobile's choice, so that monsters did not attack while the player typed). Unreal calls it off its game thread, so the game kept running, and every thread that waits on the clock spun while its audio mixing starved. On the Switch the applet blocks only the caller while the clock runs.
  • Fix: the host now does the same (hle_swkbd.c). The iOS input box froze the clock a second time around its own wait (front_ios.m), so the first fix changed nothing on the phone (4,977 ms and 2,577 ms frames, 5,181 wakeups a second): look for every caller of guest_clock_freeze_for, the front ends included. The inline keyboard keeps the freeze: its game (Monster Hunter Rise) does not block on hardware at all, and the host's input box blocks it.
  • Found on: Octopath Traveler 0.
  • Check: on the Mac DM_KBD=<text> DM_TEST_SWKBD_HOLD_MS=8000 holds the keyboard up: frozen, 8.8 fps and 7,936 wakeups a second in that window; running, 30 fps and 1,788.

The inline keyboard as a resident library applet

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

Inline keyboard window size, setters and callbacks

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

Inline keyboard text callbacks and Calc states

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

The Mac input box keeps the game's focus

  • Symptom: the name typed in the Mac input box is dropped and the game asks again; the log has oe focus state → 2 and → 1 between inline keyboard Appear and → OK (Monster Hunter Rise, character creation).
  • Cause: the box (front_text_input, an NSAlert) takes the key window, and the game window resigning key was sent to the game as out of focus. On hardware the inline keyboard is drawn inside the game, which keeps its focus.
  • Fix: front_mac.m does not report the game window resigning key while the box is up (g_text_box). ShowKeyboard's applet still sends its own out-of-focus / in-focus pair (DM_APPLET).
  • Found on: Monster Hunter Rise.

nn::ngc::ProfanityFilter vtable and by-name overloads

  • Symptom: CRASH sig=10 at a heap address, called from main+0x6bca160 right after the name was entered (Monster Hunter Rise); crash in lmem::AllocateFromExpHeap under ngc::ProfanityFilter::MaskProfanityWordsInText right after the character name is entered (DARK SOULS: REMASTERED); filter methods among the undefined imports with no hook, found from the import list before they were met (The Great Ace Attorney Chronicles).
  • Cause: nn::ngc::ProfanityFilter is polymorphic and Monster Hunter Rise masks the name through its vtable (slot 8, MaskProfanityWordsInText(int *, char16_t *, uint32_t)). The constructor hook did nothing, so the object kept what the heap held. Other games call the methods by name: DARK SOULS: REMASTERED calls the UTF-16 overload (EPiPtj) by name, and the host had taken it over only in the filter's vtable, so the SDK's version ran on a filter whose word list the host never loaded; The Great Ace Attorney Chronicles calls ngc::ProfanityFilter::MaskProfanityWordsInText(int *, char *, uint32_t), SetMaskMode and Finalize by name rather than through the vtable.
  • Fix: the filter now gets a host vtable (ngc_vtable in hle_misc.c): each slot of the SDK's _ZTVN2nn3ngc15ProfanityFilterE is matched by address with the class's methods, and slots the loader already bound to forced take-overs keep them. The unsigned short and char16_t overloads are taken over by name too, as are the char overload, SetMaskMode and Finalize (hle_misc.c). Nothing is masked or reported.
  • Found on: Monster Hunter Rise (the vtable); DARK SOULS: REMASTERED (SDK 4.5.0; one boot: the UTF-16 overload by name); The Great Ace Attorney Chronicles (SDK 10.4.1: the char overload, SetMaskMode, Finalize).

nn::ngc China-region profanity check and mask

  • Symptom: [RED] sf: the game connects to service ngct:u itself, which the host does not serve, then Break#1 reason=0 Result=0xe15 from the SDK when the player name is entered after a race (Cruis'n Blast).
  • Cause: nn::ngc::CheckProfanityWordsInTextForPlatformRegionChina and MaskProfanityWordsInTextForPlatformRegionChina are free functions, not ProfanityFilter methods. They go through nn::ngc::t::Match, which opens the ngct:u service, and the host does not serve it.
  • Fix: both are taken over by name in hle_offline.c, next to the newer ProfanityFilter overloads: the check reports no profanity, and the mask leaves the text unchanged.
  • Found on: Cruis'n Blast (0100B41013C82000, SDK 10, version 1.0.7; no profile in this repository).

Display, window and operation mode

vi::CreateLayer with the layer's size

  • Symptom: SIGSEGV in vi::CreateLayer(Layer**, Display*, int, int) (sdk+0x3f9bc4, from SDL3's Switch video driver at main+0x239304).
  • Cause: the overload with the layer's size; the host had only the two-argument one.
  • Fix: it returns the static layer (hle_vi.c).
  • Found on: Absolum (SDK 18.3.2, FNA on .NET NativeAOT with SDL3; one boot each).

vi::CreateLayer with LayerCreationSettings (SDK 22)

  • Symptom: SIGSEGV at sdk+0x44ea94 reading address 0, in vi::CreateLayer, right after am::GetSelfController(); the caller is the game's window setup (main+0x978cb6c).
  • Cause: SDK 22 adds CreateLayer(Layer**, Display*, const LayerCreationSettings*); the game builds the settings with the SDK's LayerCreationSettings(int w, int h) ({w, h, 2}, sdk+0x4526a0), and the SDK's CreateLayer asks the applet's self controller, which the host never made.
  • Fix: the host takes it over like the sized overload (hle_vi.c): the static layer, and the size recorded for an EGL surface.
  • Found on: EA SPORTS FC 27 (SDK 22.2.0): 1920x1080.

vi layer and display calls without vi service objects

  • Symptom: SIGSEGV in vi::SetLayerCrop or vi::OpenDisplay when opening a display by name.
  • Cause: the SDK's version dereferences vi service objects the host never made.
  • Fix: SetLayerCrop only records the crop for the present (The NVN present crops by the vi layer crop); DestroyLayer, CloseDisplay and vi::Finalize are host no-ops. Also hooked: vi::GetIndirectImageMemoryRequirement (asked by the inline keyboard's GetImageMemoryRequirement, The inline keyboard as a resident library applet); vi::ListDisplays (one display, "Default"; the game lists 10 into a stack buffer and does not read them, main+0x808f08), found from the import list before it was met. vi::OpenDisplay("Default") returns the same static display as OpenDefaultDisplay, so applications can open the name returned by ListDisplays without entering the SDK's absent service client.
  • Found on: The Legend of Zelda: Link's Awakening (SDK 8.2.0); Super Mario 3D World + Bowser's Fury (SDK 10: vi::GetIndirectImageMemoryRequirement); The Great Ace Attorney Chronicles (SDK 10.4.1: vi::ListDisplays); Alien: Isolation (SDK 8.2.0: vi::OpenDisplay).

Layer HDR metadata

  • Symptom: CRASH sig=11 addr=0 pc=0 with the link register just after a call to a vi::SetLayerNextHdr… or vi::ClearLayerNextHdr… function.
  • Cause: the SDK forwards a layer's HDR metadata through a table its graphics system fills, which is null on the host.
  • Fix: every layer HDR metadata call of SDK 22's free functions succeeds with nothing to apply (dm/gfx/hle_vi.c): the content gamut, the HDR to SDR tone mapping, the standard and extended content luminance ranges, the adjustable Bezier tone curve, and the clears of the gamut, the tone mapping and the curve. The host presents SDR.
  • Found on: Donkey Kong Bananza (the set calls); the curve and the clears: Mario Kart World 1.8.0 sets the curve every frame, from pull request #1116 (that build is not run here).

Layer frame numbers

  • Symptom: SIGSEGV in android::Surface::getQueuedFrameNumber under nn::vi::GetLatestFrameNumber.
  • Cause: Unreal paces frames by the layer's frame number (main+0x10140e8); the SDK reads the vi service's surface, which the host never made.
  • Fix: hle_vi.c answers with the frames presented so far; ListFrameHistory lists them, as the next entry says.
  • Found on: Octopath Traveler 0.

Layer frame history

  • Symptom: right after the opening logo movie nothing is presented again, with no red line; the watchdog reports no progress … stage present frame. The game thread is in Unreal's FlushRenderingCommands, waking each millisecond from a condition variable whose event is never triggered, while the render, RHI, frame-flip and interrupt threads all wait idle.
  • Cause: Unreal's Switch RHI learns which frames reached the screen only from nn::vi::ListFrameHistory: its "Viewport Thread" waits for each vsync, asks for the entry count (a null buffer: the SDK answers 8, the history's size), reads the 40-byte FrameInfo entries and takes the first one shown as the latest flip. Those flips complete the frame-end fences that wait for the swap chain (r.GTSyncType 2, FFrameEndSync), and the first full FlushRenderingCommands waits for every fence in that ring. The host returned no entries, so no flip ever came and the flush waited for ever.
  • Fix: dm/gfx/hle_vi.c keeps the last 8 presents (recorded at each NVN present, numbered as GetLatestFrameNumber, with the present queue's ticket) and answers as the SDK fills FrameInfo (sdk+0x4510a0 in SDK 20.5.15): status (2 shown with a present time, 1 queued and not shown yet), frame number, queue tick and present tick twice, newest first. A frame is shown once the present queue has handed it on (mtl_present_progress) and the system did not drop it. Its present time is the drawable's presented time when Metal reports one, else (headless, or before the handler runs) the time it was first seen handed on.
  • Found on: Minecraft Dungeons II; Octopath Traveler 0 and OCTOPATH TRAVELER II call it from the same RHI code (main+0x1014994, main+0x32b408c) and got no entries before. Luigi's Mansion 3 paces its presents by the history and the window's interval (A game's own present pacing reads the interval the window presents at).

Default display panel identifier

  • Symptom: SIGSEGV in vi::GetDefaultDisplayPanelIdentifier (main+0xa9a4, before the window).
  • Cause: the SDK's version calls through the vi service object the host never made. The game reads a u32 kind and, from kind 2, 0x14 more bytes; anything else makes it use zeros.
  • Fix: the host answers an all-zero identifier (hle_vi.c).
  • Note: a passed TV latency calibration in this game is saved per TV panel identity, the host's all-zero one included (TV latency calibration's late, beat-timed listening window).
  • Found on: Rhythm Heaven Groove (SDK 21.4.0, one boot).

oe calls answered without an applet proxy

  • Symptom: the first boot crashed in oe::DisableAlbumImageTakenNotification: a null read at sdk+0x338070, the first call in nnMain (main+0x6b188c); enabling those notifications also gives a null read at sdk+0x33b0f0 (Xenoblade Chronicles 3); other oe calls that reach am, found in the imports before they were met.
  • Symptom: a null read at sdk+0x36ec60 in oe::EnableAlbumImageTakenNotification, called from nnMain (main+0x286268).
  • Fix: EnableAlbumImageTakenNotification uses the same host implementation as the disable call: no system-album notifications are produced. Found on Mario + Rabbids Sparks of Hope (SDK 13.3.5); the next boot reaches the NVN frame loop.
  • Cause: the SDK asks am through the applet proxy the host never made.
  • Album notification enable: oe::EnableAlbumImageTakenNotification follows the existing disable takeover. The host produces no Horizon album captures, so enabling notifications produces no messages. Found on Xenoblade Chronicles 3 (SDK 12.3.9, main+0x127fac4).
  • Inactivity extension: oe::GetUserInactivityDetectionTimeExtensionMode also reads the absent SelfController (sdk+0x3391f0, after Xenoblade Chronicles 3's opening movie). The host retains the requested mode: 0 initially or when either setter receives false, 1 for SetUserInactivityDetectionTimeExtended(true), 2 for its Unsafe form. The boolean query is mode != 0, matching SDK 12.3.9's implementation. This state does not make the host turn the screen off. t_oe checks both setters and both queries.
  • Fix: hooked (hle_oe.c): oe::DisableAlbumImageTakenNotification; oe::IsInStoreDemoModeEnabled (no); oe::GetDefaultDisplayResolution and its change event; oe::SendServerMaintenanceOverlayNotification; oe::SetUserInactivityDetectionTimeExtendedUnsafe; oe::GetPerformanceConfiguration; oe::SetAlbumImageOrientation; oe::Begin / EndAutoSleepDisabledSection and IsAutoSleepDisabled (counted); oe::ExecuteProgram (logged red: the host runs one program). The ones with a symptom of their own follow: The startup logo, Copyright frame buffer overlay, Applet resource user ID, launch parameters and VR mode, CPU overclock calls without an apm session, Focus handling mode decides whether focus changes are notified.
  • Found on: Monster Hunter Rise (SDK 11: GetDefaultDisplayResolution, SendServerMaintenanceOverlayNotification); 13 Sentinels: Aegis Rim (SDK 11: SetUserInactivityDetectionTimeExtendedUnsafe); Super Mario 3D World + Bowser's Fury (SDK 10: GetPerformanceConfiguration, SetAlbumImageOrientation); DRAGON QUEST XI S (SDK 7.3.2: the auto-sleep calls); Octopath Traveler 0 (oe::ExecuteProgram); The Great Ace Attorney Chronicles (SDK 10.4.1, Capcom's MT Framework: DisableAlbumImageTakenNotification, then IsInStoreDemoModeEnabled from the import list); Donkey Kong Country: Tropical Freeze (SDK 4.4.0: GetUserInactivityDetectionTimeExtensionMode, a null read at sdk+0x15b73c when the first level's cinematic camera starts, CScreenDimmingUtil::DisableScreenDimming). After all of its fixes The Great Ace Attorney Chronicles runs its frame loop at 30 fps; the black screen it showed was its textures, never uploaded (CPU-written textures, the profile's tex_flush_upload and tex_dirty_upload: Mapped-range flushes taken as uploads (tex_flush_upload), CPU writes with no flush, caught by page protection (tex_dirty_upload)).

The graphics scale does not follow the operation mode

  • Symptom: a Switch 2 game renders smaller docked than handheld, or smaller than console footage: Star Fox's scene was 768x432 docked (DLSS network resized to it at boot, its window 1920x1080) and 960x540 handheld; Ocarina of Time drew 2048x1152 docked, its 3D at no more than 1536x864; Donkey Kong Bananza's docked frame buffer was 2304x1296 on a 2880x1620 swap chain.
  • Cause: the host answered oe::GetCurrentGraphicsScale (am's graphics scaling parameters: 10 / 20 / 30 → tier 0 / 1 / 2) by the operation mode: 0 handheld, 1 docked. Games size their picture and their GPU budget by the tier, full at 0: Star Fox keeps only "tier 0" (byte +0xa of its mode event, main+0xba69970) and renders 0.8 times smaller below it; Bananza scales its frame buffer by {1.0, 0.8, 2/3}[tier] (main+0x30f840) and adds 0, 9 or 13 to the GPU time its dynamic resolution counts; Ocarina of Time's output is 0.8 times smaller at tier 1. So the tier reads as how much of the GPU the game may use, which a system would lower while it needs the GPU itself (GameChat's video): an inference from the games' code, not a measurement. A docked picture smaller than the handheld one, on a GPU about twice as fast, is not what the console does, and Star Fox's console footage is nearer tier 0: Peppy's close-up 65 s into it has a mean gradient of 5.8 (x1000, luminance at 1080p), tier 0 here 5.3, tier 1 4.6.
  • Fix: tier 0 in both modes (hle_oe.c; check: t_oe). DM_GFX_SCALE=1 or 2 answers a reduced tier, to see what a game does with it. Open: which tier a console reports in which mode is not measured.
  • Found on: Star Fox (docked: scene 960x540 to its 1920x1080 window, as handheld; measured on a build of the PR author's fork, MetalFX temporal, HOST_OPT -O3, not on main). Donkey Kong Bananza: docked in the mining tunnel, frame buffer 2304x1296 → 2880x1620 and main passes 1676x943 → 2095x1179, GPU p50 about 7.0 → 8.6 ms, 60 fps held with both (M6, 16 GB, a host of chain cfcc3a82c78b with only this answer changed). Ocarina of Time: output 2048x1152 → 2560x1440, 3D 1536x864 → 1920x1080, GPU p50 12.9-14.0 → 15.0 ms at about 59 fps (M4 Pro, two contributors' runs on main with only this answer changed).

The startup logo

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

Copyright frame buffer overlay

  • Symptom: null read in oe::InitializeCopyrightFrameBuffer, right after the window setup.
  • Cause: the copyright frame buffer is an overlay the system draws only into screenshots and recordings; the SDK hands it to am as transfer memory.
  • Fix: InitializeCopyrightFrameBuffer, SetCopyrightImage and SetCopyrightVisibility are host no-ops (hle_oe.c).
  • Found on: Hyrule Warriors: Age of Calamity (SDK 9.6).

Applet resource user ID, launch parameters and VR mode

CPU overclock calls without an apm session

  • Symptom: a null read in sdk+0x35258 (apm's session) in oe::IsCpuOverclockEnabled, the first call in nnMain; null read at sdk+0x362b8 (apm::SetCpuOverclockEnabled) in the first calls of nnMain (main+0x2e1e4c).
  • Cause: oe::IsCpuOverclockEnabled and oe::SetCpuOverclockEnabled(bool) go through the apm session the host never opened.
  • Fix: oe::IsCpuOverclockEnabled is hooked; for SetCpuOverclockEnabled the host keeps the flag and the query reports it (hle_oe.c); it does not scale clocks.
  • Found on: Monster Hunter Rise (SDK 11: IsCpuOverclockEnabled); Taiko Risshiden V DX (SDK 12.3.3: SetCpuOverclockEnabled).

Focus handling mode decides whether focus changes are notified

  • Symptom: the game freezes after switching apps and back, picture still, sound on; the main loop waits in its pause handshake (main+0x5d10d4 / main+0x5cea68). Mac repro DM_OE_SCRIPT=bg=1@20,bg=0@25.
  • Cause: the game's own pause deadlocks. The game set focus handling mode 3. The SDK (oe::SetFocusHandlingMode, SDK 18 sdk+0x3c5770) turns modes 0 and 3 into "no focus notifications, the system suspends the game out of focus"; only 1 and 2 notify. The host sent message 15 anyway, and Galaxy 2's Wii layer ran a pause the console never triggers.
  • Fix: hle_oe.c now queues 15 only under modes 1 and 2 (or when no mode was set).
  • Found on: Super Mario Galaxy 2 (one of two causes of the freeze; the other is Guest clock reads wait while the app is in the background).
  • Note: modes seen: 1 Monster Hunter Rise; 2 Wonder, Breath of the Wild, Tears of the Kingdom, Link's Awakening, Cuphead; 3 Galaxy, Galaxy 2, 3D World, Odyssey.

Album calls fail with no album

  • Symptom: abort in sm::Initialize under capsrv::InitializeForApplication (album::Initialize); calls that reach the album service, found in the imports before they were met.
  • Fix: with no album they fail: album::Initialize (saving to the album now fails, hle_offline.c); SDK 7's album::SaveScreenshot without an orientation (fails); SDK 20's SaveAndShareScreenshot with a share ID and GetSharePostedLastUrl; album::SaveCurrentScreenshot.
  • Found on: Monster Hunter Rise (SDK 11: album::Initialize); DRAGON QUEST XI S (SDK 7.3.2: album::SaveScreenshot); Animal Crossing: New Horizons (SDK 20.5.4: the sharing calls); Pokémon Legends: Z-A (19.3.11: SaveCurrentScreenshot).

Accounts, network, shop and parental controls

Account, friends, play data and Nintendo Account calls

An account AsyncContext needs its SDK constructor

  • Symptom: CRASH sig=11 at main+0x5b64f8, lr=main+0x3f1b8cc (nn.account.AsyncContext$$Finalize), on an IL2CPP thread-pool worker a few seconds after the player confirms a name and birthday (Pokémon Friends 1.1.5). The object's first word, its vtable, holds stray bytes (0xffffd256 in one run, 0x15600000128 in another). The game had started account::EnsureNetworkServiceAccountIdTokenCacheAsync.
  • Cause: nn::account::AsyncContext is polymorphic. The SDK's constructor (SDK 19.3.11: sdk+0x1ad6c8) writes the class's vtable and nulls the three words after it, the IPC object pointer at +0x18 among them, and sends nothing. The host took the constructor over with one that wrote no memory, so the vtable word kept what the allocator left there. The game's C# binding deletes the native context from the managed finalizer through the virtual deleting destructor, inlined in main (main+0x5b64f0: if (p) p->vtable[1](p)), which the host's AsyncContextD1Ev takeover never sees, and jumped through those bytes.
  • Fix: the constructor is no longer taken over (hle_account.c): the SDK's own runs. No host interface sets the IPC pointer, so the SDK's destructor, reached through the vtable, finds no session to close and frees the object. friends::AsyncContext and account::NintendoAccountAuthorizationRequestContext have no vtable (their SDK constructors write none), so their takeovers stay.
  • Found on: Pokémon Friends (010008C01E742000, update 1.1.5, SDK 19.3.11; no profile in the repository yet). The managed names come from Il2CppDumper's table (IL2CPP method lookup).

Creating an ssl context needs no network

  • Symptom: the game ends right after nnMain, with a crash in its own cleanup on an object it had not created yet (DRAGON QUEST BUILDERS 2: CRASH sig=11 addr=0x60db0 at main+0x2676c8, x0 = 0, the callers main+0x85210 and main+0x7fabf8). No red line.
  • Cause: ssl::Context::Create failed in the host's offline stubs. A context is a session of the console's own ssl service, so it succeeds on Horizon with no network; only a connection needs one. The game's online library treats the failure as fatal, and its startup (main+0x84a60) returns false.
  • Fix: Context::Create and Context::Destroy succeed and connect to nothing (hle_net.c). Connection::Create and the rest still fail, as they do with no network.
  • Found on: DRAGON QUEST BUILDERS 2 (SDK 5.4.0). 53 of 82 games surveyed import the call.

EventFd needs no network

  • Symptom: abort under grpc_init at startup, with no red line: game called nn::diag abort: Result 0, call site sdk+0xb8fe8, backtrace sdk+0xba138 sdk+0x7bd670 sdk+0x777f30 main+0x21f0c80, just after fs mount _npln: (Ocarina of Time).
  • Cause: gRPC, which nn::npln links, wakes its pollers with nn::socket::EventFd. The host failed that with ENETDOWN, so gRPC found no wakeup descriptor, its poll engine's factory (main+0x21f1280) returned null and the engine selection called abort (main+0x21f0c7c). An event descriptor is local to the bsd service, so on Horizon it works with no network; only a connection needs one.
  • Fix: EventFd, EventFdRead and EventFdWrite are host counters, Poll waits on them with a condition variable and Close frees them (hle_net.c). Socket and the rest still fail, as they do with no network. An endless wait comes back with 0 after 100 ms, as it does with nothing to wait on.
  • Check: make -C dm/host/tests GAME=<id> run runs t_net_eventfd.
  • Found on: The Legend of Zelda: Ocarina of Time (SDK 22.2.8).

Event descriptors in an fd_set

  • Symptom: CRASH sig=10 (SIGBUS) in a network thread right after start (Minecraft's NetherNet Network, main+0x18b2ae8, x0=0x4000), at a load from its own stack.
  • Cause: the host numbered its event descriptors from 0x4000. The bsd service gives out the lowest free number from 0, and games size their tables for such small numbers: NetherNet puts its event descriptor in a 1024-bit fd_set on the stack for Select, and FD_SET(0x4000) reads and writes 2 KB past the set. Select also failed whenever a descriptor was set, so it could never wait on one.
  • Fix: the event descriptors are numbered as the service numbers them, the lowest free one from 0 (hle_net.c). Select (dm_sk_select, both overloads) is Poll over the descriptors in its sets: it waits on the host's event descriptors, a descriptor the host never made has nothing to say, the sets come back with the ready ones, and a descriptor closed during the wait fails with EBADF. As a game's own stale or never-valid descriptors now fall in the same range, Poll reports a closed descriptor (POLLNVAL) only for one that was open when the wait began.
  • Check: t_net_eventfd (make -C dm/host/tests GAME=<id> run).
  • Found on: Minecraft (Bedrock Edition, 1.21.120).

The local user's profile image

  • Symptom: Break#1 reason=0 Result=0 right after the first save is created, then exit 42; the stack has nn::diag::detail::OnAssertionFailure under nn::image::JpegDecoder::Decode (sdk+0x394e4), called from the game (Burnout Paradise Remastered, SDK 9.3).
  • Cause: account::LoadProfileImage failed with 0 bytes ("the host has none"). Every user on a console has a profile image, a 256x256 JPEG, and the game hands the buffer to JpegDecoder without checking the Result.
  • Fix: hle_account.c answers with the local user's image, a plain 256x256 baseline JPEG of 670 bytes built into the host: the size is the image's, the copy what the buffer holds.
  • Found on: Burnout Paradise Remastered (0100DBF01000A000, no profile; issue #1118), from the reporter's symbolized stack; not run here.

Mii database answered by the host

  • Symptom: [RED] sf: the game connects to service mii:e … mii:u itself, then an abort.
  • Cause: nn::mii::Database keeps an IDatabaseService of the mii service at +0 (Database::Initialize, sdk+0x1c5664), and AppendMii / ShowMiiEdit start the MiiEdit applet.
  • Fix: hle_mii.c takes over the public methods: a console with no Mii of its own (an empty database: no index, nothing to update) that still has the six default Miis every console has. A CharInfo the host builds is the default Mii's parameters with Switch common colours, a create ID with version 4 and variant bits (fixed per default Mii, so a saved choice finds the same Mii; fresh for BuildRandom, which the title's demo race calls for 12 racers) and the nickname "no name"; every field is within CharInfoRaw::Verify's ranges (sdk+0x1b68c8). The Mii editor is cancelled (0xE7E, which ShowMiiEdit itself treats as success).
  • Found on: Mario Kart 8 Deluxe (4.0.0, SDK 4.4.0; one boot). The game asks for the default Miis with Get(…, 6, SourceFlag 2) and never reads the count (main+0x81e954): its boot-time list of Mii icons (main+0x419dc8) builds a head model for each of the six, and zeroed entries failed the model's CharInfoAccessor::IsValid (CharModel::Initialize returned 0xd67e, main+0x8f15b0) and crashed at main+0x8f152c.
  • Note: the heads' model data is a stand-in with every part empty (System data stand-in for the MiiModel archive).

Shop service accessors

  • Symptom: the abort with no message (SVC 0x15 / 0x1f / 0x21 placeholders, then Break with sm::Finalize under ShopServiceAccessServer::Proxy::Initialize) from ec::InitializeForShopServiceAccessors, called from Unreal's FOnlineSubsystemSwitch::Init at the start of nnMain.
  • Cause: the shop service accessor library reaches the shop service.
  • Fix: the library init succeeds with nothing to set up, an accessor never initializes, and its async response is complete at once with no data (hle_misc.c); the accessor and response are stubbed whole because the SDK's destructor aborts unless Finalize succeeds. Also hooked: ec::FinalizeForShopServiceAccessors, the shop response's Cancel and GetErrorCode, and ec::ShowShopSubscriptionDetails.
  • Found on: DRAGON QUEST XI S (SDK 7.3.2: the game then runs its opening movie at 30 fps); DRAGON QUEST VII Reimagined (SDK 19.3.1: FinalizeForShopServiceAccessors, Cancel, GetErrorCode, ShowShopSubscriptionDetails, found by the import list).
  • Note: the shop's Show… pages are under System applets opened by Show… calls.

Offline purchased-item notifications

  • Symptom: a purchase watcher crashes in nn::ec::InitializePurchasedEvent after account selection, before it can wait for a notification.
  • Cause: the guest SDK initializer uses an add-on-content service object that the offline host never opened.
  • Fix: the purchased-item queue is empty offline. Its notification is a persistent, unsignalled system event that guest threads can wait on. Popping either form of purchased-item info returns false without writing a record.
  • Found on: Super Kirby Clash 1.0.1 (#987), SDK 7.4.0.

Parental controls and free communication

  • Symptom: abort in nn::sm::Initialize under nn::pctl::IsFreeCommunicationAvailable (Engage, entering its first battle map).
  • Cause: the SDK connects to the parental-control service through sm.
  • Fix: the host answers it like the other free-communication calls: not available (hle_misc.c). pctl::CheckFreeCommunicationPermission: never.
  • Found on: Fire Emblem Engage; DRAGON QUEST XI S (CheckFreeCommunicationPermission).
  • Note: pctl::CheckStereoVisionPermission is under Applet resource user ID, launch parameters and VR mode.

Network interface, LDN, socket and play report calls

  • Symptom: play report calls met entering the save select (3D World); undefined nn::ldn:: / nn::socket:: imports with no hook, found from the import list before they were met (Big Brain Academy).
  • Fix: hooked (hle_net.c, hle_offline.c): the nifm IP queries with SDK 10's socket::InAddr / in_addr mangling; prepo::AddSessionId / AddInternetConnectionStatus; ldn::GetNetworkConfig; SDK 11's socket overloads with plain sockaddr / int arguments (Connect, Accept, GetSockName, Get / SetSockOpt, Fcntl, Recv, Send).
  • Found on: Super Mario 3D World + Bowser's Fury (SDK 10: nifm, prepo); Big Brain Academy: Brain vs. Brain (SDK 11.4.3: LDN, sockets).

SDK 21 socket initialization and overloads

  • Symptom: [RED] sf: the game connects to service bsd:u itself, which the host does not serve (and bsd:s, bsd:a) at the game's first socket call, then the game's own abort: Star Fox's vsocket.nintendo.cpp:148 logs socket: 4 and stops. With only the connection cut, the game's HTTP thread aborted a few seconds in: its event loop's poll returned -1 with errno 14 (EFAULT), and the loop tolerates only EINTR (main+0xbe2efc4).
  • Cause: SDK 21 inlines nn::socket::Initialize(const Config &) into the game and connects to the bsd services from nn::socket::internal::Initialize(int), past the host's cut. nnmusl's poll then has no socket state and fails. Its socket functions also come in overloads with plain sockaddr, ShutdownMethod, Read / Write, a GetHostEntByName(const char *) without options and SetAllocator(MemoryResource *), and nifm::GetCurrentIpConfig takes IPv4 and IPv6 configurations with their counts.
  • Fix: internal::Initialize is cut as the plain Initialize is, the C library's poll goes to the host's Poll, the overloads fail as the others do (hle_net.c), and GetCurrentIpConfig reports no configuration (hle_offline.c). Poll no longer fails on a descriptor the host never made, which only a garbage value can be: it reports nothing ready at the timeout, as a console's does for a socket with nothing to say. The game logs socket: No error information and goes on offline.
  • Open: the host refuses nn::socket::EventFd, so the HTTP thread polls without a wake-up, and with a garbage descriptor beside it. Harmless offline.
  • Found on: Star Fox.

Bluetooth LE at boot

  • Symptom: [RED] sf: the game connects to service bt itself, which the host does not serve, then game called nn::diag abort at sdk+0x16ba48 and exit 134, a second after the audio renderer starts. With bt and btm:u served as zeroed host sessions the abort moves to nn::btm::user::InitializeBtmUserInterface (sdk+0xb8eb4), before either session gets a request.
  • Cause: main calls nn::bluetooth::InitializeBle at boot (main+0xa0b030; the Pokémon GO Plus + support of the 3.0.1 update), which runs bluetooth::hal::user::InitializeBluetoothUserInterface and btm::user::InitializeBtmUserInterface and aborts when they fail. Zeroed answers are not enough: the BTM setup expects real objects or handles back.
  • Fix: hle_offline.c takes over the BLE calls the game reaches without a device: InitializeBle connects to nothing, the five AcquireBle…Event calls bind an event that never fires, scan parameter, scan start and ConnectToGattServer fail, scan stop succeeds, and the scan result and connection lists are empty. The GATT calls after a connect are never reached.
  • Found on: Pokémon Violet 3.0.1 (v720896, a derived profile), on an M3, macOS 27.2: the opening plays through. Not checked: 1.0.0 (the registered build), Pokémon Scarlet 3.0.1.

Network requests stay on hold before they settle

  • Symptom: the boot sits at a black loading screen for good, CPU nearly idle, every thread waiting, about 1 boot in 6 (3 in 4 with four Mac hosts at once); the title movie is never opened, and the save files are never probed.
  • Cause: a race the host's instant offline answers lost. The game's boot state machine (main+0x1f2f0e0, state at this+0xd4, found by probing the save probe's call stack and dumping the state each tick) stayed in state 7, which polls the status word (+0x14) of the game's BCAT news runnable ("OlimarMemo") for 0, 4, 5, 6 or 7; it read 1. The runnable's constructor (main+0x203f3b0) clears the status, starts FBCATThread and only then stores 1; the thread's Run (main+0x203f510) stores 2, calls nn::nifm::SubmitNetworkRequest, sleeps while IsNetworkRequestOnHold, and stores 6 when IsNetworkAvailable is false. On a Switch the request stays on hold while the nifm service evaluates it, so the constructor's 1 always comes first; the host answered "not on hold" at once, and a thread that ran before the constructor's store finished at 6, which the 1 then overwrote.
  • Fix: a submitted request is on hold for the first IsNetworkRequestOnHold after the submission and settled as unavailable at the next (hle_net.c). On Horizon each query is an IPC to the nifm service, which evaluates the request as it serves them, offline a rejection; Pikmin 4 sleeps 10 ms between its queries, so the constructor's store always comes first.
  • Note: the first fix held the request for 50 ms of host time. A game that bounds its wait by the number of queries never saw that end: Hades' NetworkPollThread (main+0x27aaa0) yields between queries and cancels after 2,000 (2,000 IPCs on a Switch, 0.1 ms here), then submits again at once, which kept a host core busy (1.17 billion queries a minute; with A wait at two yields is not progress, 216,000 yields and half the CPU). Its stamp, now_ns() | 1, also read as settled whenever the next query came within the same 42 ns tick of the Mac's clock: 30% of Hades' submissions, and 42% back to back.
  • Found on: Pikmin 4 (Unreal Engine 4.26.2); Hades (the 50 ms hold).
  • Check: 12 of 12 boots with four hosts at once reached the title, with both fixes; a host that answers at once hung 1 of 4 in the same test.
  • Note: how it was found: the hung boots' logs diverged from good ones at the save probe (DM_FS=1), the thread dumps showed nothing but idle waits, and DM_PROBE on the state machine, then peek lines in a hung run's DM_CTL file, followed the pointers to the status word (a probe on the hot path slowed the boot enough to hide the race: 8 of 8 good).

Socket Poll and Select with nothing to wait on

  • Symptom: one thread at 100% CPU from boot, with one SVC in 99 s (NexusRevolution Socket, main+0x92c7c4, the game's network thread).
  • Cause: it calls nn::socket::Poll(fds, 0, 33): no descriptors and a 33 ms timeout, which on hardware is a 33 ms sleep. The host failed every socket call at once, so the thread spun 11,000 times a second (88,000 hits of a probe in 8 s).
  • Fix: Poll and Select with nothing to wait on (no descriptors, or only negative ones, which POSIX ignores) now wait out their timeout and return 0, in every mangling the host takes over, including SDK 4's Select(int, fd_set*, …, timeval*); an endless timeout returns 0 after 100 ms, since a thread inside a host hook pauses for IL2CPP's collector only once the hook returns (hle_net.c, dm_guest_sleep_ns in hle_thread.c).
  • Found on: DARK SOULS: REMASTERED (SDK 4.5.0; one boot). Guest CPU fell from 1,030 to 60 ms per second.

Settings, languages and fonts

Language codes

  • Symptom: the first comparison in nnMain aborted in sm.
  • Fix: hooked: settings::LanguageCode::Make and the reversed operator==(LanguageCode, Language).
  • Found on: Super Mario 3D World + Bowser's Fury (SDK 10).

Chinese language aliases in settings comparisons

  • Symptom: DM_LANG=zh-Hans or zh-Hant reaches the game unchanged, but its startup menu falls back to Japanese; the older zh-CN or zh-TW spelling selects Chinese.
  • Cause: Odyssey 1.4.1's language selectors (main+0x977b30, main+0x977d30) compare the desired code through settings::operator==(LanguageCode, Language) with only the deprecated Language::Chinese (6) and Taiwanese (11) entries, never SimplifiedChinese (15) or TraditionalChinese (16); its control data lists Simplified and Traditional Chinese, which the NSP script spells zh-Hans and zh-Hant (it turns zh-CN and zh-TW into those too). The real operator compares bytes with MakeLanguageCode, so on the console the system must hand this game zh-CN / zh-TW; the host compared bytes the same way with DM_LANG's spelling, so zh-Hans did not match zh-CN and zh-Hant did not match zh-TW.
  • Fix: both settings equality hooks share an alias-aware comparison (hle_sdk16.c): Simplified Chinese's two spellings match each other, as do Traditional Chinese's. The scripts stay distinct; GetDesiredLanguage, LanguageCode::Make and the available language list keep their original codes and indices. No title-specific override. This stands in for the system's choice of spelling, whose rule per title is not known (open): a game that compares the code with its own strings, or looks up zh-CN's index in the available list, still needs the exact spelling. A synthetic full matrix tests both operand orders, malformed codes and the unchanged list.
  • Found on: Super Mario Odyssey (1.4.1, SDK 7.3.2; fresh-save Mac startup menus in Simplified and Traditional Chinese after the comparison fix).

Available language codes and their count

  • Symptom: abort in sm::Initialize under nn::settings::GetAvailableLanguageCodes (early in nnMain, main+0x327888); abort in sm::Initialize under nn::settings::GetAvailableLanguageCodeCount() (main+0x117590).
  • Cause: Paper Mario asks for up to 32 codes and takes the index of oe::GetDesiredLanguage's code in the list as its language. Catherine compares LanguageCode::Make(0 … count − 1) with GetDesiredLanguage's code.
  • Fix: the host lists the system's codes in nn::settings::Language order, the table LanguageCode::Make uses, and reports the count of that table (hle_sdk16.c).
  • Found on: Paper Mario: The Thousand-Year Door (SDK 15.3.2, the codes), Catherine: Full Body (SDK 8.2.1, the count; one boot).

Region code from the language

  • Fix: oe::GetRegionCode answers from the language: Japan, the Americas, Europe or Hong Kong / Taiwan / Korea.
  • Found on: Animal Crossing: New Horizons (SDK 20.5.4; found in main's imports before the first boot).

Shared font address: BFTTF header or bare TTF

  • Symptom: the font engine runs on garbage; crash after the shared fonts load, looking up the "?" glyph (main+0x6c73c0, a font cache lookup returning null), with SDK 10's ScalableFontEngine::LoadFont failing with error 403 and SetFont with 301 on both stand-in fonts; or Japanese, Korean or Chinese text draws nothing in a Unity game while Latin text is fine (Patrick's Parabox's language menu showed three blank lines).
  • Cause: consumers read the shared font differently.
    • The SDK's font engine calls ScalableFontEngineHelper::Decode(ptr, size), which XOR-decodes in place unless the first word is the "already decoded" marker (LE 0x18029a7f). So for a game that decodes it the system shared font must look like BFTTF: serve marker + bswap(size ^ 0x49621806) + TTF (hle_sdk16.c). Mario decodes the pointer and needs the 8-byte BFTTF header in front.
    • 3D World records shared fonts as type 0 (main+0x975658), calls no ScalableFontEngineHelper::Decode and hands the GetSharedFontAddress pointer straight to LoadFont, so the pointer must be the plain TTF. SDK 10 and older games all take the plain TTF.
    • Unity's Switch player hands the shared fonts straight to FreeType. In Patrick's Parabox (SDK 13) the game's one font (Jost, a Unity dynamic font) falls back to the OS fonts for those glyphs, and the player loads them from the shared fonts: main+0x2390b0 maps the names Switch_Font_STD, Switch_Font_CN_Simplified, Switch_Font_CN_Extended, Switch_Font_CN_Traditional, Switch_Font_KR and Switch_Font_NIN_Extended to types 0–5 and hands GetSharedFontAddress / GetSharedFontSize straight to FreeType's FT_New_Memory_Face (main+0x23b2e0), as libnx homebrew does with the same pl offsets. The host gave every SDK 11+ game the 8-byte BFTTF header in front, so every face failed to open.
    • The SDK's GetSharedFontAddress is the shared memory base plus the pl service's offset, with no adjustment, and that offset points past the header (yuzu's pl service: "font offset and size do not account for the header"); Captain Toad (SDK 8) records its shared fonts as plain TTFs and calls Decode only for its BFTTF formats.
    • A game on a newer SDK can import Decode (and so get the header) yet hand the address straight to LoadFont the same way (The Legend of Zelda: Echoes of Wisdom 1.1.0, SDK 20.5.1: main+0x8b37c crashed at address 0x38).
  • Fix: the host first decided by the SDK version alone (SDK 10 and older the TTF, SDK 11+ the header). Now the address is the header (marker form above) only for a game on SDK 11 or later whose main imports nn::fontll::ScalableFontEngineHelper::Decode (Wonder, Breath of the Wild, Tears of the Kingdom, Pokémon Legends: Z-A); every other game gets the TTF itself (font_skip in hle_sdk16.c, dm_game_imports in loader.c; the profile key shared_font overrides). The host's LoadFont takeover passes the TTF past the header whenever it is given the host's own BFTTF base, so the address form font_skip picks only matters to Decode and other readers (hle_sdk16.c h_font_LoadFont).
  • Check: 3D World's case was found by probing the engine's error code after each call (DM_PROBE on the instruction after GetError).
  • Note: of the games on hand, the ones that call GetSharedFontAddress without importing Decode are all Unity players (Engage, Ace Attorney Investigations Collection, Brain Memory Training Academy, Patrick's Parabox). To see which fonts a Unity game asks for, probe its name mapper's entry (DM_PROBE=main:2390b0 on Patrick's Parabox 1.0.1; the function is the one main caller of GetSharedFontLoadState).
  • Found on: Super Mario 3D World + Bowser's Fury (SDK 10, the plain TTF); Super Mario Bros. Wonder, The Legend of Zelda: Breath of the Wild, The Legend of Zelda: Tears of the Kingdom, Pokémon Legends: Z-A (the Decode importers); Patrick's Parabox (the blank CJK text, 1.0.1); Captain Toad: Treasure Tracker (SDK 8, plain TTFs); Fire Emblem Engage, Ace Attorney Investigations Collection, Brain Memory Training Academy (the other Unity players); The Legend of Zelda: Echoes of Wisdom 1.1.0 (no profile).

Shared fonts requested all at once

  • Symptom: abort in nn::sm::Initialize under nn::pl::sharedresource::GetPlatformSharedResourceManager (first thing in nnMain).
  • Cause: the game asks for every shared font at once with pl::GetSharedFont(int *, SharedFontData *, int, LanguageCode) (sdk+0x40f270), not by type. SharedFontData is 0x18 bytes {u32 type, address, size}; the SDK adds the pl service's offsets, which point past each font's 8-byte BFTTF header, to the shared memory base, and the game hands each address straight to ScalableFontEngine::LoadFont (main+0x501ff0).
  • Fix: the hook returns the six types in order with the stand-in TTFs past the header, whatever the profile's shared_font (hle_sdk16.c).
  • Found on: Rhythm Heaven Groove (SDK 21.4.0, one boot).

Shared font load request

Multi-program applications: oe::ExecuteProgram

  • Symptom: [RED] oe::ExecuteProgram(<n>, <k> bytes of parameters: …); before this entry, a crash right after that line (sig=4 on a trap, or the next function running). A program started on its own quits at boot: it expects the launch parameter its launcher passes.
  • Cause: a multi-program application has one Program and one Control NCA per program index, title IDs base + index. One program starts another with oe::ExecuteProgram(index, param, size): on Horizon the process ends and the named program starts, and pops the parameters with oe::TryPopLaunchParameter. The call never returns, and what follows it in the game's code is not code to run: a trap (Super Mario 3D All-Stars' launcher, main+0x10a58) or the next function (ROMANCE OF THE THREE KINGDOMS 8 REMAKE, main+0xa31914).
  • Fix: one package over all the programs. The first program's profile lists the others (programs); the host is built over a table of their profiles (game.py header_multi, DM_GAME_* read the entry DM_PROGRAM picks), each program extracted with nx_extract --program=<title ID> into its own build folder, and build_ipa_from_nsp.py packs them as one (--program N packs one alone, for bring-up). ExecuteProgram ends the program as Horizon does (hle_oe.c):
    • The Mac, a run command or an app: DM_PROGRAMS names a table with one row per program: its host, rewritten modules, run folder and environment. The parameters go to <run folder>/launch_param.bin, and after the graceful exit (dm_host_shutdown_then) the host execves itself with this environment less every key a row sets, then the row's keys, DM_LAUNCH_PARAM and DM_PREV_PROGRAM (oe::GetPreviousProgramIndex). The NSP script writes programs.tsv in program 0's build folder; a Mac app's dm/mac/launcher.c fills in its own paths.
    • The iPhone: iOS cannot start a process. DM_EXEC_OUT (main.m: Documents/program.bin) gets the index and the parameters; the front end names the program for 1.5 s and schedules a local notification that opens the app (front_program_handoff; a player who refused notifications is asked on screen to open the app again), and the app ends. The next start reads the record, deletes it and runs that program without the settings screen, so opening the app later starts at the first program. Each program's modules are in sections of their own (gen_embed.py --tag, DM_EMBED_TAG) and its files in dm/p<N>/, p<N>/ and msl/p<N>/, packed so into the IPA or copied so by the Xcode build phase (build_ipa.sh PROGRAMS_TSV, stage_resources.sh).
    • A program not packed (Super Mario 3D All-Stars' Galaxy, 32-bit ARM): a red line, and the application's first program starts instead, as after a restart from HOME. No table (a program run by itself): ExecuteProgram of its own index starts its host again with its own arguments; another program cannot start, and the program ends.
  • Launch parameters: TryPopLaunchParameter returns the bytes of DM_LAUNCH_PARAM's file, once; a program run alone gets what its profile's launch_param describes.
  • Found on: Super Mario 3D All-Stars: the launcher starts Super Mario 64 and Super Mario Sunshine with 16 bytes (language code and two volume floats), and each game's Return to title selection calls ExecuteProgram(0). ROMANCE OF THE THREE KINGDOMS 8 REMAKE and Octopath Traveler 0 import it; neither was seen to call it.
  • Note: DM_CTL program <n> calls it with no parameters (05, Scripted input). The iPhone path compiles and packs; it has not run on a device.

oe::RequestToRelaunchApplication: the application starts again

  • Symptom: CRASH sig=11 addr=0 in the SDK's applet::RequestToLaunchApplication (Super Smash Bros. Ultimate 13.0.5: pc=sdk+0x804c8) right after a game asks to restart itself, for example after a language change.
  • Cause: oe::RequestToRelaunchApplication was not hooked. The SDK's version reads through the am application proxy, which the host never opens.
  • Fix: hooked (hle_oe.c h_oe_Relaunch): the start of oe::ExecuteProgram (oe_start) for program 0, with no launch parameter and no previous program, as from HOME; it never returns. On the Mac the plan is made first and the graceful exit (dm_host_shutdown_then) then runs only execve: a multi-program application's program 0 row, or a program run by itself its own host with the same arguments. Either gets this environment less DM_LAUNCH_PARAM and DM_PREV_PROGRAM, plus DM_RELAUNCH=1, which makes DM_CTL resume at the end of its file. On the iPhone an app holding several programs records program 0 (DM_EXEC_OUT); an app of one program cannot start itself again, so the front end asks the player on screen for 3 s to open it again (front_program_handoff), and the app ends.
  • Note: tested on the Mac with a program run by itself; the multi-program and iPhone paths have not run. t_oe checks the Mac's plans.
  • Found on: Super Smash Bros. Ultimate (13.0.5: Options > Language > Yes).

Offline HTML pages in a web view

  • Symptom: a null read in applet::StartLibraryAppletImmediately under web::OfflineWebSession::Start at boot; a game's pause menu, an offline page, never shows, or the game waits for a message from it.

  • Cause: the web applet shows the pages a game ships in its HtmlDocument NCA over the game, with the Switch browser's JavaScript extension (window.nx: content messages both ways, the footer's buttons). A game may start its page hidden at boot, wait for the page's first message before Appear, and read where the page ended (its last URL) when it closes.

  • Fix: hle_web.c reads ShowOfflineHtmlPageArg as the SDK writes it (u16 count at +0, entries from +8 of {u16 type, u16 size, u32 0} and their data: DocumentPath 0x6, CallbackUrl 0x3, KeyRepeatFrame 0x1c / 0x1d, BootMode 0x41) and shows the page in a web view of the front end over the game picture (web_apple.m, WKWebView on the Mac and the iPhone), hidden until Appear. The page is served from its folder through a scheme of its own (dmweb://doc/, so its XMLHttpRequests for its sounds work), with a window.nx stand-in, navigator.getGamepads in the standard mapping and key events from the controller (D-pad and left stick as arrows with key repeat, A as keyCode 32), its 1280x720 fitted to the 16:9 picture, transparent, with the host's extension font for the button icons. Messages are queued both ways. The page ends at its callback URL (http://localhost/ by default: exit reason 3, the URL as the last URL), on B from its first page (1), nx.endApplet (0) or RequestExit / Finalize (2). While it shows, the game reads no input (dm_input_hold in hle_hid.c; buttons held as it appears count once released). Pages come from DM_HTML, by default the folder html beside the RomFS folder (html-document/ or html<n>/html-document/, as nx_extract --html writes them; the NSP script extracts them for every program, and the iOS app carries them as html/ beside romfs/). On the iPhone the page sits over the game view and under the gamepad, and a touch no control takes goes to the page (DmGamepad.m). Without the page or a window, a stand-in answers: the page loads at once, sends one empty content message and closes when it appears (exit reason 0, no last URL); ShowOfflineHtmlPage then fails.

  • Found on: Super Mario 3D All-Stars: Super Mario Sunshine (its pause menu, launcher_pause_menu.htdocs, started hidden at boot) and Super Mario 64 of the same collection.

  • Note: WebKit matches :focus only in the key window, so the page's :focus rules are copied for the focused element (with another app in front the highlight stayed on the first item). A relative URL that climbs out of the document's folder is looked up inside it. DM_WEB_LOG=1 logs each key and the focus after it. DM_CTL webshot <path> saves the page as a PNG. The iPhone path compiles; it has not run on a device.

  • Fix: applet::GetAppletResourceUserId reports 0x80; the VR-mode calls (pl::IsVrMode / BeginVrMode / EndVrMode); pctl::CheckStereoVisionPermission (bool, permitted); oe::TryPopLaunchParameter (none, unless another program of the application started this one: Multi-program applications: oe::ExecuteProgram) (The Legend of Zelda: Breath of the Wild 1.9.0, SDK 20.5.22). oe::GetPreviousProgramIndex reports −1: launched from the home menu (The Legend of Zelda: Tears of the Kingdom 1.4.3).

Every undrawn extension code point is a ring

  • Symptom: a crash in the font engine at boot with the null glyph, from a code point no message uses (main+0x62d20 on 1.0.0, main+0x63780 on 1.1.4); ? in text for an icon the stand-in does not draw.

  • Cause: the stand-in extension font drew only the icons games were seen to use, so a lookup of any other code point found no glyph.

  • Fix: every code point of U+E000–E1FF the stand-in does not draw maps to a plain ring (make_ext_font.py), so a lookup always finds a glyph.

  • Found on: Super Mario 3D All-Stars (the launcher looks up U+E06B at boot).

    • Super Mario 3D All-Stars: Super Mario Sunshine: its recompiled PowerPC code is Hagi's .nrolz (data/gekko/<game>/nx/<game>.nrolz: the NRO's size big-endian, then one LZ4 block), which nro_collect.py decodes; the 25.3 MB NRO matches the hash .nrr/Hagi.nrr registers. Super Mario 64 of the same collection loads its recompiled N64 code (05_UNSMJ3.002.nro) and unloads it before it returns to the launcher.

nn::jit taken over by the host

  • Symptom: nn::jit::Initialize crashes reaching for the jit service (sdk+0x2e5414).
  • Cause: on Horizon the jit service loads the game's JIT plugin, an NRO the game reads from its own files, into a process of its own, maps the game's code memory read-write there and read + execute in the game, and runs the plugin's nnjitpluginGenerateCode / nnjitpluginControl for each request (yuzu's service/jit has the calling conventions). The host has no service process.
  • Fix: hle_jit.c takes over the API (Initialize, CreateJitEnvironment, GenerateCode, Control, GetJitEnvironmentInfo, DestroyJitEnvironment, Finalize). CreateJitEnvironment finds the plugin among the game's buffers by SHA-256 among the rewritten NROs (nro_collect.py collects it through the game's NRR), loads it alone (loader.c nro_load_alone: relocated against itself and left out of the game's symbol lookups), runs it as the service does (_init, GetVersion, ResolveBasicSymbols with the host's memcpy / memmove / memset and panic handlers, SetupDiagnostics off, Configure, OnPrepared) and gives it its configuration: the code memory (a MAP_JIT mapping on the Mac, one address for the plugin's writes and the game's calls), the data memory, and the work buffer's last part as its transfer memory, where it keeps its state. GenerateCode and Control run on the calling thread under one lock, with the data zero-padded to 32 bytes as the SDK sends it; GenerateCode opens the thread's JIT write window (pthread_jit_write_protect_np) and invalidates the instruction cache over the range returned. Layouts from SDK 9.5.1's nn::jit (sdk+0x2e6610 ff.): code ranges are offsets in the code / data memory, the handle is the environment, GetJitEnvironmentInfo returns {code address, size, data address, size}. DM_JIT_LOG=1 logs every request (
  • Found on: Super Mario 3D All-Stars: Super Mario 64 : command 3 at boot turns 4,235 precompiled MIPS blocks into 14.2 MB of code in 27 ms; command 4 compiles a handful of blocks in play. Its code needs nothing from the rewriter (no x18, SVC, system register or PC-relative literal in 3.55 million instructions).

A JIT plugin loaded a second time crashed

  • Symptom: a second nn::jit::CreateJitEnvironment for the same plugin, after DestroyJitEnvironment, crashes with SIGSEGV in nro_load_alone (a memcpy from address 0) right after NRO <name> loaded alone (load 2, …).
  • Cause: NRO data snapshots are lazy: preload_nros records only the length of an NRO's initialized data, and the first load copies it. nro_load did that copy, but nro_load_alone (the JIT plugin's load) did not, so its reload restored the data from a null pointer. Its reload also left the bss as the previous environment had left it, while the jit service loads the plugin fresh for each environment.
  • Fix: both loads start with nro_reset_data (loader.c): the first load snapshots the initialized data, and a reload restores it and zeroes the bss. Each load keeps its own symbol binding (linked into the game's lookups, or alone).
  • Found on: no game: a synthetic test found it, and no game has been seen to create a second environment. Super Mario 3D All-Stars: Super Mario 64 is the one game that takes this path.
  • Check: t_nro loads a module alone, dirties its data, bss and relocation slots, and loads it alone again: the data and bss are as rewritten and the slots relocated against the module itself. Without the fix it fails at the first load (no snapshot).

nn::jit served from an image on iOS

  • Symptom: [RED] jit::GenerateCode command <n>: request <key> … is not in the image …: capture it on the Mac (DM_JIT_CAPTURE) and rebuild the image, then the game crashes there (the iPhone, or the Mac with DM_JIT_SERVE).
  • Cause: iOS runs no code made at run time, so the plugin's output must come from the Mac. That output depends on where the code memory and the modules are (the plugin builds every host address as movz + three movk), and two things only the game's code tells: which bytes of a request's data make the request, and where its output lists the code's entry points, which the game computes from the address it asked for.
  • Fix: the Mac host captures each request once with the layout it was made in (DM_JIT_CAPTURE=<dir>, <key>.jit; the key is the command, the data size, the data and the input's SHA-256, an input over 1 MB by size only). dm/tools/jit_capture.py builds the image (jit.img, jit.tsv, jit.out): each address sequence into the code memory or a module becomes adrp + add + two nops to a canonical layout (the code memory at 0, the modules after it, 16 KB aligned); requests the captures placed differently go after the others, with their branches out of the block moved; output words that are host addresses become relocations, found as the words that differ between two captures in different layouts (a request captured once must hold none). The profile keys jit_key_data and jit_out_code say the rest (dm/games/README.md, Keys). Serving (iOS; the Mac with DM_JIT_SERVE=<dir>): the plugin is never loaded; the image is mapped at the layout's base (iOS: the executable's __TEXT,__dm<tag>_jit section, gen_embed.py --jit), and every module it names at its offset in one reservation (loader.c jit_layout_init; the image's layout must match each module's size, else the host stops); Control does nothing, and GenerateCode finds the request by its key and returns the image's code range, the data bytes and the output with its relocations applied. The NSP script's iPhone build of a program whose main imports nn::jit::CreateJitEnvironment boots it twice headless with DM_JIT_CAPTURE (capture_jit) and adds every other folder in jit_capture/; a Mac build's run command captures play into jit_capture/play. A request already in the folder is not captured again, so a session whose code memory lies elsewhere than every earlier one's (one that started after an in-process program switch) may hold only its later requests, whose code calls boot code it did not capture: the builder leaves such a session out with a red line (capture session <base> left out …, with how many of its requests no other session has) and builds from the rest. DM_JIT_MISS_DUMP=<dir> writes a missing request's input.
  • Found on: Super Mario 3D All-Stars: Super Mario 64: command 4's request struct leaves 4 padding bytes uninitialised, which differed once the plugin no longer ran; the image is about 14 MB, and the JIT layout a second reservation of 0.96 GB on the iPhone.
  • Check: test_jit_capture.py (dm/tools/tests).
  • Note: a request the image lacks stops the app there: play that part on the Mac and pack again. Not run on a device yet.

Nintendo-extension glyphs for button icons

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

Shared fonts on iOS

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

A shared font larger than the game's font engine memory

  • Symptom: a crash after the shared fonts load, looking up the "?" glyph (nro0+0xaef8a8, a null pointer from nn::font's texture cache), with ScalableFontEngine::LoadFont returning 201 on one engine and 0 on another, and SetFont 301.
  • Cause: a game sizes an engine's work memory for Nintendo's fonts, and the host's stand-in, Arial Unicode (23 MB, about 50,000 glyphs), needs more for its tables. Mario Tennis Fever gives its first engine 512 KB and its second, the one its glyph thread uses, 50 KB, so FSS_load_font ran out of memory on the second (201). Its error is ignored: the name stays unknown to that engine, SetFont fails, and every lookup misses.
  • Fix: ScalableFontEngine::Initialize gives an engine with less than 512 KB that much host memory (hle_sdk16.c, FONT_WORK_MIN). The game's buffer stays unused, and GetPointerToWorkBuffer still returns it, as the game frees that.
  • Found on: Mario Tennis Fever (SDK 20.5.17).

C library, time and random numbers

Time zone rules, calendar conversion and the network clock

  • Fix: hooked: time::ToPosixTime (host mktime), and time::ToPosixTime with an explicit rule (the host's zone, as for ToCalendarTime); time::LoadTimeZoneRule and the ToCalendarTime overload with a rule (the host's zone is used); time::StandardNetworkSystemClock::IsAccuracySufficient (offline: no). time::GetDayOfWeek is a pure computation and needs nothing.
  • Found on: 13 Sentinels: Aegis Rim (SDK 11, ToPosixTime); Monster Hunter Rise (SDK 11, the rest); Octopath Traveler 0 (GetDayOfWeek); Pokémon Legends: Z-A (19.3.11, ToPosixTime with an explicit rule, found in the imports before it was met).

Steady clock, clock snapshots and user clocks

  • Symptom: SDK calls that reach the time service, each aborting in sm::Initialize (or found in the imports before they were met).
  • Fix:
    • time::StandardSteadyClock::GetCurrentTimePoint: host monotonic seconds, fixed source ID; GetSpanBetween runs in sdk (The Legend of Zelda: Breath of the Wild 1.9.0, SDK 20.5.22).
    • time::ClockSnapshot::CreateWithStandardSystemClock and CreateWithAdjustableSystemClock: 0xD0 bytes, filled on the host from the steady clock, the user clock and the calendar; the network fields stay 0. The type byte at +0xcd is 1 for standard and 2 for adjustable snapshots (0 means uninitialized), as verified in Tears of the Kingdom's SDK. The adjustable entry also dereferences the absent time-service session without a host take-over. t_time checks both registrations, guarded buffers, the adjustable calendar and location, and snapshot arithmetic. The adjustable crash and boot fix were reported for Tomodachi Life: Living the Dream in PR #385; that gameplay report has not been repeated here.
    • The adjustable user clock (time::AdjustableUserSystemClock::GetCurrentTime, which the game's days follow) reads the time service's shared memory in the SDK (MonotonicSystemClock::GetCurrentTime, sdk+0x4421cc), which the host never maps, so it is the host's clock, as the user clock; time::GetStandardSteadyClockCurrentTimePoint, the free-function form of the steady clock (Animal Crossing: New Horizons SDK 20.5.4, found in main's imports before the first boot).
  • Found on: The Legend of Zelda: Breath of the Wild, The Legend of Zelda: Tears of the Kingdom, Animal Crossing: New Horizons.

A periodic benefit claim without the time service session

  • Symptom: Break#1 reason=0 Result=0 svc at sdk+0x370f24 right after the first save at the title, with bt: sdk+0x175f00 sdk+0x175fc4 sdk+0x176060 sdk+0x375b88 main+0x6ea74 … (diag::detail::OnAssertionFailure called from time::ClaimPeriodicBenefitWithUserSystemClock).
  • Cause: the public time::ClaimPeriodicBenefitWithUserSystemClock (a once-a-day reward) asserts that time::g_pStaticService, the time service session time::Initialize opens, is set; the host takes over Initialize and opens none. The work is in time::detail::ClaimPeriodicBenefitWithUserSystemClock, which only compares the snapshots' fields through CalculateSpanBetween and CalculateStandardUserSystemClockDifferenceByUser (both host take-overs) and asks the service two things: when automatic correction was last switched, if the context's snapshot and this one differ in it (+0xcc), and the device's location and when it was set, if they differ in location name. Host snapshots never have automatic correction and always carry the host's zone name, but a claim context saved on a Switch (a location such as Asia/Tokyo) or a Mac whose zone changed reaches both, again with no session.
  • Fix: an HLE_FORCE on the public entry calls the SDK's detail:: implementation directly (hle_time.c), and logs time: periodic benefit claim → <result>; past its session check the public entry does only that (SDK 6.5 and 20.5). The two service questions are taken over too: detail::GetDeviceLocationNameAndUpdatedTime answers the host's zone name, as its snapshots carry, and both it and detail::GetStandardUserSystemClockAutomaticCorrectionUpdatedTime answer a cleared time point. The host does not know either moment, and the claim uses such a time point only when its source ID is the current snapshot's steady clock's, which a cleared one never is.
  • Found on: Tetris 99 (010040600C5CE000) 2.5.0, SDK 6.4 (the same sdk module as ASTRAL CHAIN), from a provisional profile: the claim returned 0, then 1 on the next check, and the title screen kept running for three minutes at about 56 fps.

Hooks whose return type did not match the SDK

  • Symptom: a call the host answers reads as failed when it succeeded, or reads leftover register bits. Super Smash Bros. Ultimate refills the Spirit Board at every visit, since the elapsed time between two snapshots never reads as known.
  • Cause: four take-overs declared another return type than the SDK's own function: time::CalculateSpanBetween returns bool (true when the span is stored), and the host returned a Result 0; nifm::HandleNetworkRequestErrorResult returns bool, and the host returned a Result whose low bit happened to be 0; audio::AcquireAudioDeviceNotificationForOutput and pl::BeginVrMode return a Result, and the host's were void. Checked in the sdk of SDK 12 to 20 (Metroid Dread sdk+0x3ab540, +0x336210, +0x534a0; Breath of the Wild sdk+0x412970).
  • Fix: each returns what the SDK does: the span call 1 or 0; the network call 0 (not resolved, offline); the notification 0 the first time and 0x499 (already acquired) after; BeginVrMode 0xd08e, the SDK's own answer when pctl::CheckStereoVisionPermission refuses, which the host now does too, having no VR display (Breath of the Wild's options then stay 2D, as MacNativeGames found). t_time checks the span call's values.
  • Found on: reported by MacNativeGames on Super Smash Bros. Ultimate (the Spirit Board) and Breath of the Wild 1.6.0 / 1.9.0 (the VR option); the Spirit Board was not replayed here.

clock_gettime(CLOCK_REALTIME) through the time source pointer

  • Symptom: std::terminate (Break from sdk+0x5bfa48) at the first real-time read (right after the first-boot dialog, main+0x3f3f68 → system_clock::now()).
  • Cause: clock_gettime(CLOCK_REALTIME) calls through a source pointer (sdk+0xb05010) that nntimeStartup sets once time::Initialize succeeds: 1 = nn::time::GetStandardUserSystemClockCurrentTimespec, 2 = the adjustable clock's (sdk+0x8e414). Both asked the time service.
  • Fix: they are host take-overs now (hle_time.c), and system_clock::now() works on any build.
  • Found on: Ace Attorney Investigations Collection (1.0.0.2, SDK 17.5.3).

Guest clock reads wait while the app is in the background

  • Symptom: after switching apps and back, one guest thread at 100% in a tight loop (main+0x9abd0, x10 = 0); the game freezes, picture still, sound on. Mac repro DM_OE_SCRIPT=bg=1@20,bg=0@25.
  • Cause: the frame pacing measured a frame period of 0 (main+0x5926a8: the clock difference between two frames). The host freezes the guest clock in the background but its threads went on, so two frames read the same time.
  • Fix: guest reads of the system counter (the cntpct gate, svc GetSystemTick) now wait while the app is in the background (hle_os.c guest_clock_read_wait); host work and the keyboard freeze (The software keyboard blocks only its caller) keep the frozen clock.
  • Found on: Super Mario Galaxy 2 (one of two causes; the other is Focus handling mode decides whether focus changes are notified).

Cryptographically random bytes

  • Symptom: abort in sm::Initialize under nn::crypto::GenerateCryptographicallyRandomBytes (Unicorn Overlord: std::random_device → the sdk's own getentropy → InitializeCsrng; Z-A: the first call in nnMain, main+0x1734b4).
  • Cause: the bytes come from the csrng service. The sdk defines getentropy, so the host's getentropy hook never sees the call, but the call into nn::crypto goes through the sdk's PLT.
  • Fix: an HLE_FORCE on it fills from the host's entropy stream, the same stream as getentropy (hle_os.c).
  • Found on: Unicorn Overlord (SDK 16.2.4; one boot), Pokémon Legends: Z-A (19.3.11; one boot).

clock_gettime is nn::time's steady clock (SDK 22)

  • Symptom: libc++abi: terminating due to uncaught exception of type std::__1::system_error (the arguments came out as rubbish, the format named the failure) once the game's own code ran, with sdk+0x5f2e60 (std::terminate) and sdk+0x66ad2c/sdk+0x66acc8 (get_terminate/terminate) in the backtrace.
  • Cause: SDK 22's exported clock_gettime (sdk+0xa1430) accepts CLOCK_REALTIME and CLOCK_MONOTONIC and returns ENOTSUP for ids 2–11; both accepted ids jump through musl ifunc slots in the SDK's data (sdk+0xba5018, sdk+0xba5020) whose R_AARCH64_GLOB_DAT relocations bind nn::time::GetAdjustableUserSystemClockCurrentTimespec, _ZN2nn4time41GetStandardUserSystemClockCurrentTimespecEP8timespec and _ZN2nn4time37GetStandardSteadyClockCurrentTimespecEP8timespec (sdk+0xb92298). The first two were answered from the CLOCK_REALTIME entry; the steady one was not, so the SDK's shared-memory client ran, could not read the time service's page, and returned an error, which std::chrono::steady_clock::now() (sdk+0x5f2e10: mov w0,#1; bl clock_gettime; cbnz w0 → std::__1::__throw_system_error) turned into the exception.
  • Fix: hle_time.c answers GetStandardSteadyClockCurrentTimespec with the host's monotonic clock (struct timespec from clock_gettime(CLOCK_MONOTONIC)). The network-clock variants (_ZN2nn4time44GetStandardNetworkSystemClockCurrentTimespecEP8timespec, _ZN2nn4time46GetAdjustableNetworkSystemClockCurrentTimespecEP8timespec) are left unbound: none of the games seen reaches them, and the network clock is not accurate from the host anyway. To name such a failure, hle_sdk16.c takes nn::diag::detail::VAbortImpl (an assert, an uncaught C++ exception or an SDK check with a message): it logs [diag] abort at <function>: <expression> (<file>:<line>) message format <format> and then aborts as AbortImpl does. Only the format is printed: the host cannot expand a va_list the guest built.
  • Check: with DM_AUDIO=1, a run to the first room has no [diag] line and the frame counter keeps advancing.
  • Found on: Well Dweller (SDK 22.2.0), in std::chrono::steady_clock::now() called from GameMaker's frame-timing helper.

Modules and symbols

Host symbol indexes avoid repeated name scans

  • Symptom: relocation repeatedly scans all HLE and forced-takeover entries, and named-stub reuse becomes quadratic as more imports are missing.
  • Cause: module exports already had a name index, but resolve_import, hle_implements, force_lookup and stub reuse still searched their tables linearly.
  • Fix: open-addressed name indexes store entry numbers. HLE duplicates retain the first entry; forced-takeover duplicates still abort. Stub insertion grows its index above half capacity and retains existing stub numbers. Resolution is serialized at startup and by the module write lock during runtime NRO loading; module string tables stay mapped across unload.
  • Found on: The Legend of Zelda: Breath of the Wild, reported in PR #261.
  • Check: make -C dm/host/tests GAME=010015100B514000 symbols nro covers collisions that wrap around the table, duplicate precedence/rejection, absent sections and names, counting-wrapper selection, all 4,096 stubs through growth and reuse at capacity, and NRO relocation/lifecycle. The symbol test also passes AddressSanitizer and UBSan. A local arm64 Mac synthetic 270-entry HLE table with 25% misses, Apple clang 21 at -O1, took 375–442 ns per linear lookup versus 15 ns indexed (25–29x; three runs of 360,000 lookups). This measures lookup cost, not overall game loading time; no new gameplay run here.

Global symbol resolution in load order

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

Undefined weak imports bind to 0

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

nn::ro and its port

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

ro::ProtectRelro runs natively

  • nn::ro::ProtectRelro (called from main's DSO init), a new import of SDK 20, needs nothing: it runs natively on the host's no-op SetMemoryPermission / SetMemoryAttribute. Found on The Legend of Zelda: Breath of the Wild (1.9.0, SDK 20.5.22).

Loading NROs with ro::LoadModule

  • Symptom: the frame loop runs, but the game waits for code it loads itself (ro::RegisterModuleInfo / ro::GetBufferSize red lines; main imports 1,045 lua2cpp::create_agent_* functions, global, that no ExeFS module defines, bound to stubs at start).

  • Cause: the fighters' code is 98 NROs inside data.arc, registered by .nrr/cross2_*.nrr. The SDK's own LoadModule (SDK 8.2.1 LoadModuleInternal, sdk+0x347460) uses the service only to map the image, then zeroes bss, relocates through rtld's module lists, calls the module's init and binds the variables of the modules loaded before; the host replaces rtld, so it takes over the whole call.

  • Fix: the NROs are collected at pack time by the SHA-256s the NRRs list (dm/tools/nro_collect.py: loose files, the current files of an indexed archive, or a scan for stored and zstd-framed NROs; Smash's NRRs of older releases also register 666 superseded copies, which data.arc still holds and which are left out), rewritten like any module (patch_nso.py reads NROs: the flat image, segments at +0x20, build ID at +0x40) and listed in <patched dir>/nro.tsv. The host maps them all at start and keeps them out of symbol lookups; LoadModule finds one by the image's SHA-256, restores its data and bss (after the first load), relocates it against the modules loaded then in load order, binds the earlier modules' symbol relocations it defines (main's lua2cpp stubs), fills nn::ro::Module as the SDK does (+0 module, +8 state 1, +0x10 image, +0x18 buffer) and calls its DT_INIT; UnloadModule calls DT_FINI and binds every relocation that pointed into it again (loader.c "NRO modules", hle_ro.c). GetBufferSize returns the bss size from header +0x38 as the SDK does. The startup init pass must skip NROs (their init ran unrelocated and crashed). Only data is written at a load, so it works with code embedded in the iOS executable: gen_embed.py packs the NROs into three sections (__dm_nro_x0, __DM_NRO_W, __dm_nro_x1) and a table of their pieces (__dm_nro_tab), since a Mach-O file has at most 255 sections and three per NRO would pass it, and map_image maps an NRO from there (loader.c nro_pieces).

  • Found on: Super Smash Bros. Ultimate (13.0.5): it loads six NROs in its first 30 s and calls none of the stubs. Played on an iPhone.

  • .nrolz: NERD's Hagi keeps a recompiled game as an NRO packed as a big-endian u32 size, then one LZ4 block (Super Mario Sunshine (Super Mario 3D All-Stars): data/gekko/MarioSunshine/nx/MarioSunshine.nrolz), which it unpacks and hands to ro::LoadModule, so its SHA-256 is the unpacked image's. dm/tools/nro_collect.py unpacks such files too and matches them against the NRRs.

NRO tables sized from the manifest and snapshots taken on demand

  • Symptom: FATAL .../nro.tsv: more NROs than module slots (MAX_MOD 128) before nnMain (GitHub issue #372); after increasing the capacity, startup eagerly copies several GB of inactive NRO data and bss.
  • Cause: the host used fixed 128-entry module and activation tables. It also allocated and copied every NRO's full writable image at startup so reload could restore it, including initially zero bss.
  • Fix: count the manifest once before loading and allocate both tables at their final size, preserving every module_t address. Keep each NRO's initialized data only when it is first loaded, before relocation and initialization. Reload restores that data and zeroes bss; unused NROs need no snapshot. The original NRO header supplies the initialized data size.
  • Found on: FINAL FANTASY X/X-2 HD Remaster, 1,432 NROs plus three initial modules; 5,836 MB of writable data and bss versus 767 MB of initialized data across all NROs. Most effect modules stay inactive on a short route.
  • Check: make -C dm/host/tests GAME=01007BB017812000 nro exercises manifest sizing, the last slot of a 1,435-slot table, load order, initialized-data restoration and zeroed bss after reload.

Online SDKs in NROs are never loaded

  • Symptom: [EOS] Failed to create platform (the game plays offline); refused …: an online SDK (Epic Online Services), not loaded; refused an NRR that registers only online SDKs (1 hashes).
  • Cause: Epic Online Services in an NRO (rom:/nro/EOSSDK-Switch-Shipping.nro, loaded with ro::LoadModule; main imports the EOS_* functions). Before the host loaded NROs, the imports stayed UNIMPL stubs and EOS_Platform_Create handed back a garbage handle; the game checks it and plays offline.
  • Fix: the host now loads NROs (Loading NROs with ro::LoadModule), but never an online SDK, whatever the game: an NRO that exports EOS_Platform_Create is left out of the pack (dm/tools/nro_collect.py ONLINE_SDKS, a refused row in nro.tsv) and ro::LoadModule refuses it with one log line (refused …: an online SDK (Epic Online Services), not loaded), also by the export when an older nro.tsv has it. An NRR that registers only such modules fails RegisterModuleInfo, as every registration did before the host loaded NROs, so the game takes the path it took then. So both games behave as before: offline, local saves.
  • Found on: Absolum (SDK 18.3.2, one boot each); Hades: it never asks for the module (checked on 1.0.38233: refused an NRR that registers only online SDKs (1 hashes), no LoadModule, its frames as before). Hades' main imports only EOS Auth, Connect and PlayerDataStorage, Epic-account cross-save the host cannot serve; the real SDK would at best fail its login and at worst wait on time-outs or contact Epic's servers, with an unknown risk to the player's account.
  • Note: open question for the maintainer: always load, refuse online SDKs (as now), or decide per profile.

GPU driver modules that are never loaded

  • Symptom: main imports EGL (SDL3's GL support) and vkGetInstanceProcAddr (its Vulkan support), but the game renders with NVN (FNA3D_Driver_NVN.cpp).
  • Cause: NVIDIA's OpenGL and Vulkan drivers (subsdk1, subsdk0) program the GPU through NvRm, which the host does not have.
  • Fix: they are never loaded (profile_guess.py GPU_DRIVERS); the EGL imports stay unbound stubs that nothing calls.
  • Found on: Absolum (FNA3D).

Modules only the NROs import from

  • Symptom: UNIMPL _ZN5movie12SetAllocatorEPFPvmmS0_EPFvS0_S0_EPFS0_S0_mS0_ES0_, UNIMPL glslcInitialize, then UNIMPL _ZN5movie9ExtractorC1ENS_13ContainerTypeE and a crash reading address 0x18 in an NRO (pc=nro18+0x97f4) as the first movie starts.
  • Cause: the provisional profile loads a subsdk only when main imports something from it that the host does not supply. The survey extracts only the code modules, so the NROs in the RomFS were not looked at. Portal's main is a launcher with SDL; its Source engine NROs play the movies through NintendoSDK_movie (subsdk2, imported by video_nx.nro) and initialize and call GLSLC (subsdk0, imported by materialsystem.nro and video_nx.nro). Left out, their imports were bound to stubs, and the stub constructor of movie::Extractor left the object empty.
  • Fix: once the rewrite stage has collected the NROs, the NSP script adds to a provisional profile every subsdk whose exports an NRO imports and that no loaded module, other NRO or host hook supplies (profile_guess.py nro_modules, written under a section After the NRO collection). NVIDIA's GPU drivers stay out, as above.
  • Found on: Portal (modules main subsdk0 subsdk2 sdk; subsdk1, NVIDIA's OpenGL driver, stays out).

ro::LookupModuleSymbol for a name the module does not define

  • Symptom: [RED] ro::LookupModuleSymbol BuiltDebug: not defined in that module once at boot; nothing goes wrong after it.
  • Cause: the Source engine asks each module it loads for BuiltDebug, which only its debug builds define. ldr:ro fails such a lookup on Horizon too, and the game takes the failure as "release build".
  • Fix: the host's answer was already Horizon's (a failure result). The line is no longer red when the module is a loaded one; a lookup in a module the host does not know stays red (hle_ro.c).
  • Found on: Portal.

IL2CPP common string table intact

  • Not needed in the end: the tolerant common string table of pull request #15. Ace Attorney Investigations Collection 1.0.0.2's main has the table intact at main+0x46169ac, 109 entries ending at Hash128.

Code inherited from another title at fixed addresses

  • Symptom: harmless on the build it was written for, silent memory corruption on any other.
  • Cause: code that calls fixed module addresses. d2r-mobile's 3D render scale read a renderer pointer at main+0x31e0848 and called main+0x8582f0; in Mario v0 that is the middle of a static initializer and only did nothing because the pointer happened to read 0.
  • Fix: such code checks module_t.build_id now (written into fixups.tsv by the rewriter) and stays off on other builds.
  • Found on: Super Mario Bros. Wonder (v0, code inherited from d2r-mobile).

Built-in fonts and the audio device notification of SDK 21

  • Symptom: an abort connecting to pl:u at boot; a failed nn::audio call at boot.
  • Cause: SDK 21 has new names: nn::pl::GetBuiltInFontData (a Switch 1 and a Switch 2 overload, each filling type, address and size, and returning whether the font is loaded) and nn::audio::AcquireAudioOutputDeviceNotification. Unhooked, they reach the services the host does not have.
  • Fix: both hooked (hle_sdk16.c, hle_audren.c), with the same stand-in fonts and the same never-signaled event as their older names.
  • Found on: Splatoon Raiders.

The Joy-Con mouse pointer

  • Symptom: an abort in nn::hid at boot, in a Switch 2 game that sets up the Joy-Con's mouse mode.
  • Cause: InitializeNmouse opens the hid service, and the host has neither the service nor the sensor.
  • Fix: InitializeNmouse, GetNmouseHandles (none), the button configuration calls and the merged pointer's calls (a handle with no states) are hooked (hle_hid.c). SDK 21's forms too: InitializeNmouse with a pointer type, the pointer range, movement area and restriction (kept or dropped), the USB mouse's button configuration and handle (none), and the calls on a handle, which return the SDK's bool (SDK 22.2.0 sdk+0x6d5510: its result is success): the merged handle 0 takes the pointer settings and has no state and no controller; any other handle is unknown.
  • Found on: Fire Emblem Fortune's Weave, Pokémon Pokopia. SDK 21's forms: Mario Kart World 1.8.0 at boot, from pull request #1116 (that build is not run here).

The Joy-Con 2 mouse sensor and pointer

  • Symptom: a Switch 2 game's mouse mode does nothing with Joy-Con 2s: its pointer stays still, or a game that offers its mouse modes only to Joy-Con 2 controllers finds none.
  • Cause: the host answered the sensor's calls as having no sensor (GetJcmsStates none, GetJcmsState a cleared state, GetNmouseHandles none), and a Joy-Con 2 pad had only the plain Joy-Con styles. A Switch 2 game can also support only the Joy-Con 2 forms of the Joy-Con styles: style bits 12, 13 and 14 for JoyDual, JoyLeft and JoyRight. Those bits are inferred: Nintendo Switch 2 Welcome Tour's mouse exhibits support only 0x1000 and test a pair with 0x1004, a left Joy-Con with 0x2008 and a right one with 0x4000 beside JoyRight.
  • Fix:
    • A front end reports each Joy-Con's sensor with dm_joy_mouse (dm_input.h): the movement since its last report and whether it is connected and on a surface. A Joy-Con 2 pad says it is one (dm_joy_attach with DM_JOY_2), and its npad then shows the Joy-Con 2 style beside the plain one to a game that supports it (hle_hid.c).
    • GetJcmsStates / GetJcmsState read a ring of 16 states per player and Joy-Con. A JcmsState is 32 bytes: 8 bytes the game does not read, the sampling number at 8 (newest first), the movement at 0x10 and 0x14, the attributes at 0x18 (bit 0 connected, bit 1 on a surface). A JcmsType is 0 for the left Joy-Con and 1 for the right.
    • The pointer (Nmouse): GetNmouseHandles gives a handle per Joy-Con whose sensor reports, the one last used on a surface first (it stays first while lifted, to press a button), since Welcome Tour moves its pointer by the first handle only. GetNmouseState fills a 48-byte NmouseState: 8 bytes, then the Switch's MouseState fields (sampling number, position on the game's pointer range, movement, wheel), the buttons at 0x28 (left 1: R or ZR on a right Joy-Con, L or ZL on a left one; right 2) and the attributes at 0x2c (bit 1 connected, which the game requires). SetNmousePointerPosition moves it. The movement is a quarter of the sensor's counts.
    • The Switch 2 transport turns the sensor on with its own feature bit, 0x10 (S2_FEATURE_MOUSE, switch2_proto.h), only once the game has set up the sensors or the pointer (InitializeJcms / InitializeNmouse, dm_joy_mouse_wanted), as the console does for a game that uses them; a controller that connected before is sent the two feature commands again with the bit (not yet checked on hardware), and keeps it on while connected. Measured on a right Joy-Con 2 slid on a desk: report bytes 0x10 and 0x12 are 16-bit position counters that wrap (x right, y toward the player). 0x14 and 0x16 jump from about 1,600 and 150 on a surface to about 4,600 and 3,200 when lifted, and 0x16 under 1,000 counts as on a surface. While lifted, no movement is reported. The Pro Controller 2 has no sensor.
    • DM_JCMS_LOG=1 logs the game's sensor and pointer calls, at most once a second each kind.
  • Found on: Nintendo Switch 2 Welcome Tour (040057C02159A000; its note and profile are their own pull request): its cursor and its mouse exhibits, with a left and a right Joy-Con 2. t_hid checks the states, the handles and their order, and the styles.

Home button protection on a Switch 2 controller

  • Symptom: a Switch 2 game crashes as a Joy-Con 2 joins: [RED] sf: the game connects to service hid itself, which the host does not serve, then CRASH sig=11 addr=0 at sdk+0x63ce10, called from nn::hid::EnableUnintendedHomeButtonInputProtection.
  • Cause: nn::hid::EnableUnintendedHomeButtonInputProtection(const NpadIdType&, bool) turns on a guard against a Home press made by accident on that controller. It returns nothing; the SDK's own sets the flag through the hid service, which the host does not serve, and calls through the null session.
  • Fix: the host answers it (hle_hid.c), records the setting per npad (No 1-8, Other, Handheld) and logs it (hid: EnableUnintendedHomeButtonInputProtection npad 0 on). The question, IsUnintendedHomeButtonInputProtectionEnabled(const NpadIdType&), whose SDK function asks the service too, answers what the game set, off by default. The host handles the Home button itself (home_quit.h), so nothing else changes.
  • Found on: Drag x Drive (040041601D83A000, v196608, SDK 20; no profile in the repository yet): 43 s in, as player 1's Joy-Con 2 pair joined (main+0x2c80c4). Nintendo Switch Sports Resort (04008E001F098000, v65536; no profile yet) asks the question at boot: the SDK's own aborted on its error (Result 0x58cca at sdk+0x6ca40c).
  • Check: make -C dm/host/tests GAME=<id> run (t_hid).

GameShare guest displays

  • Symptom: a crash at boot in nn::vi under nn::streamplay::CreateIndividualDisplays.
  • Cause: a game can create its guest displays at boot, before any session, and the call starts a second display service the host does not have.
  • Fix: CreateIndividualDisplays, DestroyIndividualDisplays and GetNativeWindowHandle answer that there is no guest (hle_misc.c).
  • Found on: Pokémon Pokopia.

GameChat at boot

  • Symptom: [RED] sf: the game connects to service chat:u itself, which the host does not serve, then game called nn::diag abort: Result 0xe15 under nn::chat::Initialize.
  • Cause: a Switch 2 game can initialize GameChat at boot. The SDK's Initialize opens chat:u, and its other calls go through the service object that Initialize makes.
  • Fix: nn::chat::Initialize (ready, no chat), IsChatting (no) and SetScreenSharingMode (nobody to share with) are hooked (hle_misc.c). Another GameChat call a game imports needs a hook of its own: each of the SDK's calls checks a count only its own Initialize raises, and aborts otherwise. Also hooked: AttachToRoomInfoChangedEvent (bound, never signaled), and the camera video storage, local and remote. SDK 22.2.0 (sdk+0x6b43c0) checks 32 to 1920 by 32 to 1080 pixels, 1 to 4 images and a page-aligned buffer, and zeroes the 0xe0-byte storage type. With no camera each image is a blank native image of that size, zeroed memory with room for 16 bytes a pixel (hle_codec_video.c dm_native_image_blank), so the game's NVN textures on them (nvnTextureInitializeFromNativeImage) are made as on a decoded picture (AV1 movies through SDK 22's decoder). The storage type keeps their handles, and the game's buffer is one page that stays unused.
  • Found on: Hyrule Warriors: Age of Imprisonment. The room event and the camera storage: Mario Kart World 1.8.0 (three local images of 384x216 at boot), from pull request #1116 (that build is not run here).

GameChat queries from the title screen (SDK 21)

  • Symptom: CRASH sig=11 addr=0 pc=sdk+0x6be4d4 in nn::chat::GetChattingUid(), from the game's title screen code.
  • Cause: SDK 21 games ask the chat library who is chatting, for the room, the sharing key, the virtual camera, the members' audio levels and the camera streams; the SDK's versions read the service object Initialize would have made.
  • Fix: hooked as "no chat" (hle_misc.c): a zero Uid, GetRoomInfo fails (with RES_FAIL, the chat library's real result not being known), the sharing and virtual camera calls succeed and keep nothing, the camera streaming calls succeed and stream nothing, the next-image requests fail, no audio levels.
  • Found on: Star Fox: 33 nn::chat imports.

The camera through the graphics system

  • Symptom: nn::diag abort in gs::sdk::CheckGraphicsSystemInitialized, or a failed camera::sdk::CreateCameraContext (0x3af3), at boot.
  • Cause: the Switch 2's camera (a USB camera, for GameChat) is a session the SDK asks the graphics system's user session (gsv) for. The host never opens that session, and the SDK's check that its graphics system is booted reads a flag only the SDK's own graphics boot sets.
  • Fix: CheckGraphicsSystemInitialized passes (hle_nv.c): the host's graphics layer is up from the start. CreateCameraContext gives a host session (hle_sf.c), a 32-bit handle (SDK 22.2.0 sdk+0x2c42c0 writes it), that answers as a console with no camera. Every command fails with 0xdc4, except command 21, the camera's event, which copies the read end of a kernel event that never fires (hle_thread.c hle_new_event) in the response's special header. The game is said to take 0xdc4 from command 3, opening the camera, as "no camera"; any other error aborts.
  • Found on: Mario Kart World 1.8.0, from pull request #1116, not checked here (that build is not on this Mac): the meaning of 0xdc4 and the commands are inferred there from the game's code. t_sf checks the session's answers.

The camera commands of SDK 21's own camera module

  • Symptom: with the camera session answering 0xdc4 to every command, Star Fox logs This platform does not support the camera function. (its vwebcam.switch2.cpp:997) and dereferences a null on the way out (main+0xba54e4c); with 0x7c4 to every command its module aborts in Initialize (subsdk3+0x1ef4) or Finalize (subsdk3+0x200c).
  • Cause: the game ships its own camera module (subsdk3, nn::camera) and sorts the results itself. Initialize sends command 3 with the device name Default and takes success or 0xdc4, the latter passed to the game as "no camera support", a path a console with a camera port never takes; QueryDeviceConnection (command 29, a DeviceRequest) reads 0x7c4 as No camera device detected.; Finalize sends command 4 and aborts on any failure; the connection event is command 21.
  • Fix: camera_answer (hle_sf.c) answers commands 3 and 4 with success, 21 with an event that never fires, and the others, 29 among them, with 0x7c4, so the game sees a console whose camera port has nothing plugged in. nn::applet::BeginUsingCameraResource / EndUsingCameraResource (the module takes the applet's camera resource) and nn::camera::sdk::DestroyCameraContext succeed too; the SDK's versions read service objects the host never made. The client also ends the session with an HIPC Close (message type 2), answered with the closed-session result 0xf601 (hle_sf.c); before that it logged [RED] sf: camera: HIPC message type 2 not served. Mario Kart World 1.8.0, whose reading of 0xdc4 the old answer followed, is not re-tested here.
  • Found on: Star Fox.

Whether the language selection is limited

  • Symptom: a null read at boot in nn::oe::IsLanguageSelectionLimited, right after the game asks for its language.
  • Cause: the SDK asks the am session at vtable +0x238, and the host never opens that session (oe::Initialize is taken over).
  • Fix: hooked: no limit; the host's language is DM_LANG alone (hle_oe.c).
  • Found on: Hyrule Warriors: Age of Imprisonment.

The target frame rate (graphics frame length)

  • Symptom: a null read at boot in nn::os::SdkMutexType under nn::gs::sdk::SetGraphicsFrameLengthMode, called from nn::oe::SetGraphicsTargetFrameRate.
  • Cause: SetGraphicsTargetFrameRate maps up to 59 fps to mode 1, up to 119 to mode 2 and above to mode 3 (SetGraphicsTargetFrameRateAuto to mode 0) and calls SetGraphicsFrameLengthMode, which locks the gs graphics server's context and sends it the mode. The host has no graphics server, so the context is null.
  • Fix: SetGraphicsFrameLengthMode is hooked and logs the mode once per change; both oe paths reach it. The host paces by the window's present interval (hle_oe.c).
  • Found on: Hyrule Warriors: Age of Imprisonment: 60 / its present interval at boot.

The display's largest buffer

  • Symptom: a Switch 2 game presents at 720p, with its upscaler at the matching low size.
  • Cause: the game chooses its output from nn::vi::QueryDisplayBufferMaxResolution (its tables: 0 is 720p, 1 is 1080p, 2 is 1440p, 3 is 4K), and what the game got before the hook gave it 720p.
  • Fix: hle_vi.c answers as a Switch 2 does: in handheld mode its own 1080p screen; in console mode the largest of the four that the display the game is shown on holds, as a docked Switch 2 does with its TV. The display's size comes from the front end (front_display_pixels: on the Mac the window's screen in its current mode, an iPhone's or iPad's screen), 1080p when it is not known. The host logs the answer once.
  • Found on: Fire Emblem Fortune's Weave: its DLSS output is 2048x1152 for 1440p and for 4K, from 1024x576; for 1080p it renders 960x540 into 1920x1080, a fifth fewer pixels at each stage, and the output matches the screen.

A game that decodes its code at run time

  • Symptom: registered NROs not found from nro_collect.py in a game whose RomFS has an NRR, or [RED] ro::LoadModule: no rewritten NRO with SHA-256 …. main is small (about 600 KB of code) beside a data segment of random-looking bytes.
  • Cause: main is a boot stub. Mario Tennis Fever's (nxboot) starts six threads, one per stream, which unscramble and unpack the game's code out of main's data segment into one buffer; it then registers the RomFS's NRR and calls nn::ro::LoadModule on that buffer. No file holds the NRO, so nothing can rewrite it offline, while iOS runs no code loaded at run time and the host only loads rewritten copies.
  • Fix: the host's run of the stub is the decoder. DM_NRO_CAPTURE=<folder> writes the image of a LoadModule it has no rewritten copy of to <folder>/<SHA-256>.nro and ends the run (hle_ro.c). The NSP script (capture_nro) boots the rewritten main and sdk so when nro_collect.py --missing-ok lists a registered hash no file holds, about 2 s on the Mac, checks the image against the hash the NRR registers, adds it to the collected NROs and rewrites. A build then runs like any game with NROs, and the shader split takes the folder (DM_NRO_DIR) as well as main. Decisions that follow what the game imports count the NROs too (dm_game_imports): this main imports no ScalableFontEngineHelper::Decode, its NRO does.
  • Check: scripts/build_ipa_from_nsp.py <folder> --platform mac after removing patched_* and nro_* from the build folder: rewrite, capture, rewrite, split.
  • Found on: Mario Tennis Fever.

NRR version 1

  • Symptom: a crash in ro::RegisterModuleInfo, or an NRR that seems to register millions of hashes: the words at +0x340 and +0x344 are random.
  • Cause: an NRR with 1 in the byte at +5 (the Switch 2's) has a header 0x180 bytes longer. The same fields follow it: program ID +0x4b0, size +0x4b8, hash table +0x4c0, its count +0x4c4.
  • Fix: hle_ro.c and nro_collect.py read the table by that byte.
  • Found on: Mario Tennis Fever.