Skip to content

Ocarina of Time (Switch 2): GPU-written texture ranges fix the terrain holes; opt-in texture release saves ~2.3 GB; a page on playing it - #1447

Open
Peak8D wants to merge 3 commits into
seedds:mainfrom
Peak8D:oot-host-perf-memory
Open

Peak8D wants to merge 3 commits into
seedds:mainfrom
Peak8D:oot-host-perf-memory

Conversation

@Peak8D

@Peak8D Peak8D commented Nov 6, 2026

Copy link
Copy Markdown

Two host fixes found while bringing up The Legend of Zelda: Ocarina of Time (Switch 2, 0400486024DEC000) on the Mac, plus a page on what it takes to play it. The texture release builds on the GPU-written ranges, so they come as one pull request in that order; each commit stands on its own and is switchable.


GPU-written texture storage is kept as address ranges: Ocarina of Time's terrain no longer has holes

Branch oot-host-perf-memory, commit 1 of 3 (GPU-written texture storage is kept as address ranges ...). Independent of every open PR; off main (c83c48f).

What and why

Symptom. At Kokiri Forest's pond in The Legend of Zelda: Ocarina of Time (Switch 2, 0400486024DEC000), low ground with no model over it was missing: the sky showed through, and clover and pebbles floated over the holes (read at first as a flat turquoise "water" patch).

