Files
nxnative-easy/dm/games/0100F2C0115B6000/README.md
T
seeddsandAleksandr Svidchenkov 5b0c11a4f3 Fix NVN dirty-page upload races (#197)
Take upload stamps before protection, initialize cached checks from those
stamps, and wait out pages another thread is unprotecting before retrying.

Publish page generations before advancing the clock again in both the fault
handler and batched host pre-write path. A shared stamp_page helper closes
the remaining batched publication race in the original PR.

Add deterministic publication regressions for both paths and document the
mechanism and the limits of the game-level evidence. The regressions fail
on main, and the batched case also fails with the original PR applied.

Validation: full graphics suite, Wonder Mac host build, all 133 iOS source
compile checks, and documentation tests pass. No fresh gameplay or iPhone
runtime validation.

Co-authored-by: Aleksandr Svidchenkov <a.svidchenkov@gmail.com>
2026-10-26 02:06:27 +08:00

16 KiB
Raw Blame History

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

Update 1.4.3 (v720896) over the base game, SDK 20.5.22. Nintendo EPD's engine (sead / agl / gsys, NintendoWare G3d2 / Ui2d / Vfx2, Havok) on NVN. Modules main, subsdk0 (the movie library) and sdk; subsdk0 and sdk are the same builds as Breath of the Wild 1.9.0's (01007EF00011E000). Plays on the iPhone; 60 fps (30 on the Switch) through the host's frame step.

What it needs

Setting Where Why
Frame step, fps_target 60 patches.py FRAME_STEP, game.tsv 60 fps at normal speed; see Frame rate
Node area cut 1 GB → 62 MB dm/tools/sdk_patches.py, sdk build 574284b4… the resource heaps ran out of alias region (issue #70); see Memory
5 GB guest layout: mem_reserve 0x140000000, mem_alias 0x98000000, mem_stack 0x10000000, app_pool 0xCD500000 game.tsv 256 MB in front + 2.375 GB alias + 256 MB stack + 2.125 GB heap region. 5 GB is the total proven on a phone (one reservation of at most 6 GB without the extended virtual addressing entitlement)
render_cond on game.tsv the 3D scene was black: the game skips clears with conditional rendering
tex_flush_upload on game.tsv streamed textures are written by the CPU into pool memory in GPU layout and published with nvnMemoryPoolFlushMappedRange
tex_dirty_upload on game.tsv textures filled from the CPU with no flush at all (the magenta cave, the unlit stone blocks)
Integer output rows shader_facts_manual.tsv no PC version, so the translator has no output types: agl's BC4 encoder and the Ultrahand mask (e3fb0bb58a691e10, R8Uint, from PR #11; the Mac route does not reach it)
pad_layout full game.tsv the 3D gamepad layout: the right stick turns the camera

Frame rate

  • Current: the game picks a present interval each frame (main+0x2db174: 2 = 30 fps, 3 under load) and steps its game time by 60 / fps (int fps at timing +0x50, read at main+0x2daa54 and main+0x3e562c). Stubs in patches.py (sites 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, and the float fps at timing +0x24 the same. Prerendered movies hold 30. DM_FPS_TARGET=30 saves power, 0 gives the game's own step back (every stub then runs the original instructions).
  • Current: 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.
  • Measured 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.
  • 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. The stub multiplies in the view size function (main+0xd5c24), unlike Breath of the Wild's, which sets the scale at the dynamic-resolution block.
  • Resolved: the yield back-off used to start after 200 µs, which caught the 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 (development.md, Power).
  • The spin-wait rewrite finds 843 loops in main; 701 of them are one inlined atomic decrement (a compare-and-swap retried after another thread changed the count), which yields only on a second retry in a row.

Memory

  • Current (issue #147): confirmed transient upload-memory bug on the Mac; iOS validation pending. With update 1.4.3, the corrected issue #70 SDK module, shader chain dec5ee141a9f and 30 fps, walking from the Great Sky Island save produced 10.70 GiB peak footprint after about 474 s; a shorter repeat reached 12.81 GiB after about 150 s. Live staging peaked at 4.48 / 5.82 GiB and the open batch at 4.41 / 5.75 GiB, then memory fell back near 4 GiB. The shorter burst logged 3,084.2 MB of uploads in one frame. A temporary 50 ms sampler and the kernel footprint-peak ledger caught peaks missed by the periodic reports and the 8 GiB sampled watchdog. With bounded batches and upload-completion retirement, the 220 s shortened route with deferred encoding enabled peaked at 4.24 GiB footprint and 216 MiB live staging, without GPU errors. A 520 s replay of the original input timings peaked at 4.23 GiB / 200 MiB, but landed differently and reached Game Over earlier, so it is not an equivalent traversal comparison. These local results do not establish the cause of the reporter's full-device reboot or rule out longer-term cache growth; the requested save/route, full logs and Jetsam/panic reports are still needed for that comparison.
  • The NPDM declares 24 MB of system resource, so IsVirtualAddressMemoryEnabled is true and the game's memory lives in the alias region. nninitStartup also calls SetHeapSize(0x86000000) (2.1 GB of the pool as its heap; a 2 GB heap region aborts there with 0xd401). What it can map is bounded by the pool less the heap (1.1 GB).
  • Resolved (issue #70): the resource heaps are address regions in the alias region (2.375 GB here, 64 GB on the Switch). With the SDK's ~1 GB node area at its start, the alias region was used up to +2.25 GB at the first descent from the Great Sky Island with 1.00 GB mapped. The game then took a memory shortage: its frame check (main+0x1fd32ec) logs MSF, then R and Rf (reason bits of one resource heap's object), and plays the memory-shortage Blood Moon, which puts Link back at the start. With the node area cut to 62 MB (as Fire Emblem Engage's, Hollow Knight: Silksong's and other builds have it) the high-water mark is +1.75 GB, and no shortage came in 3.7 minutes on the island (it came within a minute in 7 of 8 runs without it).

Rendering

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

  • Resolved: the 3D scene was black. The game wraps nearly every ClearColor / ClearDepthStencil in SetRenderEnableConditional(RENDER_IF_NOT_EQUAL, rec) … SetRenderEnable(true), each clear followed by ReportCounter(TIMESTAMP_TOP, rec). The CPU sets rec to 1 when a target needs a clear, and rec + 16 is never written. A timestamp report puts 0 in the counter field, so after one clear the two counters are equal and later conditional clears of that target are skipped. The host ignored the condition, so every clear ran: the first tile-lighting round, the half-resolution scene depth the light volumes test against, and the light accumulation were all cleared before the composite read them. Fixed by render_cond. With it, the game's scissored colour clears (50×50 tiles of a 500×400 target, about five a frame) are all skipped.
  • Resolved: the cave was magenta. The terrain samples two 121-layer ASTC 8×8 arrays (42 MB each), created on zeroed memory and filled from the CPU with no nvnMemoryPoolFlushMappedRange or FlushTexels (its cache maintenance runs natively). They stayed GPU-only with content 0, and Metal decodes an all-zero ASTC block as (1, 0, 1). Fixed by tex_dirty_upload.
  • Resolved: the stone blocks and steps of the opening cave got no torch light (Mac and iPhone). Their fragment shader 54e8c08998050e79 takes its roughness from a 5120×5120 BC4 atlas (roughness = the square root of its red, packed into the upper six bits of blue in colour target 3, which the tile-light shaders read). The game fills 1024×1024 BC4 material textures on the CPU with no flush, reads them back with nvnTextureReadTexels on its own threads before any draw uses them, and copies the blocks into the atlas, which it fills after first binding it. ReadTexels returned the never-uploaded host texture (zeros). Found by running MacNativeGames' build on the same save (their atlas held data). Traced with DM_NVN_TEXDUMP (roughness bits in colour target 3), the shader's MSL, DM_NVN_TEXRAW on the atlas and DM_NVN_TEXINFO, then lldb reading the atlas in their process.
  • Resolved: draws into a 32×32×32 BC4 3D texture were lost. agl's BC4 block encoder (0544282c162305db, the same shader Mario Wonder ships) encodes it one depth plane per pass through an RGBA16UI view. It draws through the compressed-target shadow (Breath of the Wild's), with its output declared uint in shader_facts_manual.tsv. A raw dump at frame 1500 of the new-game route: all 32 planes hold encoded blocks.
  • Resolved: a draw with no render target. An agl fragment shader that only stores to a storage buffer draws one triangle into a 1×1 viewport with no target (it samples a 64×64 cube map; the game reads the buffer back). With the attachment-less pass, the cave's rock and stone blocks get ambient light they lacked, and Zelda's bloom halo in the descent cutscene is gone.
  • How the tile lighting was traced: the G-buffer class byte selects per-class half-resolution passes through a depth buffer of class values (gl_FragDepth = mix(0.5, 1, nibble-swapped class / 255), then full-screen or per-tile quads with depth EQUAL), so DM_NVN_DEPTHOVR (depth test forced to ALWAYS) with HILITE tells "rejected by depth" from "never rasterized".
  • The game issues a depth clear scissored to 8×8 of a 64×64 target outside a pass, which the host still clears whole (depth-stencil clear with a scissor or partial stencil mask, cleared whole).
  • About one draw per 8-bit index widening, up to a dozen widenings a frame on the title and load screens, no pass split (log line 8-bit index GPU expansion).
  • Its 14,766 vertex shaders have no transform feedback.
  • Attachment loads and stores per frame on the Mac went from 1,100 to 700 MB once an inline uniform update inside a render pass stopped ending the pass (development.md, Power).

Command memory

  • Resolved (issue #61): CRASH sig=10 in mtl_exec after minutes of play, the fault address equal to x19 and shaped like 0x1000000003. The game gives each model's prerecorded lists fixed slots of the size a recording of that list measured (GetCommandMemoryUsed), slot k at base + size × k, and passes AddCommandMemory max(size, 512) (main+0xbb120); its memory callback is empty. A JUMP the host wrote after a 496-byte list reached 8 bytes into the next slot. The host now never writes past what GetCommandMemoryUsed reported (OP_RESERVE, Prerecorded lists in slots of their measured size).

Services

Files and shaders

  • Most RomFS files are zstd with dictionaries (*.zs); the dictionaries are Pack/ZsDic.pack.zs, which is also the profile's romfs_marker.
  • Shaders: Shader/*.bfsha.zs (BNSH; material.bfsha holds most) and the layout shaders in Shader/ApplicationPackage.Nin_NX_NVN.release.sarc.zs.
  • Resolved in the host: duplicate pipeline keys from unused colour state. MacNativeGames commit f3e2f66, reported in PR #196, found 8,479 of 24,146 recorded render pipelines (35%) differed only in unused state. The general normalization and prewarm deduplication are ported; this is the source project's measurement, with no new gameplay or prewarm-count measurement on this game here.

Mac routes

  • The issue #70 save: DM_KEYS="20:A*6/4,f2480:LUp~5" (title, load, jump, steer into the pond).
  • The issue #147 short streaming route uses a fresh copy of the issue #70 save and its corrected SDK module: DM_FPS_TARGET=30 DM_KEYS="20:A*6/4,f2480:LUp~9,f4000:LUp~20,f4700:LRight~5,f5000:LUp~20", run for at least 220 s. This is a fall/respawn stress route, ending at Game Over in both short comparison runs; it is not a long surviving traversal test. The movement timings are repeatable, but the upload burst varies with streaming and CPU/GPU timing, so the deterministic staging regression complements it.
  • The in-cave autosave (run/save): keys f2500:A,f2800:A,f3100:A,f3600:A, shot at frame 4500. DM_DETCLOCK cannot be used: the game stops at frame 1 waiting for guest time.

Found on this game

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

Open

  • Texture layout: NVN's automatic block height now truncates for every game (pick_tile(ah * 3 / 16)). That changes layouts at 6–10, 17–21, 38–42 and 81–85 block rows, which matters for this game's flushed textures. Not re-checked here.
  • game.tsv still describes the game as "In bring-up", and some memory values as "Breath of the Wild's", not measured for this game.