Skip to content

Ocarina of Time: fix the "plaid" terrain / missing texture tiles (GPU-drawn textures now reach the storage the game copies) - #2077

Closed
roandegraaf wants to merge 1 commit into
seedds:mainfrom
roandegraaf:gpu-texture-storage-readback
Closed

roandegraaf wants to merge 1 commit into
seedds:mainfrom
roandegraaf:gpu-texture-storage-readback

Conversation

@roandegraaf

Copy link
Copy Markdown
Contributor

What and why

Symptom. In The Legend of Zelda: Ocarina of Time (0400486024DEC000, v131072) on the Mac, after several area changes with dungeon entries and exits the ground turns into crossing stripes ("plaid"), tile by tile, and stays that way until the game is restarted. Loading a save alone does not bring it. This is one of the wrong-terrain kinds reported in #1865 (refs #1865; it does not claim the others).

Cause. On the hardware a render pass, a texture clear or a copy into a texture writes the texture's storage, and a game may read it there. The host wrote that storage only for textures it uploads from their storage (Render passes into textures uploaded from their storage: Tears of the Kingdom's terrain normal maps, which a flush makes CPU-written).

Ocarina of Time has the same terrain defragmentation (SeqExecuteDefragmentationForTexture: memcpy to a new address in the pool, then the texture is re-created there) but never flushes its normal tiles (260x260 RG8, 9 levels, a CPU_CACHED | GPU_CACHED pool). They stay GPU textures, so their drawing lived in the host texture alone and the memcpy moved zeros. The texture made at the new address was then taken from those zeros: a zero normal.

How it was shown, not guessed: a 32-byte tag written into each drawn normal tile's storage was later found at a different address 172 times in one play session (the earliest 12 frames after the tile was drawn, half after about 1,290). In the frame that showed the stripes, 42 of the 57 registered normal tiles had been made from storage holding a copied tag and otherwise zeros, 16 of the 18 bound to the terrain draws among them.

Fix (every game, no profile key). What the GPU writes into a GPU texture whose storage could be its content is read back into that storage, through the existing mirror_readback path:

Part What it does
tex_storage_gpu Admits an uncompressed 2D texture that tex_storage_ok admits (no depth, display, video-decode, COMPRESSIBLE, IMAGE, LINEAR, multisample or buffer texture; a pool the CPU can access, not virtual)
gsb_mark Render passes, texture clears and buffer/texture copies mark the level, slices and region they write, by storage range
gsb_flush Reads the marks back once at the end of the submission, so a level written in several passes is read once
Cost bound A level written within the two frames before waits until two frames pass without a write: a baked tile is read with no delay, a target drawn every frame costs two readbacks in all, not one a frame
mirror_rb_own Lands only in storage that is still that texture's and the GPU's (its recorded span, not taken, no CPU write since); then stamps the span and the registered texture's entry again, so the host's own write is not taken for the CPU's and the texture stays a GPU texture

tex_storage_dst is unchanged, and the new kind never uses the CPU mirror of a copy, g_mrb or g_abs. One addition to the existing path: a texture clear of a texture uploaded from its storage is now read back as its render passes are. DM_NVN_GPU_STORAGE=0 turns the new readbacks off (to measure).

Reference entry with the open points: docs/reference/nvn-objects.md, "GPU textures in storage the CPU can read". Game note line under "Found on this game".

Checked

Docs

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

…aches that storage: Ocarina of Time's terrain no longer turns to crossing stripes ("plaid") after dungeon entries and exits

On the hardware a render pass, a texture clear or a copy into a texture writes
the texture's storage. The host wrote it only for textures it uploads from
their storage (Tears of the Kingdom's terrain normal maps, which a flush makes
CPU-written). Ocarina of Time has the same terrain defragmentation but never
flushes its normal tiles (260x260 RG8, 9 levels, a CPU_CACHED | GPU_CACHED
pool): they stay GPU textures, their drawing lived in the host texture alone,
and the defragmentation's memcpy moved zeros. The texture re-created at the new
address was taken from them: a zero normal.

tex_storage_gpu admits an uncompressed 2D texture that tex_storage_ok admits.
Render passes, clears and copies mark the level, slices and region they write,
by storage range (gsb_mark); the marks are read back once at the end of the
submission (gsb_flush) through mirror_readback. A level written within the two
frames before waits until two frames pass without a write, so a target drawn
every frame costs two readbacks, not one a frame. The bytes land only in
storage that is still that texture's and the GPU's (mirror_rb_own); the span
and the registered texture's entry are stamped again, so the host's own write
is not taken for the CPU's. A texture clear of a texture uploaded from its
storage is read back as its render passes are. DM_NVN_GPU_STORAGE=0 turns the
new readbacks off.

