Files
nxnative-easy/dm/games/01006BB00C6F0000
seeddsandv0lumehi 92fc2f2711 Build and ship offline Metal pipeline archives (#226)
Compile eligible shader-pair bases on the Mac with metal-tt, package one
archive per selected iPhone GPU, and use a matching bundle before falling
back to on-device preparation. Carry the profile depth mode through packing
and include the pulling libraries required by the bundled bases.

Integrate PR #226 with four confirmed fixes: validate archive container
bounds before invoking Metal; require at least one actual successful bundle
probe; hash consumed library bytes into offline cache keys; and emit device
remainder lists for constant-only plans instead of aborting packaging.
Keep game measurements in the game note and document the general findings.

Validation on current main: 52 real Metal bundle checks, 57 archive checks,
79 prewarm checks on each pipeline path, 73 Python regressions covering
offline tools, packaging, documentation and chain checks, and compilation
of all 133 iOS source files. No new iPhone gameplay validation. The archive guard validates
container structure, not private Metal payloads.

Co-authored-by: v0lumehi <aljic.zijad@gmail.com>
2026-10-26 10:52:50 +08:00
..

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

Update 1.1.0 (v131072) over the base game, SDK 8.2.0. Grezzo's engine on NintendoWare (G3d / Ui2d / Vfx / Atk / Font), on NVN (API 53). Modules main, subsdk0 (GLSLC, NVN's runtime shader compiler), subsdk1 (NintendoSDK_movie) and sdk; Captain Toad: Treasure Tracker 1.4.2 runs the same sdk build (01009BF0072D4000). Plays on the iPhone; gameplay at 60 fps as on the Switch, event cutscenes at 30 by the game's own choice. The README lists its IPA at about 6 GB.

What it needs

