Skip to content

Uncompressed textures are taken from their storage unless the GPU wrote that storage, not their pool: Ocarina of Time's ground no longer opens in holes after walking a distance - #1604

Closed
fmilioni wants to merge 1 commit into
seedds:mainfrom
fmilioni:fix/gpu-written-storage-ranges

Conversation

@fmilioni

@fmilioni fmilioni commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

What

In Ocarina of Time (0400486024DEC000), the ground opens in holes after walking a distance: loading a save in a cave, walking out and on, the terrain starts missing (black holes, water and sky showing through), and walking back into the cave shows it broken too. Saving and loading makes everything right again, until the next areas stream in.

Cause

tex_cpu_content takes an uncompressed 2D texture from its storage at first use only in a pool the GPU has not written (pool_t.gpu_written): once anything in a pool had been a render target, a clear's or a copy's destination, or a shader image, no uncompressed 2D texture of that pool was taken from its storage again.

Ocarina of Time keeps its terrain tiles' height maps (260x260 R32F) and blend / hole maps (260x260 RGBA8, one and two layers) in the same CPU_CACHED | GPU_CACHED pool (flags 0x24) as the tiles' normal maps (260x260 RG8), which the GPU draws. The CPU fills the height and blend maps before nvnTextureInitialize, with no flush, so neither tex_dirty_upload (it stamps at registration) nor the flushed-range fill sees the write. After the first normal map was drawn, every tile streamed in became a GPU texture of zeros. A reload hid it because the area's tiles were read again before the first normal map of the session was drawn.

A diagnostic line at GPU texture creation (storage holding data, made as a GPU texture) showed exactly those textures: 260x260 format 0xa and 260x260x1/x2 format 0x25, pool flags 0x24, gpu_written 1, not flushed, about 25 seconds into the walk.

Fix