Reference entry: docs/reference/nvn-objects.md, "GPU textures in storage the
CPU can read". Refs seedds#1865.
@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
@roandegraaf roandegraaf changed the title Ocarina of Time: fix the "plaid" terrain after dungeon visits (GPU-drawn textures now reach the storage the game copies) Ocarina of Time: fix the "plaid" terrain / missing texture tiles (GPU-drawn textures now reach the storage the game copies) Nov 9, 2026
Aznracer pushed a commit to Aznracer/Mighty-Green-Kokiri-Kid that referenced this pull request Nov 10, 2026
…aches that storage: Ocarina of Time's terrain no longer turns to crossing stripes ("plaid") after dungeon entries and exits (PR seedds#2077)
betogzo added a commit to betogzo/nxnative that referenced this pull request Nov 10, 2026
seedds#2030, seedds#2077, seedds#2022, seedds#2075, seedds#2085), last merged at 04811a6, and the Castle Town result on a MacBook Air M4

Castle Town on a 16 GB MacBook Air M4 at 30 fps and full resolution: GPU p50 26-27 ms a frame and 6.9 GB of GPU memory allocated with seedds#2085, against 16.2 GB and about 9 fps before it.

Tests: dm/tools/tests/test_docs.py passes.
FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
…e "plaid" terrain / missing texture tiles (GPU-drawn textures now reach the storage the game copies)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@akratch

akratch commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

This is a strong lead for the persistent plaid terrain seen in our OOT playthrough. Our earlier terrain regression began with CPU-filled content; it did not establish the GPU-generated normal-map → backing storage → CPU relocation direction you traced here. Thank you for documenting that distinction and the exact-branch gameplay limit.

Two additional synthetic cases would help make this safe to take generally. These are source-review concerns, not claims of failures reproduced in our gameplay:

  1. A valid CPU relocation immediately after the second completed submission. Write A, wait, advance one frame, write B, wait, then memcpy the backing storage and recreate/sample the texture at the new address. The current hot-level policy deliberately leaves A there until the quiet period; the repeated-frame test asserts that stale value. This is an explicit documented limitation, but it means submission completion alone does not provide storage visibility for a legitimate copy after a hot write.
  2. A different GPU writer over the same storage while an old hot entry waits. Make A hot, then write that range through an IMAGE or CPU-uploaded texture B excluded from tex_storage_gpu. Check that A cannot later land over B after the quiet period. gsb_mark cancels overlaps only when the new writer itself passes tex_storage_gpu, while mirror_rb_own checks the exact range, taken, CPU stamps and liveness without requiring the writer generation/serial that scheduled the readback. Pool removal and address reuse deserve the same oracle. A GPU pixel result after recreation is preferable to checking only mark bookkeeping.

For our integration we are holding this change separately until those visibility/ownership cases are covered. We are not proposing a full-size stand-in, blanket texture flag change or lower rendering resolution as a substitute.

FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
Conflicts resolved by hand:
- seedds#2085, seedds#2078 and seedds#2065 came back from upstream as reworked commits (6f50de0, 12a44e0, 5e1c1ae): our three merges were reverted first, so the merge brings upstream's version instead of two copies.
- GPU copies into a texture (copy_b2t, mirror_rb_land, nvn_obj.h): upstream's seedds#1310 rework (mspan_t, pkg_copy_absorb, for the readback case) and our seedds#2077 (mirror_rb_own, gsb_mark, for a texture the GPU owns) cover different cases and both stay: own = !rb is taken before the synchronous mirror, so the two paths exclude each other. mirror_rb_own passes no span.
- loader.c: upstream's render-scale parse with the stub's largest scale (Hades II) and our graphics-scale tier block. t_readback.m: upstream's additive tests added to ours. pso_offline.py: its header comment. Docs and tables keep both sides; DM_RENDER_SCALE rows, render_base and the settings table take upstream's Hades II range plus our text.
- dmsh_chain.h regenerated; .github/workflows/tests.yml follows upstream (a test list).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
seedds added a commit that referenced this pull request Nov 10, 2026
… written into that storage on the GPU, in the same submission: Ocarina of Time's terrain no longer turns to crossing stripes ("plaid") after dungeon entries and exits (PR #2077)

