Skip to content

A draw that writes depth and samples its own depth attachment reads a copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone - #1917

Closed
FifteenPen wants to merge 1 commit into
seedds:mainfrom
FifteenPen:pr/depth-snap

Conversation

@FifteenPen

Copy link
Copy Markdown
Contributor

What and why

In The Legend of Zelda: Ocarina of Time (0400486024DEC000, v131072), the dark rock above the cave arch at Death Mountain Trail's cave entrance shows short screen-aligned light-blue dashes, the sky behind it, changing from frame to frame and staying on the same surfaces as the camera moves. A player also reported blocks on Death Mountain Trail's cliffs on an iPhone.

Per-pass snapshots (DM_NVN_TEXDUMP with DM_NVN_TEXSNAP, taken on a tree where the marks were larger blocks) show the rock drawn whole, then a later full-screen pass painting the marks over it; a pass dump of main's build shows the same draws in the same pass with the same bindings. That pass, 25a8adc977ff813d, samples the scene's depth attachment (1536x864 Depth32Float) through a texture slot and writes the same attachment (depth test LEQUAL, depth writes on); five decal draws after it do the same. On the console each fragment reads the depth stored before the draw. On Apple's GPUs a depth attachment sampled by the pass that renders into it reads whole compressed blocks of undefined content, so the shader's comparison against the sample fails or passes block by block.

fb_prepare marks the depth attachment as read for such a binding and ends the pass before the next draw that writes depth (the depth half of the snapshot rule), which cannot help a single draw that does both. It is the case PR #1430's assumption leaves out (Pool shaders taken as depth readers): a shader that reads the depth it writes.

The change, fb_depth_snap in nvn_mtl.m, called by fb_prepare, for every game:

  • A draw that writes depth or stencil and samples its own depth attachment through a texture slot ends the open pass; the attachment's level and layer are copied into a snapshot (fb_snap, the colour snapshots' cache), and the draw's binding reads the copy.
  • The copy is reused, without ending the pass, until the game's next barrier, as the colour snapshots are: NVN makes rendering visible to texture reads only through a barrier. At the cave entrance the game issues no barrier between the six draws; a copy for each and a copy reused gave the same picture.
  • Only a single-level 2D view of the attachment's level and layer, bound in a texture slot, is copied. Reads through pools, TEXU handles or dynamic tables are left as they were, and so is a depth-only pass (fb_prepare returns early without a colour attachment, as for the colour rule; noted in the entry).
  • DM_NVN_FB_DSNAP=0 reads the attachment directly, as before. The first copy logs same-pass depth sampling: copy #1.

#1866 is complementary: it ends the pass before a draw that samples depth an earlier draw of the pass wrote, and leaves alone a draw that writes depth itself. Both touch the same line in fb_prepare, so whichever lands second needs a one-line rebase.

Docs: a new reference entry, Depth sampled by the draw that writes it, the switch's row in docs/env_switches.tsv, and a line under the game note's "Found on this game".