Setting Where Why
Physics completion task release → ret patches.py CUTS, main 909e904a… at main+0x10cd070 a late destructor wiped the rebuilt task and crashed the physics workers; see Physics
Float vertex inputs 1–5 for 147c829e79fb322f, 03c43da3e3f46da8, 079700f3577e311f, 15c02683a10f8e9d, 2344874045b1e758 shader_facts_manual.tsv the integer-input guess declared them uint4 while the game feeds 16-bit or 8-bit normalized formats; see Rendering
4 GB layout: mem_reserve 0x100000000, mem_alias 0x10000000, mem_stack 0x10000000, app_pool 0xCD500000 game.tsv no system resource in the NPDM: the game uses SetHeapSize. 13 Sentinels' partition (256 MB in front + 256 MB alias + 256 MB stack + 3.25 GB heap), "until measured"
depth_mode zero game.tsv the game sets the near = 0 depth mode, so the pipeline bases made before it runs (the NSP script's Mac prebuild, and a pipeline archive made offline for the iPhone when one is built with DM_PSO_GPUS) take its vertex shaders' main0_z entries (the host itself follows the mode the game sets)

No dm/tools/sdk_patches.py cut for its sdk build (3ae34e68…). No pad_layout: the virtual gamepad starts in the 2D layout.

Rendering

Game-specific findings; the general host changes they led to are listed under Found on this game.

  • Current: lower-left window origin. It never calls nvnDeviceSetWindowOriginMode, so it keeps NVN's default lower-left (OpenGL) origin, where NDC y = −1 lands on a render target's first memory row; the six games before it all switched to upper-left. Read as upper-left, every texture the game renders and samples came out flipped and cancelled out, but text it renders into textures for the title and the menus came out upside down, and it copies the frame with MIRROR_Y. The host flips the direction inside the viewport (negative viewport height), reverses the front-face winding, and flips the window image at present and in raw screenshots (g_origin_ll in nvn_mtl.m).
  • Current: depth mode near = 0. Every vertex library has a GL entry point and a near = 0 one (main0_z), picked from the mode the game set. Resolved: outdoors the whole screen was blurred (Mac and iPhone). The depth-of-field composite (52fcbb4ca2a78907, postprocess.bfsha) blends the sharp frame with two blurred levels by a blur amount computed from the half-size depth copy: pow(clamp((c5[2].y / (depth + c5[2].x) − c5[3].x) · c5[3].y), c5[3].z) above a screen line, by screen position below it. With every vertex shader translated for GL, all depths sat in 0.5..1 and everything was out of focus. Found with a save from the iPhone (Mabe Village outside the house), DM_NVN_PASSDUMP / DM_NVN_TEXDUMP of that frame, and LASRecomp, which rewrites z only for the GL mode.
  • Resolved: outdoors was dim and nothing cast a sun shadow (Mac and iPhone). The sun's shadow map (2048×2048, 32-bit float depth) was correct, but every lookup failed. The surface shaders compute the lookup from a world position the vertex shader passes in locn24, divided by its w; the vertex shader writes only x, y and z. The translation started that output at 0 instead of the hardware's (0, 0, 0, 1), so w was 0 and every compare value infinite. Found with DM_NVN_TEXRAW on the shadow map, the light matrices from DM_NVN_CBDUMP, and a hand-edited surface shader that wrote the compare value and the stored depth as its colour. Every vertex output now starts at (0, 0, 0, 1) (vs_init); 12,281 of the game's 18,410 vertex outputs never write w. Compared with forcing the compare to pass: the same brightness, plus shadows from the fences, the house and the trees.
  • Resolved: flickering hard-edged white tiles on the step of Marin and Tarin's doorway (Mac and iPhone). The room shaders use tiled forward lighting: the fragment position picks a 64×64-pixel tile (trunc(y / 64) × ceil(width / 64) + trunc(x / 64)), whose light count and indices come from two storage buffers a compute shader (light_culling_compute.bfsha, one thread per workgroup, one workgroup per tile) fills every frame. The culling shader computes its tile index as workgroup y × c0[0x200] + workgroup x, reading the dispatch's workgroup count from the driver segment c0, which the host passed as zeros (dmsh_build.py's drv_reads matches only fragment-shader names). Every row's workgroups wrote row 0's entries, the last writer won (a different one each frame), and the bottom tile row, where the step is, got other rows' lights. A dump of the tile lists showed only the first of 17 rows filled. A declared driver-segment binding now counts as read (nvn_refl.m), and compute c0 carries the dispatch size at 0x200, the uniform buffer table at 0x210 + 16 r and the storage buffer table at 0x310 (encode_args_). The doorway now shows the game's soft light from outside.
  • Resolved: missing palm tree and flowers in Marin and Tarin's house (the pipeline 147c829e79fb322f + 0ebba5ff6a44aa52, shadowmap_pbr.bfsha variant 641, failed to build). Inputs 1–5 only move bits (in a loop: x bit-cast to an integer, y copied as a float), so the translation declared them uint4, and the game feeds them 16-bit normalized formats (RG16 / RG16_SNORM), which Metal will not pair with an integer input. These are the foliage's shadow-map and depth pre-pass draws; the main pass shades with depth test EQUAL, so without the pre-pass the plants never appeared. Declared float, the bits match the hardware's. Four outdoor shaders with the same loop failed the same way on the iPhone in Mabe Village (Half2, RG8 / RG16 normalized) and are listed too; input 7, a bone index used as an integer, stays uint. The guess is not wrong in general (1,437 of the game's vertex shaders have integer-typed inputs, many of them bone indices), so overrides are added only for shaders seen failing with float formats (failed to create render pipeline … Cannot convert attribute …). The table is on the chain, so it changes only this profile's chain version.
  • Resolved: no ambient occlusion. The occlusion pass (aa05724ffa62cb82, a 480×270 R8 target cleared to 1.0) only writes where stencil bit 1 of the quarter-size depth-stencil copy is clear; the copy left the stencil undefined (new depth-stencil textures are not zero-filled), so on the Mac the mask kept 1.0 everywhere. The copy now carries the stencil.
  • Copies a blit cannot do (copy_t2t_draw): RG11B10F → RGBA16F every frame, half-size depth-stencil copies (depth and stencil written from a fragment shader, stencil through [[stencil]] export from an X32_Stencil8 view), and a scaled MIRROR_Y copy of the frame to 1280×720 RGBA8. Each is one render pass per layer.
  • Uniform updates inside a pass no longer end it: about 15% less attachment traffic on the Mac.

Audio and movies

  • Resolved: no music and no sound effects, only the movies' sound (the log repeated audren: first-frame commands … every frame and never printed DeviceSink inputs). SDK 8 numbers the DSP commands differently: its command list goes 4×6, 7×24, 11×2, 16×1, 21×1 where SDK 20's goes 7×6, 11×24, 15×2, 20×1, 25×1, and the output command (DeviceSink, the same 0x148-byte layout with device name MainAudioOut, six inputs) is 0x10, not SDK 16's 0x13. When the expected ID is missing from the first full command list, capture in hle_audren.c takes the one 0x148-byte command with 1 to 6 inputs as the sink and logs the ID it adopted.
  • Resolved: an abort with Result 0x499 in audio::GetAudioRendererWorkBufferSize. SDK 8's AudioRendererParameter is one word longer than SDK 16's (its revision at +0x38); the host copied only 0x38 bytes before switching it to CPU rendering, so CheckValidRevision read garbage. It copies 0x40 now.
  • Movies: VP9 with a 5.1 Vorbis sound track (decoder type 5, output format 1, PCM16). The host decodes the sound with stb_vorbis, which needed a local fix for type-2 residues with more than two channels (README). Resolved: before it the opening and title movies sounded distorted and gurgling, with pops.

Physics

  • Resolved: the physics workers crashed when a conversation or cutscene began (pc=main+0x10ccf28, lr=main+0x13c236c; on the iPhone and in 2 of 5 Mac runs, in every Mac run under CPU load). The physics step (main+0x15730, main thread) rebuilds each world's PhysX completion task in place (a PxLightCpuTask at world+0xc8) before every simulate. A worker runs it (main+0x10ccf80: fetchResults, world+0x1d8 done = 1, notify, unlock) and then calls its release (main+0x10cd070), which calls the task's own destructor (vtable back to the PxLightCpuTask base, mTm = 0, main+0x10cced0). At 30 fps the game steps physics twice per frame (step, wait, step, wait); the unlock hands the world's mutex to the woken main thread, and when the worker is descheduled there the main thread's next step rebuilds the task before the late destructor runs, which then wipes the new one. The next simulation's NpSceneCompletion::release reads mTm = 0.
  • Current: the release becomes a plain ret: the destructor only resets the vtable and mTm (no resources), the step rebuilds every field, and this release is used by this one vtable (main+0x1c51308 slot 7) only.
  • How it was found: 12 busy shell loops beside a scripted Mac run made the crash happen in every run (and the frame rate switch between 60 and 30 as on the phone), and observation gates (OBS) on the functions involved gave the order of events (05, Racy crashes).
  • Confirmed, 2026-10-26: the recorded crash has priority-44 physics workers on cores 1 / 2 (masks 0x2 / 0x4) and a priority-44 main thread with mask 0x7. Their overlap is legal; no same-core priority handoff can protect the task. A controlled replay of the original constructor stores (main+0x157a4 and 0x157dc), relocated destructor vtable, release and removeReference reproduces the exact null load at main+0x10ccf28 when the prior release follows reconstruction. Release-before-rebuild passes, as does the late release with the registered cut. This confirms a guest lifetime race; the earlier claim about nanosecond Switch timing was not measured and supplies no synchronization guarantee. The cut remains build-gated and justified. See the replay command and the lifetime contract.

Services and SDK 8

  • Resolved: an abort in os::WaitSystemEvent under oe::PopNotificationMessage: the host's oe::Initialize now creates the notification event on its queue.
  • Resolved: SIGSEGV in vi::SetLayerCrop: SetLayerCrop, DestroyLayer, CloseDisplay and vi::Finalize are host no-ops.
  • Resolved: the game never read the controller (the title's "Press L+R" saw no press; no GetNpadState call): it waits for the event bound by hid::BindNpadStyleSetUpdateEvent, which the host now signals when bound for a connected controller and when a player's controller connects or goes away.
  • nvnWindowGetPresentInterval and nvnCommandBufferSetCopyImageStride (the layer stride of texture ↔ buffer copies) implemented.
  • Shared fonts: SDK 8, so the address is the plain TTF. Focus handling mode 2 (focus notifications are sent).
  • Rewriter: offset jump tables in its GLSLC and movie subsdks with the bounds check on another register (the index in w0, a copy, or a masked copy), which jump_offsets now follows.

Files and shaders

  • RomFS: bfres models and maps (region_common/map, region_common/actor), shaders in region_common/shader/*.bfsha (BNSH), particle effects in region_common/effect/*.ptcl, layouts in */ui/*.arc (SARC).
  • 9,698 shaders from the bfsha archives, effects and layouts.
  • GLSLC. subsdk0 is NVN's runtime GLSL compiler and main imports glslcCompile*; game.tsv says the *_uber.bfsha are compiled at run time through it, but up to the house, the beach and the menus every shader the game loaded was in the offline tables.

Mac routes

  • 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=).
  • Racy crashes: run 12 busy loops beside the route (for i in $(seq 12); do (while :; do :; done) & done).

Found on this game

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

Open

  • SR_Y_DIRECTION. 2,487 of its pixel shaders read the window's Y-direction system register, which is −1 under a lower-left origin. The Ryujinx recompiler answers a constant +1 (a TODO in InstEmitMove.cs; LASRecomp answers −1). In the room shader the value only scales a depth slope under abs(); other shaders may use its sign (dFdy-based effects). Answering it per profile means a translator change, i.e. every game's chain.
  • Two pixel shaders do not translate (9d8f6b8543355374, 9338e3f1b597fe7c: variants 0 and 1 of one program in postprocess.bfsha @0x252000; SPIRV-Cross throws on a null pointer). No run so far has loaded either.
  • development.md calls SDK 8.2.0 "the oldest so far"; Mario Kart 8 Deluxe (SDK 4.4.0) is older now.
  • game.tsv still says "In bring-up" and its memory values "until measured".