Skip to content
NX Native

Pokémon Legends: Z-A

Other engines0100F43008C44000
Pokémon Legends: Z-A with nxnative
Title ID
0100F43008C44000
Tested build
2.0.2
Nintendo SDK
19.3.11
Engine
Game Freak's Trinity engine
Graphics API
NVN

Build this game

Keep [0100F43008C44000] in your file names, put the base game, update and DLC in one folder and run the script. Follow the build guide, then the sideloading guide. Needs an iPhone 15 Pro or newer, or an M1 iPad or later.

Technical notes

The project's own record of bringing this game up: what its profile needs and why, what was found and what is still open.

Update 2.0.2 (v393216) over the base game, SDK 19.3.11 (NVN 55). Game Freak's Trinity engine with Havok 2022.2 on NVN. Modules main and sdk. Started from the provisional profile the NSP script wrote from its survey (dm/games/profile_guess.py). Playable on the iPhone at 30 fps with a memory footprint of about 2 GB; on the Mac brought up through the language select, character creation, the opening and the first minutes of play in Lumiose City at a steady 30 fps.

What it needs

Setting Where Why
Allocator address space 12 GB → 3.94 GB the host's StandardAllocator cap (hle_svc.c), every build StandardAllocator::Initialize(nullptr, 0x300000000) does not fit a phone; see Memory
Node area cut 1 GB → 62 MB dm/tools/sdk_patches.py SDK-wide rules, sdk build df7a3960… (sdk+0x1423fc / +0x142468) the alias region must hold the node area and the allocator's region
5 GB guest layout: mem_reserve 0x140000000, mem_alias 0x110000000, mem_stack 0x10000000, app_pool 0xCD500000 game.tsv the 4.25 GB alias region holds 62 MB + 4 GB (SDK 19 puts the region 4 MB aligned right after the node area); the 5 GB block Tears of the Kingdom runs in
mem_map_limit on game.tsv the SDK keeps the pages of nninitStartup's probe mapped until a map fails; the pool is enforced as on Horizon, so it gives them back
Integer output rows b767103838f729ca, c509e77bef5b4249 shader_facts_manual.tsv no PC version, so the translator declares every output float; these two pixel shaders (variations 0 and 3 of one BNSH in the imgui.trsha pack, the env_ikkaku system shaders) write an integer ID (255, or one taken from the game's uniforms) to output 1, an R8_UI target; Metal refused them on the character creation screen
pad_layout simple game.tsv the 2D gamepad layout
depth_mode zero game.tsv the game sets near = 0 with nvnDeviceSetDepthMode; see Pipeline bases made before the game runs

render_cond stays off: it changes nothing in this game.

Memory

  • With virtual address memory (a 16 MB system resource), nninitStartup (main+0x1b2e650) initializes its StandardAllocator over 12 GB of address space, StandardAllocator::Initialize(nullptr, 0x300000000) (main+0x1b3e3ec), as the Switch's 64 GB alias region allows.
  • Resolved: StandardAllocator::Initialize aborted in nninitStartup (main+0x1b2e690). The alias region had to hold the SDK's 1 GB node area and that region, 13 GB, more than a phone gives a process (one 6 GB reservation). Two cuts made it fit the 5 GB block: 4 GB of address space for the allocator (a cut on main, since replaced by the host's cap: below) (the pool, 3.2 GB, bounds what it maps; its largest block is nninitStartup's own probe of pool − used, 3.15 GB, allocated and freed at once) and Engage's node area cut. SDK 19's AddressRegionManager puts the region right after the node area (+0x3fe2c000 before the cut), not 1 GB aligned as Engage's SDK did, so a 4.25 GB alias region holds both. QueryMemoryInfo reporting more than 4 GB would take the game's big-memory path (heaps of allocatable − 4.27 GB instead of − 2.76 GB).
  • Current: the host's StandardAllocator cap, for every build; the main cut and its matched rule are gone (the profile has no patches.py; StandardAllocators over 12 to 62 GB of address space). The gate at StandardAllocator::Initialize gives the 12 GB request 3.94 GB, what the 4.25 GB alias region has room for. It had already lowered the cut's 4 GB to 3.94 GB since it came in. Without the gate the uncut main aborts at sdk+0x134214, as before the cut. Checked on 2.0.2 with the route into Lumiose City and a walk there (380 s, frame 11,100), headless from a fresh save with the same keys, against the same build with the cut: the same frames reached, [RED] kinds and alias region use, screenshots of the same scenes (only animation and fade timing differ). The base game 1.0.0 ships the same sdk (df7a3960…) and makes the same call, so the cap covers it too. The NSP script's region check on v0 packed alone (a derived profile, 2026-10-28): 12 GB through StandardAllocator::Initialize, returning to main+0x19e8760, 1.55 GB mapped in its first 15 s in nnMain, which the host gives 3.94 GB on the iPhone layout; no abort. It has not been played since the matched rule went.
  • Resolved: the base game 1.0.0 (v0) aborted in nninitStartup (GitHub issue #266; nn::diag abort at sdk+0x134214, called from main+0x19e8760, the v0 place of main+0x1b2e690). The NSP script packs a folder holding only the base game from a derived profile, and the allocator cut was registered for 2.0.2's main only. v0's main (7222e13e…) makes the same call at main+0x19f84b4 in a function (main+0x19f8480) that nso_fnmap.py matches exactly to 2.0.2's main+0x1b3e3b0, with the same nso_match.py fingerprint; its sdk is 2.0.2's (df7a3960…), so the node area cut and the 5 GB layout apply to it unchanged. The cut was then also in MATCH_CUTS (Function-matched cuts); the host's cap now does it for every build (above). Measured on the Mac (M4 Pro, v0's own RomFS, the 2.0.2 shader libraries, headless, no input): without the cut the abort; with it nnMain, the startup map refused once at a full pool as on 2.0.2, then 90 s to frame 2418 at 30 fps, with no crash and no shader key missing from the map. The reporter's v0 run reached Lumiose City at 30 fps on an M1. v0 is not a build row: the shader tables are 2.0.2's, so v0 is packed from a derived profile with its own shaders either way. Open: v0 has not been played with input on this Mac, packed through the NSP script, or tested on an iPhone.
  • Resolved: the alias region's mapped total ran past the application pool in the first minute. nninitStartup allocates and frees everything allocatable (pool − used, 3.15 GB, mapped at the top of the region) and the SDK keeps the freed pages mapped; later allocations map new pages at the bottom until a map fails, and only then does it unmap the cached block and retry. The host never refused a map, so the block stayed mapped beside the 0.4 GB the game used. Current: with mem_map_limit (hle_svc.c) a map past the pool fails with ResultLimitReached, and the used memory GetInfo reports counts the mapped pages, as on Horizon (without that, the startup probe itself, sized as total − used, was refused). Measured: one refused 4 MB map, the 3.15 GB unmap, the same map again.
  • Current: a memory pool created while the game maps and unmaps its 4 GB region was sometimes refused once by newBufferWithBytesNoCopy, then accepted. mtl_pool_add retries up to 12 times with a short back-off and prints [RED] memory pool … created at retry N (one of the red lines on the Mac route); on a final failure it logs the VM regions around the pool.

Services

The import list against the hooks (the survey's nso_syms.py table, then main's undefined nn:: symbols that no HLE_FORCE names) left 86 candidates; most only fill the game's own structures (error and web argument builders, prepo report buffers, swkbd configuration, the audio resampler, nn::async). Those that reach a service were hooked before they were met where the answer is clear: time::ToPosixTime with an explicit rule (the host's zone, as for ToCalendarTime), album::SaveCurrentScreenshot (no album: fails), err::ShowApplicationError (logged) and the ShowShopAddOnContentList(UserHandle) overload. Then one boot each (Listing a game's unhooked imports before they are met):

  • Resolved: abort in sm::Initialize under crypto::GenerateCryptographicallyRandomBytes (the first call in nnMain, main+0x1734b4): the bytes come from the csrng service; the host fills them from its entropy source, the same stream as getentropy (hle_os.c).
  • Resolved: a null read in audio::GetAudioOutBufferCount on Wwise's sink wakeup thread (sdk+0x1e7ed0, from main+0x30faae8): after every release event Wwise asks how many buffers are still queued and marks the sink starved at 0; the SDK's version read the audout session the host never made. It answers from the host queue: appended and not yet played to the end (hle_audio.c).
  • Resolved: NVN state read back through getters (red NVN unimplemented function called lines for nvnColorStateGetBlendEnable, nvnBlendStateGetBlendFunc, nvnPolygonStateGetCullFace, nvnDepthStencilStateGetDepthTestEnable, nvnBufferBuilderGetSize, nvnTextureBuilderGetWidth, …). Trinity builds its pipeline states from state objects it reads back; the red-reporting stub returned whatever was in x0, the object's address, so a blend enable or depth test read as on and a texture builder's width as an address. The getters read the packed state objects (hle_nvn.c); the multisample setters the host lacked are recorded in fields ms_st already had.
  • Resolved: nvnCommandBufferFenceSync unimplemented, every SyncWait timed out at once. The game fences once per frame from a command buffer and waits on it; a record (OP_FENCE_SYNC) now fences the sync at replay.
  • WaitForAddress type 3 (Horizon 18's 64-bit "wait if equal") was added for SDK 19 (hle_thread.c); this game does not call it.

Files and shaders

  • Almost everything is in one archive, arc/data.trpfs (4.4 GB, 13,181 packs, 238,546 files, 17.7 GB unpacked; the profile's romfs_marker), indexed by arc/data.trpfd; both are FlatBuffers. The descriptor's root table holds the file hashes (u64, sorted), the pack names (arc/<the first file's path without its slashes>.trpak), one info table per file (its pack index) and one per pack (size, file count). The file system starts with ONEPACK\0 and the u64 offset of its own root table: the pack name hashes and the pack offsets. The name hash is not a plain FNV-1a 64 of the descriptor's name, so a pack is named through its first file's hash. A pack is a table of file hashes and file tables {1 compression i8, 2 level u8, 3 length u64, 4 data}; compression 3 is Oodle (every file but 15,528 small stored ones), and every Oodle stream is Mermaid (header 8C 0A, CC 0A for an uncompressed chunk) (01, Compressed RomFS content).
  • Oodle is linked into main and there is no Mermaid decoder with a licence fit for this repository, so the shader split runs the game's own OodleLZ_Decompress in the Mac host's tool mode (dm/host/hle_oodle.c, DM_OODLE_SERVE=1). The host finds it in main by the one function that references its name string and calls it as the game does (main+0x1a9b7bc on 2.0.2: fuzz safe, no CRC check, thread phase 3). The split's full scan (every file decoded) takes 5 minutes with 12 workers on a 12-core Apple M6.
  • Shaders: 28,603 BNSH (11,633 vertex, 16,845 pixel, 125 compute) and 26,051 variation pairs (shader_split.py trpfs_jobs / trinity_shaders): the material shaders in system_resource/gfx2/shader/NX64/env_ikkaku (material/standard.bnsh, material/transparent.bnsh, system/light.bnsh, and the special/imgui.trsha pack with the engine's system shaders), particle effects (.ptcl / .trtml in ik_effectdata, 19,842 of the shaders), UI layouts (__ArchiveShader.bnsh in the .truiv packs of uidata) and the 40 BNSH in main. The layout archives' control sections are 104 KB long, and their stage descriptors run 456 bytes past the control's own length (control +0x20), with bytes that differ between copies of one shader; same_shader compares copies only up to the control's own length.

Rendering

Each item was found from a red line or a pass dump on the way through the language screen, character creation and the first minutes in Lumiose City. The general host changes are listed under Found on this game.

  • Resolved: characters collapsed into a spike (transform feedback skinning). The character preview drew one thin spike. Trinity skins on the GPU with transform feedback: a vertex-only program (28 such shaders, e.g. 681cc42e48c0bbc0 in the special/imgui.trsha pack; skin indices in attribute 12, weights in 13) runs under SetRasterizerDiscard between BeginTransformFeedback / EndTransformFeedback and writes positions (location 0 xyz, 12 bytes a vertex) and packed normal and tangent bits (locations 1 and 2, x only, 4 bytes) into three feedback buffers; the character draws then read those buffers as vertex streams 0–2 (RGB32F, RGB10A2SN, RGB10A2SN). The host ignored all of it, so the streams kept whatever was there. The pass dump gave it away: a pass of 19 draws with a vertex shader and no fragment shader (fs=-) before the character pass. Every buffer in this game holds one output from x on (strides 12/4/4, 12/12/12, 12/4, 12).
  • Resolved: a crash in the driver's compute encoder end, two minutes into a new game (SIGSEGV in -[AGXG18GFamilyComputeContext_mtlnext performEndEncoding], from rp_begin_ex_ or in its dealloc on the encoding thread, about frame 3390 on the Mac, with the encoding thread on and off). Metal validation (MTL_DEBUG_LAYER=1 MTL_DEBUG_LAYER_ERROR_MODE=nslog) named it: "Command buffer already has an open command encoder". The transform feedback draw (tf_draw) opened its encoder while the copy encoder (g_blit) was open; it now ends it first (GitHub issue #101).
  • Resolved: a layered pass whose pipeline could not be built. Two full-screen vertex shaders (57246f9a04af8529, b29f08619f379a83: one triangle per instance, layer = instance + offset, 20 pixel shaders read the layer) write render_target_array_index, and the translator writes a point size in every vertex shader: no input primitive class fits both.
  • Resolved: fragmentFunction main0 cannot be used to build a pipeline state (a Metal validation assertion) on one imgui pixel shader that reads a texel buffer: SPIRV-Cross declares the optional function constant spvLinearTextureAlignmentOverride there.
  • Integer fragment outputs: the two shader_facts_manual.tsv rows above. A scan for outputs built from integer operations was not trusted: the translated code keeps float bits in integer variables (register reuse), so it cannot tell an integer output from raw float bits stored into a float target.
  • Current: a 2D texture in a cube slot (a placeholder for an environment map; one of the red lines on the Mac route) has no cube view: the zero cube is bound instead (tex_fit).
  • Resolved: a black world once the opening reached Lumiose. The game builds its 32×32×32 colour grading LUT with b29f08619f379a83, one instance per depth plane, each writing its plane through render_target_array_index, and tone-maps the frame through that LUT. Without a layered render pass every plane went to plane 0 and the rest stayed black.
  • Resolved: striped lighting. Every lit surface had black and white bands that followed depth. The deferred lighting pixel shader (ead6e2a32540b19a, 31 bindings) first reads the depth-stencil buffer through a view whose nvnTextureViewSetDepthStencilMode is STENCIL, tests stencil bit 6 and discards; the host ignored the mode and bound depth, so the test read bit 6 of the depth value's float bits. Inputs ruled out on the way: the G-buffer albedo, the normals (12-bit pairs packed into 3 bytes, noisy by design), the screen-space shadow mask, the scaled depth copies and conditional rendering.
  • Resolved: characters' legs folded back from the hips once they walked. From the first step on, the player's legs stuck straight out behind her at hip height, and the rival's forward in a later cutscene; standing still looked right. The bone palette the game computes on the CPU was already bent. The cause was six occlusion queries a frame, one 36-vertex box each (a793f436dcabd350, base instance 61, 62, …), drawn against mip level 2 of the depth pyramid: every query came back with 0 samples, and the game took its characters for hidden and posed their legs that way. Forcing every result to visible fixed the legs, and depth testing with ALWAYS did not. The box shader picks its transform with (instance index − base instance) + c0[0x644], where the NVN driver keeps the base instance; the host left 0x644 at 0, so every box was box 0. 793 of the game's vertex shaders read that word. How it was found: the raycasts were logged with scratch observation gates (all correct), then DM_HLECOUNT showed nothing walking-specific, then the counter reports.
  • Resolved: a min-reduction sampler averaged. The game builds that depth pyramid with SamplerBuilderSetReductionFilter 1 (151607c7a92386cc samples the level above at an explicit LOD), which the host used to average.
  • Resolved: image atomics past their buffer. From about frame 5300 of the route (the scene after the story opening's first cutscene), the fragment shader 2bd5037003980716 does an atomic max into a 480x270 r32ui 2D array; sent to the 64 KB dummy buffer, the atomics ran about 1.4 MB past it. They wrote into zero-copy pools over the game's heap (pointers in the game's objects and the host's program objects got their upper 4 bytes replaced, and the game or the replay crashed) and into unmapped addresses (kIOGPUCommandBufferCallbackErrorPageFault, after which macOS ignores the process's command buffers and the picture freezes). An iPhone 17 freezing that way was reported in GitHub issue #101. Under MTL_SHADER_VALIDATION=1 the scene played. Three runs of the whole route then reached frame 12,300 with no GPU error.
  • Resolved: invalid coordinates could exceed even an image-sized scratch buffer. PR #178 reports 1,836 invalid stores into a 3 MiB buffer on Z-A 1.0.0 on M1 Max, including offsets around 16 GiB. Its development build reduced those reports to zero, and the tester reported no freeze without shader validation. The shared bounds fix is applied here; that development result does not establish gameplay or device validation of this revision or of update 2.0.2. The earlier scratch-size fix above addresses a separate insufficient-capacity case. Both scratch cases are gone since the atomics are native (next entry).
  • Current: the atomic max runs on texture 302 itself (native texture atomics, Image atomics), so its results reach the passes that read the image; before, they went to a scratch buffer that nothing read. Chain 0ce4b5e8aefb builds all 28,603 shaders. Update 2.0.2 from a fresh save on the docs' route, headless on the Mac under Metal API validation (2026-10-27): image atomics on image slot 1, texture ID 302 (target 4, block-linear, 2211840 bytes), then on to frame 12,000 with no validation error, GPU fault or new red line. The picture of that scene was not compared, and the iPhone freeze of GitHub issue #149 is not re-checked.
  • Current validation (2026-10-25): with the bounds fix and strict descriptor-format/bindless-handle checks, chain 467efdfd0b62 rebuilt all 28,603 shaders for Mac and iPhone with zero failures and no undecoded bindings. After rebuilding the host, update 2.0.2 ran for 240.1 seconds / 6,987 frames around the station from an isolated copy of the save, with movement and camera changes, shader validation disabled, and no observed freeze, GPU page fault, missing shader or atomic-binding rejection. The log confirmed the new chain and the 480x270, three-layer image using 3 MiB scratch. This is a Mac smoke test, not confirmation of the reported iPhone/version cases or the station-to-world transition. Artifacts are in the NSP folder's 0100F43008C44000-work/pr178/ (shader libraries and gameplay/).

Pipeline bases made before the game runs

  • Resolved: the Mac app's bundled pipeline archive was built for the wrong depth mode (GitHub issue #614, MacBook Air M4, Mac app at 38b8907). The game calls nvnDeviceSetDepthMode with near = 0 ([nvn] depth mode near=0 in the Mac report and in both iPhone logs of issues #149 and #292), and the profile had no depth_mode, so the NSP script's prebuild made the bundled archive's 1.2 GB of bases for near = −w (main0 vertex entries). The host follows the game's mode and found none of them: pipeline archive bundled with the app: made for … depth near=-w … this run needs … depth near=0 … the device builds the bases it does not hold. Every base then compiled on the Mac while playing (archive lookups … found none 6, bases compiled 3 / 258.6 ms up to frame 1200), one cause of the hitches on new moves and effects that the report describes. Current: depth_mode zero, as Legends: Arceus, Scarlet and Violet set; it changes only the bases made offline (the shader input digest and the libraries are unchanged), so an app or IPA packed again gets an archive the game uses. Open: not yet re-measured with a repacked app; shader library loads (shader libraries 12 in the report's 105 ms frame) are not covered by the archive. The report's other symptom, frame intervals alternating 33 / 50 ms in fullscreen with the GPU at 23–25 ms a frame (old frames dropped by merging 157 per 300 ticks, gone while macOS screen capture runs), is not explained by this; see Frame latency 2 for the display link in fullscreen.

Mac routes

  • 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.
  • A 5-minute walk: GPU about 6 ms a frame, no crash.

Found on this game

General host changes first needed here; each applies to every game:

Open

  • iPhone GPU page faults (GitHub issues #149, iPhone 16 Pro, and #292, iPhone 17): the picture freezes 30–60 s into the opening or in 3D cutscenes (the gate into Lumiose after the station, the stolen-bag scene, the hotel balcony) with kIOGPUCommandBufferCallbackErrorPageFault, after which iOS ignores every submission. Both logs come from builds before native texture atomics (they print image atomics … not followed, the atomics go to a scratch buffer), so they ran the scratch path that Image atomics replaced. Not reproduced on the Mac; not re-checked on an iPhone since. The Metal 4 commit feedback names no pass, so the faulting pass is unknown.
  • The driver-segment word 0x640, added to the vertex ID by 58 shaders, stays 0 until what the driver puts there is known.
  • Transform feedback: only plain array draws are captured; indexed, indirect or instanced ones give a red line. Feedback layouts other than one output per buffer from x on get a red line and no capture entry point.
  • c85de3713170e519 ORs bit fields into output 0 and has not been seen with an integer attachment (no row).
  • The development notes list "the render condition the host does not evaluate" among the Mac route's red lines; render_cond changes nothing here and stays off.
  • game.tsv's header says "Plays on the Mac"; AGENTS.md and the development notes say it is playable on the iPhone.

These notes are republished from the nxnative repository under its MIT license (license and attribution). The original, which may be newer, is the game's notes on GitHub. This site hosts no game files.

More Other engines games