On the hardware a render pass, a texture clear or a copy into a texture writes its storage, and a
game may read it there. The host wrote that storage only for textures it uploads from their
storage (a CPU readback when the submission completes). Ocarina of Time's terrain normal tiles
(260x260 RG8, 9 levels, a CPU_CACHED | GPU_CACHED pool, never flushed) stay GPU textures, so their
drawing lived in the host texture alone; the terrain defragmentation moves them with memcpy and
re-creates them, and the texture made at the new address was taken from zeros. Shown by Roan de
Graaf with a tag in each drawn tile's storage, found at the copies' addresses.

gsb_store runs after each render pass, texture clear and buffer or texture copy into such a
texture (tex_storage_gpu: uncompressed 2D, tex_storage_ok, not CPU-written, not one the host
uploads from its storage, a host format of the storage's texel size): the region written is
copied from the host texture into the frame arena and swizzled into the pool's buffer by the new
swz kernel (dm/gfx/msl/deswz.metal, the inverse of deswz), only the region's texel bytes. It is in
the same command buffer, so it lands before any fence after the write, and page protection does
not see it: the storage stays the GPU's and the texture a GPU texture. The PR's first version read
the region back on the CPU and held a level written in two nearby frames until two quiet frames
passed; a copy within that time got the older content, and the late landing needed guards
against reused storage. Written on the GPU, every write is in the storage when its fence signals
and none lands later. A texture clear of a texture uploaded from its storage is read back as its
render passes are. The power line gains "[nvn] GPU texture storage". DM_NVN_GPU_STORAGE=0 turns
the stores off.

Checked (M4 Pro): make -C dm/gfx/tests GAME=0400486024DEC000 run exits 0; t_cpu_storage 136
checks (a write the frame after another is in the storage at once and a memcpy right after it
gets it; nothing lands over storage the game reused, however many frames pass; a partial region;
every level of a 260x260 RG8 9-level tile, every texel) and t_readback 204 cases, 9 and 5 of them
failing with DM_NVN_GPU_STORAGE=0. Minecraft Dungeons II, title to first control, three on/off
pairs: about 470 regions (116-125 MB) stored while loading, about 280 (47 MB) entering the level,
none in play; 60 fps in play either way, the same picture. Ocarina of Time not run on this design
(its build's libraries are of an older chain); regress.py check not run (no smoke route on the
games built here). Refs #1865.

Co-authored-by: Roan de Graaf <36153028+roandegraaf@users.noreply.github.com>
@seedds

seedds commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Landed reworked in 6c3aeb3, with you as co-author. Thank you: the tag in the storage made the cause certain, and the diagnosis, the tex_storage_gpu rule, the hook points in render passes, clears and copies, the tests and the docs are all yours and kept.

What changed is how the drawing reaches the storage. Instead of a CPU readback when the submission completes, the GPU writes it: after each render pass, texture clear or copy into such a texture, gsb_store copies the written region from the host texture into the frame arena and swizzles it into the pool's buffer with a new swz kernel (dm/gfx/msl/deswz.metal, the inverse of deswz). It is in the same command buffer, so it is in the storage before any fence after the write, as on the hardware. That removes:

  • the GSB_QUIET wait and the race it brought (a copy within two frames of a repeated write got the older content): every write is now in the storage when its fence signals;
  • the late landing and its guards (mirror_rb_own, the compare after writing, the restamp under the registered textures' lock): nothing lands after the game could have reused the storage. GPU writes are not seen by page protection, so the storage stays the GPU's without a restamp.

akratch's two cases are tests in t_cpu_storage now: a write the frame after another is in the storage at once and a memcpy right after it gets it; after the game reuses the storage, nothing of the older texture lands over it however many frames pass, and a texture made there holds the CPU's bytes. Also covered: a partial region, and every level of a 260x260 RG8 tile with 9 levels.

Checked on an M4 Pro: make -C dm/gfx/tests GAME=0400486024DEC000 run exits 0 (t_cpu_storage 136 checks, t_readback 204 cases; 9 and 5 fail with DM_NVN_GPU_STORAGE=0). Minecraft Dungeons II, title to first control, three on/off pairs: about 470 regions (116-125 MB) stored while loading, about 280 (47 MB) entering the level, none in play; 60 fps either way, the same picture. The power line now has [nvn] GPU texture storage up to frame … with the count.

Not run here: Ocarina of Time on this design (the build here has libraries of an older chain). A play session with dungeon entries and exits on current main, and a count of the GPU texture storage lines, would be very welcome from you; DM_NVN_GPU_STORAGE=0 gives the before.

Closing this PR, as the change is on main.

@seedds seedds closed this Nov 10, 2026
@roandegraaf
roandegraaf deleted the gpu-texture-storage-readback branch November 10, 2026 06:28
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
seedds#1901 seedds#1459 seedds#1928 seedds#1942 seedds#1940 seedds#1952 seedds#1611 seedds#2065 seedds#2083 seedds#2008 seedds#2079 seedds#2022 seedds#2075 seedds#2077 seedds#2020 seedds#2021 onto main 04811a6

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#1459 by PromethOps (open): DM_UPSCALER=metalfx takes agl's agl_ndsr_vr.pck networks too, and a variant with no curve and no power is drawn as one colour space: Ocarina of Time's upscaler is MetalFX's
- seedds#1611 by Esteban-V (open): An argument block whose only change is a uniform buffer is patched instead of encoded again
- 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#1824 by cocoman93 (open): Clamp texel fetches past a sampled 2D texture's edge: no hard edge in Ocarina of Time's mist
- seedds#1901 by FifteenPen (open): The game's compute dispatches with no game barrier between them run side by side (profile key dispatch_overlap, on for Ocarina of Time): Kokiri Forest GPU p50 15.63 → 14.76 ms
- 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#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
- seedds#1952 by AdyaGMD (open): Compute moves ahead of scene passes past occlusion queries and conditional rendering; clears wait across dispatches
- seedds#2008 by bquilty25 (open): nvn: reactive memory watchdog, 16-way view cache, and texture upload batching
- seedds#2020 by padsiwono (open): Ocarina of Time v131072: Output Native draws the full output size instead of the game's 80 % frame (`DM_OUTPUT`)
- seedds#2021 by padsiwono (open): Ocarina of Time v131072 renders at the launcher's Resolution (`DM_RENDER_SIZE`), docked
- seedds#2022 by sleepyeyedjoe (closed): Ocarina of Time: large models no longer go black for one frame twice, 4–5 frames apart, when the game streams new textures into them (a texture ID a shader has read keeps its read mark when it gets another texture)
- seedds#2030 by humanperzeus (open): A NaN colour at alpha 0 is returned as 0, as NVIDIA's blender takes a zero factor: Ocarina of Time's white clouds over the ferns are gone
- seedds#2035 by Know-It-All-Brother (open): The shader build runs as worker processes, and no shader waits for a batch (Ocarina of Time's cold Mac map: 3,002 s to 1,298 s)
- seedds#2065 by Raymon82777 (closed): A vertex library with layer entry points of its own gets bases for its main0 pairs: Ocarina of Time's 230 ms camera-turn hitch in Hyrule Field goes to 39 ms
- seedds#2075 by sleepyeyedjoe (closed): Ocarina of Time: a patch of scenery no longer draws dark for one frame when the game gives it a sharper texture (a pool texture the walk does not make holds a stand-in of its own colours until its first read)
- seedds#2077 by roandegraaf (closed): Ocarina of Time: fix the "plaid" terrain / missing texture tiles (GPU-drawn textures now reach the storage the game copies)
- seedds#2079 by uncraftablexd (open): Render pipelines that share a vertex function are taken from the archive or compiled one at a time: the AGX driver crashes in createVertexProgramVariant's completion block
- seedds#2083 by FifteenPen (closed): Ocarina of Time's prewarm list gets 41 more task / mesh pipelines from later play
betogzo added a commit to betogzo/nxnative that referenced this pull request Nov 10, 2026
seedds#2085: upstream merged them (6c3aeb3, 6d9edb0, 6c67644, 6f50de0), and the merge that follows takes upstream's versions

Reverts 0218338, faa7afe, cfce7d8, f92d3f3 and a74098c.
betogzo added a commit to betogzo/nxnative that referenced this pull request Nov 10, 2026
 and seedds#2085 in upstream's versions, the host at -O2, Ocarina of Time's camera-turn hitch in Hyrule Field (230 → 39 ms), 41 more task / mesh pipelines and 4,959 render / compute pipelines in its prewarm list, GPU writes into uploaded textures kept on the GPU, compact NVN recordings; chain 8f2d8a09c3f9

PR seedds#2030 (blend_zero) stays the fork's own: upstream moved the shader build's MSL rewrites into dmsh_rewrite.py (80fc2ec), so blend_zero and BLEND_ZERO move there, dmsh_build.py finish calls dmsh_rewrite.blend_zero, and test_dmsh_blend_zero.py imports it. dmsh_chain.h regenerated (8f2d8a09c3f9).

Tests: test_dmsh_*.py 26/26 OK (test_dmsh_pairs with DM_GAME=0400486024DEC000); t_pool_chunks 151/0, t_cpu_storage 135/0, t_readback 218/0, t_pool_seen_replace 44/0, t_pool_standin 31/0, t_pool_notes 81/0, t_mesh_first_use 53/0.
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