Files
nxnative-easy/docs/development.md
T
seedds 69d99b85e9 DRAGON QUEST XI S at 60 fps (30 on the Switch): profile fps_target 60
The host writes 60 into Unreal's t.MaxFPS and 1 into nvn.SyncInterval (hle_fpslock.c, this build's rows); DM_FPS_TARGET=30 in Documents/env.txt keeps the game's 30. Mac (M4 Pro): the field holds 60 fps (frame time p95 17 ms, GPU 6.6 ms p50) at normal speed with sound. README, AGENTS.md and the development note say so
2026-10-22 09:53:26 +08:00

100 KiB
Raw Blame History

Development

The README covers packing and playing. This page is for working on nxnative itself: how it is built, the script's options, running games on the Mac, adding a game, and per-game notes. Agent-oriented details (bring-up failure catalogue, graphics specifics, debugging recipes) are in docs/agents.

How it works

Part State
Rewriter (dm/tools/patch_nso.py) Runs unchanged on every module of every supported game. Per-build cuts are keyed by the module's build ID
Host (dm/host) Stands in for the Horizon kernel and the system services the games call: threads and synchronization, memory, file system with transactional saves, HID, audio, movies (VP9 through libvpx, Vorbis sound through stb_vorbis), software keyboard, focus and lifecycle
Graphics (dm/gfx) NVN on Metal. Shaders are extracted by shader_split.py from Nintendo's containers and from Unity's Shader objects, then translated offline. Mario: 16,266 of 16,268 shaders compile. Engage: 9,268 of 9,269
Audio Games that carry the SDK's audio renderer (Mario) run it in-process; games that use AudioOut directly (Engage: Wwise) feed it straight. Output goes through Core Audio (default on iOS, DM_AUDIO=1 on the Mac)
Input iOS: the touch gamepad and physical controllers; shaking the phone shakes the controller (its six-axis sensor, hle_hid.c; the accelerometer in DmGamepad.m, threshold DM_SHAKE_G, 1.5 G by default). Mac: scripted input (DM_KEYS=<seconds>:<button>,…, Shake for a shake, with DM_KBD=<text> answering the system keyboard) or DM_WINDOW=1 for a window with keyboard input (Space shakes) and physical controllers (buttons by position, as on the iPhone). The window goes fullscreen from its green button or ⌃⌘F; DM_FULLSCREEN=1 starts it that way
iOS app One unsigned IPA for iPhone and iPad. The rewritten code is embedded in the executable; the shader libraries, the RomFS and the game icon are inside the app
Known gaps Conditional render is always drawn; MultiDraw*IndirectCount waits on the CPU for the GPU-written count; geometry shaders do not translate. TBDR optimizations are off by default (they don't understand bindless textures yet). No amiibo. The bundled prewarm lists are empty. On the Mac, Mario's menu and dialogue text render blank (fine on the iPhone)

The packing script in detail

scripts/build_ipa_from_nsp.py (-h lists every option):

# written to the Desktop with your own home-screen name
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys -o ~/Desktop/Mario.ipa --app-name "Mario Wonder"

# pack Mario 1.0.0 instead of 1.2.1 (the folder still needs both NSPs: the shader tables cover both versions)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys -p v0

# delete the extracted game files after the IPA is built (they are kept by default, to skip extraction next time and to run the game on the Mac)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys --no-keep-extracted

# play the game in Japanese (a code or a name: ja, fr, de, es, zh-Hans, zh-Hant, ko, en-GB, ..., Japanese; English by default)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys -l ja

# show the game's frame rate on the iPhone (frames on screen per second, top left)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys --show-fps

# a diagnostic build with environment switches baked in (one KEY=VALUE per line)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioWonder -k ~/keys/prod.keys --env-file diag_env.txt

# a damaged NSP (files failing the NCA's hash check) stops the build after listing them; this packs those files as read
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioGalaxy -k ~/keys/prod.keys --allow-damaged

# a game without a profile: survey, provisional profile (a heap check on the Mac when the game needs one), best-effort IPA
python3 scripts/build_ipa_from_nsp.py ~/Games/NewGame -k ~/keys/prod.keys

# test the guesses on a game that has a profile: build from a provisional one (the IPA is named <ipa>-provisional.ipa)
python3 scripts/build_ipa_from_nsp.py ~/Games/MarioGalaxy -k ~/keys/prod.keys --provisional --allow-damaged

In Spyder, open the script, fill in the settings at the top (NSP_DIR, PROD_KEYS, and optionally PACKAGE, OUT_IPA, APP_NAME, BUNDLE_ID, LANGUAGE, KEEP_EXTRACTED, SHOW_FPS; for a game without a profile HEAP_CHECK, HEAP_CHECK_SECS, PROVISIONAL), and press F5. Empty paths are asked for at run time.

What the script does:

  1. It finds the game profile from the title ID (the [title ID] in the file names, or the NSP's ticket; the update by its [vN] tag).
  2. It extracts the NSPs.
  3. It rewrites the code modules, checking each build ID against the profile. A provisional profile (a game without one in dm/games) then gets mem_used from the rewritten modules, and, when the game has virtual address memory and sets a heap, a heap check: the Mac host is built into <id>_build/tools/dmhost and the game boots in it until its first heap request (a few seconds, log <id>_build/logs/heap_check.log), which sizes the heap region (see dm/games/README.md, Adding a game).
  4. For a game whose main holds GLSL sources and calls glslcCompile (Three Houses), it captures the shaders the game compiles at run time, which no game file holds. It builds the Mac host into <id>_build/tools/dmhost and boots the game in it, headless and silent, with DM_GLSLC_CAPTURE pointing at <id>_build/extract/<version>/glslc_capture. The save, temporary storage and log all stay in <id>_build/. It stops the game as soon as every capture file named in the profile's shader map is there (Three Houses: 9, about a second after launch), and gives up after 30 s. A folder that already holds them all is reused. A Vulkan game without SPIR-V files in its RomFS (Super Mario Galaxy) gets the same kind of run: the game boots in the Mac host with DM_VK_CAPTURE pointing at <id>_build/extract/<version>/vk_capture until every SPIR-V key in the profile's vk_capture.tsv is there (Galaxy: 3,089, about 7 s after launch), and gives up after 120 s.
  5. It splits out the shaders and checks them against the profile's tables (a Vulkan profile: it translates the RomFS's SPIR-V and the capture with vk_shaders.py instead).
  6. It builds the iOS shader libraries.
  7. It calls build_ipa.sh. Xcode builds the app, and dm/ios/tools/pack_ipa.py packs it with the shader libraries, the add-on content and the RomFS straight from where they are. It reads and deflates files on all cores and writes at about the T7's write speed (300 MB/s): Breath of the Wild's 16 GB IPA takes 57 s, Info-ZIP's copy and zip took 2 min 50 s.

A repeat build skips work whose inputs have not changed. The extraction is kept (extract/). The rewrite, the shader split and the iOS (or Vulkan) shaders each write a key beside their output: a sha256 of their inputs (the game files, the tools, the profile, Xcode's and spirv-cross's versions). When the key matches, the stage line says reused (inputs unchanged) and the step does not run. Any change to a tool or the profile runs the step again; deleting the .key file forces it.

While it runs, the console shows one line per stage: building nx_extract, extracting each version, the DLC, the rewrite, the run-time shader capture (only for such profiles), the shader split, the iOS shaders, the app build and packing the IPA. Each line shows [k/n], the stage and its current step. Where the work can be counted, it adds a bar with done/total, percent, elapsed time and ETA. Counted work is bytes extracted, modules rewritten, shaders (cache check, translation, compile), files compiled by Xcode, and bytes packed into the IPA. Elsewhere a spinner shows the elapsed time. A total marked ~ is an estimate. A version's first extraction is estimated from the NSPs' size, and later extractions use the previous run's exact size. The IPA is estimated from the previous IPA, or from the files' size before compression. In a terminal or in Spyder the line is redrawn in place. When the stage ends, it becomes a ✓ line with the elapsed time and a summary:

[15:24:28] ✓ [2/7] extract v327680 (0:20): 9,779 files, 5.0 GB
⠸ [7/7] IPA pack · packing  ████████▍░░░░░░░░░░░  42%  1.9/~4.5 GB · 13,597 entries  0:05 · ETA 0:07

When the output goes to a file or a pipe, the script prints a plain line instead, every BEAT seconds (30). --progress bar|lines|off (PROGRESS in Spyder) overrides that choice; off prints only each stage's start and end. The tools' own output goes only to the logs in <id>_build/logs/, one per step. When a step fails, the script prints the last 40 lines of that step's log and the log's path.

Everything it writes stays in the NSP folder: intermediate files and logs in <id>_build/, the IPA next to the NSPs. The extracted game files stay in <id>_build/extract/ for the next build; --no-keep-extracted (KEEP_EXTRACTED = False in Spyder) deletes them at the end. It exits with 0 when done and 1 on an error. The app's bundle ID is org.nxnative.<base game title ID> (for example org.nxnative.0100a6301214e000), and its version is the game's display version: the update's when an update is packed, else the base game's (Engage 2.0.0); --app-name / --bundle-id (or APP_NAME / BUNDLE_ID in Spyder) override the name and ID. The game language (--language, LANGUAGE; English by default) is what nn::oe::GetDesiredLanguage returns. As on the console, it must be one the game's control data lists (SupportedLanguageFlag, nacp.py --languages); the packed version's control data counts, since an update may add languages. The same language for another region stands in (en-GB for en-US, es for es-419, fr for fr-CA, pt for pt-BR, and back). A language the game lacks stops the build with the game's list; English, the default, falls back to the game's first language.

On the device, environment switches go in Documents/env.txt (one KEY=VALUE per line), and game files are also accepted at Documents/romfs.

Port another game

Each game has a profile in dm/games/<id>/:

  • game.tsv holds the title ID, known builds, code modules, RomFS marker, memory split, app name and bundle ID;
  • the shader tables: shader_map.tsv, shader_facts.tsv, shader_pairs.tsv, c1_full.tsv, cb_extent.tsv, pso_manifest.tsv;
  • dmsh_chain.h holds the chain version.

The shader tools pick a profile with DM_GAME=<id>; make -C dm/host and build_ipa.sh pick it with GAME=<id>. Profiles are named after the base game's title ID. There is no default game: without GAME / DM_GAME the tools stop and list the profiles. python3 dm/games/game.py list lists the profiles. Host code that relies on one build's fixed addresses checks that build's ID.

To add a game:

  1. Run scripts/build_ipa_from_nsp.py on its NSP folder. With no matching profile, it writes a survey to <title ID>_survey/survey.md. The survey covers the modules, SDK, engine, containers, rewrite sites, and the imports compared with the host's hooks. It then writes a provisional profile into <title ID>_build/profile/<title ID>/ (outside the repository; tools find it through DM_GAMES_DIR) with the keys dm/games/profile_guess.py reads off the game files: the graphics API, the modules to load, where the shaders come from, the memory partition, SDK and engine presets. After the rewrite it sets mem_used from the rewritten modules and, for a game with virtual address memory that sets a heap, boots it on the Mac until its heap request (the heap check, a few seconds) to size the heap region. Then it generates the shader tables and chain header and packs a best-effort IPA that may not boot. --survey-only stops after the survey, --no-heap-check skips the heap check, and --profile-out puts the profile elsewhere. --provisional does all this for a game that has a profile, to test the guesses; python3 dm/games/profile_guess.py --check compares the static guesses with every profile. Details in dm/games/README.md.
  2. Add a profile as described in dm/games/README.md.
  3. Bring the game up following docs/agents: extraction, binary survey, a catalogue of host bring-up failures, graphics, and debugging recipes.

Build by hand (Mac)

You need the same tools as the script. The shader translator is the prebuilt dm/gfx/tools/dmsh_xlate_bin, the build that matches dmsh_chain.lock; a fresh dotnet publish gives different fingerprints. The commands use three variables:

  • G, the profile;
  • W, the game's work folder: <title ID>-work inside the game's NSP folder, outside this repository and off the home folder (it grows to the size of the game files);
  • VER, a version folder named after the NSP version.
G=010015100B514000; W="<NSP folder>/$G-work"; VER=v327680
# 1. extract: the base game alone (update -), or an update layered over it (the last argument lets unchanged files become clones)
dotnet build dm/tools/nx_extract -c Release -o $W/build/nx_extract
$W/build/nx_extract/nx_extract <prod.keys> <base NSP> - $W/data/v0
$W/build/nx_extract/nx_extract <prod.keys> <base NSP> <update NSP> $W/data/$VER $W/data/v0/RomFS
# 2. rewrite the profile's code modules
for m in $(python3 dm/games/game.py $G modules); do python3 dm/tools/patch_nso.py $W/data/$VER/ExeFS/$m $W/build/patched_$VER $m; done
# 3. shaders: extract from every version in the profile's shader_versions (comma-separated, base first), then translate into <cache>/b-<chain version>/
DM_GAME=$G python3 dm/gfx/tools/shader_split.py $W/data/v0/RomFS,$W/data/$VER/RomFS $W/data/v0/ExeFS/main,$W/data/$VER/ExeFS/main $W/shader/pkg dm/games/$G/shader_map.tsv dm/games/$G/shader_facts.tsv dm/games/$G/shader_pairs.tsv
DM_GAME=$G python3 dm/gfx/tools/dmsh_chain.py --header
DM_GAME=$G python3 dm/gfx/tools/dmsh_build.py $W/shader/pkg $W/build/cache/metal_shader_cache
python3 dm/gfx/tools/c1_guard.py $W/build/cache/metal_shader_cache/b-<chain version> dm/games/$G/c1_full.tsv
python3 dm/gfx/tools/cb_extent.py $W/build/cache/metal_shader_cache/b-<chain version> dm/games/$G/cb_extent.tsv
# 4. the host, and the button-icon font for the Nintendo-extension shared font
make -C dm/host GAME=$G dmhost
python3 dm/tools/make_ext_font.py $W/build/font/nintendo_ext.ttf
# 5. run on the Mac
DM_ROOT=$W DM_ROMFS=$W/data/$VER/RomFS DM_SAVE=$W/run/save DM_TEMP=$W/run/temp DM_MSL_DIR=$W/build/cache/metal_shader_cache DM_FONT_DIR=$W/build/font dm/host/dmhost $W/build/patched_$VER
# 6. iOS shader libraries (about 15 min for Mario), then the IPA (default <NSP folder>/<the profile's ipa>)
IOSOUT=$W/build/ios/dmsh_b DM_GAME=$G python3 dm/gfx/tools/dmsh_build.py $W/shader/pkg $W/build/cache/metal_shader_cache
GAME=$G W=$W dm/ios/tools/build_ipa.sh [out.ipa]

Notes on these steps:

  • dmsh_build.py compiles each shader's Metal source through a cache keyed by that source, the compile flags and the compiler (dm/gfx/tools/dmsh_mtlcache.py, in <cache>/cas/metallib/). A chain change that leaves a shader's MSL unchanged, such as a translator fix, costs that shader no Metal compile. Before building, the cache takes the libraries of the earlier b-*/ folders beside its output (and beside IOSOUT), and of DMSH_PREFILL=<Mac root>[=<iOS root>] (several separated by :). It takes a folder only when its recorded flags are the current ones and a fresh compile of a few of its shaders reproduces their libraries byte for byte. The summary line Metal cache: shows reused, compiled and filled counts per target.
  • build_ipa.sh takes the rewritten modules from $W/build/patched_<package> and the RomFS from $W/data/<package>/RomFS; PATCHED and ROMFS override them. The shader libraries, the RomFS and the add-on content never go through Xcode: pack_ipa.py packs them into the IPA beside the app Xcode built, as DmIOS.app/msl/b-<chain version>, romfs and aoc.
  • It also bundles a standard font (macOS's Arial Unicode, STD_FONT= overrides it) and the generated nintendo_ext.ttf.
  • The icon is the icon.jpg that nx_extract saves next to RomFS/; DM_ICON= points at another image.
  • DM_ENV_FILE= bakes environment switches into the app, and DM_DISPLAY_NAME= renames it.
  • DM_LANG=<code> is the game language (DMLanguage in Info.plist, default en-US; main.m sets DM_LANG from it unless Documents/env.txt or the baked env file sets it).
  • DM_SHOW_FPS=1 turns the frame-rate counter on (DMShowFPS in Info.plist, which front_ios.m reads; DM_SHOW_FPS in Documents/env.txt overrides it). It counts the frames put on screen each second (mtl_present_count), so frames the display link merges away are not counted.
  • If Xcode's Locations setting puts products in a custom place, the script finds the app through the build log.
  • hactool cannot read every update (Mario 1.2.1's patch NCA gives "Invalid BKTR layout!"), which is why nx_extract uses LibHac.

Prewarm lists

Metal compiles each combination of shaders and pipeline settings for the GPU the first time it is used. Each takes tens of milliseconds, so a burst of them on entering a level is a visible freeze. The host records what it had to compile and rebuilds those pipelines on a background queue at the next launch:

  • pso_user.tsv sits next to the save directory: Documents/pso_user.tsv on iOS, the parent of DM_SAVE on the Mac (DM_PSO_USER=path overrides it). It is appended while playing, one line per item built on the spot: a shader (S), a render pipeline (R, the hex of pso_skey in dm/gfx/nvn/nvn_pso_skey.h) or a compute pipeline (C).
  • dm/games/<id>/pso_manifest.tsv is the seed list bundled with the app, prewarmed before pso_user.tsv.

The lines name shaders by content key and hold no compiled code and nothing personal, so a list recorded on one device works on any other. To seed the bundled list:

  1. Record on the Mac with prewarm off, so everything is built on demand: DM_PSO_MANIFEST=off DM_PSO_RECORD=<file>, playing the scenes players reach first. Engage's list covers boot, the opening movie, the title, a save load and the prologue battle.
  2. Append the recorded lines below the header comments, in the recorded order. Duplicates are dropped at load.
  3. Rebuild the IPA.

Record on the game version you ship. At launch the log names each list it read and its line count (pipeline prewarm list …: N lines), or says that pso_user.tsv does not exist yet.

Power

The game's own work and its frame rate stay as they are; the host avoids work of its own that the Switch never did. Every 300 frames the log has a power line (CPU energy in watts and per frame, CPU time, wakeups, thermal state, the threads that yield most) and a residency set line; 05 explains them.

  • Yield loops. On the Switch, a yield with nothing else to run is free; a guest loop that polls the clock or a flag and yields is a busy spin on the host. Three Houses waited out each frame this way (228,000 yields a frame on an iPhone) until a per-build cut made its frame limiter sleep (see its notes), and Breath of the Wild's PlacementMgr and RadarMgr wait for work this way. After 2 ms of consecutive yields with no other SVC, each yield sleeps an eighth of the wait so far, from 10 to 250 µs (DM_YIELD_BACKOFF=0 turns it off, =<cap µs>[:<start µs>] sets the cap and the start). Three Houses on the Mac, before that cut: CPU time 78% → 19% of a core, same frame times. The back-off first started after 200 µs, which also caught Tears of the Kingdom's main thread waiting for its workers on the frame's critical path: frames ran past 33 ms and the game dropped itself to 20 fps (9 times in 300 s on the Mac; on the iPhone the threads also stayed on efficiency cores). From 2 ms on it never backs off there.
  • Waits block instead of polling: the in-process audio renderer, upload batches, nvnQueueFinish. A signalled event wakes only the threads waiting on it; leaving an SVC takes the global lock only while a pause (IL2CPP's garbage collector) is pending.
  • Real-time policy. The thread that acquires the window texture declares a Mach time-constraint policy, which tells the performance controller the frame deadline. DM_RT=0 leaves it on its QoS, for measuring.
  • Frame-step probes. A game that waits out a longer hold (nvn_fstep.h, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). The GPU clock follows the hold too: Breath of the Wild on an iPhone went from 14.4 to about 25 ms of GPU time a frame at 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (nvn_fstep_gpu_slowed; the reference is not updated during a probe) also allows probes, under the same gates. A hitch starts probes too. A step up after 8 s or more at 60 fps keeps what that run's frames cost (frames on time only), and a cost at the longer hold of at most 1.1 times it means the scene costs what it did (nvn_fstep_same; measured, so no thermal gate). The fit trial alone did not bring such a game back: the iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold. Breath of the Wild, stepped up by pipeline compiles after a cutscene, took 12.8–15.2 ms of GPU time a frame at 30 fps (p50), where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: frame step: hold 2 → 1 … (probe, CPU N% on performance cores), (probe, GPU 1.70× its time at hold 1) or (probe, cost 1.04× the cost that held hold 1), the probe held / failed, and on a step up hold 1 held at 0.86. DM_TEST_PRESENT_LAG=1,10;1200,30,3 imitates such a hitch on the Mac.
  • Thread classes. Every guest thread runs at QoS user-interactive, whatever its Switch priority. DM_QOS_MAP=1 (off by default, for a phone A/B) maps priority ≤ 44 to user-interactive, 45–59 to user-initiated and the rest to utility (DM_QOS_EDGES=44,59 moves the edges). SetThreadPriority moves a thread's class too, and a thread waiting on a lock lends its class to the owner until the lock is handed over. Host helper threads then set their own class (audio notify, silent sink and vsync user-interactive, movie decode user-initiated) instead of inheriting it from the guest thread that made them.
  • Replay CPU. Memory-pool addresses are cached per thread; per-encoder draw state is sent only when it changes; texture IDs of GPU-only textures stay cached when the game writes other memory; upload staging blocks are reused (Breath of the Wild used to allocate, make resident and free a 32 MB block for each re-upload of a small CPU-written texture, about 3 GB/s) and the residency set is committed only when it changed.
  • Vertex bindings. Normal and transform-feedback draws reuse resolved stream addresses until the guest address or memory-pool generation changes. The vertex argument table skips unchanged addresses, including slot 30's zero buffer; converted streams and shader-argument writes update the same table cache. DM_NVN_VBUF_CACHE=0 restores repeated lookups and table writes for an A/B run. Every 300 frames, vertex bindings reports per-frame lookups/reuse and table writes/skips (the table counts include vertex shader arguments). Compare the existing submit CPU/instruction and onscreen interval logs as well: fewer binding calls alone do not establish a frame-rate gain. make -C dm/gfx/tests GAME=<id> t_vbuf builds the GPU-readback regression; run it with MTL_DEBUG_LAYER=1, both with the cache enabled and with DM_NVN_VBUF_CACHE=0.
  • GPU. An inline uniform update inside a render pass no longer ends the pass (Metroid Dread ended about 246 passes a frame this way, each one storing and reloading its attachments): the update lands when the pass ends, and the draws after it read a copy of the updated data (DM_NVN_UB_DEFER=0 restores the old behaviour). When the update covers only part of what a draw's shader reads of the block, the rest of the copy comes from memory and the other inline updates queued in the submission, unless a copy, fill, storage write or an earlier submission still running may have written it (DM_NVN_UB_PARTIAL=0 ends the pass instead; DM_NVN_UBP_CHECK=1 checks every such copy against the GPU's memory). Attachment loads and stores per frame on the Mac, old → new: Fire Emblem Engage's battles about 1,850 → 360 MB (88 → 38 passes), Tears of the Kingdom 1,100 → 700 MB, Link's Awakening and 13 Sentinels about 15% less, Super Mario 3D World 5%; Breath of the Wild 386 → 158 passes; Mario Wonder and Metroid Dread unchanged (their updates already covered what the draws read). Clears with no draw after them no longer open a pass of their own when the game sets other targets: the next pass that attaches the same target clears it as its load action (DM_NVN_LAZY_CLEAR=0 restores the old behaviour). A format-changing texture copy that covers the whole destination level does not load it first. DM_NVN_BARRIER=game lets a pass's vertex work overlap the previous pass (04 section 7); it is off by default, as the games issue their own barriers before most passes anyway.

Game notes

Super Mario Bros. Wonder (010015100B514000)

  • Versions: v0 (1.0.0) and 1.2.1 (v327680). With the update layered over v0, unchanged files become APFS clones, so the merged RomFS adds only about 114 MB to the 3.5 GB base.
  • Shaders: 12,811 in v0 and 16,268 across both versions; the tables hold both.
  • 1.2.1: it is a new main build (SDK 16.2.12), whose resource-heap cut (+16 KB slack) was carried over with dm/tools/nso_fnmap.py. It also makes new SDK calls, which the host answers: nn::nfp finds no amiibo, pl::IsRunningOnOunce is false, the shop call is a no-op, and account::PopOpenUsers returns none.
  • Mac input: DM_KEYS="12:A,14:A,20:A,26:A,32:A" reaches the opening course.

Fire Emblem Engage (0100A6301214E000)

  • Engine: Unity 2020.3 IL2CPP, SDK 12.3.7. The shaders are read out of Unity bundles (unityfs.py).
  • Memory: a 5 GB guest block, the total proven on a phone, with a 2.5 GB application pool. A per-build cut shrinks the SDK's address-region node area to 62 MB.
  • Audio: Wwise drives AudioOut directly.
  • Movies: VP9, decoded on the host.
  • 60 fps: the Switch version runs at 30; one patch to its code switches it to the 60 fps timing the game already has, so nothing runs too fast.

The Legend of Zelda: Breath of the Wild (01007EF00011E000)

  • 60 fps at normal speed (30 on the Switch). The game steps its time by the frame interval its framework picks (ksys::VFR: interval / 2 of a 30 fps tick). With the profile's fps_target (60) the host picks it instead, in whole vsyncs (patch_nso.py FRAME_STEP, hle_nvn.c fstep_tick, nvn_fstep.h, the same frame step as Tears of the Kingdom's): a stub at the framework's setWaitVsyncInterval hands the game the host's step once a frame, and the host keeps each frame on screen that many vsyncs.
    • It holds 1 (60 fps). Three late frames in a row (the frame or its GPU work more than 1.7 ms over) step up to the cost, 2 (30 fps) as a rule: no 40 fps on 120 Hz screens. 30 frames that would fit the shorter hold try it again, and each failed try doubles the wait before the next (1 → 8 s). After a hitch, a cost at 30 like the one that held 60 tries it too (probes, Power).
    • Real time lost to a late frame or a hitch is paid back one vsync per frame (at most 15; the rest of a long load is dropped), so game time = real time. The log line frame step: game time 30.00 s in 30.00 s (1.000 of real time) checks it every 1,800 presents, with the frames held 1 / 2 / 3 vsyncs, late frames, paid-back vsyncs and hold changes.
    • Scenes that fix the game's step themselves (the VFR's override: 2 at main+0xc3b588 / +0xc5ebf8, 1 at main+0xc609d8) keep it, and the host holds frames that long (log line the game fixes its own step; not reached on the Mac yet).
    • Mac (M4 Pro), from the title into the save's scene: 1.000 of real time at 60; with 12 ms added to every frame (DM_TEST_PRESENT_LAG=1,12) it drops to 30 and stays at 1.000. DM_FPS_TARGET=30 holds 30 (saves power).
  • The game's own interval controller (at DM_FPS_TARGET=0, and the interval the framework keeps while the VFR override holds): patches to it lift its floor from 2 to 1. The game's time step follows its frame interval, so the speed is right at a steady 60, 30 or 20. The game picks the interval from the frame's CPU time and its GPU time, which it reads from two GPU timestamps around the frame; the host gives each timestamp the GPU time of the work around it (profile key gpu_timestamps, which its dynamic resolution needs too). Before that it saw only the main thread's CPU time (about 3 ms a frame in the first shrine) and stayed at 60 in slow motion: at 40 fps, about two thirds of the normal speed (an iPhone 16 Pro Max in handheld mode, GitHub issue #9; 04). Under load it still ran at 0.93–0.98 of real time.
  • Outdoors on the Mac (M4 Pro, the Great Plateau): about 165 render passes a frame instead of 386 and GPU p50 about 6.5 ms instead of 9.3 since a partly updated uniform block no longer ends the pass (the game updates 64 bytes of a 64 KB block before each draw group), and the G-buffer clears ride on its first pass (04). The frame still loads and stores about 800 MB of attachments, most of it where compute dispatches of the GPU particles split passes.
  • Render scale: DM_RENDER_SCALE=0.5–1.0 (also from Documents/env.txt) fixes the 3D views at that fraction of 1600×900 docked (1280×720 handheld) and turns the game's own dynamic resolution off; 1.0, the default, keeps it. A stub sets the scale where the game applies its own (main+0x1266880), so every scene pass, the shader's resolution ratio and the LOD check follow it: at 0.5 the 1600×900 passes become 800×450 and the 800×450, 400×225, 200×112 and 100×56 chains halve too, while the shadow maps and the 1920×1080 output keep their size and the HUD stays sharp. It holds in handheld mode too (960×540 at 0.75). On the Mac (M4 Pro) the GPU time does not change (about 13 ms in the first shrine at 1.0 and at 0.5): a frame has about 650 passes, most of them tiny, whose fixed cost outweighs their pixels there. Whether it helps an iPhone, whose GPU loads and stores each pass's attachments, is what the setting is for.
  • The world-load crash (about 1 run in 3 on the Mac) came from the host's command record sizes. About 3 s after Start, a task worker's operator new (main+0x155a79c) got null and the game wrote through it (main+0x155a7a4). Each model file gets a heap of its resource size table entry + 0x1008; the file goes in 4 KB aligned, and the model's setup (main+0x155a2ac) takes the rest as a frame heap. Part of that setup is GPU memory for the model's prerecorded commands, which the game sizes by recording each mesh's draw and each shape's state for each render pass into a scratch command buffer and adding up nvnCommandBufferGetCommandMemoryUsed, so the host's own record sizes count against the table. Draw records had grown to carry the indirect and multi-draw fields (0x38 → 0x50 per recorded draw on 2026-10-18 and 10-20), and every recording began with an 8-byte BEGIN record. Obj_TreeConiferous, Obj_TreeBroadleaf_B and FldObj_HyliaBridge_A-02 then needed one 4 KB page more than their entries allow, which fit only when the file's alignment pad happened to be small (about 15% of loads each failed). Plain draws now leave those fields out and recordings have no BEGIN (0x30 per recorded draw, nvn_obj.h draw_rec): all 510 models of the world load fit whatever the pad. How it was measured: 03.
  • Memory: about 2.8 GB in the first shrine in handheld mode, idle or walking (Mac measurement), against the 3.4 GB an 8 GB iPhone gives the app.
  • The game waits for each frame's GPU work before submitting the next, so the host hands its translated commands to the GPU in segments (04).
  • HUD prompt shadows and glows, stamina wheel, System menu background: the game rebuilds each layout capture (a blurred mask of a prompt bar, the menu's background) on the same storage with its own channel mapping and keeps the new texture only if nvnTextureCompare calls the two different; the host's comparison ignored the mapping, so captures read alpha 1 (hard-edged dark boxes, cream cloud rectangles, a black menu background). It compares the mapping now (04).

The Legend of Zelda: Link's Awakening (01006BB00C6F0000)

  • Engine: Grezzo's engine on NintendoWare, SDK 8.2.0 (the oldest so far), NVN 53. Modules main + subsdk0 (GLSLC, NVN's runtime shader compiler) + subsdk1 (movie) + sdk; 9,698 shaders from the bfsha archives, effects and layouts.
  • Window origin: the only game so far that keeps NVN's default lower-left (OpenGL) window origin; the host flips its viewports and winding and the window image at present (04). Before that, text the game renders into textures came out upside down.
  • Movies: VP9 with a 5.1 Vorbis sound track; the host decodes the sound with stb_vorbis.
  • Lighting: tiled forward lighting whose tile lists a compute shader builds each frame; it needs the dispatch size in NVN's driver segment c0 (04). Before the host supplied it, lights piled into the bottom tile row and the doorway step flickered with white tiles.
  • Sound: SDK 8 numbers the audio renderer's commands differently (the output command is 0x10, not 0x13); the host recognises the output command by its size. Before that, music and sound effects were silent and only the movies had sound (03).
  • Mac input: the title wants L+R: DM_KEYS="f6000:L+R~0.4,f6300:A,f6500:A,f6700:Down,f6800:A" reaches a new file's name entry (a fresh save directory via SV=).
  • Frame rate: gameplay runs at 60 like on the Switch; the game itself drops to 30 (present interval 2) for event cutscenes.
  • Depth of field: outdoors the game blurs the top and bottom of the screen by depth. It sets NVN's near = 0 depth mode, which the shader translation now follows for every game (an entry point per mode, picked at run time); before that the whole screen was blurred (04).
  • Sunlight: outdoors the sun's shadow test failed everywhere, so the scene was dim and nothing cast a shadow. The world position its shaders pass between stages has no w written, and the translation filled it with 0 instead of the hardware's 1; every game's shaders now get the hardware's default (04).
  • Physics: at 30 fps the game steps physics twice per frame. Its completion task destroys itself after it runs, and on Apple devices that destructor could land after the next step had already rebuilt the task, so the physics workers crashed when a conversation or cutscene began. A per-build cut drops the destructor call (03).

Super Mario Odyssey (0100000000010000)

  • Engine: Nintendo EPD's sead / agl with NintendoWare, SDK 7.3.2 (the oldest so far), update 1.4.1. Modules main + subsdk0 (movie) + sdk; 27,984 shaders (agl sharcb and bfsha in ShaderData, particles in EffectData); its three geometry shaders (a thunder effect) do not translate.
  • Start-up services: it calls several system services no other game did, each answered by the host: the aborting account::OpenPreselectedUser and GetLastOpenedUser, news delivery (bcat: the delivery storage does not mount, which the game treats as "nothing delivered"), HOME-button blocking, the parental 3D-mode setting, vi::SetLayerScalingMode, time::ToPosixTimeFromUtc, the debug pad, and the SDK 7 names of two audio-device calls (03).
  • Movies: MP4 / H.264 (the videos in its tutorial hint boxes, the kingdom travel videos), 41 of 44 High or Main profile with B-frames. The host decodes them with VideoToolbox; the SDK's demuxer passes the parameter sets as the avcc record (the MP4 avcC box), and pictures leave in presentation order through a small queue.
  • Sticks: the game reads all 16 entries of the controller history whatever count comes back; the host now fills every entry (before, only the first, and neither stick did anything in play while buttons and menus worked).
  • Motion: it reads the six-axis history the same way (16 entries per sensor, the count ignored, main+0x93fd84). Its motion analyzer (main+0x5d5590) turns the newest acceleration, seen against the controller's pose, into a swing value per hand and sets the swing flags when it exceeds 0.2. A still controller gives 0, and the host's shake (±3 G side to side) gives up to 1.5, so a Shake sets the left, right and any-swing flags for its length. Before, the host reported no sensor and no motion move could happen (03).
  • Mac input: a fresh save wakes Mario in the Cap Kingdom with DM_KEYS="f1000:A,f8200:RLeft~6,f9200:LUp~4": the "Look Around" hint waits for the right stick held about 6 s one way (left then right cancels out). Play starts at about frame 9,900, and the game saves there. From that save, f1000:A,f1300:A resumes into play at about frame 1,500, and f1600:LUp~7 walks straight across the fog to the hills by the bridge, where Cappy flees from Mario.
  • HUD: the coin counters are layout panes that take their alpha from single-channel render targets, as in Breath of the Wild; the profile lists the 26 layout shaders under alpha_from_red (before, they were solid white cards). Since the nvnTextureCompare fix the game's own capture mapping does that and the list no longer fires on the Mac (04).
  • Ground: a depth pre-pass followed by a less-or-equal G-buffer pass needs bit-identical positions from different vertex shaders; every shader is now compiled with -fpreserve-invariance (before, the Cap Kingdom ground showed stale 32×32 blocks on every fourth frame) (04).
  • Fog: the Cap Kingdom ground fog parts around Mario through a small fluid simulation fed by point-sprite splats. Their vertex shader clamps the point size to the driver segment's c0 + 0x610, which the host left at 0, so no splat was ever drawn and the fog never moved; the host now writes the largest point size there (04).

Hades (0100535012974000)

  • Engine: Supergiant's engine on The Forge renderer, SDK 9.3.0, update 1.0.38233. It renders with Vulkan: main imports 100 vk* functions from subsdk0 (the Vulkan loader, over NVIDIA's driver in subsdk1). The host implements them on Metal itself (dm/gfx/vk/hle_vk.m), so only main and sdk are loaded.
  • Vulkan layer: one Tegra X1-like device (Vulkan 1.1); device memory is a shared MTLBuffer made on first use, images are their own textures; command buffers are recorded and replayed into one Metal command buffer per submit on the layer's own queue; swapchain images are shown through the NVN layer's present path once their frame's GPU work is done. Names the game asks for through vkGet*ProcAddr are logged once each.
  • Shaders: 74 SPIR-V files in Content/EffectsForgeSwitch. dm/gfx/tools/vk_shaders.py <RomFS> <shader dir> [--ios <dir>] translates them with SPIRV-Cross into <dir>/vk/: one library, and a table of the Metal slot each (set, binding) moved to. The game hands the same words to vkCreateShaderModule, so the host finds each shader by the hash of its SPIR-V.
  • Notification event: the game polls oe::GetNotificationMessageEvent every frame; the SDK's own event was never created (the host's oe::Initialize is a no-op), so the host now hands out an event of its own, signalled while a notification is queued.
  • Online: its EOS_* imports (Epic Online Services, an NRO it would load through nn::ro) stay offline.
  • Mac input: DM_KEYS="f800:A,f1000:A,f1200:A,f1500:A,f1900:A" goes through the title and the intro into Tartarus.
  • Packing: for a Vulkan game the NSP script runs vk_shaders.py (Mac and iOS libraries in <id>_build/shader/vk and shader/ios/vk) in place of the NVN shader split and chain build.