Checked

  • Tests, on this branch (main 7a7acfe + this change), Apple silicon, Metal debug layer:
    • make -C dm/gfx/tests GAME=0400486024DEC000 check_t_feedback: 218 checks, 0 failed. The new depth_snap_case covers a copy for a draw that writes depth and samples it (pass ended), reuse by the next such draw without ending the pass, a fresh copy after a game barrier, a direct read by a draw that leaves depth alone, the switch off, and the copy's content. In one earlier run the existing timestamp check "two reports in one command buffer read two times" failed; it is flaky on unchanged main too (1 failure in 4 runs of d8b2b64 here; A draw that samples the depth attachment its render pass already wrote ends the pass first (Red Dead Redemption's sky blocks) #1866 reports 2 in 6), and the next four runs passed.
    • make -C dm/gfx/tests GAME=0400486024DEC000 run: every test passes.
    • python3 -m unittest dm/tools/tests/test_docs.py dm/tools/tests/test_env_switches.py, python3 dm/tools/docs_symptoms.py.
    • dm/tools/lint_c.py tidy (LLVM 23.1.4) on the changed files: 0 file/check pairs above the baseline.
  • Games run: The Legend of Zelda: Ocarina of Time (0400486024DEC000, v131072), a player's save at Death Mountain Trail's cave entrance.
  • Devices and OS: Apple M4 Pro (20-core GPU, 24 GB), macOS 27.2.
  • regress.py impact: every NVN game, through nvn_mtl.m (Breath of the Wild, Pokémon Scarlet and Tears of the Kingdom have usage manifests). The rule acts only on a draw that writes depth and samples its own depth attachment through a texture slot, and logs its first copy. regress.py check was not run: of this Mac's NVN games only Mario Kart World has a smoke route, and none has a build for the current chain.
  • Not checked: iOS (the player's report is the same symptom), other games, depth-only passes (not covered, see above).

Docs

  • The finding is in its game note or its docs/reference/<subsystem>.md entry, and python3 dm/tools/docs_symptoms.py was run (the symptom index)
  • A chain tool changed: not applicable (host only)
  • No game files, keys or data extracted from a game in the change or the attachments

… copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone

A full-screen pass (25a8adc977ff813d) and five decals after it sample the scene depth through a texture slot while writing it with LEQUAL. On Apple's GPUs a depth attachment sampled by the pass that renders into it reads compressed blocks of undefined content, so the comparison against it failed block by block. fb_depth_snap (called by fb_prepare, every game) ends the pass, copies the attachment's level and layer into a snapshot (fb_snap) and binds the copy, reused until the game's next barrier as the colour snapshots are. DM_NVN_FB_DSNAP=0 reads the attachment directly. Check: check_t_feedback (depth_snap_case).
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation game: 0400486024DEC000 A game labels Nov 9, 2026
@akratch

akratch commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Independent test coverage from an M4 Max, macOS 27.2 / Xcode 27.2. The submitted RGBA stand-in is useful for substitution and reuse, but cannot exercise an actual depth format or the depth attachment's store/copy path.

I added a second case that clears a private Depth32Float attachment to 0.375 through the production render-pass path, calls fb_prepare, checks that the replacement is a distinct depth snapshot, and reads back all 16 floats with Metal validation enabled. It also checks allocation and submission completion. The existing RGBA/reuse/off-switch cases remain unchanged. On our OOT integration based on 7238bfd18c86, all 225 feedback checks pass, and the full Metal graphics/host suites and lint ratchets pass. This is integration-level synthetic GPU validation; the newly rebuilt game's long-session behavior and other games/iOS remain unchecked.

The test-only diff below is against this PR's a671ec7674a1 test file. No game data or shader-chain change is involved.

Additional real-depth regression
diff --git a/dm/gfx/tests/t_feedback.m b/dm/gfx/tests/t_feedback.m
index 5b151b8a..215ec54f 100644
--- a/dm/gfx/tests/t_feedback.m
+++ b/dm/gfx/tests/t_feedback.m
@@ -597,6 +597,43 @@ static void depth_snap_case(NvProg *pr) {
     mtl_tex_release(&output); mtl_tex_release(&depth);
     printf("depth copy: %s\n", before == failed ? "PASS" : "FAIL");
 }
+// Exercise the depth snapshot with an actual private Depth32Float attachment.
+// The RGBA fixture above checks substitution and reuse; it cannot catch a depth
+// format, render-pass store or depth-to-buffer copy error.
+static void real_depth_snap_case(NvProg *pr) {
+    tex_t colour, depth;
+    target(&colour, 99930);
+    depth = (tex_t){.magic=MAGIC('T','E','X',0), .serial=99931};
+    depth.d = (nvn_texdesc){.target=NVN_TEXTURE_TARGET_2D, .format=NVN_FORMAT_DEPTH32F,
+        .width=4, .height=4, .depth=1, .levels=1, .samples=1};
+    id<MTLTexture> dt = [g_dev newTextureWithDescriptor:tex_desc(&depth, 0)];
+    CHECK(dt != nil, "real depth attachment allocates");
+    if (!dt) { mtl_tex_release(&colour); return; }
+    res_add(dt); mtl_tex_init(&depth); serial_put(depth.serial, dt);
+    mtl_reg_tex(653, &depth, NULL);
+    id<MTLBuffer> result = [g_dev newBufferWithLength:1024 options:MTLResourceStorageModeShared];
+    CHECK(result != nil, "real depth readback allocates");
+    if (!result) { mtl_tex_release(&colour); mtl_tex_release(&depth); return; }
+    res_add(result);
+    state_reset(); memset(&R, 0, sizeof R); mtl_sub_begin();
+    R.has_rt = 1; R.rt.n = 1; R.rt.color[0] = &colour; R.rt.depth = &depth;
+    S.ds.dt = S.ds.dw = 1; S.tex[1][0] = 653; pr->sh[1]->rf.b[0].reg = 0;
+    R.clear_d = 1; R.cd = 0.375;
+    clear(MTLClearColorMake(0, 0, 0, 1));
+    fb_prepare(pr);
+    id<MTLTexture> snap = g_fb_n == 1 ? g_fb_use[0] : nil;
+    CHECK(snap && snap != dt && snap.pixelFormat == MTLPixelFormatDepth32Float,
+          "real depth feedback substitutes a Depth32Float snapshot");
+    if (snap) { blit_barrier(); readback(snap, result, 0); }
+    R.rt.depth = NULL; S.ds.dt = S.ds.dw = 0; S.tex[1][0] = 0; rp_end();
+    CHECK(mtl_wait(mtl_sub_commit(), 5000000000ull) && !mtl_last_commit_error(),
+          "real depth copy and readback complete under Metal validation");
+    int valid = snap != nil;
+    for (int y = 0; y < 4; y++) for (int x = 0; x < 4; x++)
+        valid &= *(const float *)((const uint8_t *)result.contents + y * 256 + x * 4) == 0.375f;
+    CHECK(valid, "every real depth sample retains the completed attachment clear");
+    mtl_tex_release(&colour); mtl_tex_release(&depth);
+}
 int main(int argc, char **argv) { @autoreleasepool {
     setenv("DM_NVN_TBDR", "0", 1); setenv("DM_NVN_GPU_TS", "segment", 1);
     mtl_init(); NSError *error = nil;
@@ -623,6 +660,7 @@ int main(int argc, char **argv) { @autoreleasepool {
     for (int i = 0; i < 3; i++) slot_case(pr, pipeline, slots[i], i);
     reuse_case(pr);
     depth_snap_case(pr);
+    real_depth_snap_case(pr);
     shape_case();
     mip_chain_case(pr);
     fs->fn = [lib newFunctionWithName:@"mask"];

ivan-leschinsky pushed a commit to ivan-leschinsky/nxnative that referenced this pull request Nov 9, 2026
… copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone

Ported from seedds#1917 (open upstream) onto this branch: its own changes from its merge base with main, conflicts resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U35V38c6UYLbzyyPx6Up7P
seedds added a commit that referenced this pull request Nov 9, 2026
… a draw that writes depth and samples it reads a copy, one after a depth write of its pass ends the pass first, in depth-only passes too. Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance and Red Dead Redemption's 8x8 sky blocks over the trees are gone (PR #1917, PR #1866)

On Apple's GPUs a depth attachment sampled in the pass that writes it reads whole compressed blocks of undefined content, and Metal has no tile read for depth. fb_prepare only ended the pass before a depth write that followed a read, and only in passes with a colour attachment. fb_depth_rule, run before the colour snapshots and tile reads:

- A draw that writes depth or stencil and samples the attachment through a texture slot reads a copy (fb_depth_snap, the fb_snap cache), reused until the game's next barrier. Ocarina's full-screen 25a8adc977ff813d and its five decals sample the scene depth while writing it with LEQUAL.
- A draw that samples it after an earlier draw of its pass wrote depth or stencil (g_fb_dwrote), and writes none itself, ends the pass first, whether the depth is bound in a slot or named by a uniform-buffer handle. Red Dead Redemption's cloud fog c0ab3c051753e36d reads it through a handle after the billboards and terrain.
- It runs in depth-only passes too, counting only reads it can name: pool shaders, dynamic tables and handle tables in c2 (TEXA) stay possible readers in colour passes only, as before, so shadow passes are not cut at every draw.

DM_NVN_FB_DEPTH=0 reads the attachment in the pass, as before. A draw that writes depth and reads it through an image slot, a handle, a pool or a dynamic table still reads it in the pass (open, in the reference entry).

Unlike the PRs: one rule and one switch; the pass ends before the colour bindings are judged instead of in the middle of the binding scan; depth-only passes are covered; the RED key moved to 163.

Checked: check_t_feedback, 251 checks (the copy, its reuse and the barrier, the pass end after a write, and an actual Depth32Float and Depth32Float_Stencil8 attachment copied and read back in a colour and a depth-only pass under Metal validation); the graphics suite (t_nvn passes after the rebase onto 68869cc; t_ubp_running fails as it does on main); test_docs.py, test_env_switches.py; lint_c.py tidy at the baseline. regress.py check on Octopath Traveler II against 1d37720: PASS, both paths taken on its Depth32Float_Stencil8 depth (a copy reused by about 21 draws, about one pass end a frame), no new red line, fps 59.8, frame p99 18.19 -> 18.27 ms, the same screenshots. Fire Emblem Three Houses did not build (stale shader package). Ocarina and Red Dead Redemption were checked by the PR authors, by eye in play; not run on iOS.

Co-authored-by: FifteenPen <354289956+FifteenPen@users.noreply.github.com>
Co-authored-by: robandstuff <20628004+robandstuff@users.noreply.github.com>
Co-authored-by: akratch <70729875+akratch@users.noreply.github.com>
@seedds

seedds commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Landed in 8190693, folded together with #1866 into one depth rule (fb_depth_rule): a draw that writes depth and samples it reads your copy (fb_depth_snap, unchanged apart from the RED key), and a draw that samples depth an earlier draw of its pass wrote ends the pass first (#1866). The rule now also runs in depth-only passes, so the limit you noted is gone, and one switch, DM_NVN_FB_DEPTH, replaces DM_NVN_FB_DSNAP. akratch's real-depth test is in, extended to Depth32Float_Stencil8 and a depth-only pass. regress on Octopath Traveler II, which takes the copy on its Depth32Float_Stencil8 depth: PASS, no new red line, the same frame times. You are credited as co-author. Thanks for the careful diagnosis and measurements.

@seedds seedds closed this Nov 9, 2026
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
… a draw that writes depth and samples it reads a copy, one after a depth write of its pass ends the pass first, in depth-only passes too. Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance and Red Dead Redemption's 8x8 sky blocks over the trees are gone (PR seedds#1917, PR seedds#1866)

On Apple's GPUs a depth attachment sampled in the pass that writes it reads whole compressed blocks of undefined content, and Metal has no tile read for depth. fb_prepare only ended the pass before a depth write that followed a read, and only in passes with a colour attachment. fb_depth_rule, run before the colour snapshots and tile reads:

- A draw that writes depth or stencil and samples the attachment through a texture slot reads a copy (fb_depth_snap, the fb_snap cache), reused until the game's next barrier. Ocarina's full-screen 25a8adc977ff813d and its five decals sample the scene depth while writing it with LEQUAL.
- A draw that samples it after an earlier draw of its pass wrote depth or stencil (g_fb_dwrote), and writes none itself, ends the pass first, whether the depth is bound in a slot or named by a uniform-buffer handle. Red Dead Redemption's cloud fog c0ab3c051753e36d reads it through a handle after the billboards and terrain.
- It runs in depth-only passes too, counting only reads it can name: pool shaders, dynamic tables and handle tables in c2 (TEXA) stay possible readers in colour passes only, as before, so shadow passes are not cut at every draw.

DM_NVN_FB_DEPTH=0 reads the attachment in the pass, as before. A draw that writes depth and reads it through an image slot, a handle, a pool or a dynamic table still reads it in the pass (open, in the reference entry).

Unlike the PRs: one rule and one switch; the pass ends before the colour bindings are judged instead of in the middle of the binding scan; depth-only passes are covered; the RED key moved to 163.

Checked: check_t_feedback, 251 checks (the copy, its reuse and the barrier, the pass end after a write, and an actual Depth32Float and Depth32Float_Stencil8 attachment copied and read back in a colour and a depth-only pass under Metal validation); the graphics suite (t_nvn passes after the rebase onto 29a921b; t_ubp_running fails as it does on main); test_docs.py, test_env_switches.py; lint_c.py tidy at the baseline. regress.py check on Octopath Traveler II against 518fc22: PASS, both paths taken on its Depth32Float_Stencil8 depth (a copy reused by about 21 draws, about one pass end a frame), no new red line, fps 59.8, frame p99 18.19 -> 18.27 ms, the same screenshots. Fire Emblem Three Houses did not build (stale shader package). Ocarina and Red Dead Redemption were checked by the PR authors, by eye in play; not run on iOS.

Co-authored-by: FifteenPen <354289956+FifteenPen@users.noreply.github.com>
Co-authored-by: robandstuff <20628004+robandstuff@users.noreply.github.com>
Co-authored-by: akratch <70729875+akratch@users.noreply.github.com>
Xipzer added a commit to Xipzer/nxnative that referenced this pull request Nov 9, 2026
…epare after one look; the tile plan looks textures up only for attachments

fb_prepare and fb_tile_plan looked up every fragment texture binding of every draw (a lock and a texture each,
with a retain and release in the plan). A first pass over the bindings' serials under one lock returns for draws
that sample none of the pass's attachments, and the plan takes a texture only for a binding that names one.
Mac, BotW, snapshots on: about 12,020 -> 11,930 instructions a draw (11,030 with DM_NVN_FB_SNAP=0).

On the depth rule (PR seedds#1917): the look names the colour attachments only, as fb_depth_rule settles the depth
attachment before it; it keeps g_fb_dwrote, and a draw the depth rule gave a copy takes the long way. The
instruction counts are from before the depth rule and were not measured again.
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…ctions compiled out, DMSH_BORDER_FC), seedds#1930 (native float atomic add), seedds#1920 (GPU-written sliver does not own a texture: terrain tiles), seedds#1917 (depth attachment sampled by a depth-writing draw reads a copy), seedds#1928 (enc_thread on), seedds#1942 (idle-GPU late frames do not step the hold up), seedds#1940 (prewarm lines after the first frame), seedds#1936 (resolution families uncompressed)

Hand-resolved: image_texture keeps the virtual pools we admit; fb_prepare keeps our pass split and local dread flag after the depth snapshot; the compute key keeps the NDVE direct draw before the border constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1774 by uncraftablexd (closed): Compute threads that exit before a barrier keep arriving at it, and every shader loop has a cap (GPU hang defences), chain change
- seedds#1917 by FifteenPen (closed): A draw that writes depth and samples its own depth attachment reads a copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone
- seedds#1920 by nawangwe (closed): A GPU-written span's sliver inside a texture does not make it the GPU's: Ocarina of Time's terrain tiles no longer vanish or show another tile's surface (seedds#1566)
- seedds#1928 by FeReuss (open): Ocarina of Time: enc_thread on, the replay encodes on a thread of its own
- seedds#1929 by FeReuss (open): Unused border corrections are compiled out of pipelines that bind no sampler with a twin (function constant DMSH_BORDER_FC)
- seedds#1930 by FeReuss (open): A float add to storage memory with no result read is one native atomic add instead of a compare-and-swap loop
- seedds#1936 by TheGoldfish11 (closed): Write resolution-family image textures uncompressed
- seedds#1940 by uncraftablexd (open): A prewarm list's lines after its #@after_first_frame line are built after the first frame, in the background, instead of in the prepare step (issue seedds#1747)
- seedds#1942 by uncraftablexd (open): Frame step: late frames with the GPU idle do not step up, and a longer hold the GPU does not explain is probed
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…1909 seedds#1897 seedds#1917 seedds#1920 seedds#1936) and seedds#1902, ahead of merging main

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7

Upstream pull requests applied in this commit (their authors' work, brought onto this tree with a three-way merge; the description is in each PR):
- seedds#1897 by Know-It-All-Brother (closed): HMMA with an f16 accumulator rounds once per 8 products, as Ampere does (chain change)
- seedds#1902 by fxd0h (open): HFMA2 rounds once to half, as Ampere does (chain change)
- seedds#1909 by oz-rinkliff (open): Bound translated fragment uniform reads to the NVN binding length
- seedds#1917 by FifteenPen (closed): A draw that writes depth and samples its own depth attachment reads a copy of it: Ocarina of Time's light-blue dashes over the rock at Death Mountain Trail's cave entrance are gone
- seedds#1920 by nawangwe (closed): A GPU-written span's sliver inside a texture does not make it the GPU's: Ocarina of Time's terrain tiles no longer vanish or show another tile's surface (seedds#1566)
- seedds#1936 by TheGoldfish11 (closed): Write resolution-family image textures uncompressed
Xipzer added a commit to Xipzer/nxnative that referenced this pull request Nov 13, 2026
Snapshots: a draw that samples no attachment of its pass leaves fb_prepare after one look; the tile plan looks textures up only for attachments

fb_prepare and fb_tile_plan looked up every fragment texture binding of every draw (a lock and a texture each,
with a retain and release in the plan). A first pass over the bindings' serials under one lock returns for draws
that sample none of the pass's attachments, and the plan takes a texture only for a binding that names one.
Mac, BotW, snapshots on: about 12,020 -> 11,930 instructions a draw (11,030 with DM_NVN_FB_SNAP=0).

On the depth rule (PR seedds#1917): the look names the colour attachments only, as fb_depth_rule settles the depth
attachment before it; it keeps g_fb_dwrote, and a draw the depth rule gave a copy takes the long way. The
instruction counts are from before the depth rule and were not measured again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants