Files
seeddsandv0lumehi b3f97efe22 regress.py usage / impact: which games a change can reach (#329)
From PR #329 (stacked on #328): a game's usage manifest, dm/games/<id>/usage.tsv,
written by its owner with regress.py usage (shader features of its chain build,
and the NVN / HLE functions, SVCs and present path of one smoke-route run;
names and counts only), and regress.py impact <base>..<head>, which maps the
functions around the changed lines through the host's callers and the rules of
dm/tools/impact_map.tsv to manifest rows. Manifests for Breath of the Wild,
Tears of the Kingdom and Hades.

Fixed on top of the PR:
- impact checked the head out as a git worktree under the harness root, so it
  stopped (PermissionError on /Volumes/NX) for exactly the contributors it is
  for, who have no games and no harness root. The call graph is now read from
  the git object store at the head revision, and the report is printed and
  written only to --out or an existing harness root.
- A hunk mapped only to the function at its first line: a hunk spanning two
  functions named one. Every added line is mapped now, and a pure deletion maps
  to the function around it (it was moved one line up).
- Functions defined in headers were not in the call graph: the frame step
  (nvn_fstep.h, static inline, called from nvnQueuePresentTexture) reached no
  entry point, so 4acde72 named no game. Headers are scanned, and their
  functions are visible to every file. 4acde72 now names both Zeldas through
  nvnQueuePresentTexture.

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

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

Update 1.9.0 (v1114112) over the base game, SDK 20.5.22. Nintendo EPD's engine (sead / agl / gsys / ksys, NintendoWare G3d / Ui2d / Vfx / Atk, Havok) on NVN. Modules main, subsdk0 (NintendoSDK_movie) and sdk; subsdk0 and sdk are the builds Tears of the Kingdom 1.4.3 runs (0100F2C0115B6000). Plays on the iPhone; 60 fps (30 on the Switch) through the host's frame step. The README lists its IPA at about 16 GB.

What it needs

Setting Where Why
Frame step, fps_target 60 patches.py FRAME_STEP (main cd57b23f…), game.tsv 60 fps at normal speed; see Frame rate
Interval controller floor 2 → 1 (five cuts) and GPU-time path (two cuts) patches.py CUTS the game's own controller at DM_FPS_TARGET=0 and while its VFR override holds; see The game's own interval controller
Render scale stubs patches.py frame_step (main+0x1266880, main+0x126686c) DM_RENDER_SCALE; see Render scale
gpu_timestamps segment game.tsv the interval controller and the dynamic resolution read the GPU time between the frame's two timestamp reports
Integer output rows 8e2cc2671cae6020, 93aa5e22a8202409 shader_facts_manual.tsv agl's BC4 block encoder (3D and 2D source) writes uint; no PC version gives output types
pad_layout full game.tsv the 3D gamepad layout: the right stick turns the camera
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)
Node area cut 1 GB → 62 MB dm/tools/sdk_patches.py, sdk build 574284b4… registered for Tears of the Kingdom's issue #70 on the same sdk build, so it applies here too