Hades II (0100A00019DE0000)

  • Engine: Supergiant Games' engine, SDK 19.3.9, base game v0 (the NACP's display version is 0.0.126424). Unlike Hades it renders with NVN, and its ExeFS holds only main and sdk. The NSP script's provisional profile booted it on the Mac with no host change: title, a new save, the opening and Erebus at 60 fps. It plays on an iPhone.
  • Shaders: 88 BNSH files in Shaders/NVN/bin (.vert / .frag; the .shader files list each program's pair). They bind textures at fixed, sparse slots: 0, 1, 3, 6, 8, 11, 15, 21, 25, 26, 30, 31 and 34. Slot 34 (SceneFade.frag, c2 0x130) lies past the driver's 32 combined slots, inside the image table at 0x120; the game has no image bindings, and a bound texture there wins over an unbound image (nvn_mtl.m).
  • Online: its EOS_* imports (Epic Online Services, nro/EOSSDK-Switch-Shipping.nro) stay offline, as in Hades: ro::RegisterModuleInfo fails (red line), so the game never loads the NRO. SDK 19's nn::ro::LoadModule takes one more bool (…mib); the host hooks that form and ro::Finalize too, so neither reaches the SDK's ldr:ro client.
  • Memory: no system resource; the game asks for a 3.10 GB heap, which the 4 GB layout's heap region holds. main has an 82 MB bss, so the profile's mem_used is 108 MB. Resident memory levels off at about 3.4 GB in Erebus on the Mac.
  • Not a bug: two long lines through Melinoë. While the staff's Omega attack charges, two thin white lines run from her to the screen edges, one forward and one back. They are that weapon's aim lines (WeaponStaffSwing5: AimLineCountOverride = 2, AimLineAngleOffsetOverride = 180, animation AuraAimLineNoMovementStaff). The sprite is a long arrow with white edges, a dark fill drawn emissive and its tip off-screen, and the game hides it while she moves. Traced with DM_NVN_CBDUMP vertex dumps and DM_PROBE on the aim-line update (main+0x39ade0); a scripted run that taps Y shows them often.
  • Mac input: DM_KEYS="f700:A,f900:A,f1100:A,f1300:A,f1500:A,f1800:A,f2100:A,f2400:A,f2700:A" goes through Play, the first save slot and the opening into Erebus (control around frame 3000).

Hyrule Warriors: Age of Calamity (01002B00111A2000)

  • Engine: Koei Tecmo's KTGL on NVN, SDK 9.6.0, update 1.3.0 (v327680), four DLC packages (one is only a licence). Modules main + subsdk0 (movie) + sdk.
  • Files: everything sits in RDB archives (asset/*.rdb index; asset/*.rdb.bin* and asset/data/*.file hold IDRK entries, zlib in 16 KB chunks) (01).
  • Shaders: 40,726 GLSLC outputs: KTGL's G2S containers and one _DOK container in system.rdb.bin*, plus 117 in main. Many have 0x850-byte controls and the compute ones no code header (04). The game builds 8,705 programs during its first frame (about 10 s on the Mac).
  • Heap: its three modules take 66.6 MB, more than the 64 MB the host reported as used, so SetHeapSize failed and nnMain returned at once; the profile's mem_used reports 72 MB.
  • Sound: the engine's 15 MB heap was too small on the host (renderer work buffer plus four players, three of them 5.1). The sound setup then gave up quietly, the first sound archive never loaded and the main thread waited forever; a per-build cut moves 4 MB from the 142 MB sound data heap to it (03). The multi-stream hardware Opus decoder is the SDK's software one.
  • Graphics fixes it brought: compute programs get their own binding in the replay (draws after a dispatch were dropped, so the intro movie stayed black), nvnTexturePoolRegisterImage, indirect draws (its GPU-driven effects are indirect quads, converted by a compute pass), and the movie planes (CPU-written, published with nvnMemoryPoolFlushMappedRange) through tex_flush_upload.
  • 60 fps (30 on the Switch). The game steps a fixed amount per update, from three 30 fps values: a KIDS task on KIDSTaskScheduler threads (main+0x166d980) waits until 1 / fps has passed since the last update and steps the game clock by a frame-rate object's 1/30 s (not by the time that passed); the battle steps 1 / the game's frame rate per update (a block at main+0x269e150, {30, interval 2, 30, 30.0}, read by the scene update's caller at main+0x8c3610 and its jobs); and the event scripts step by the KTGL device's frame rate, 59.94 / the present interval in its config (2). The first 60 fps cuts (interval 1, a 1/60 s wait) were checked only on cutscenes, which follow the device: DM_PROBE showed the game clock at 2.00 × real time, and battles ran twice as fast on an iPhone. Fixing the clock alone was not enough: in a 3 s stick hold Link still covered twice the ground of the 30 fps original, and the enemy waves came early, until a sample profile of a battle led to the frame-rate block. The game's updates are not held by the presents (the task kept 60 updates a second while the host held frames 3 vsyncs), so three frame-step stubs (patch_nso.py FRAME_STEP) run the updates at the cap: the task's fps and the frame-rate block become the cap, the object's step is scaled by its fps × vsyncs per update / 60, the device frame rate becomes 59.94 / vsyncs, and nnMain's device config gets interval 1 above a 30 fps cap. Every step stays per update, as on the Switch, so an update rate below the cap slows them all together. The host shows each frame as soon as it is ready (profile fps_hold off); the updates keep their speed whatever is shown. Profile fps_target 60; DM_FPS_TARGET=30 runs updates and presents at 30, and DM_FPS_TARGET=0 is the Switch original (interval 2, the stubs' original paths). On the Mac the forced 30 and the original put Link on the same spot after the same stick hold, and the opening scenes at frame 2N at 60 match the original's frame N (same subtitle, same poses).
  • Battles: vertex conversion over the draw's vertices (vconv_range). The soldiers' Half2 and Short2 attributes feed integer shader inputs, which the replay converts on the CPU (vconv_bind in nvn_mtl.m). KTGL binds whole mesh pools of up to ~590,000 vertices, so converting the bound ranges came to 4.4 million vertices per submission in the first battle: 24 of the 32 ms the replay took per frame on the Mac, 26 fps at a 30 or a 60 fps target. The profile's vconv_range converts only what each draw reads (219,000 vertices per submission, about twelve submissions a frame), and the half and 8 / 16-bit integer formats have conversion loops of their own. Keeping the conversions across frames did not pay: the soldiers' attributes change every frame. DM_NVN_VCONV_CHECK=1 found no stale index range in 983,000 reuses. The visibility buffer ring was 4 deep, so the submit thread waited for the previous frame's GPU work; it now has as many buffers as command allocators (8). The battle runs at 58.6 fps on the Mac (M4 Pro; frame p50 16.9 ms, replay CPU 10 ms, GPU 11 ms). On an iPhone the GPU decides (14–16 ms per frame at p50 in the first battle, 40–60 ms once the phone reaches thermal state 2): the frame step holds 30 there.
  • Battles after the first cutscene: compute dispatches and texture views. An iPhone log (2026-10-21, fps_hold off, thermal state 0) went from about 57 to 17 fps after the first battle's first cutscene: the same 1,000–1,400 draws a frame, but NVN calls from 82,000 to 287,000 a frame, GPU 15 → 51 ms and the replay 12 → 24 ms. The battle then runs more small compute dispatches (on the Mac about 280 → 900–1,500 a frame, each with a game barrier and about 17 texture binds), and two host costs grew with them: every dispatch had a compute encoder of its own with full queue barriers on both sides (about 10 µs of GPU each on the Mac), and KTGL registers a texture into the next ID of a ring before every bind, so the per-ID view was rebuilt for nearly every bind (half of the 8,000–28,000 registrations a frame made a new Metal texture view, 45% of the replay's time). Dispatches now share the copy encoder (about 1.3 µs each) and views are cached by texture serial (04). Mac runs side by side, after the cutscene: GPU 18.0 → 8.1 ms and frame 35 → 22 ms with the dispatch change; the view cache took the replay before the cutscene from 14.3 to 10.8 ms (2,036 views made in 15 minutes).
  • Light and decal lists, vertex formats: two general fixes for its last red lines. 16,438 shaders read KTGL's light and decal lists from buffer textures with the instruction Maxwell also uses for 1D textures. The translation read them as 2D and the host bound a dummy, so every list read 0. The lists are now read as rows of 16,384 texels from a linear 2D texture over the buffer ("row wrap"). The first battle is darker and more saturated than with the zeros. The CPU vertex conversion of the soldiers' Half2 / Short2 / RGBA8UI attributes was a quarter of the replay thread; it is replaced by shadow vertex inputs: 2,259 side libraries, 77 MB each for Mac and iOS. Mac battle at 60 fps against the CPU conversion: submit + present 10.0 → 5.7 ms, CPU 3.97 → 2.44 W (04, both general). A 2D texture bound where three vertex shaders sample 3D (slots 8 and 9, texture type mismatch … declares slot 8 as Metal type 7) is still bound as is; it was hidden behind the earlier red line.
  • Other games' saves: it asks whether Breath of the Wild's (and 0100A2C01320E000's) save exists for bonus items; the host says no.
  • Mac input: with a save whose first-boot settings were made, DM_KEYS="f300:A,f420:A,f540:A,f660:A,f780:A,f900:A,f1300:Plus,f1350:Plus" confirms the settings, skips the intro movie and plays the opening into the first battle, The Battle of Hyrule Field (about frame 4000).

Fire Emblem: Three Houses (010055D009F78000)

  • Engine: Koei Tecmo's older KTGL (the game is co-developed with Intelligent Systems), SDK 6.5.0 (the oldest so far), update 1.2.0. Modules main + subsdk0 (GLSLC, NVN's runtime shader compiler) + subsdk1 (movie) + sdk. RomFS: the LINKDATA archive (DATA0.bin is the index, DATA1.bin holds 6.9 GB), loose overrides in patch1–patch4, WebM movies and KTSL sound banks; compressed entries and *.gz files are Koei Tecmo's chunked zlib (01).
  • Shaders: 1,364. shader_split.py reads the SPKG packages (G2S containers, as in Age of Calamity) in DATA1.bin and patch*/nx/shader, a _SPK4100 post-effect package, and the BNSH and GLSLC outputs in main. SDK 6's GLSLC writes 0x848 / 0x850-byte control sections and compute code with no header, which the reader used to drop (04).
  • Shaders compiled at run time: KTGL compiles 9 built-in GLSL shaders at boot with the game's own GLSLC (2D primitives, text, the loading quill). No game file holds their output, so they are captured on the Mac. The game runs with DM_GLSLC_CAPTURE=<version dir>/glslc_capture (hle_glslc.c), and shader_split.py reads that folder. The outputs are identical on every run. The NSP script does this by itself in its GLSLC capture stage, so the NSPs and prod.keys are enough. The stage builds the Mac host into <id>_build/tools/ and boots the game until the 9 files are in <id>_build/extract/v327680/glslc_capture, which takes about 10 s in all. Without the folder the split tables would differ from the profile, and the script would stop at its table check. By hand, run the game for a few seconds with that variable (step 5 of the build by hand).
  • Host changes it needed (03, 04):
    • it submits on two NVN queues from two threads (submissions are serialized now);
    • its command buffer records into one 16 MB ring chunk and frees the ring by GetCommandMemoryUsed, which now counts from the last AddCommandMemory as NVN does (frames stopped after about 15 s);
    • its two texture pools and two sampler pools use the same IDs, now kept apart (menus showed plain bars or only the cursor);
    • SDK 6's account::OpenPreselectedUser returns a Result (the save never mounted and the first loading screen never ended);
    • SDK 6's audio command header has its own layout (no sound);
    • its character shaders read Half2 UVs through integer-declared inputs, now converted to float32 bits at replay (black silhouettes, then flat tan characters);
    • depth clamp is replayed.
  • Fixed: the ground under close-up battle cameras showed as flat blue. The terrain's signed 16-bit heights were read zero-extended, so the vertices with negative heights went out of view (04, Three Houses).
  • Fixed: the first loading screen took 78 s on the iPhone. The loader calls nvnQueueFinish after each resource it uploads, and that call waited for the display to take every queued frame; it now waits only for the GPU (04, Three Houses).
  • Light and decal lists read since row wrap (04, general): 321 shaders read them through the instruction Maxwell also uses for 1D textures, and they used to read 0. Torches and burning houses now light the prologue battle map and the close-ups, and its Half2 / RGBA16I vertex attributes go through shadow vertex inputs instead of the CPU conversion. To confirm: 64-pixel rectangles in the ground where decals land, which skipping the decal pass removed; they did not appear on the Mac prologue route in either build.
  • Mac input to a first fight: from a save right before the prologue battle, with DM_AOC_DIR=<NSP folder>/010055D009F78000_build/aoc (or a "DLC deleted" dialog blocks loading), DM_KEYS with A every 90 frames from f720 to f2610, then f2700:B,f2800:B,f2900:A,f3000:Up,f3060:Up,f3120:Up,f3180:Up,f3240:A,f3340:A,f3440:A,f3540:A,f3640:A,f3740:A closes the movement tutorial, moves Byleth up to the thief and attacks: the combat close-up runs at frames 3,400–3,700.
  • Memory: about 3.5 GB resident and 2.8 GB of Metal allocations on the Mac; the game itself makes a 1.5 GB GPU-only memory pool.
  • 60 fps: the Switch version shows 30, but KTGL already updates the game 60 times a second on its main thread, each update a fixed 1/60 s step; the render thread draws the latest update and presents it with the interval of the render mode (2 for the docked 1080p and handheld 720p modes; the engine also has a 720p and a 1080p mode at interval 1 that nothing selects). Per-build cuts on 1.2.0 main set every mode's interval to 1, so every update is shown and the game runs at the same speed; the render thread's own per-frame steps (a fade counter, a scroll) read the same table. Two more cuts make the waits cheap. The frame limiter (main+0x5be430) yielded and polled the clock, which on the host spun 2 ms a frame on a performance core and ran at 59.5 updates a second; it now sleeps the time left. The render thread waits for the next update with a timeout of one frame given in milliseconds to a wait that takes microseconds, so it polled every 16 µs (about 50,000 wakeups a second at 60 fps); it now waits a frame. Prologue battle on the Mac: 59.9 fps, 55% of a core and 1.8 W of CPU energy (the original at 30 fps: 44% and 1.7 W). The render thread also forced interval 2 while either of two flags was set: one for the opening movie, one for scenes such as the character creation screen after it (the engine's own 60 fps modes dropped to 30 there to keep 1080p). A fourth cut ignores both (main+0x50fb7c), so those scenes run at 60 too; the movie still lasts 210.6 s (6,318 frames at 30 fps), as in the original.
  • Mac input: a fresh save needs DM_KBD=<name> for the name entry, and its confirm dialogs default to No. From a new game into the prologue battle (frame-based at 60 fps, about 7 minutes): DM_KEYS="f720:A*185/90,f2400:Plus*35/438,f17400:Left~0.3*120/500,f17480:A*120/500,f17560:Left~0.3*120/500,f17640:A*120/500,f18020:Plus*100/562"; the game offers a save right before the battle. The repeating Left, A, Left, A reaches Yes whether a confirm dialog is open or not (A, Left, A loops once it starts on an open dialog: A answers No, Left changes the selection, A reopens it). A presses during the opening movie pause it, so the scenes after it start a few seconds later from run to run.

Super Mario Galaxy (010099C022B96000)

  • Engine: the Wii game (codename Comet) on NERD's Hagi layer, SDK 18.3.5, update 1.3.1 (v327680). The PowerPC code is statically recompiled into main (hagi.config: Cpu Jit false, Statifier true), so there is no JIT; Hagi stands in for the Wii hardware: GX on Vulkan, the DSP with its ROMs in data/dsp, the disc from the RomFS, HD texture packs in data/texture_replace and data/ondemand_texture_replace. Modules main + subsdk0 (movie) + sdk; subsdk1 (GLSLC), subsdk2 (nn::pcm) and subsdk3 / subsdk4 (NVIDIA's OpenGL and Vulkan drivers) are not loaded.
  • Memory: main's bss is 2.9 GB (the emulated memory); the game never calls SetHeapSize, so the guest block is 1 GB. Footprint on the Mac 1.9 GB, 1.1 GB of it Metal.
  • Vulkan: 99 vk* imports through Hades' layer (hle_vk.m), which Galaxy extended: push constants, descriptor arrays, dual-source blending from an output at location 1, copies inside a render pass, linear images over their memory, timestamp and occlusion queries, pipeline caches, vkCmdDraw, image copies and read-backs, the GX vertex formats (USCALED / SSCALED, three components), viewport and scissor kept across render passes, the swapchain's INHERIT transform and the vi layer crop (04).
  • Shaders: Hagi generates its GX shaders as SPIR-V at run time. While it shows "Initializing system..." it creates 3,089 modules (the 56 built into main among them) and 4,535 pipelines from data/database/pip/pipelineDB.pip, the same on every boot (about 15 s on the Mac). The RomFS has no SPIR-V file, so the NSP script's Vulkan shader capture stage, with the profile's key list vk_capture.tsv, boots the game in the Mac host with DM_VK_CAPTURE=<version dir>/vk_capture until every listed key is there (under a minute), then vk_shaders.py translates the RomFS and the capture. Its two geometry shaders (GX points and lines, expanded to quads) are skipped: those draws come out as one-pixel points and lines.
  • Pointer: docked, Galaxy's pointer is the Joy-Con gyro, which the host does not have; handheld, it is the touch screen. The profile's op_mode handheld starts it in handheld mode (720p). On the iPhone, touches no gamepad control takes go to the game's touch screen (in the 3D layout, outside the right third of the screen, which is the right stick; all of them while a controller is connected); on the Mac the mouse is a finger, and DM_KEYS taps with T<x>x<y> (normalized picture coordinates).
  • CPU: Hagi polls with SleepThread(100 ns), which the host used to return from at once: one thread kept a core at 100%. Sleeps under 20 µs now count as yields and get the yield back-off (03).
  • Not yet: GX points and lines (the geometry shaders), sampler LOD bias (GX uses it; Metal samplers have none, red line). The base NSP in hand was damaged (147 MB zeroed near its end: the two WebM prologue movies, which the storybook prologue does not use); it was packed with --allow-damaged.
  • Mac input: from a fresh save, DM_KEYS="f1500:A,f1950:T0.373x0.70,f2300:A,f2700:A,f3200:A,f3600:LRight,f3800:A,f4200:T0.247x0.426,f4500:A,f4800:LRight,f5000:A,f5400:T0.659x0.366,f5800:A,f6200:LRight,f6400:A,f7000:A,f8000:A,f9000:A,f10000:A,f11000:A" creates file 1 (Mario icon, Regular Mode) and plays the storybook prologue into the Star Festival (about frame 12,000). Screenshots of the handheld picture need DM_NVN_SHOT_OUT=1280x720 (the raw swapchain image is 1920x1080 with the picture in its top-left 1280x720).

Super Mario Galaxy 2 (0100FD8022DAA000)

  • Status: brought up on the Mac (title, file select by touch, the opening and the first storybook stage at 59 fps) and playable on an iPhone.
  • Engine: the same Hagi layer as Super Mario Galaxy, update 1.4.0 (v393216), with Galaxy's sdk and movie module builds (SDK 18.3.5); main's bss is 2.6 GB. Everything Galaxy needed from the Vulkan layer and the host carries over; the profile is Galaxy's with its own memory layout and shader list.
  • Memory: the movie player initializes its own StandardAllocator with virtual address memory, 384 MB (main+0x62c280), which aborted the game at boot: the guessed 256 MB alias region holds nothing after the SDK's 1 GB address-region node area. A rewriter cut for this sdk build (shared with Galaxy) shrinks the node area to 62 MB, and the alias region is 512 MB; the guest block is 1.25 GB, about 4 GB of address space with main. Footprint on the Mac 1.93 GB, flat over five minutes (1.0 GB of it Metal).
  • Shaders: 3,797 SPIR-V modules, all created about 7 s after launch (Galaxy: 3,089), captured by the NSP script as for Galaxy (vk_capture.tsv); the same two geometry shaders (GX points and lines) are skipped.
  • Damaged NSP: the base NSP in hand fails the hash check on data/texture_replace/MarioGalaxy.hix and MarioGalaxy_Sub.har; it was packed with --allow-damaged.
  • Mac input: from a fresh save, DM_KEYS="f1600:A,f2000:T0.268x0.579,f2400:T0.641x0.778,f2800:T0.128x0.426,f3200:T0.641x0.778,f3600:T0.659x0.366,f4000:T0.8125x0.889,f4500:A*6/300,f6000:LRight+A~1.2*200/100,f6050:Y*200/100" creates file 1 (Mario icon, Regular Mode), plays the opening and walks the first storybook stage to the spin tutorial and on (about frame 11,000). DM_KEYS runs its steps one after another, so a hold blocks the steps after it: walking and jumping go in one step (LRight+A).

The Legend of Zelda: Tears of the Kingdom (0100F2C0115B6000)

  • 60 fps at normal speed: the game picks a present interval each frame (2 = 30 fps, 3 under load) and steps its game time by 60 / fps. Stubs in patch_nso.py (FRAME_STEP; the sites come from masagrator's FPSLocker config for this build) replace that logic with the host's frame step in whole vsyncs, as in Breath of the Wild: int fps = 60 / step. Prerendered movies hold 30. On the Mac (M4 Pro) the opening cave holds 60 at 1.000 of real time; with 12 ms added to every frame it drops to 30 after three late frames and stays at 1.000. DM_FPS_TARGET=30 saves power, 0 gives the game's own step back (every stub then runs the original instructions).
  • Before the host has decided a step (the first frames): the stub steps by the measured frame time, capped at the target. Game time that ran ahead after a short frame is paid back by the next frames (the idea from OJZen/totk-mobile); frames longer than 1/15 s are not caught up.
  • Render scale: DM_RENDER_SCALE=0.5–1.0 multiplies the size of the 3D views (1600x896 docked) and fixes it there; the game's own dynamic resolution runs only at 1.0. The HUD keeps its resolution.

Monster Hunter Rise (0100B04011742000)

  • Engine: Capcom's RE Engine, SDK 11.4.3, update 16.0.2 (v2228224, Sunbreak). The base game is an XCI (cartridge dump; nx_extract reads its secure partition), the update an NSP, and its 252 DLC NSPs are licences only. main has 125 MB of text: the rewriter puts its patch area right before the base (BIG_TEXT, a second pass with DM_PATCH_FRONT).
  • Files and shaders: everything sits in KPKA PAK archives, later patch PAKs overriding earlier ones by path hash. The 63,111 shaders are in SDF files (shader_split.py kpka_jobs), with combined and separate texture tables at once (separate binds are texture slots 128 + n).
  • GPU heap: one 64 GB sparse virtual pool built from 4 MB physical pools. The host aliases mapped pages (vm_remap) and keeps a 4 KB page table the replay reads textures through (streamed textures 32 GB in, mip tails at 4 KB offsets). The scene is GPU-driven (MultiDrawElementsIndirectCount).
  • Keyboard: the hunter, Palamute and Palico are named through the inline keyboard (DM_KBD on the Mac, the input box on the iPhone); the name is masked through ngc::ProfanityFilter's vtable, which the host fills.
  • Frame rate: 60 fps (30 on the Switch). The frame loop (main+0x6b106c8) sleeps until [obj+0x430] / [obj+0x434] sixtieths of a second have passed and steps the game by the time that really passed; the game sets the limit at [main+0x1295b3f0]+0x434 to 30 after boot and asks for present interval 2. The host writes the profile's fps_target there (hle_fpslock.c, FPSLocker's address for this build) and the profile's present_interval display ignores the interval; both are needed. DM_FPS_TARGET=30 gives back 30, and the Mac DM_KEYS routes below count frames at 30, so run them with it.
  • Mac input: a fresh save (with the first-boot notices accepted) reaches character creation with DM_KEYS="f4100:A~0.3,f6300:A~0.3,f7000:A~0.3,f7600:A~0.3". With DM_KBD=Hunter, DM_KEYS="f4100:A~0.3,f6300:A~0.3,f7000:A~0.3,f7600:A~0.3,f8000:A~0.3,f8150:Down~0.25*6/40,f8450:A~0.3,f8650:A~0.3,f8900:A~0.3,f9100:A~0.3,f9500:A~0.3,f9800:Down~0.25*9/40,f10250:A~0.3,f10450:A~0.3,f10700:A~0.3,f10950:A~0.3,f11300:A~0.3,f11800:Down~0.25*10/40,f12250:A~0.3,f12450:A~0.3,f12700:A~0.3,f12950:A~0.3,f13300:A~0.3,f16000:A~0.3,f16300:A~0.3" goes on through creation (hunter, Palamute, Palico, each named) and saves to slot 1. Holds under 0.25 s are not seen, and 0.5 s repeats in lists. From a save made that way, f4100:A~0.3,f6300:A~0.3,f6800:A~0.3,f7100:A~0.3,f7500:A~0.3 continues into the opening.

Ace Attorney Investigations Collection (010005501E68C000)

  • Status: brought up on the Mac (first-boot settings, title, Investigations 1's menus, Episode 1's opening and first investigation up to the first Logic prompt; 25 minutes with no red line) and playable on an iPhone. Much of the bring-up was worked out first by armyja in a fork (GitHub issues #17, #18, #19 and pull requests #15, #16).
  • Engine: Unity 2022.3.17f1 IL2CPP, SDK 17.5.3, update 1.0.0.2 (v131072). The base game is an XCI; its DLC NSP is a licence only. Two IL2CPP thunks end in an indirect tail call through x18 (main+0x1d271bc, +0x204ebec), which the rewriter now recognises (03).
  • Shaders: 4,951 (2,475 vertex / pixel pairs and one geometry shader, which Metal cannot express) in the player's serialized files and the Addressables bundles. Unity 2022 keeps each program's parameters as blob entries of their own and dropped the subprograms' local keywords; the player's files borrow the Shader type tree of the unitybuiltinshaders bundle (04). Binding layout as Engage's.
  • Files: the C# file API goes through the SDK's C library (File.Exists → stat → open), which checks every path with nn::fs::IsPathMounted and the length-limit queries before calling nn::fs. The host answers them now; before that Addressables found no settings.json and the game crashed on a null asset (main+0x31100ec).
  • Clock: the C library's real-time clock reads nn::time::GetStandardUserSystemClockCurrentTimespec, now the host's clock; std::chrono::system_clock::now() called std::terminate right after the first dialog.
  • Memory: the guest block is Engage's 5 GB with the same node-area cut on this sdk build, and a 1 GB application pool: StandardAllocator commits only 0.19 GB, and with Engage's 2.5 GB pool its reservation left Unity's own pools so little address space that the live regions reached the end of the alias region after 140 s (ten minutes: +3.91 GB of 4.375 with a 1.5 GB pool, +2.85 GB with 1 GB). On the Mac about 1.55 GB resident and 1.25 GB of Metal allocations, flat from the first investigation on (25 minutes; address space at +2.81 GB).
  • Frame rate: 60 fps, as on the Switch (present interval 1).
  • Mac input: a fresh save goes through the first-boot notices (the privacy prompt's second button is "Next"), the title and Investigations 1's Episode 1 with DM_KEYS="f600:A~0.3,f1200:A~0.3,f1800:A~0.3,f2400:A~0.3,f3000:Down~0.3,f3200:A~0.3,f3800:A~0.3,f4400:A~0.3,f5000:A~0.3,f5600:A~0.3,f6200:A~0.3,f6800:A~0.3*270/120" (the first investigation in Edgeworth's office at about frame 18,000; at about frame 30,000 the first Logic prompt waits for L).

Unicorn Overlord (010069401ADB8000)

  • Status: brought up on the Mac (logos, title, a new game and the opening story scene; no red line) and playable on an iPhone.
  • Engine: Vanillaware's engine, as in 13 Sentinels, SDK 16.2.4, update 1.05 (v262144). Its ExeFS holds only main and sdk. All data is in 7 CRI CPK archives; audio is CRI ADX2 with Opus streams. Both DLC NSPs are licences only.
  • Shaders: 2,188 GLSLC outputs compiled into main (1,725 distinct: 614 vertex, 1,111 pixel), single-stage programs combined per draw. Every binding decodes.
  • Service calls: three host changes, each found by one boot (03):
    • the hardware multi-stream Opus decoder's C interface, forwarded to the SDK's software decoder;
    • nn::crypto::GenerateCryptographicallyRandomBytes, which the sdk's own getentropy calls under std::random_device;
    • the profile key alloc_big_align it shares with 13 Sentinels. Without it, a ring heap cut from a memory pool kept base 0 and the game wrote to 0x268 right after the title screen.
  • Memory: no system resource; the game sets a 3.12 GB heap (pool − used), which the 4 GB layout's heap region holds. The rewritten modules are 92 MB (mem_used). On the Mac about 2.9 GB resident in the opening scene. On an iPhone (A20 Pro): a 2.88 GB footprint with at least 3.2 GB of headroom.
  • Frame rate: 60 fps. The game sets present interval 1; on the iPhone the frame time is 16.7 ms at the median.
  • Mac input: DM_KEYS=f2400:A*20/300 goes from the title screen (about frame 2,500) through a new game into the opening story scene (about frame 4,900).

Octopath Traveler 0 (01005270232F2000)

  • Engine: Unreal Engine 5 (the paged global distance field shaders and the LowLevelTasks task system) on its mobile renderer, SDK 19.3.13, update 1.0.5 (v327680); the base game is an XCI. Everything is in one pak, pakchunk0-Switch.pak (AES-encrypted index; the key is found in main and never printed), with CRIWARE sound banks and a USM movie beside it.
  • Shaders: 10,324, in the pak's two shader code archives, each one Oodle-compressed. The NSP script decodes them on the Mac with the game's own OodleLZ_Decompress (as for any game with Unreal paks: dm/gfx/tools/uepak.py, DM_OODLE_JOB), about 10 s for all of them. 61 vertex shaders (particles) pick a texture slot at run time; the profile key tex_dyn_slots binds them (04). 2,084 shaders do atomics on buffer images, now bound to the image's memory. 16 compute shaders with 3D image atomics (Unreal's paged global distance field, which the mobile renderer does not dispatch) do not build.
  • Memory: Unreal reserves one address region (62 GB on the Switch) and its allocator, FMallocBinned3, 37 GB of it. A cut skips the allocator's constructor and the host runs Unreal's heap on a malloc zone (hle_uemalloc.c); the region is capped at 2 GB (game_args) and the SDK's node area cut to 62 MB, so the guest block is 3.75 GB. 15 minutes into the story on the Mac: region mapped up to 1.26 GB of the alias region, Unreal heap 0.75 GB, 2.8 GB resident, 1.84 GB of GPU allocations.
  • Frame rate: 60 fps (30 on the Switch). The game's config caps Unreal at 30: t.MaxFPS=30 in [ConsoleVariables] of DefaultEngine.ini (and FrameRateLimit=30 in SwitchGameUserSettings.ini, which sets it again), and rhi.syncinterval=2 in SwitchDeviceProfiles.ini, the present interval 2 the game asks for. The shipping build reads no console variables from the command line (no -ExecCmds, -DPCVars or -ini:), so the host writes 60 and 1 into the two console variables (hle_fpslock.c, profile key fps_target 60), whenever the game sets them back; the game then asks for present interval 1 itself. Unreal steps the game by the time that really passed: on the Mac, DM_NVN_REGLUM traces of the same input script from a fresh save at 30 and 60 fps (frame time p50 33.4 → 16.7 ms) put the 1.5 s fade to white 70 s in at the same real time with the same shape, and both runs reach the same screens at the same times. DM_FPS_TARGET=30 keeps the game's own 30.
  • Keyboard: the character's name and favourite dish come from the system keyboard (DM_KBD on the Mac, the input box on the iPhone). The game keeps running behind it, so the guest clock does too (it used to freeze: the sound stuttered and the box lagged on the iPhone).
  • Mac input: a fresh save reaches the story with DM_KBD=Lyra DM_KEYS="20:Plus,24:A*27/4,150:Plus,154:A*62/3,340:Right*12/3,341.5:A*12/3,380:Plus*5/3,396:A*20/3,470:A,474:Up,476:A,480:A*140/3" (initial settings, the prologue, character creation with three belongings, "Begin Lyra's journey?").
  • Saves: the first save (about 13 minutes in) needs fs::GetSaveDataSizeMax; four saves in a 26-minute run, and a later boot continues from them.
  • Status: brought up on the Mac through character creation and the story opening into free play (26 minutes from a fresh save with no red line, 2.74 GB resident), and playable on an iPhone: 30 fps through character creation, name entry and the story opening, no audio underruns, three saves, a footprint of about 2.35 GB after 3.4 minutes; it plays at 60 fps on an iPhone too (fps_target 60). Battles are not tested yet.

Pokémon Legends: Z-A (0100F43008C44000)

  • Status: brought up on the Mac: language select, character creation, the opening and the first minutes of play in Lumiose City at a steady 30 fps (a 5-minute walk: GPU about 6 ms a frame, no crash; the red lines are the render condition the host does not evaluate, the placeholder cube and an occasional memory-pool retry). Playable on an iPhone: 30 fps, with a memory footprint of about 2 GB.
  • Engine: Game Freak's Trinity engine on SDK 19.3.11 (NVN 55) with Havok 2022.2, update 2.0.2 (v393216). The SDK 19 calls the host had to answer are in 03.
  • Files and shaders: every file sits in the Trinity packs of arc/data.trpfs (FlatBuffers, Oodle Mermaid). The shader split decodes them with the game's own OodleLZ_Decompress, run in the Mac host's tool mode (hle_oodle.c; the NSP script sets it up for any game with Trinity packs), and finds 28,603 shaders (01).
  • Memory: the 5 GB guest block with two cuts for 2.0.2: the StandardAllocator's address space 12 GB → 4 GB and the SDK's node area 1 GB → 62 MB. With mem_map_limit the host refuses a map past the application pool, as Horizon does, so the SDK gives back the 3.15 GB it keeps mapped after nninitStartup.
  • Graphics: characters are skinned with transform feedback, the colour grading LUT is drawn as a layered pass, the deferred lighting reads stencil through a stencil-mode texture view, and the occlusion queries that decide how characters are animated need the draw's base instance in the driver segment (04).
  • Mac input: a fresh save reaches play in Lumiose City with DM_KBD=Taylor DM_KEYS="f450:A,f1200:LRight~1,f1600:A,f1900:Down,f2000:Down,f2100:A,f2400:A*80/90"; a save made there continues with DM_KEYS="f300:A*3/150" (play from about frame 600), and LUp~12 then walks along the platform.

Cuphead (0100A5C00D162000)

  • Status: brought up on the Mac with no host change: title, save slots, the opening storybook, Elder Kettle's house, Inkwell Isle One and Forest Follies at 60 fps with no red line; playable on an iPhone.
  • Fixed: in about half the runs, sprites broke into dark slivers or went missing (a log in Forest Follies splintered for a whole attempt), and on the iPhone the picture flickered every fourth frame. The host reported a ZCull storage size of 0; Unity's NVN pool allocator, asked for a zero-size block per depth target, gave out the offset of a live block, lost that block from its map of allocations, and later freed it with a garbage size. Buffers allocated afterwards overlapped live ones, so copies read staging bytes another copy had overwritten (every fourth frame is the upload ring's period). The size is now never 0 (hle_nvn.c zcull_size).
  • Engine: Unity 2017.4.9f1 IL2CPP, SDK 5.5.0 (the oldest so far, NVN API 53.313), update 1.3.7 (v655360); its DLC (The Delicious Last Course) is mounted as add-on content 1. Unity 2017.4's serialized and Shader formats (unityfs.py, shader_split.py) and the rewriter's jump-table form (patch_nso.py) were found by armyja on this game (GitHub pull requests #1, #2).
  • Shaders: 3,118 (1,558 pixel, 1,560 vertex) from the player's serialized files and AssetBundles, plus 4 GLSLC outputs in main. Every binding decodes.
  • Memory: no system resource, so no virtual address memory: Unity sets a 3.12 GB heap (application pool − used memory) in a 4 GB block. The rewritten modules take 80 MB, so the profile reports 84 MB of used memory (mem_used); with the default 64 MB, SetHeapSize fails and the game aborts at its first allocation. On the Mac about 3.2 GB resident and 2.7 GB of Metal allocations, flat over the house and the map.
  • Saves: the game writes a slot when it reaches the map, not when the slot is picked.
  • Mac input: a fresh save goes through the title (L+R), a new slot, the storybook and the house to the map with DM_KEYS="20:L+R,26:A,32:A,38:A,44:A,50:A,56:A*20/3,120:LRight~4,125:A,127:LRight~3,131:A*10/2,152:LLeft~3,155.5:A" (the map at about 160 s). From that save, DM_KEYS="20:L+R,26:A,32:A,35:A,40:LRight~1.5,42:LDown~1.5,45:LRight~3,49:LUp~1,51:LRight~3,54.5:LLeft~0.5,55.2:LUp~0.25,55.6:LRight~2,58:LUp~0.2,58.4:LRight~0.25,58.8:LUp~0.2,59.2:LRight~0.25,59.6:LUp~0.2,60:LRight~0.25,60.4:LUp~0.2,61:A,63:A" crosses the bridge (its entry is narrow between the posts) and enters Forest Follies (play from about 68 s; Y shoots).

FINAL FANTASY TACTICS - The Ivalice Chronicles (010038B015560000)

  • Status: brought up on the Mac: initial settings, the opening movie, the prologue and the Orbonne Monastery battle up to the player's first turn, at 60 fps with no red line (about 1.4 GB resident); playable on an iPhone.
  • Engine: Square Enix's engine on SDK 19.3.9 (its sdk is byte-identical to Hades II's), NVN, update 1.4.0 (v393216). The OpenGL driver bundled as subsdk0 is not loaded. CRI Mana movies and ADX audio run in the game's own code.
  • Shaders: 306: 257 GLSLC outputs in the technique files (resources_nx/release/master/shader/*.tec), which shader_split.py now reads like main's 49, and one geometry shader Metal cannot build.
  • Job system: its pools wake one worker through a shared auto-clear event and relied on the workers sleeping in index order; a build cut makes a worker woken in another's place pass the wake on (patch_nso.py; the boot hung in 4 of 6 runs) (03).
  • Graphics: UI textures are written by the CPU into uncached pools after creation (tex_flush_upload, tex_dirty_upload), and the UI is composited into the window texture by fragment-shader image stores read back as r32ui, between game barriers inside one pass (04).
  • Mac input: a fresh save reaches the first battle turn at about frame 21,000 with DM_KEYS="22:A*60/2,150:Plus,152:X~2.5,165:Plus,167:X~2.5,175:A*70/2" (A through the settings and the birthday, then + and a held X skip each part of the opening).

Absolum (0100A83022A22000)

  • Status: brought up on the Mac: the logo movie, the licence agreement, the main menu, the campfire character select, the intro and the first level (the "Strike / Skill" tutorial and the cave after it), at 60 fps with real sound and saves; about 2.5 GB resident. Playable on an iPhone.
  • Engine: FNA (XNA 4 in C#) compiled with .NET NativeAOT (its own GC, regions enabled), SDL3, FNA3D's NVN driver, Wwise, SDK 18.3.2, update 1.1.1 (v393216). NVIDIA's OpenGL and Vulkan drivers (subsdk1, subsdk0) are not loaded; subsdk2 is the movie module (H.264 logos). Epic Online Services comes as an NRO the host does not load; the game plays offline without it.
  • Code: main starts its text with 1.7 MB of data, which the rewriter now leaves alone (dm/tools/nso_code.py).
  • Shaders: 162 GLSLC outputs in shaders.nx.bin. The picture is flipped by a viewport swizzle, which the host now applies (04).
  • Memory: everything is virtual address memory: malloc's StandardAllocator (40 GB of address space, cut to 2.5 GB on this build; 1.75 GB of it is FNA3D's GPU heap), the .NET GC's region range (game_env DOTNET_GCRegionRange=60000000: 1.5 GB instead of 3.75), and the SDK cuts for its node area and its 1 GB alignment of large regions. They take 4.13 GB of the 4.375 GB alias region in a 5 GB block (03).
  • Mac input: a fresh save reaches the first level at about frame 9,000 with DM_KEYS="f1500:A,f1900:A,f2300:A,f2700:A,f3000:A,f3300:A,f3600:A,f4000:A,f4400:A,f4800:A,f5400:A*30/150" (any button, accept the licence, New game, the character select, then A through the intro and the first dialogue); LRight walks, Y strikes and X uses the skill.

Captain Toad: Treasure Tracker (01009BF0072D4000)

  • Status: brought up on the Mac with no host change:

    • the opening course, the cutscene that follows it and the title menu;
    • the first save, the Episode 1 book and Plucky Pass Beginnings;
    • the Special Episode's book (add-on content 1).

    It runs at 60 fps with no red line. Three minutes in a course: about 950 MB resident and 690 MB of Metal allocations, flat. Playable on an iPhone.

  • Engine: Nintendo EPD Tokyo's engine (the al library over sead / agl, as Super Mario 3D World and Super Mario Odyssey) on SDK 8.2.0, update 1.4.2 (v393216). Its sdk is the build Link's Awakening 1.1.0 runs. main exports its symbols (48,804 defined), so sdis.py --find names most functions.

  • Shaders: 7,258, all with Mario's binding layout. The fin fur geometry shader does not build, as in 3D World. main imports glslcCompile but no module defines it, so nothing is compiled at run time (04).

  • Graphics: agl's post-effect program samples its own attachment, as in 3D World, and here the 16x16 magenta blocks showed on the Mac too: the profile's feedback_snap.

  • Input: the profile plays it handheld (op_mode): docked, the game's pointer (touch blocks, stunning enemies) is the Joy-Con gyro; handheld, it is the touch screen. The virtual gamepad starts in the 3D layout (right stick: camera).

  • Add-on content: index 1 is the Special Episode; index 2 holds a 1-byte marker file.

  • Memory: no system resource; the game sets a 3.00 GB heap (pool − 64 MB) in the 4 GB layout.

  • Mac input: a fresh save starts in the opening course with control of Toad. DM_KEYS="f900:LUp+LRight~2.0,f1030:LUp+LLeft~2.2,f1170:LDown+LLeft~2.8,f1345:LDown+LRight~0.4,f1375:LUp+LRight~2.0,f1500:LDown+LRight~1.5,f1595:LUp+LRight~1.4,f1685:LUp+LLeft~0.9" walks him to the star:

    • through the arch, up the back ramp, around the rock on the grass top and over the roof;
    • the course clears at about frame 2,100 and the title menu comes at about 5,200, with the first save;
    • A dismisses the title's notices.

    From that save, DM_KEYS="f600:A,f1000:LRight~0.3,f1300:A" starts Plucky Pass Beginnings (play from about frame 1,900). How the route was found (position probe, the stage map's guide rail) is in 05.

Brain Memory Training Academy (0100F5D01DA3A000)

  • Status: brought up on the Mac: the title, the controls screen, the level select and the levels with their Level Complete / Level Failed screens, at 60 fps with real sound and saves, no red line; about 0.75 GB resident, flat over 20 minutes. Playable on an iPhone.
  • Engine: Unity 2022.3.5f1 IL2CPP with FMOD on SDK 16.2.4 (its sdk is Unicorn Overlord 1.05's build), base game 1.0.0; subsdk0 is the movie module.
  • Shaders: 496 in the player's serialized files (Data/*.assets, Data/Resources) and main. There are no bundles, so no file carries a Shader type tree to borrow; unityfs.py has Unity 2022.3's layout written out (KNOWN_TREES, the hash Ace Attorney Investigations Collection's bundles carry). The built-in VR blit's geometry shader does not build (04).
  • Memory: virtual address memory as in Ace Attorney Investigations Collection: nninitStartup gives StandardAllocator pool − used, which needs the node area cut on this sdk build (patch_nso.py; Unicorn Overlord, which has no system resource, runs the same with it). Ace Attorney's 1 GB pool and 5 GB block; the live regions end at about +2.1 GB of the 4.375 GB alias region (03).
  • Input: controller only (stick or D-pad, A, B, +); the game never reads the touch screen, so it stays docked at 1920x1080 and the virtual gamepad starts in the 2D layout.
  • Mac input: DM_KEYS="f400:A,f700:A,f1000:A,f1200:Right,f1300:A" starts a new game, enters level 1 and clears it (Level Complete at about frame 1,350).

The Great Ace Attorney Chronicles (010036E00FB20000)

  • Status: brought up on the Mac: the first-boot notices (the DLC found, auto-save), the title and its Options, a new game of The Great Ace Attorney: Adventures, the opening movie, the first episode's opening scenes and its trial, at 30 fps with sound and saves; about 1.1 GB resident, flat over 14 minutes. Playable on an iPhone.
  • Engine: Capcom's MT Framework on SDK 10.4.1, NVN, base game 1.0.0 (v0). Files are ARC archives of zlib entries under nativeNXx64/ (01); subsdk0 is the SDK's movie module, whose extractor runs natively while the host decodes the H.264 movies (VideoToolbox).
  • Shaders: 460 (230 vertex, 230 pixel) in one MT Framework shader package (sa/NX_64/root.arc), which shader_split.py now reads (04); all build, every binding decodes.
  • Host: SDK 10 calls that reach a service, found from the import list (album notifications, store demo mode, vi::ListDisplays, fs::RenameDirectory, save thumbnails, shop and friend applets, the profanity filter called by name), and two NVN calls (03).
  • Graphics: the textures are copied into a CPU-cached pool by the CPU after creation, with no flush of any kind (tex_flush_upload, tex_dirty_upload; 04).
  • Add-on content: index 1 (DLC.dlcp); the game reports it found and added to Special Contents.
  • Memory: no system resource; the game sets a 3.14 GB heap (pool − 64 MB) in the 4 GB layout.
  • Mac input: DM_KEYS="f700:A*1000/24" (A every 24 frames) goes through the notices, the title and a new game into the opening movie; the first episode's dialogue starts at about frame 6,000 and its trial at about 20,000.

STEINS;GATE RE:BOOT (01008BC024FDE000)

  • Status: the logos, the title menu (its background is a movie), a new game through the prologue movies into the story, the config pages, save and quick load, at 60 fps with music, voices and movie sound; no red line in 15 minutes of story. Playable on an iPhone.
  • Engine: M2's ADV engine (advengine, m2lib, the E-mote player for the animated characters) on SDK 20.5.17, update 1.0.3 (v131072). subsdk0 is the SDK's movie library: its movie::Extractor demuxes the 87 H.264 / AAC MP4 movies and the host decodes them (output 4, NV12 buffers). Everything else is in the game's own PSB archives (*_info.psb.m + *_body.bin), which it reads itself.
  • Shaders: 487 in five BNSH in main (483 pixel, 4 vertex); RomFS holds none.
  • Host changes: nn::audio::SetAudioOutVolume / GetAudioOutVolume (the movie sound's AudioOut, set every frame; 03); the storage-sharing texture table drops entries whose storage another texture takes over, which kept GPU memory near 1 GB where it used to grow with every picture shown (04).
  • Graphics: 1280x720 in a cropped 1920x1080 window in both modes, by the game's own choice; the backgrounds' scratches, dust and colour fringes look like the art's own film effect.
  • Input: buttons only: it reads the touch screen in handheld mode but takes nothing from it, so the profile keeps the default docked mode and the 2D pad layout (ZL / ZR phone trigger; quick save on R3 needs the 3D layout).
  • Memory: no system resource; the game sets a 3.05 GB heap in the 4 GB layout. About 1.0–1.1 GB resident in the story on the Mac.
  • Mac input: A on the title, Right to CONTINUE, A, Right to QUICK LOAD, A, A on the save, Left to OK, A loads the quick save. The menu takes presses late while its background movie starts (100 to 500 frames after the title's A), so check a DM_KEYS route's frames with screenshots.

Big Brain Academy: Brain vs. Brain (0100620012D6E000)

  • Status: brought up on the Mac: the title, Solo, enrolling a new student (birthday, profession, appearance, online features, the creepy-crawly filter), the main menu (Practice, Ghost Clash, Test), the Practice categories and activities from each (Whack Match, Species Spotlight, Frame Filler, Fast Focus, Flash Memory, Cubegame, the photo quiz, Tick-Tock Turn, Shadow Shift) with their results, coins and unlocks, at 60 fps with sound, the H.264 activity previews and saves, by controller and by touch. Playable on an iPhone.
  • Engine: NintendoWare Bezel Engine 1.56 on SDK 11.4.3, NVN, update 1.2.0 (v131072). Nearly all of RomFS is in Bezel Engine archives (.bea, zstd entries, 01); subsdk0 is GLSLC (imported, never called: main holds no GLSL source) and subsdk1 the movie module.
  • Shaders: 2,125 BNSH in the archives' .bfsha entries (most of them the engine's own, in System.nx.bea), the layouts and main; shader_split.py reads the archives (bea_members, 04). The 12 geometry shaders of Bezel's line renderer do not build: the main menu's chart is drawn with it every frame (a line strip with adjacency expanded into thick lines, then copied out of the frame), so the chart's outlines are missing. Ten pixel shaders pick their texture at run time from one of two places in the game's uniform buffers; they do not load and their draws are skipped. None of the activities above uses them; one 15-minute run of activities met one of them (e10f24835bbaa5f6, 03GamePerception.bnbshpk) and the process aborted moments later, before the host caught SIGABRT, so its cause is not known; a second 15-minute run was clean.
  • Host: nninitStartup chooses virtual address memory or a heap by IsVirtualAddressMemoryEnabled, which the host answered yes for every game; the profile key sys_resource reports this NPDM's none. SDK 11's Opus work-buffer call, the IR camera, cached Nintendo Account info, per-file data caches and SDK 11's socket overloads (03). The activity previews loop with Flush and Start from four threads, which corrupted the movie decoder (a host fix, hle_movie.c).
  • Input: the profile plays it handheld with Boost (op_mode handheld, perf_mode boost): handheld, the game reads the touch screen (every menu and activity, and Party's two-player touch battles); docked it does not. Handheld Normal draws 1280x720, Boost 1920x1080. The buttons work as well; the virtual gamepad starts in the 2D layout.
  • Memory: no system resource; the game sets a 3.14 GB heap (pool − used) in the 4 GB layout. It creates 1.5 GB of NVN memory pools in its first 25 s (384 + 256 MB CPU-uncached, a 320 MB physical pool for sparse mappings, 192 + 64 MB of render targets), so about 1.3 GB is resident at the title and 1.5 GB in the first activity. Over 15 minutes of activities it rose to 1.86 GB in steps as each new activity loaded (pools 217 → 292, textures 444 → 1,173), flat in between.
  • Mac input: from a fresh save, DM_KEYS="f1900:A,f2400:A*9/150,f3800:Right*14/150,f3875:A*14/150,f6000:A*4/150,f6900:Down*5/60,f7250:A,f7500:A*17/150,f10050:A" enrolls a student (birthday 01/01/2000, the default appearance, no online features) and starts Whack Match from Practice at about frame 10,050 (<work>/run/enroll.sh). In handheld mode T<x>x<y> taps work too: f1900:T0.5x0.88 taps Start on the title.

Patrick's Parabox (0100E44016C22000)

  • Status: brought up on the Mac: the first-boot language menu (all ten languages drawn), the title, the hub, Intro 1 solved and Intro 2 with undo, reset and the menu, at 60 fps with music and saves (progress kept across a restart); no red line, about 0.42–0.48 GB resident, flat over four minutes. Playable on an iPhone.
  • Engine: Unity 2020.3.30f1 IL2CPP with FMOD on SDK 13.3.7, update 1.0.1 (v65536); subsdk0 is the movie module, which the game never uses.
  • Shaders: 486, all Unity built-ins (Data/Resources/unity default resources, unity_builtin_extra) plus the player's 11 GLSLC outputs in main. There are no bundles, so unityfs.py has Unity 2020.3's Shader layout written out (KNOWN_TREES: the type tree of Engage's bundles, the same hash). The built-in VR blit's geometry shader does not build (04). The whole game draws with three shader pairs.
  • Fonts: its only font, Jost, falls back to the shared fonts for Japanese, Korean and Chinese text, which Unity's player opens with FreeType straight from GetSharedFontAddress; the host gives a game that imports no ScalableFontEngineHelper::Decode the TTF itself (03).
  • Memory: the NPDM declares 16 MB of system resource (sys_resource), so virtual address memory as in Ace Attorney Investigations Collection and Brain Memory Training Academy: nninitStartup gives StandardAllocator pool − used, which needs the node area cut on this sdk build (patch_nso.py); their 1 GB pool and 5 GB block. The alias region is mapped up to +1.5 GB with 0.27 GB in use.
  • Input: controller only (stick or D-pad to move, A redo, B undo, X reset, + menu): it stays docked at 1920x1080 with the 2D pad layout.
  • Mac input: a fresh save solves Intro 1 with DM_KEYS="f700:A,f1000:A,f1300:A,f1600:A,f2400:Right,f2450:Down,f2500:Down,f2550:Down,f2600:Down,f3000:Up*3/30,f3090:Right*3/30,f3180:Down*2/30,f3240:Up*3/30,f3330:Left*4/30" (English, the title, into Intro 1 from the hub at about frame 2,700, solved and on to Intro 2 at about frame 3,500).

Celeste (01002B30028F6000)

  • Status: brought up on the Mac with no host change: the logos, the title, a new file and the prologue (walking, jumping, the camera following Madeline), at 60 fps with music and sound, no red line; about 1.6–1.7 GB resident, flat over five minutes. Playable on an iPhone.
  • Engine: MonoGame for the Switch: MonoGame's and the game's C# compiled to C++ by Sickhead's BRUTE into main (78 MB with its bss), FMOD Studio (its banks in Content/FMOD/Desktop, sound through AudioOut), SDK 15.3.2, NVN, update 1.4.1.1 (v589824; the title shows v.1.4.1.0). Only main and sdk.
  • Shaders: 26 (20 pixel, 6 vertex) in MonoGame effects: the 13 XNB files in Content/Effects and SpriteBatch's built-in effect in main, which shader_split.py now reads (mgfx_effect, 04). Every binding decodes.
  • Memory: no system resource (sys_resource 0), so no virtual address memory: the game sets a 3.11 GB heap (pool − used memory) in the 4 GB layout. The rewritten modules need 100 MB of used memory (mem_used).
  • Input: controller only; the game maps its buttons by position, so B and X jump. The virtual gamepad starts in the 2D layout.
  • Mac input: a fresh save reaches the prologue with DM_KEYS="f1850:A,f2200:A,f2600:A,f3000:A" (the title, Climb, a new file, Begin; control of Madeline from about frame 5,000); then f5100:LRight~1.0,f5400:LRight+X~0.3 walks to the first ledge and jumps onto it.

DRAGON QUEST XI S: Echoes of an Elusive Age – Definitive Edition (01006C300E9F0000)

  • Status: brought up on the Mac through the opening movie, the title (name entry, system settings), the prologue movie (skipped with +) and into free play at Cobblestone Tor (the hero, Gemma, the villagers, the HUD and the minimap), at 30 fps with sound and saves; no red line, about 2.1 GB resident (1.1 GB of it Metal allocations), 1.5 GB mapped in the guest. Playable on an iPhone.
  • Engine: Unreal Engine 4.18 on SDK 7.3.2, update 1.0.4 (v262144), with Square Enix's own sound driver (SQEX::Sd, "SEAD") on the audio renderer; subsdk0 is the movie module, driven by Unreal's Switch movie player (VP9 with two 6-channel Vorbis tracks).
  • Files and shaders: legacy version 4 paks whose blocks are LZ5-compressed, decoded on the Mac with the game's own LZ5_decompress_safe (uepak.py, DM_OODLE_SERVE after nninitStartup, since the decoder uses the game's malloc). The 38,892 shaders are inline in the cooked packages and in GlobalShaderCache-GLSL_SWITCH.bin, each a zlib stream of an LSLG record (shader_split.py ue_legacy_jobs). The colour-grading LUT's slice-writing geometry shader is folded into its vertex shader's layer entry points (prog_fold_gs); the other 22 geometry shaders do not build (04).
  • Sound and movies: SEAD refills its voices without waiting on the renderer event, so catch-up frames wait for queue space. The movie player registers its decoder events before filling them (read at signal time), plays only when every track holds a decoded output (audio decoders keep 32), and after a skip sends an end-of-stream packet for every input it gets (none is handed out after end-of-stream) (03).
  • Memory: Unreal's one address region is capped at 4064 MB (game_args, MB up to 4095); two cuts on the sdk build (node area 62 MB, 32 MB alignment for regions of 1 GB and more) put it 64 MB into Engage's 5 GB block. Its cube-map array (510 faces) needed NVN's layer-face count (04).
  • Frame rate: 60 fps (30 on the Switch). The profile's fps_target writes 60 into this build's t.MaxFPS and 1 into nvn.SyncInterval (hle_fpslock.c, both threads' copies; the game's config sets 30 and 2); DM_FPS_TARGET=30 (Documents/env.txt on the iPhone) keeps the game's 30 and saves power. On the Mac (M4 Pro) the field holds 60 fps (frame time p95 17 ms, GPU 6.6 ms p50 / 9.7 ms p95) at normal speed, since Unreal steps the game by the time that really passed, with sound intact. The game's dynamic resolution budgets are for 30 fps (r.DynamicResolution.*TimeBudget 28–35 ms in SwitchEngine.ini), so it does not lower the resolution to reach 60: where a frame needs more than 16.7 ms, frames are dropped instead.
  • Mac input: with the save of an earlier run (the name entered), DM_KEYS="20:A*150/3,250:Plus*4/10,480:B,483:LUp~4" skips the prologue movie at about 270 s and walks on Cobblestone Tor at about 480 s.

Tools (dm/tools)

Tool What it does
nx_extract/ NSP → ExeFS / RomFS / icon with LibHac, merging an update over the base game (C#, dotnet build). --manifest writes ExeFS and a RomFS file list only, for surveys
nso_fnmap.py Carries offsets from one build to another (function matching with addresses masked; globals via their references)
nso2elf.py Converts NSO to ELF for Ghidra / REA
insn_census.py Counts the instruction sites the rewriter must handle
nso_code.py Function ranges from .eh_frame and the data blocks in a text segment, which the rewriter and the census leave out
nso_syms.py Compares the SDK import surface of two games, or of one game (- for the reference) with the host hooks
nvn_slots_got.py Builds the NVN fetch table and call sites (GOT-indirect loaders)