Cause. tex_cpu_content takes an uncompressed texture the CPU wrote from its storage only while its pool has had no GPU write (pool_t.gpu_written, from #1109's rule for Mario Kart World's terrain). Ocarina of Time keeps its TerrainGround patches (260x260 R32F heights, RG8 / RGBA8 and layered RGBA8 maps; CPU-written, never flushed) in one ~200 MB CPU_CACHED | GPU_CACHED pool (flags 0x24) that also holds textures it renders into. The first GPU write anywhere in that pool turned every patch into a GPU texture of zeros, so each patch was a flat plane at its base height under the real ground; where no model covers it, the depth prepass stayed 1 and the sky was drawn.

Fix (every game). mtl_tex_gpu_written(tex) replaces mtl_pool_gpu_written(pool) at the seven call sites (render targets, texture clears, copy / downsample destinations, image stores). It records the texture's storage range in a sorted set of merged [lo, hi) ranges (dropped with the pool at finalize, cpu_tex_drop_pool); tex_cpu_content and cpu_unc_drop test the texture's own range instead of the pool flag. The pool flag is still set, so DM_NVN_GPUW_RANGE=0 restores the old rule exactly.

Measurements

  • texraw of the bound heightmaps: 0.0 everywhere before, 0.13-0.18 after (memdump of their storage: the same heights, ~150-270k non-zero bytes per patch).
  • Same-binary A/B (DM_NVN_GPUW_RANGE=0 vs default), user save at the pond, 6 cameras: holes gone, the real pond still renders water.
  • No new [RED], no GPU errors; memory equal (footprint max 10.3 vs ~10.6 GB).
  • Mario Kart World: its terrain pool is never GPU-written, so it takes the same path as before (not re-run).

Switches

  • DM_NVN_GPUW_RANGE (default 1; 0 = per-pool rule). Log line: GPU-written texture storage tracked by address range.

Risks

  • A texture whose storage the GPU writes through another, non-overlapping texture description is unaffected (ranges are by address, so any overlap counts).
  • GPU writes that reach texture storage only through raw global memory or buffer stores are not recorded as ranges — exactly as they were not recorded by the pool rule before (it also needed one of the seven call sites).
  • The range set grows with distinct GPU-written storage; render targets are merged and repeated writes are a binary search hit.

Tests

  • make -C dm/gfx/tests GAME=010015100B514000 check_t_cpu_storage: 38 checks, 0 failed. The test now uses mtl_tex_gpu_written and adds "a GPU write elsewhere in its pool leaves its storage the content".
  • t_cpu_tex, t_feedback, t_direct_upload pass; Mac host builds for the three CI profiles; tool tests (test_docs, test_env_switches) pass.
  • Not run: scripts/regress.py check --games (needs local game files; maintainer or a contributor with MKW / BotW / TotK should run it — those three share the CPU-written storage path).

Docs

docs/reference/nvn-objects.md: new entry "Uncompressed storage the CPU wrote, next to storage the GPU writes"; docs/agents/symptoms.md regenerated; docs/env_switches.tsv row. No game note yet: Ocarina of Time's note comes with #1299 (a "Found on this game" line should be added there).


DM_TEX_RELEASE gives back the guest copy of an uploaded texture, and a texture made again over that storage copies the one released there

Branch oot-host-perf-memory, commit 2 of 3 (DM_TEX_RELEASE gives back the guest copy ...). Off main; touches nvn_mtl.m near commit 1's hunks only by context (it can be reviewed and taken on its own; if taken first, commit 1 rebases trivially). Opt-in: nothing changes unless DM_TEX_RELEASE is set.

What and why

Symptom. Ocarina of Time on a 24 GB Mac was stopped for memory pressure as gameplay started. Of its guest memory, ~3.1 GB is texture storage, ~2.8 GB of it dirty or compressed, sitting beside the private Metal textures that hold the same content: 1.8 GB in CPU-visible pools whose textures are never uploaded again ("re-uploads on pool content change: 0"), 1.3-2.2 GB in CPU_NO_ACCESS pools (render and depth targets whose storage the host never reads).

Fix. Once the upload batch that read a texture has completed on the GPU (up_flush feedback → rel_run), the whole pages inside its storage become zero pages (nvn_dirty_discard, new in nvn_dirty.c: an anonymous MAP_FIXED mapping, the method guest_release uses):

  • DM_TEX_RELEASE=1: textures in CPU_NO_ACCESS pools, and GPU-only textures made there with no initial fill;
  • DM_TEX_RELEASE=2: also CPU-visible textures uploaded by the tracked path, only pages still write-protected since the upload; they stay protected, so a later CPU write still faults and re-uploads;
  • DM_TEX_RELEASE=probe: counts what it would release.

Left alone: virtual / sparse pools, multisample textures, a range a residency-set pool covers (its no-copy buffer keeps the old pages wired), entries of a pool finalized before the batch completes (rel_pool_gone from mtl_pool_del), a failed batch, and guest memory kept in files (the iPhone layout of #1315: an anonymous mapping would be charged to the footprint again; new dm_guest_in_files() in hle_svc.c, weak default 0 for tests).

Second bug the release exposed (DM_TEX_REL_DONOR, on with the release). The game registers new texture objects over boot-time storage without writing it again (Link's hat embroidery: two BC4 512x512 masks, registered again at gameplay entry), so their first upload read the released zero pages and the embroidery rendered black. rel_push remembers per storage address the last tracked texture queued for release; pkg_upload_ of a texture over released pages looks it up and, when the description matches, the host texture is alive, the storage was not written since its upload (nvn_dirty_since_span) and the Metal shape matches, makes the new texture a GPU copyFromTexture of it (encoded in up_flush after the buffer copies; sources held until the batch completes). A miss logs [RED] texture made again over released storage ... : <reason>; an all-zero re-read logs [RED] texture made again over released storage reads only zeros (relpg_check, with =2).

Measurements (Ocarina of Time, 24 GB M5 Pro Mac, guarded 190-260 s runs)

  • ~2.3 GB given back within ~10 s of gameplay entry (restop guest copy release line).
  • With this and the same session's other memory changes (not in this PR, see the follow-ups), walking runs of 260 s survived to the timer (footprint median 10.8 GB, min available 3.3 GB) where 4 of 4 had been killed at entry. Run-to-run noise is ~0.5 GB, so the release alone is not separated in these numbers.
  • Hat embroidery: masks' non-zero bytes 121,863 / 116,387 with the donor (same as DM_TEX_RELEASE=0); 8+ donor copies at entry; no new [RED], no GPU error.
  • Screenshots after walking (house, forest, pond): no missing or garbled textures. 0 hits of the all-zero detector in all runs.

Switches

  • DM_TEX_RELEASE = 0 (default) / 1 / 2 / probe; recommended for Ocarina of Time: 2.
  • DM_TEX_RELEASE_METHOD=free (diagnostic): madvise(MADV_FREE_REUSABLE) instead of fresh zero pages.
  • DM_TEX_REL_DONOR (default 1; 0 = off).

Risks

  • Lossy by design: a texture of another description made over released storage that the game did not write again reads zeros (logged as a donor miss). WIP: DM_NVN_TEX_DEDUPE gives back the guest copy of uploaded textures (Ocarina of Time: about 1.9 GB less footprint) #1386 (DM_NVN_TEX_DEDUPE, restore-on-fault, open) is the lossless alternative for CPU-visible pools; it cannot cover CPU_NO_ACCESS pools, which this does. The two could coexist (=1 here + dedupe there), or this PR can be reduced to =1 if the maintainer prefers.
  • g_reldon (donor table) keeps one small entry per released address for the run; it is not dropped at pool finalize (the donor check re-validates the host texture and the storage's write state, so a stale entry can only miss).
  • Not checked: other games, the iPhone (the file-backed guest is skipped by design), long area changes beyond the 260 s walks.

Tests

  • New dm/gfx/tests/t_dirty_discard.c (added to the CI CPU list): partial pages kept, written-since page kept, discarded pages read zero and stay protected (a later write is seen and lands), untracked memory only with tracked = 0. 11 checks pass.
  • t_dirty_slots, t_cpu_storage, t_cpu_tex, t_direct_upload pass; host builds for the three CI profiles; Python tool tests pass with the tool venv (test_nsp_scan needs dotnet on PATH); t_symbols / t_nro fail to link on main too.

Docs

docs/reference/memory.md: new entry "The guest copy of uploaded textures given back"; symptom index regenerated; three rows in docs/env_switches.tsv.


Docs

docs/games/ocarina-of-time.md: what a player needs (their own dump as a .nup, no keys), the open pull requests it depends on (#1299 for the profile, #1405 for the raw-load path), build steps, recommended settings, known issues and measurements on an M5 Pro. Once #1299 lands it can move into the game's note.

🤖 Generated with Claude Code

Peak8D and others added 3 commits November 5, 2026 20:48
…ssed texture the CPU wrote is taken from its storage unless the GPU wrote that storage: Ocarina of Time's terrain no longer has holes

Symptom: at Kokiri Forest's pond, low ground with no model over it was missing; the sky showed through and the clover and pebbles floated over it.
Cause: tex_cpu_content stopped taking an uncompressed texture's storage at the first GPU write anywhere in its pool (pool_t.gpu_written). The game keeps its
TerrainGround patches (260x260 R32F heights, RG8 / RGBA8 maps; CPU-written, never flushed) in one ~200 MB CPU_CACHED | GPU_CACHED pool that also holds
textures it renders into, so every patch became a GPU texture of zeros and a flat plane at its base height under the real ground.
Fix: mtl_tex_gpu_written records the storage range of each texture the GPU renders, clears, copies or downsamples into, or writes as an image (a sorted set of
merged ranges, dropped with the pool at finalize); tex_cpu_content and cpu_unc_drop test the texture's own range. DM_NVN_GPUW_RANGE=0 restores the per-pool rule.
Checked: texraw of the heightmaps reads 0.13-0.18 instead of 0, the holes are gone in a same-binary A/B at six cameras, memory unchanged; Mario Kart World's
terrain pool is never GPU-written, so it takes the same path as before. t_cpu_storage covers a GPU write elsewhere in the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the GPU has it, and a texture made again over that storage copies the one released there: Ocarina of Time keeps about 2.3 GB less

Symptom: Ocarina of Time on a 24 GB Mac was stopped for memory pressure at gameplay entry. About 3.1 GB of guest memory was texture storage, 2.8 GB of it dirty
or compressed, beside the private Metal textures that hold the same content: 1.8 GB in CPU-visible pools whose textures are never uploaded again, 1.3-2.2 GB in
CPU_NO_ACCESS pools of render and depth targets.
Fix (opt-in, off by default): once an upload batch has completed on the GPU, rel_run replaces the whole pages inside the storage with zero pages
(nvn_dirty_discard, as guest_release does). DM_TEX_RELEASE=1: CPU_NO_ACCESS pools, and GPU-only textures made there with no fill; 2: also tracked CPU-visible
uploads, only pages still write-protected since the upload, kept protected so a later write faults and re-uploads. Left alone: virtual pools, multisample
textures, pools in the residency set, a pool finalized before its batch completes, and guest memory kept in files (iPhone). Releasing showed a second bug: the
game registers new texture objects over boot-time storage without writing it again (Link's hat embroidery masks, BC4 512x512), and their upload read zeros.
DM_TEX_REL_DONOR (on with the release) makes such a texture a GPU copy of the one released there when the description matches, its host texture is alive and
the storage was not written since; a miss is a [RED] line with its reason. DM_TEX_RELEASE=probe counts without releasing; restop prints the totals.
Measured: ~2.3 GB given back within 10 s of gameplay; with it and the session's other memory changes, walking runs of 260 s survived (min available 3.3 GB)
where 4 of 4 had been killed at entry. Hat embroidery green again, masks' non-zero bytes as with the release off. t_dirty_discard covers the page rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…these changes (a page for the game's note once its profile lands)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation ci Continuous integration and repository settings (.github) labels Nov 6, 2026
@lunks

lunks commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Thanks. The terrain-holes fix in the first commit is #1390 (open since Nov 5; the same seven call sites, ranges by address, played on a 16 GB M4: the path, grass and Link's shadow are back). Could you try #1390 on your pond save? If it does the same, one of the two should go. The texture release overlaps #1386's DM_NVN_TEX_DEDUPE.

fpedrolucas95 added a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…ack the guest copy of uploaded textures (opt-in); a page on playing Ocarina of Time. Commit 1 (GPU-written ranges) is already here from seedds#1390

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 6, 2026
…rina of Time, with the reasons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK29T18U8p1c9LsVY8g
@seedds

seedds commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thank you for the work and testing. Main fixed the terrain holes another way (tex_flush_upload and tex_dirty_upload in the profile, landed on main in b1d7c70 and 6121fd7). I'm leaving this open because the opt-in texture release and its memory saving aren't covered. Please rebase it and say what it still saves on current main. Final checks on the rebased tree (full shader build, walks, regression) are still running, so please report anything you see.

john62036203-ops added a commit to john62036203-ops/NXNativeWindows that referenced this pull request Nov 6, 2026
… of uploaded textures) over seedds#1453 and seedds#1455

pool_heap_texture's fill takes seedds#1455's again argument (0: the placement is new).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SzykpcKm2eZPvPjGPKx1C
john62036203-ops added a commit to john62036203-ops/NXNativeWindows that referenced this pull request Nov 6, 2026
…red pool buffers no longer pile up) over seedds#1447, seedds#1453 and seedds#1455

alias_let_go also clears seedds#1455's lookup stamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SzykpcKm2eZPvPjGPKx1C
fpedrolucas95 added a commit to fpedrolucas95/nxnative that referenced this pull request Nov 6, 2026
…n Kokiri Forest, about 4 GB less memory) beside this tree's own work

Kept from this tree: the growing raw pool table with two-word hints (plus upstream's read-before-OR on its write
log), seedds#1439's held count, seedds#1436's diagnostics, seedds#1447's texture release, seedds#1435, the idle evictions, the GPU watchdog,
range-tracked GPU writes, the packaged-texture cache (g_pkg_alias), the synchronous task / mesh pipeline build with
prewarm, this tree's F2IP.

Taken from upstream: the pool tables (only what shaders read is made, within a budget; pool draws count as depth
readers only when one was seen reading, replacing seedds#1430's rule; table reuse), sub_cut's busy rule, the indirect
task / mesh draw through mdx, the empty mesh fragment, the larger GPU-write table (65,536 slots), linear textures by
rows, the atomic alias on any 32-bit format, its eventfds (with this tree's select and fcntl on them), its
prepo Send and audio update lock, its shader split and Ocarina profile and tables.

Duplicates both sides added were removed (SDK 22 answers, F2IP, test stubs); RED_ONCE 153 split; t_pool_table
(the replaced pool table code) removed; tests adapted to the taken rules. Translator rebuilt; chain 695a9393d125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
m-gnr pushed a commit to m-gnr/kid-with-sword-remake that referenced this pull request Nov 7, 2026
- Reconcile upstream PRs (seedds#1455, seedds#1447, seedds#1394, seedds#1439, seedds#1489, seedds#1590, seedds#1663) and commits (ffebfd5, e5f1ade) against local fork
- Confirm PRs seedds#1455, seedds#1447, seedds#1394, and seedds#1439 are already implemented in ad99858
- Establish that host-only renderer optimizations require zero shader rebuilds and reuse the 42,854-entry b-dc815e0f38ca.img library
- Establish that native subword stores (ffebfd5) affect only 515 shaders and leverage cas.db compilation caching
- Rank candidates for base M3 Normal + Handheld (>=40 FPS target, ideal 60 FPS) and identify the top three candidates
- Recommend the smallest independently verifiable first optimization (host-only PSO archive epoch separation from PR seedds#1590)
- Update documentation index, plan, changelog, and known issues
@Amost-

Amost- commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

DM_TEX_RELEASE: a second way level 2 loses texture data, and a race at any level

On #1641, ktbunch reports Hyrule Castle rendering pink at level 2, fixed by DM_TEX_RELEASE=0. Bumper2l6 then found that level 1 renders correctly where level 2 shows magenta textures in Hyrule Field, on the way to Lon Lon Ranch and in Gerudo Valley. Both runs were on the kid-with-sword-remake fork, whose profile default is 2. The log lines in that report are the gap this PR already names: new textures made over released storage that rel_donor cannot cover (no texture released at this address, another description, no host texture).

A read-only review of this PR's code found two more things. I reviewed it as I carry it on main 028a2631; the functions are the same in this PR's head 71f96763. I run with the switch off, so I have reproduced neither in game.

  • Level 2: a partial write after the release re-uploads zeros. rel_push also queues the tracked uploads of CPU-visible pools. rel_run then calls nvn_dirty_discard with tracked=1, which by default maps fresh zero pages over every whole page of the texture's storage that is still write-protected. The pages are protected again, but their guest bytes are now zero.
    • When the game later writes part of such a texture (one region or one mip), the fault marks it dirty, and the packaged re-upload reads the whole storage. So every page it did not rewrite uploads as zeros, and the game's own CPU reads of those pages return zeros too.
    • In the log this would show up as rel_donor's same serial variant.
    • Level 2 has no cheap fix inside rel_run, since a re-upload after a partial write needs the bytes the release threw away. So env_switches.tsv's "re-uploads as before" does not hold. Until that changes, 1 is the level to recommend, as Bumper2l6 suggests.
  • Any level: a check-then-act race on residency. rel_run, on a Metal completion thread, checks with rel_wired, which takes and drops g_mu, then discards while holding only g_rel_mu. pool_res, reached from mtl_addr / mtl_gpu_addr usually on the submit thread, wires pools under g_mu and never takes g_rel_mu.
    • A pool addressed for the first time in that window has its buffer made resident while its pages are being replaced. By the rule rel_wired exists for (a resident no-copy buffer keeps its pages wired), the GPU then keeps the old pages while the CPU sees the new zero pages.
    • The window is narrow: the pool has to be addressed for the first time just as an upload batch completes. I have not seen it happen.
    • Holding g_mu across the check and the discard would close it, and keeps the existing lock order.

@FifteenPen

Copy link
Copy Markdown
Contributor

DM_TEX_RELEASE=2 lost textures for us after area changes. Ocarina of Time's textures made again over released pages read zeros: magenta and black surfaces from Hyrule Castle Town into Hyrule Field. Level 1 did not lose them. It may be worth limiting the switch to 1, or documenting 2 as unsafe.

@AmateurWizrd

Copy link
Copy Markdown
Contributor

Two things about DM_TEX_RELEASE (0034c911, in this PR's head 71f96763), from a review of a port of it, and a measurement on another Mac.

  1. Mode 1 gives back the storage of a GPU-made texture's one-time fill (tex_init_fill: it has no stamp, so no donor record is kept). A texture made again over that storage, after the host has let its texture go, is filled with zeros, and no [RED] line says so. On a fork such fills are left alone: AmateurWizrd/nxnative@78764a9d. That may cut part of the CPU_NO_ACCESS saving; I have not measured by how much.
  2. Mode 2: a texture the game rewrites in part re-uploads zero pages for the rest. Only the written pages fault back; the others were replaced by zero pages. Not fixed on the fork, where mode 2 is documented as measuring-only.

Measured with mode 1 and the fix: Ocarina of Time (0400486024DEC000), M3 Pro 18 GB, macOS 27.3, Kokiri Forest, a scripted stand of 30 s and a walk, two runs each way.

Footprint at the stand (MiB) Peak (MiB) GPU / frame ms
off (3 runs) 11,494 to 11,542 12,008 to 12,122 21.3 / 21.8
DM_TEX_RELEASE=1 11,241, 11,417 11,675, 11,854 unchanged

So 100 to 276 MiB less here, well under the 2.3 GB of the 24 GB Mac in the commit message; the build measured already carries other memory changes. Together with DM_NVN_UPLOAD_IDLE_MS=2000 and DM_NVN_WINDOW_ALIAS=1 the peak went from 12,400 to 12,447 MiB down to 10,754 MiB, for 1.0 ms more GPU time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Continuous integration and repository settings (.github) docs Documentation host The Horizon host: SVCs, services, threads, memory (dm/host) 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.

6 participants