The decision is made per address range instead of per pool (nvn_mtl.m):

  • The host keeps the ranges a render target, a clear, a copy into a texture or a shader image is recorded over (mtl_tex_gpu_written, gws_span), dropped with their pool. It replaces mtl_pool_gpu_written at the same seven sites, and pool_t.gpu_written goes.
  • tex_cpu_content refuses an uncompressed 2D texture only when the GPU wrote its own storage.
  • Storage reused for a texture the CPU fills is told from storage the GPU still owns by page protection: each range keeps the dirty-page generation of its last GPU write and has its whole pages protected, so a CPU write there afterwards makes the storage the content again. A texture taken that way becomes a GPU texture at the next GPU write there (cpu_unc_drop, now triggered by those ranges instead of a pool's first write). Only whole pages count, since a range's first and last pages are shared with whatever lies beside it.
  • Reference entry Uncompressed storage beside storage the GPU writes, the previous entry's fix updated, the game note and the symptom index.

Related pull requests

Tests

  • In game on the Mac (M5 Max): the same route (cave save, walk out and far, back into the cave). Before, the ground opened within the walk; with the fix the ground stays whole, confirmed by eye, and the log shows the 260x260 R32F height maps taken from their storage. Frame time unchanged (p50 about 20 ms), no new red line.
  • t_cpu_storage: 40 checks, 0 failed. New cases: a texture beside a GPU-written texture in the same pool is taken from its storage; one over storage the GPU wrote and the CPU then rewrote is taken, and becomes a GPU texture at the next GPU write there.
  • dm/gfx/tests: every test of the run list passes except t_nvn and t_gfx_suspend (they do not link on main, fixed by The graphics run list passes on main again: t_nvn and t_gfx_suspend link, t_fuse_check checks its kept target by serial #1578) and t_fuse_check (2 of 10 checks fail on main as well).
  • lint_c.py tidy (LLVM 23.1.4) on nvn_mtl.m, hle_nvn.c, t_cpu_storage.m: no new finding; the 3 file/check pairs above the baseline in nvn_mtl.m are the same on main.
  • test_docs.py: pass.

regress.py

Games: Super Mario Bros. Wonder, the only local build with a route that runs the NVN texture paths. Breath of the Wild has a route but its local build is on another chain. Ocarina of Time has no route; it was checked by hand above. Wonder never reaches the changed path (neither run logs a texture taken from storage), so its run shows no collateral change, not the fix. Unchecked: Mario Kart World, the game the previous per-pool rule was found on (its terrain patches lie in a pool the GPU does not write, which the range rule treats the same way), and the other profiles with tex_dirty_upload.

regress.py check (full): PASS

Baseline 88c7e03 88c7e03, candidate 731096b 731096b3615a; Apple M5 Max, macOS 27.0.1, Xcode 27.2; 743.7 s

game status shaders (base → cand) newly failing newly building changed MSL interface (new / known) boot fps p50 frame p99 ms peak MB prepare s new RED
Super Mario Bros Wonder 010015100B514000 PASS 16268/16268 → 16268/16268 0 0 0 0 / 0 ok 59.7 → 59.7 20.24 → 21.82 2503 → 2528 None → None 0

Super Mario Bros Wonder 010015100B514000: PASS

  • screenshots (s: dHash bits / RGB diff / baseline noise): 50: 2/0.68/0, 58: 3/0.69/0
  • baseline: exit None, first frame 0.8 s, max frame 3832, reached True, prepare None s, prebuild 82.3 s (18300 built, 0 failed), fps 59.7, p50/p99 19.96/20.24 ms, peak 2503 MB
  • candidate: exit None, first frame 0.8 s, max frame 3846, reached True, prepare None s, prebuild 91.7 s (18300 built, 0 failed), fps 59.7, p50/p99 19.98/21.82 ms, peak 2528 MB
  • time: chain 170.9 s, smoke 572.6 s

…te that storage, not their pool: Ocarina of Time's ground no longer opens in holes after walking a distance

tex_cpu_content refused every uncompressed 2D texture of a pool once anything in that pool had been a render target, a clear's or a copy's destination, or a shader image. Ocarina of Time keeps its terrain tiles' height (260x260 R32F) and blend / hole maps (260x260 RGBA8) in the pool its tiles' normal maps are drawn into, and the CPU fills them before nvnTextureInitialize with no flush: after the first normal map was drawn, every tile streamed in became a GPU texture of zeros.

The host now keeps the address ranges the GPU writes (mtl_tex_gpu_written, gws_span), dropped with their pool; each keeps the dirty-page generation of its last GPU write and has its whole pages protected, so storage the CPU rewrites afterwards is taken again. Reference entry, game note, symptom index and t_cpu_storage cases.
@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 7, 2026
@lunks

lunks commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Thanks for this, and for the careful route test. #1390 (open, from our fork) fixes the same symptom the same way: GPU-written storage kept as address ranges instead of per pool, so a pool holding both kinds no longer leaves the CPU-written textures at zero. Worth a look before both land, since they touch the same code (mtl_tex_gpu_written, tex_cpu_content, cpu_unc_drop) and will conflict.

One idea in yours that #1390 lacks: protecting the pages of a range, so a later CPU write to reused storage makes the storage the content again. #1390 decides that case with gpu_holds and by dropping the range. The two are worth comparing on the reuse case.

@fmilioni

fmilioni commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for pointing to #1390. I missed it when I searched for related PRs before opening this one, and it has the same core idea, earlier.

I measured the reuse case on Ocarina of Time. A diagnostic counter counted every uncompressed texture taken from its storage over storage the GPU had written, where the CPU then wrote again: that is, every texture a range rule without a CPU-rewrite check would refuse. Same route as before: load the cave save, walk out a long way, walk back in. About 9,900 frames.

  • 150 textures taken from their storage, 20 of them (13%) over storage the GPU had written.
  • 10 of those 20 are terrain maps: 3 height maps (260x260 R32F) and 7 blend / hole maps (260x260 RGBA8, 1 and 2 layers). With ranges that are never cleared until the pool goes, each would come out as zeros, a hole in the ground.
  • The other 10 are the tiles' RG8 normal maps, which the GPU draws right after. They become GPU textures at that draw either way.
  • The reuses come throughout the walk, not only at load, so the count grows with the distance walked.

So tile storage does move between GPU-drawn and CPU-filled textures in this game. A range has to stop counting once the CPU writes it again, which is what the page protection in this PR does.

Happy to rebase this onto #1390 if that is easier to review: its range table, with this PR's page-protection and re-stamp on top. Or the other way round. Your call with the maintainers.

@lunks

lunks commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Thank you for the numbers: 10 terrain maps in 20 reuses over a walk is exactly the case to get right. Yes please, rebasing your page protection and re-stamp onto #1390's range table would be welcome; one range table is easier to review than two. I'll check the reuse case against #1390 on our side too.

seedds added a commit that referenced this pull request Nov 7, 2026
…te that storage, not their pool: Ocarina of Time's terrain height and blend maps no longer come out as zeros, the ground opening in holes after walking (PR #1604)

tex_cpu_content refused every uncompressed 2D texture of a pool once anything in that pool had been a render target, a clear's or a copy's destination, or a shader image. Ocarina of Time keeps its terrain tiles' height (260x260 R32F) and blend / hole maps (260x260 RGBA8) in the pool its tiles' normal maps are drawn into, and the CPU fills them before nvnTextureInitialize with no flush: after the first normal map was drawn, every tile streamed in became a GPU texture of zeros.

The host now keeps the address ranges the GPU writes (mtl_tex_gpu_written, gws_span), dropped with their pool. Each keeps the dirty-page generation of its last GPU write and has its whole pages protected, so storage the CPU rewrites afterwards is taken again, and a texture taken there becomes a GPU texture at the next GPU write (cpu_unc_drop). Ranges do not merge: a GPU write over part of one cuts it and the rest keeps its own stamp. Storage the CPU cannot write (CPU_NO_ACCESS, virtual) gets no range, and a range keeps its dirty-page handle, so the check at each render target, clear and copy scans no pool list.

Ocarina of Time (M4 Pro, Kokiri autosave and a walk): the blend and height maps are taken from their storage (none on main), 433 CPU-written textures alive against 270, faults 523 against 538, frame and GPU time unchanged. Minecraft Dungeons II (menu to first control): the same picture and frame time; mprotect 44.7 ms over the run against 5.4 ms. t_cpu_storage 44 checks, 0 failed.

Co-authored-by: Felipe Milioni <20670722+fmilioni@users.noreply.github.com>
@seedds

seedds commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Landed as e2b76d4, with you as co-author. Thank you for the diagnosis, the route test and the reuse-case numbers: the page protection that lets storage go back to the CPU is what made this the right fix.

Changed on top of your branch:

  • Ranges do not merge. A GPU write over part of a range re-stamped the whole merged range (in gws_add, and in gws_rewritten for a write inside a range), so a CPU rewrite elsewhere in it was forgotten and a texture made there later came out as zeros again. A GPU write now cuts the ranges it overlaps (gws_set), and the parts outside keep their own stamps.
  • Storage the CPU cannot write gets no range (a CPU_NO_ACCESS or virtual pool). tex_cpu_content takes no texture there, and most render targets live there, so they are no longer protected or looked at.
  • A range keeps its dirty-page handle (nvn_dirty_since_cached), so the check at each render target, clear and copy no longer scans every registered pool. In tex_cpu_content, the locked range check now runs after the cheap flag checks.
  • Two more t_cpu_storage cases (44 checks), and an open limit in the reference entry: GPU writes are stamped when recorded, so a command buffer submitted again does not stamp them again.

Checked on an M4 Pro:

  • Ocarina of Time, Kokiri autosave and a walk: the 260x260x2 blend maps and a 260x260 R32F height map are taken from their storage, none on main. 433 CPU-written textures alive against 270, with the same frame and GPU time.
  • Minecraft Dungeons II, menu to first control: the same picture and frame time. mprotect takes 44.7 ms over the run against 5.4 ms, about 11 µs a frame.

@seedds seedds closed this Nov 7, 2026
john62036203-ops added a commit to john62036203-ops/NXNativeWindows that referenced this pull request Nov 7, 2026
…ir storage by GPU-written range, PR seedds#1604; a revoked GPU stops the game, PR seedds#1332) into switch2

The fork's own GPU-written range tracking (DM_NVN_GPUW_RANGE) gives way to upstream's gws_* spans, which do the same for Ocarina's terrain; the NDVE scale marks its target through mtl_tex_gpu_written. front_gpu_lost uses the fork's front_stop_alert beside front_gpu_memory_stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SzykpcKm2eZPvPjGPKx1C
cfryerdev pushed a commit to cfryerdev/nxnative that referenced this pull request Nov 7, 2026
…il cached view, on the test stack

seedds#1604 uncompressed textures are taken from their storage unless the GPU
wrote that storage, by address range, not their pool: the terrain no
longer opens holes after walking a distance (streamed tiles came out
zero once any texture in their pool had been a render target; the
260x260 tera_tile special case goes with it). Hand-merged with the
stack's own ownership code: serial GPU writes (seedds#1558) and the BC
render-target shadows (seedds#1590) stay; the image bind records the range
through mtl_tex_gpu_written beside them; mtl_pool_gpu_written is gone
with its last caller.

seedds#1607 a texture ID with no texture drops its cached view: nil == nil
kept a released texture valid on the pool tables' fast path, and the
fast paths gave the shaders its resource ID after the host had released
it (GPU page faults). The nil guard keeps the alias-view parent match.

Tested in game on an M3 Max: a walk between areas holds the ground
whole, no page faults, no regression of the earlier fixes (upscaler
lines, load-time holes, stutters).

Front-ends: host change only, chain unchanged (2027aa29b1b7).
seedds added a commit that referenced this pull request Nov 7, 2026
…whose pages were write-protected are no longer lost (issue #1591)

A zero-copy Metal buffer made while any of its pages is read-only drops every GPU write through it, silently. Chunks of large pools, a lengthened chunk, the whole-pool buffer
of raw global memory and a small pool sharing a page with a protected neighbour were all made that way. pool_buf makes them under nvn_dirty_hold: protected pages become
writable and count as written (packaged textures are read again), and GPU-written storage the CPU had not written gets its stamp and protection back (PR #1604's spans).
Open: a CPU write into such storage while the buffer is being made is not seen.

Co-authored-by: daviddiblasi <92635269+daviddiblasi@users.noreply.github.com>
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 7, 2026
…merges

clang-tidy found one more core.NullDereference in nvn_mtl.m (tex_cpu_content's
first-use log line read t->pool->flags without a pool, seedds#1604), and one more
nullability.NullablePassedToNonnull each in t_copy_alias.m (a kernel function
looked up by name, seedds#1556) and t_pso_archive.m (the archive path from a boxed
C string, seedds#1307). The log line prints 0 for a texture without a pool; the
tests stop when the lookup fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK6QbhFrzr2Vop9t34k
Aznracer pushed a commit to Aznracer/Mighty-Green-Kokiri-Kid that referenced this pull request Nov 8, 2026
…age again: GPU-written spans keep the dirty-page stamp of their last GPU write and protect their whole pages; Ocarina of Time's terrain tiles reused over drawn normal maps no longer open holes in the ground after leaving the Deku Tree (from upstream e2b76d4, PR seedds#1604, on this tree's ranges)
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…te that storage, not their pool: Ocarina of Time's terrain height and blend maps no longer come out as zeros, the ground opening in holes after walking (PR seedds#1604)

tex_cpu_content refused every uncompressed 2D texture of a pool once anything in that pool had been a render target, a clear's or a copy's destination, or a shader image. Ocarina of Time keeps its terrain tiles' height (260x260 R32F) and blend / hole maps (260x260 RGBA8) in the pool its tiles' normal maps are drawn into, and the CPU fills them before nvnTextureInitialize with no flush: after the first normal map was drawn, every tile streamed in became a GPU texture of zeros.

The host now keeps the address ranges the GPU writes (mtl_tex_gpu_written, gws_span), dropped with their pool. Each keeps the dirty-page generation of its last GPU write and has its whole pages protected, so storage the CPU rewrites afterwards is taken again, and a texture taken there becomes a GPU texture at the next GPU write (cpu_unc_drop). Ranges do not merge: a GPU write over part of one cuts it and the rest keeps its own stamp. Storage the CPU cannot write (CPU_NO_ACCESS, virtual) gets no range, and a range keeps its dirty-page handle, so the check at each render target, clear and copy scans no pool list.

Ocarina of Time (M4 Pro, Kokiri autosave and a walk): the blend and height maps are taken from their storage (none on main), 433 CPU-written textures alive against 270, faults 523 against 538, frame and GPU time unchanged. Minecraft Dungeons II (menu to first control): the same picture and frame time; mprotect 44.7 ms over the run against 5.4 ms. t_cpu_storage 44 checks, 0 failed.

Co-authored-by: Felipe Milioni <20670722+fmilioni@users.noreply.github.com>
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…whose pages were write-protected are no longer lost (issue seedds#1591)

A zero-copy Metal buffer made while any of its pages is read-only drops every GPU write through it, silently. Chunks of large pools, a lengthened chunk, the whole-pool buffer
of raw global memory and a small pool sharing a page with a protected neighbour were all made that way. pool_buf makes them under nvn_dirty_hold: protected pages become
writable and count as written (packaged textures are read again), and GPU-written storage the CPU had not written gets its stamp and protection back (PR seedds#1604's spans).
Open: a CPU write into such storage while the buffer is being made is not seen.

Co-authored-by: daviddiblasi <92635269+daviddiblasi@users.noreply.github.com>
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