No tex_flush_upload (a minute's boot saw it flush 32 CPU-filled textures with data, and it needs none), no feedback_snap.

Frame rate

The frame step (current)

  • 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 (FRAME_STEP in patches.py, 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, below).
  • 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).
  • The stubs: setWaitVsyncInterval (main+0x11269b8, called once a frame at main+0x11267c0; the framework keeps the interval at fw+0x244, getter vtable +0x138, read by both callers of VFR::updateInterval and directly in 43 places, and passes it to nvnWindowSetPresentInterval) takes the host's step unless the VFR override (+0x29 flag, +0x2c interval) is set. ksys::VFR::updateInterval (main+0x151d3d0, x0 = the VFR at [main+0x1e93890], called from GameScene::calc and the per-frame delegate main+0x1280f80) takes the framework's interval in the next frame (ratio = step / 2, base interval 2, and the int interval 16 pieces of logic read); its stub publishes the override at state +0x24 so the host holds frames that long. The step is not written into the VFR call itself, because the game presents on its own thread and the step can change between the two VFR calls of a frame.
  • 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, also from Documents/env.txt); 0 runs the game's own interval controller.
  • Frame-step probes (iPhone). The GPU clock follows the hold: on an iPhone the game 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 that alone does not fit 60 (nvn_fstep_gpu_slowed) now allows probes. Stepped up by pipeline compiles after a cutscene, it 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 step up after 8 s or more at 60 now keeps that run's cost, and a cost at the longer hold of at most 1.1 times it allows a probe (nvn_fstep_same). Log lines: frame step: hold 2 → 1 … (probe, …), the probe held / failed, hold 1 held at 0.86. Details: development.md, Power.
  • Screens whose rate is not a multiple of 60 (from PR #27). A cheap 50 ms frame shows 54.5 ms at 165 Hz; counted as late it kept the game at 20 fps for good, so the frame step lets a frame on time show one refresh longer than its hold (shown_slack, nvn_fstep.h).
  • Resolved: menu repeat twice as fast at 60 fps (2026-10-26). sead::ControllerBase::updateDerivativeParams_ (main+0xeb0ef0) adds a constant 1.0 (main+0xeb0f78..88) to each held button's count and repeats when fmod(count - delay, pulse) falls below that step, so delays and pulses were counted in frames: holding Down in the System menu for 0.3 s went 5 entries at 60 and 2 at 30 (found by MacNativeGames, 866beb9). A FRAME_STEP stub at main+0xeb0f88 takes the VFR's ratio (+0x3c, interval / 2) instead, keeping 1.0 when there is no VFR or the ratio is not positive. Mac, 60 fps: 0.2 / 0.3 / 0.4 s now move 1 / 2 / 3 entries, as the game does at 30. The pause menu's fast-repeat countdown (main+0xe67d00) already reads the VFR's delta.
  • DM_NVN_CUT growing segments (64, 128, 256 … draws) were tried for fewer commits: the game fell from 60 to 30 fps in the same scene (GPU idle while the long last segments were encoded).

The game's own interval controller

Used at DM_FPS_TARGET=0 and for the interval the framework keeps while the VFR override holds. DM_FRAME_STEP=legacy, which ran this method on its own, was removed in 79d82f7b.

  • sead::GameFrameworkNx's main loop (main+0x112660c, framework +0x267 = 1) runs a dynamic interval: from the last frame's time it computes how many vsyncs were needed (rounded up), clamps that to [2, 3] (the maximum from the interval controller +0xc, built at main+0x1126c04), steps up after three overrunning frames and down one level every six frames, and sets the result through setWaitVsyncInterval (vtable +0x130, main+0x11269b8). ksys::VFR is initialized with a constant base interval of 2 (main+0x127e740 → VFR::init main+0x151d2d0) and every frame VFR::updateInterval (main+0x151d3d0, called at main+0xcf9ad4 / main+0x1281044) takes the framework's interval, a frame ratio of interval / 2. The game's time step follows its frame interval, so the speed is right at a steady 60, 30 or 20.
  • Five cuts lift its floor from 2 to 1: the candidate floor (main+0x112670c fmov s0,s11 → fmov s0,s10, main+0x1126710 cmp w27,#2 → #1), the step-down target recorded when stepping up (main+0x112665c mov w24,#3 → #2, main+0x1126748 cmp w8,#3 → #2) and the step-down stop (main+0x1126794 cmp w9,#3 → #2). Probes confirmed it: interval 1 with ratio 0.5, and back to 2 with ratio 1 on a long frame. The zeldaret/botw decompilation (VFR.cpp) gave the semantics; 1.9.0's addresses were found by the constants (60 / interval, the fmov s11, #2.0 floor).
  • Resolved: slow motion when the GPU is the bottleneck (GitHub issue #9). The controller measured a frame as framework +0x250, the main thread's CPU span from the start of procFrame to before its draw step (main+0x1126cec → main+0x1126d20): about 2.7 ms in the shrine (probe at main+0x11266a4, @x19+0x250; "about 3 ms" in development.md). Only while the object at [main+0x1e3a638] + 0x1d2b8 exists did it take max(CPU span, GPU time), in 64 of 6,046 shrine frames. An iPhone 16 Pro Max in handheld mode at a 25 ms frame p50 never left interval 1 in 4,800 frames: at 40 fps, about two thirds of the normal speed. The GPU time (framework +0x220) is the difference between the frame's two timestamp reports, TIMESTAMP_TOP at the start of its command buffer (main+0xe94584) and TIMESTAMP at its end (main+0xe94664), read after the draw step waited for the frame's fence (main+0xe949bc); it read 0–6 ticks, because the host wrote all 5 reports of the frame's one submission with one time. Two cuts (main+0x112691c, main+0x1126974: cbz → b) make the controller always take the GPU path, and gpu_timestamps segment gives each report the GPU start or end of its sub_cut segment (rep_ticks in nvn_mtl.m).
  • Measured on the Mac with two runs sharing the GPU under a heavy CPU load (load average about 75): GPU time p50 16–23 ms; the fixed run stayed at interval 2 (3 while the GPU time passed 1.9 vsyncs, 1 for under 2% of the frames) at 0.93–0.98 of real time (Σ VFR ratio / 30 per wall second, probe main+0x151d3e0 @x0+0x30: +0x30 ratio, +0x34 interval). A run with the old timestamps (DM_NVN_GPU_TS=submission) spent 10–70% of its frames at interval 1 and ran at 0.78–0.95.
  • The same GPU time drives the dynamic resolution (main+0x1267070): a running average as a percentage of a 60 Hz frame scales the resolution down above 175% (29 ms) and back up below 150% (25 ms). With 0 it always sat at its maximum, and it still does whenever the game holds 30.
  • Resolved: Link's move pace speeding up when pressing B (iPhone). Sprinting flaps between interval 1 and 2 for runs of 6 or more frames, and the VFR step at interval 2 is a whole 30 fps tick. The acquire waited one display-link tick per frame whatever the interval, so the game kept producing frames every 16.7 ms while the screen held each interval-2 frame for 33 ms and dropped the one between (old frames dropped by merging): those runs went at double speed. mtl_link_wait_tick now waits as many ticks as the present interval.

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 dynamic resolution off; 1.0, the default, keeps it. Factors above 1.0 are refused by the loader (the per-frame texture pool's size is unknown).
  • The dynamic-resolution method (main+0x12666e4) keeps its scale in s8 (0.8–1.0 in 0.05 steps, +0x1218, main+0x1266818, or the forced 0.75 / 0.5 of +0x284 & 0x1800) and applies it from main+0x1266880: the views' scale (+0x28a0 / +0x28a4 = s8 × base width (+0x1214: 1600 docked, 1280 handheld) / output width), from which the size function (main+0x106340c) sizes the scene buffers each frame, the shader ratio uking_dynamic_resolution_ratio and the other readers (main+0xc3c3f4, an LOD check at main+0x14126b4). That block runs only when the dynamic path has just recomputed the scale (+0x280 bit 4, set at main+0x1266830) or in a forced mode, else main+0x126686c skips it. The stub sets s8 at the block and, for a factor other than 1.0, takes the block every frame. A multiply in the size function (Tears of the Kingdom's way) would leave the ratio and those readers at the old scale.
  • 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 (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. Whether it helps an iPhone, whose GPU loads and stores each pass's attachments, is what the setting is for.

CPU

  • Resolved: CPU hitches (Mac). In steady play, frames with 30–210 ms of CPU work came every 1–10 s (Zora's Domain, M2 Max): the main thread and the two workers spinning in one job queue poll (main+0xef62dc). The first item of some of its jobs is for the thread of core 0; the threads of cores 1 and 2 wait until it is taken (the queue's flag at +0x98, its cursor at +0x94), taking and releasing the queue's lock (+0x1c) on every pass. Polled at full speed by the two workers, the lock went back and forth between their cores, and the main thread lost nearly every exclusive store: in 40 s of a Time Profiler trace it never held the lock while each worker held it about 750 times, and the three spun for 30–210 ms at a time. The spin-wait rule's lock case takes that loop: the workers leave the lock a moment between passes. Zora's Domain on an M2 Max at 120 Hz (42 s of steady play, three runs each, before → after): standing still, frames over 30 ms of CPU work 2.8 / 6.3 / 3.5 → 0 per 30 s, the frame's work p99 20–21 → 8.3–8.4 ms, late frames 2.8 / 5.6 / 2.8 → 2.1 / 0.7 / 2.1 per 30 s; turning the camera, CPU hitches 11.9 / 13.3 / 6.3 → 0, p99 21–25 → 9–15 ms, late frames 12.6 / 13.3 / 7.7 → 1.4 each. Found with DM_HITCH_DUMP (the main thread and two workers in one guest loop with no SVC; x0 held the item index) and an xctrace Time Profiler trace.
  • The spin-wait search takes 310 loops in main (308 before the lock and store rules; the new two are the job queue and a state setter at main+0xf82230 that stores its state and returns on the second pass unless another thread changed it) and widens one in subsdk0 (0x27804, the movie library's thread loop, 6 → 23 instructions). Rewriting main takes 26 s of CPU instead of 19.
  • PlacementMgr and RadarMgr yield once per item of their batches (the back-off stretched a 94,263-item radar batch to about 30 s). A yield whose caller's x19–x28 moved now returns at once: outdoors on the Mac, CPU energy went from 0.35–0.74 to 0.21–0.34 W at 60 fps, CPU from 76–83% to 96–106% of a core, and the frame interval's p99 from 19 to 20–21 ms. A sched_yield on each of their 200,000 yields a second cost a third of a core more than the back-off had.
  • Address → memory pool lookups: the game keeps about 1,760 memory pools alive, and mtl_addr / mtl_gpu_addr scanned them all for every buffer binding of every draw (about 1,000 samples of a submit-thread profile). Last hit, then a binary search (pool_find): host submit time in the shrine 21 → 7 ms a frame (p50), the median frame 38 → 21 ms.
  • Upload staging blocks are reused: the game 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.

GPU work

  • Submissions in segments. The game does not submit frame N+1 until frame N's GPU work is done: in the shrine the main thread sat in nvnSyncWait 59% of the time and the render thread in nvnQueueSubmitCommands 40%. That submit is the host's translation (about 3,600 draws and 347 passes, 7 ms), so the GPU ran 13–14 ms and then sat idle 7.7 ms a frame. The replay now commits every 64 draws between two passes (sub_cut, DM_NVN_CUT): GPU idle 7.7 → 3.8 ms, most of the rest the game's own work between its fence wait and the next submit (about 2.5 ms).

  • Shrine of Resurrection on the Mac (M4 Pro), after the above: docked (1600×900) GPU about 13 ms, frame p50 16.7 ms (60 fps; p90 17.6 ms), was 20.8 ms before segmenting; handheld (DM_OPMODE=0, 1280×720) GPU p50 9.3 ms and a steady 60. Host translation is 8 ms a frame (22,000 instructions per draw), spread over Metal's encoder creation and draw calls, argument encoding, retain / release and pool lookups. DM_NVN_PASSGPU is off under Metal 4, so GPU time cannot be attributed to passes. The frame loaded 376 MB and stored 432 MB of render-target data (325 of 347 passes load and store everything).

  • Outdoors (2026-10-21, the user's Great Plateau save, Mac M4 Pro). A frame had about 386 render passes and loaded and stored about 900 MB of attachments (on an iPhone at 60 fps about 55 GB/s, near the memory's bandwidth). 220 passes ended only because the game updates the first 64 bytes of a 64 KB vertex uniform block (0x70c38c0000) before each draw group while its shaders read up to 544 bytes of it (the terrain pixel shader eb0afe555b86da79 indexes the whole block). Partial-cover uniform shadows (ubp_get, DM_NVN_UB_PARTIAL) and cb_extent.tsv keep those passes open: 386 → about 165 passes, GPU p50 about 9.3 → 6.5 ms, submit 5.7 → 4.9 ms; DM_NVN_UBP_CHECK=1 found every shadow equal to the GPU's memory (968,000 checked). Lazy clears fold the four clear-only passes before the G-buffer into its first pass (about 30 MB less a frame at 1600x900). The frame still loads and stores about 800 MB: the NintendoWare VFX GPU particles run one compute dispatch per emitter between draws (ten 800x450 half-resolution passes and two G-buffer passes, about 190 MB a frame; the G-buffer ones read the depth being drawn), and decals sample the G-buffer right after the game sets it again. Tried and dropped: DM_NVN_TBDR=1 (10–15% fewer stores, more CPU, red ledger lines, deferred uniform updates off), DM_NVN_BARRIER=game (no gain), same-target continuation outside TBDR mode.

  • Cube map filter every tenth frame (current, resolved). Every tenth frame starts by filtering the environment cube map: 132 one-draw passes into 64x64 to 8x8 faces (22 levels of 6 faces, f8d9bbda1e0ebb0a with 2ab186d450f5b32c / cfc85e532ccf8dd2, game barrier 0x12 only between levels), so the G-buffer started 7–9 ms into that frame instead of 1.5 ms and the frame took about 5 ms more GPU time: the regular spike in the Metal HUD's frame-time line. The faces of a level now run as one layered pass (passes into other layers of one texture level): 284 → 173 passes in that frame; Zora's Domain at 900p, FPS 120 (M2 Max), frame p50 10.51 → 10.23 ms, p95 13.16 → 11.11 ms, mean window max 15.6 → 12.1 ms, GPU p95 13.0 → 8.7 ms, 95 → 98 fps.

  • Particle dispatches between effect draws (resolved). The NintendoWare VFX GPU particles' one dispatch per emitter ended the G-buffer's and the half-resolution passes' effect draws into about 20 extra passes and compute encoders a frame. They now run ahead of the pass (compute dispatches between the draws of a pass run ahead of it): 28 → 8 compute encoders, 149 → 132 render passes; same scene and switches as the cube map filter above, frame p50 9.92 → 8.97 ms, p95 10.70 → 9.98 ms, GPU p50 7.69 → 6.70 ms, 101 → 111 fps.

  • Same-pass feedback: over a thousand draws a frame sample the G-buffer of their own pass (4d3d5a4408c2e8d9, 753d0169ae3ce4f0 and others on the 1600×900 G-buffer); snapshotting all of them took the Mac frame time from 16.6 to 36.6 ms, and none has shown magenta blocks on the iPhone so far. The decals that sample targets of their own pass read the stored content on the Mac; DM_NVN_FB_SNAP=all would snapshot them at a large cost.

  • Present snapshots (Mac). The game's two swapchain textures held its CPU work to display ticks and the GPU at 444 MHz on an M2 Max (present snapshots). Judged by cost, the frame step stepped to 30 at a GPU time of 0.75 vsyncs. Zora's Domain from a Cemu save (120 Hz), frames on screen longer than 1/60 s by a quarter: 64 of 8,684 with neither, 41 with the GPU filler alone, 4 of 10,800 with snapshots alone (no step to 30 in 3 minutes, at 444 MHz throughout).

Rendering

  • Resolved: the 3D scene black, only particles and the HUD drawn. The engine binds almost every texture with nvnCommandBufferBindTexturesDeferred (main+0xec2e64, one handle, the pointer being the handle field inside the texture-sampler object): the GPU reads the handle when the command executes, and the game records its command blocks once and writes the handles afterwards. The host copied them at recording, mostly zeros, so the deferred-lighting composite read the dummy texture 0. The bind record keeps the source address and replay refreshes the handles first (bh_fresh). Found with a data-flow table from DM_NVN_PASSDUMP_ALL: the composite's B lines were all tid=0 dummy (without _ALL only the first draw of each pass is listed).
  • Resolved: the same texture attached at two colour slots. The G-buffer passes attach the albedo and another buffer twice each (c1 = c2, c3 = c4) with write mask 0 on the duplicates. On Apple's TBDR the later copy (loaded, never written) overwrote the other's writes, per 32×32 tile: the albedo held only scattered squares. A repeated slot is left unattached (rt_dup_slot).
  • Cube textures sampled as cube arrays: 1,512 shaders sample the environment probes as texturecube_array with a single cube bound; NVN cube textures are now 6-layer 2D arrays bound through views (tex_fit).
  • Resolved: a compressed texture as a render target (GitHub issue #4, iPhone 14 Pro). agl's BC4 block encoder (8e2cc2671cae6020) writes a 32×32 RGTC1 (BC4) texture through an RGBA16UI view (one texel per 8-byte block). Metal can neither attach a compressed format nor view one uncompressed, and pipeline validation aborted, so the draws did nothing. The pass now draws into an uncompressed shadow of blocks × blocks (bcrt_attach / bcrt_flush, from PR #11); its output is declared uint in shader_facts_manual.tsv, as is its 2D-source twin 93aa5e22a8202409, whose failure showed up in an iPhone run. The A16 has no BC formats; the old decompression path (about 3.9 GB instead of 2.8 GB for this game, over iOS's limit on a 6 GB iPhone) has been removed.
  • Resolved: the sky brownish grey, the distant haze brown (GitHub issue #52, Mac and iPhone). Each frame a pass (08fe25061d583637, uking_pass_shader.sharcb) turns a 256×32×16 RGBA16F 3D table of in-scattered light into a 256×256 sky map, which the sky draw (8110c74078548172) and the materials' aerial perspective sample. The table and two 2D ones (transmittance 256×64, irradiance 64×64) come from System/KSys/sky.skybin in Pack/Bootup_Graphics.pack (1,212,416 bytes). The game makes a CPU_CACHED | GPU_CACHED pool per texture, creates the textures, copies the data in with memcpy and flushes (main+0x1257d50, once; the flush goes through main+0xebf4c4). The host made the 3D table a GPU texture of zeros. DM_NVN_TEXRAW=212,214 and DM_NVN_TEXINFO=256x32 (storage 1048576 bytes non-zero 781567, swizzled=0) showed it. Every game now fills a texture from storage the game flushed before its first use (nvn_flushed, tex_init_fill; DM_NVN_FLUSH_FILL=0 turns it off). In the Great Plateau save it fills that one texture; the flushes (about 15 a frame) merge into about 530 ranges, frame times unchanged.
  • Resolved: dark boxes and cream cloud rectangles behind HUD prompts, a black stamina wheel and a black System menu background (layout captures, GitHub PR #6). Panes tagged CaptureOn (about 140 layouts, e.g. PaGuideKeySmoke_00; user data A8, CaptureScale 0.5, MultiFilter) are drawn, blurred at half size and copied into a capture texture that replaces the placeholder FBLayout_00. The game creates it with its per-format default mapping (main+0xecb0bc: R8 → (R, 0, 0, 1), RG8 → (R, G, 0, 1), …), then rebuilds it on the same storage with the capture's mapping: main+0xf597dc picks (1, 1, 1, R) for an A8 capture and alpha 1 when the pane says CaptureOutputAlpha 255 (read at main+0xf58e98); main+0xecacf0 stores the mapping and rebuilds (main+0xecabe8), and main+0xec33f0 replaces the registered descriptor only if nvnTextureCompare reports a difference. The host ignored the mapping, so the captures kept (R, 0, 0, 1): hard-edged dark boxes (the shadow pane e6a88e12dbae9743) and cream cloud (the glow aa209f57b133e862). The earlier alpha_from_red workaround (all 26 layout pixel shaders, main's BNSH at 0x1d83000; since removed from the host) fixed the alpha but zeroed green and blue (a black stamina wheel) and left the System menu black. nvnTextureCompare now compares the mapping (an unset mapping counting as (R, G, B, A)); checked on the Mac by the PR's author: green stamina, the purple noise meter, the blurred System menu.
  • The game keeps NVN's GL depth mode. It has an RG8_UI2F vertex attribute (converted on the CPU, vconv_i2f). 101 compute and 4 vertex shaders read the driver segment c0; they render the same since compute c0 carries the dispatch size.
  • It finalizes its transient render targets before every submit and builds them again the next frame, which the storage-takeover rule guards with a 16-submission window (alias_take_over).
  • Windowed on the Mac with present_now's residency commit: its title, 60 fps, no GPU error.

Upscaling (current)

Handheld, the game draws 1280x720 into its 1920x1080 window textures and crops the window to it. Docked it scales its 900p scene to 1080p with one draw and draws the HUD on the 1080p target. The optional MetalFX present scales that presented picture in either mode; it cannot recover detail lost in the game's own upscale.

Measurements reported in PR #215, the present command buffer's GPU time:

Device and content → output MetalFX Standard
M2 Max, handheld 1280x720 → 2560x1440, full screen 0.74–1.32 ms 0.07–0.11 ms
M2 Max, docked 1920x1080 → 2560x1440, full screen 1.35 ms 0.11 ms
M2 Max, 1280x720 → 2868x1320, offscreen 0.40–1.17 ms 0.04–0.07 ms
iPhone 17 Pro Max, docked 1920x1080 → 2346x1320 p50 2.1–5.0 ms per 600 presents; p95 up to 6.1 ms Not measured on that build

On the Mac, frame times stayed at 60 fps; crop comparisons showed sharper edges on Link, his shield and the map text, with unchanged colours. The iPhone ran at 60 fps with the launcher setting reaching the host; a cold docked run reached thermal state serious after 226 s, a warm start after 64 s. The matching iPhone Standard run and MetalFX's share of the thermal cost remain unmeasured. Standard stays the default. These gameplay measurements are the PR author's; this integration has not repeated a device gameplay run.

Memory

  • Resolved: 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 (vtable main+0x1d4f950, head +0xe0, tail +0xe8). Part of the setup is GPU memory for the model's prerecorded commands, sized by recording each mesh's draw and each shape's state for each pass into a scratch command buffer and summing nvnCommandBufferGetCommandMemoryUsed (main+0xf9aeb4, main+0xf9bb54), so the host's 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 (0 to 0xff8) was small (about 15% of loads each failed; most models have about 0xd00 to spare). 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. To check a whole scene, probe where the setup heap is cut (main+0x155a520 with !x21) and the end of the setup (main+0x155a5f4 with !x2) with DM_PROBE_HEAP=28,38,40,e0,f8 DM_PROBE_HEAPFRAME=1d4f950 DM_PROBE_HEAPWORDS=1. A model fits any placement when entry + 0x1008 covers the resource heap's header and object (0x310), the file, the frame heap's block header and its peak use, plus 0xff8 of pad.
  • Resolved: the iPhone app was killed for memory in the first shrine (GitHub issue #9, iPhone 16 Pro Max, handheld). An 8 GB iPhone gives the app about 3.4 GB (resident plus headroom to the system memory limit stayed at 3,375 MB). Resident memory grew about 0.2 MB a frame from 2.6 GB at the shrine load until the kill, with the residency set and texture counts flat. The deferred-release drain ran on the guest submit thread with no autorelease pool (defer_drain now has its own @autoreleasepool): on the Mac a host without it grew 1 MB a frame in the shrine (GPU 3.0 → 4.4 GB in 2,400 frames); with it the shrine stays at 2.7–2.9 GB resident and 1.5–1.7 GB GPU (vmmap at 2.8 GB: about 1.6 GB guest memory, 0.75 GB private textures, 0.17 GB IOAccelerator). Found on the way: about 10 serial-table entries a frame were never deleted (finalized transient targets replay resolved again; 276,000 after 25,000 frames), now in a 512-entry table (tex_put, g_ghost, finalized textures still referenced); and the memory-pool tracking table had 4,096 slots against 4,006 pools on the phone, now growing in blocks of 1,024 (nvn_dirty.c, up to 262,144).
  • About 2.8 GB in the first shrine in handheld mode, idle or walking (Mac), against the 3.4 GB an 8 GB iPhone gives the app; the README says it comes close to that limit without the Increased Memory Limit capability.

Audio

  • Resolved: crashes in CommandListProcessor::Process (SIGSEGV under render_main, at 60 s and 134 s in the shrine). SDK 20's AudioRenderSystem has another layout (getters inlined, GenerateCommand takes and returns a Span<std::byte>, device and mode at +0x48 / +0x4c, command buffer at +0xf8 / +0x100, UpdateImpl locks +0x2d0; DeviceSink is command 0x14). The public ExecuteAudioRendererRendering (sdk+0x221e90) reaches AudioRendererImpl::ExecuteAudioRendererRendering (sdk+0x22ca88) through the vtable, so the host's takeover of the Impl function was never called: the SDK's own AudioRenderSystem::ExecuteAudioRendererRendering ran with no update lock, every RequestUpdate rendered a catch-up frame, and two threads wrote the same command buffer. The host now also takes over the PLT target; catch-ups fell from one per update to 3 per run.

Services and loading

  • Resolved: every pointer in .data / the GOT a module offset, crashes everywhere. SDK 20 packs relative relocations as DT_RELR: main has 223,994 of them (relr_apply in loader.c).
  • Resolved: SIGBUS inside Metal (AGCDeserializedReply) at a guest-stack address. Ending a render pass inside nvnQueueSubmitCommands made the driver compile its fast-clear blit on the render thread's small stack; every implemented NVN function except the recording calls now runs on the 512 KB host stack (nvn_big_entry).
  • SDK 20 calls it needed: applet::GetAppletResourceUserId (SDK 20's OpenAudioRenderer aborts on ARUID 0; the host reports 0x80), pl::RequestSharedFontLoad, pl::IsVrMode / BeginVrMode / EndVrMode, pctl::CheckStereoVisionPermission (permitted), oe::TryPopLaunchParameter (none), time::StandardSteadyClock::GetCurrentTimePoint, the console six-axis sensor (hid::*SevenSixAxisSensor*: zero states) and more nfp calls (AttachActivateEvent / AttachDeactivateEvent crashed in the SDK at sdk+0x3f2a78; two-argument Mount, Unmount, GetCommonInfo, GetRegisterInfo, GetState, Finalize). nvnPrivate0 (a flag in sdk's bss) stays 0, and nn::ro::ProtectRelro runs natively on the host's no-op permission calls.
  • main imports ScalableFontEngineHelper::Decode (SDK 20), so the shared font is served as BFTTF. Focus handling mode 2 (focus notifications are sent).
  • Rewriter: 1 to 6 halfword jump tables in main changed when half_count padded only to their largest entry.

Files and shaders

  • RomFS is almost all Yaz0 (.s* extensions) and SARC (Pack/*.pack); romfs_marker Pack/Bootup_Graphics.pack.
  • 19,592 shaders: Shader/*.sbfsha (BNSH), the gsys / ksys resource packs in Pack/Bootup_Graphics.pack and the terrain pack in Pack/TitleBG.pack, particle effects in Effect/*.sesetlist (11,373 shaders, 101 compute), layouts (.sblarc) and 37 in main. All bind with Mario's texture handle layout; the full build had no failure and no unresolved binding.
  • Resolved: duplicate pipeline keys from disabled blend state. A recording held 4,829 keys for 1,982 distinct pipelines from 1,818 Metal 4 bases; 2,845 keys differed only in blend factors of targets whose blending was disabled. Pipeline keys omit unused colour state describes the shared normalization.
  • pack_ipa.py packs its 16 GB IPA in 57 s (Info-ZIP took 2 min 50 s).

Pipelines on a cold start

  • pso_pairs.tsv: 3,012 shader pairs from agl's BAHS program variations (the agl / gsys / terrain resource packs), which shader_pairs.tsv does not hold. 109 of the 543 pairs drawn on entering Zora's Domain came from them; with the table all 543 are known. The prebuild (15,767 bases) takes 910 MB in 16 files, 187 s with 7 workers for the first 12,755 and 19 s for the 3,012 more; the files open in 2.2 s cold, 0.25 s from the file cache, and add about 850 MB of footprint.
  • Mac (M2 Max), the Zora's Domain save, Metal shader cache empty (a new bundle ID per run):
Start Frame entering the area Draws that waited, entering it
DM_PSO_MTL4=0, no pso_user.tsv 990 ms, then 535 / 738 / 496 ms frames 512 draws, 3,325 ms
Hybrid, archive prebuilt from shader_pairs.tsv, no pso_user.tsv 830 ms, no other frame over 250 ms 102 draws, 650 ms
Hybrid, archive prebuilt with pso_pairs.tsv too, no pso_user.tsv 340 ms, no other frame over 150 ms 50 draws, 20 ms (no pair without its pulling base)
Hybrid, archive and pso_user.tsv of an earlier run no frame over 100 ms 17 draws, 4.6 ms

Every kind of cold start also shows the game's own 130–220 ms frames every 9–13 frames for a while after the load, with no pipeline work in them.

  • The prepare step with the seed list pso_manifest.tsv (1,789 lines: New Game to the Shrine of Resurrection, then a Zora's Domain save load). Mac (M2 Max), runs started at a 1-minute load average below 4: Metal cache empty (a new bundle ID per run), the prebuilt archive, that save loaded. Frames over 25 ms with new pipelines or shader library loads, in the 60 s after the last menu press:
Start Before the first frame Frames with pipelines / library loads
No list (the profile before its list) – 4 / 5 (75 libraries), 168 ms of that work, worst frame 128 ms
The list, background prewarm (DM_PSO_PREPARE=0) – (14.6 and 16.0 s on one worker, during boot) 0 / 0 (two runs)
The list, prepare step 5.2 and 2.2 s on 12 workers, 3.3 s on 8 (872 bases compiled) 0 / 0 (three runs)
Second start (Metal cache and archive warm) 0.08 s, no screen 0 / 0
A player's list (5,783 lines with the manifest), warm 0.23 s, no screen (2.7 s in the background) 0 / 0
  • The pipeline archive made offline (development.md): every one of the 15,767 pairs makes a base (none left to the device, none left out); metal-tt built the iPhone 17 Pro Max's archive (applegpu_g18p, iOS 27.0) in 252 s on an M2 Max: 653 MB, 219 MB zipped. On that iPhone (iOS 27.0.1) a test app found all 200 bases of a 200-pair archive, at 0.06–0.3 ms each. On the Mac the same build for the M2 Max (applegpu_g14s, 615 MB, 253 s) held all 15,767 bases: the prebuild tool with it opened it in 1.8 s from the external drive, found 16 of 16 probed bases in 57 ms and built nothing, where the device's own prebuild took 187 + 19 s on 7 workers. The process's footprint rose by about 615 MB with the archive open. Resolved on the iPhone 17 Pro Max (iOS 27.0.1): the app with the archive (622.8 MB) opened it in 555 ms, found 16 of 16 probed bases in 17 ms, and its prepare step took 0.53 s with no base compiled (1,722 bases from the archives), where the device's own prebuild took 7–8 minutes on a first start; footprint 1,424 MiB at frame 300 and 2,579–2,679 MiB in play to frame 2,100, with the device's own earlier prebuild (932.7 MB in 30 files of pso_archive/, from before the archive was shipped) still open beside it.

  • A Mac app (--platform mac-app, M2 Max). Before the shader pairs went into the app: pipeline prebuild: no …/Resources/dm/shader_pairs.tsv, nothing to build, no hybrid draws, and first-seen pipelines compiled on the submit thread in frames of 100–546 ms (Shader-pair tables in a Mac app). With the pairs and the archive for applegpu_g14s in the app (586.2 MB): 16 of 16 probed bases found in 20.6 ms, prepare step 0.23 s, no base compiled, no red line. The first launch of the freshly signed app waited about 100 s while macOS checked its signature; later launches start in a few seconds. The run stayed in the title and menus, so no hybrid draw occurred in it (PR #241). Repeated on an M4 Pro (applegpu_g16s) with an empty Application Support folder: metal-tt built the archive in 6:12 (629.4 MB, an APFS clone in the app; ad-hoc signing of the 16.9 GB app took 4:40). The first launch waited about 4 minutes for the signature check before the host started. The archive opened in 250.5 ms from the external drive and covered every pair (16 of 16 probed bases in 34.5 ms), so no pair base was built. The prepare step still compiled 871 bases in 2.80 s on 14 workers: the prewarm list's bases with a vertex layout, which the archive never holds, and which went into the app's own pso_archive/ (19.9 MB). No red line in the 90 s after.

On this Mac the background prewarm also ends before the save loads, so the step changes the first minute only where the prewarm is slower than the way into play: a slower device, fewer cores, a longer list, a player who skips through the title. Frames over 25 ms without pipeline work (20–71 per run) are the game's own and vary with the machine's other load.

Mac routes

Final synchronization and pass validation (2026-10-26, current)

#158 and #213 with the review fixes, on base c6c4fac, were tested in outdoor gameplay on a Mac M4 Pro, macOS 27.0.1, at the supported 60 fps target and docked 1600x900 rendering. The supplied outdoor save was copied into an isolated seed; each run copied that seed again. The modules were regenerated with that rewriter, and all 19,592 Mac shaders rebuilt for chain 8393d85a4eae. Every measured run used one retained host binary and the same source/save hashes. No game or graphics code changed during measurement.

The primary comparison ran off / on / on / off: all three switches (DM_NVN_FAST_WAKE, DM_NVN_SLICE_MERGE, DM_NVN_CS_HOIST) at 0 or 1, with encoding thread and present snapshots both 1, cut 64, silent audio, and one shared warmed pipeline archive. Each run reached frame 6,000; the measured window was frames 3,001-5,700 (2,700 frames), including a stationary interval, camera turn, walking and a jump. The windows had no new shader loads or pipelines. Values below average the two runs' means, except the p95 row, which gives the two runs separately:

Metric Off On
Frame interval mean 16.667 ms 16.673 ms
GPU time mean 7.051 ms 7.144 ms
GPU time p95, each run 11.155 / 11.782 ms 11.708 / 12.018 ms
Submission CPU mean 3.456 ms 3.653 ms
Render passes per frame 158.55 146.94

Result: pass savings survive (about 7.3%), but these runs do not demonstrate a frame/GPU timing speedup. Submission CPU cost rose about 0.20 ms (5.7%). Both pass optimizations activated; a typical 300-frame on window recorded 2,280 merged passes/layer continuations and roughly 1,400-1,800 moved dispatches. Separate stationary and post-movement windows also showed no consistent GPU win.

Single-switch follow-ups, plus another off baseline, gave GPU means of 6.963 ms (wake only), 7.518 ms (slice only), 6.977 ms (hoist only), and 6.970 ms (off). Hoist-only submission CPU was 3.645 ms versus 3.432 ms off. These are single runs, with a few library/pipeline events in the wake/hoist windows, so they are attribution checks rather than repeated timing proof. A further direct-encoding, snapshots-off pair gave GPU means 7.331 → 7.199 ms and submission CPU 4.630 → 4.864 ms, both still 60 fps. Blocking SyncWaits were rare in these 60 fps scenes; sparse diagnostic windows showed wake delay 0.090 → 0.064 ms, insufficient to confirm the original GPU-bound wake gain. Segment publishing remained enabled throughout: it has no runtime off switch.

All ten measured BotW runs exited cleanly, with no red line or crash. Screenshots in both modes showed the outdoor scene and the changed camera/character position without an obvious rendering regression. Wind, animation and second-based input holds cause nonzero image differences, including between repeated runs in the same mode; these are not bit-exact pixel checks. GPU clocks were not fixed. This does not reproduce the earlier M2 Max/Zora's Domain/120 fps workload or establish its reported gains, and it is not an iPhone test.

Artifacts in the NSP work folder: run/perf-final-gameplay-20261026/, with summary.json, per-run run.json, source.json, frames.tsv, host.log, analysis*.json, screenshots, seed, rewritten modules, retained host and reproduction scripts. The earlier DLC-menu and shrine pilots are excluded from the timing comparison. The general correctness changes are linked under “Found on this game”.

Before publishing, the fixes were integrated with main's pipeline archive and GPU profiling changes (7753b7b). The full graphics suite, 37 ordering checks in three encoding/switch modes, 8 CPU completion checks under TSAN, 52 bundle / 57 archive checks, 28 profiler checks, both game host builds, documentation checks and all 133 iOS source compiles passed. Two additional on/off frame-loop checks reached frame 5,000 and exited without a red line or crash. Their display link stalled and dropped frames, and the requested screenshots were not produced; they are execution checks only, excluded from the gameplay/timing evidence above. Their source identities, hosts, saves and logs are retained separately in run/perf-main-integration-20261026/. Main's subsequent dac7ba6 changes the rewriter's Taiko matcher, not these host sources.

Routes

  • From the title menu into the autosave: DM_KEYS=f2500:A,f2800:A,f3100:A,f3400:A (control at frame ~3900).
  • For the copied outdoor save with the downloaded-DLC notices: DM_KEYS=f300:A,f500:A,f700:A,f900:A,f1100:B,f1300:A,f1500:A,f1700:A; gameplay is visible by frame 2,600. The measured route adds f4100:RRight~0.15,f4250:LUp~0.4,f4400:X~0.15,f4600:LDown~0.4.
  • The frame step under load: DM_TEST_PRESENT_LAG=1,12 (12 ms added to every frame: 30 fps at 1.000 of real time).

Found on this game

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

Open

  • The shipped pipeline archive without the device's own earlier prebuild files (the test iPhone's pso_archive/ still held them, Pipelines on a cold start): the footprint against the app's memory limit and the frame times after it.

  • The GPU particles' compute dispatches and the G-buffer decals still split passes; about 800 MB of attachments a frame outdoors.

  • DM_RENDER_SCALE does not change the Mac's GPU time; the sources give no iPhone measurement.

  • The VFR override's the game fixes its own step path has not been reached on the Mac.

  • game.tsv still says "In bring-up", and its memory comment "until measured".

  • 05's DM_PROBE_POLL paragraph cites PlacementMgr in main+0x5aaac0 as the one thread in guest code "for that flag" without naming the game; PlacementMgr is this game's.