Skip to content

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) - #2022

Closed
sleepyeyedjoe wants to merge 2 commits into
seedds:mainfrom
sleepyeyedjoe:oot-pool-seen-keep-1109

Conversation

@sleepyeyedjoe

@sleepyeyedjoe sleepyeyedjoe commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

In short

Two problems, one fixed here

Paired dark frames (fixed here) Single first-read frame (not fixed here: #1969, fix proposed in #2075)
What you see A model black for one frame, twice, 4–5 frames apart A surface darker for one frame, once
Texture ID One a shader has already read; the game puts a new texture into it One no shader has read yet
Why the dummy is read The new registration cleared the ID's read mark, so the walk left the replacement out The ID has no read mark, and the walk's room is 0, so the walk leaves it out
Example The Castle Town wall's material set The cliff left of the Castle Town wall

What and why

In Ocarina of Time (0400486024DEC000 v131072), large static and mesh-shader models go black for one frame, and again 4–5 frames later, a few times a minute in play. The models include the Castle Town wall, Hyrule Castle, rocks and trees near the town, and Lon Lon Ranch's barn.

  • They keep their shape, and the fog is still drawn in front of them. Terrain, foliage and sky are unaffected.
  • A player's 64 s screen recording (M4 Pro) shows three such frames:
    • the barn at 38.8 s;
    • the wall and castle at 58.53 s and 58.60 s, 4 frames apart.

Cause.

  • The models' pixel shaders read their textures through the bindless pool table. When a walk (pool_gpu) writes an ID's entry (pool_entry), it makes the ID's Metal texture in either of two cases:

    • the texture fits the walk's room (pool_room(): the frame's POOL_FRAME_BYTES share, within pool_budget());
    • whatever the room, a shader has read the ID before (its read mark, pool_seen), up to POOL_REQ_BYTES (128 MB) of such textures a frame.

    Otherwise the entry is the dummy, and a read of it gives 0 (black). After such a read, pool_seen_scan sets the mark and makes the texture, so the next frame is right.

  • In this game the room is always 0. pool_budget() is a twentieth of Metal's working set: 909 MB on a 24 GB M4 Pro. currentAllocatedSize is 15–18 GB (details in Ocarina of Time (0400486024DEC000 v131072, Mac): parts of the scenery flash dark for a split second when the game loads a sharper version of them #1969). So only IDs with the read mark get their texture made before they are drawn.

  • mtl_reg_tex cleared the read mark whenever the game registered another texture into an ID.

  • The game's streaming replaces the 17 textures of the wall's material set at once, at 3–7 MB each:

    • it finalizes the old texture first, then registers the new one;
    • it registers each step twice, 4–5 frames apart.
  • With the marks cleared, the walk left the replacements out, so the shaders read the dummy for one frame. The model is black, and the fog is applied after the read. Each of the two registrations of a step gives one dark frame, which makes the pair.

Fix. In mtl_reg_tex, an ID that held a made texture keeps its pool_seen mark when another texture is registered into it.

  • A made texture is one retired by the game, or one still viewed. Checking the view alone is not enough, because the game's finalize drops the view before the new registration arrives.
  • Still cleared as before: an ID that held no texture, or one that is emptied.
  • The replacement: the next walk makes it under POOL_REQ_BYTES in the same frame, instead of leaving it out.
  • DM_NVN_POOL_SEEN_KEEP=0 restores the old clearing.

Not fixed here: the single first-read frame (#1969). A fix for it is proposed in #2075, built on this PR.

Evidence

The evidence comes from windowless replays of the player's recorded route (DM_KEYS, DM_FRAME_DELTA=0, from the save it was recorded on), run with a diagnostic build. The diagnostics are not part of this PR; they can follow as a separate one if wanted.

  • It reproduces without any stress. The paired dark frames come at the wall's streaming steps, at presents about 1470–1600 (the exact frames vary a little between runs). Pictures of the presented frames confirm them: the wall, gate, rock and castle are pitch black, while a banner and a rock in front are lit.

  • A per-frame trace names the textures. The trace records which pool IDs the table left out. In exactly the two dark frames of one run (presents 1531 and 1535) it names the castle textures:

    • IDs 12566–12570, at 3.4 / 2.5 / 2.5 / 6.2 / 4.6 MB;
    • IDs 19442–19453.

    All of them had their read mark cleared.

  • The uploads line up. In another run, the three dark presents (1584, 1588, 1593) were exactly the three frames with upload batches of newly registered large textures (10, 10 and 27 MB).

  • Seen independently on a second Mac (comment in #1969): an M5 Max, macOS 26.6.2, main 270b6fd5 plus that user's own Ocarina of Time fixes.

    • Without the change: in the Castle Town market their flash detector caught two dark presents 5 apart. Their log showed the same ID (20670, 7.8 MB) read as the dummy twice, 5 frames apart, with a new registration in between. They matched the presents to those frames through a 6–11 frame present lag.
    • The change they made: the same kind as this one. An ID a shader has read keeps its mark when it gets another texture, and is queued for the next pool_seen_scan. Their condition is the read mark itself, where this PR's is that the ID held a made texture.
    • With it: no flashes seen in one 5.2-minute session around Hyrule Castle, its moat and the market. The detector's 6 hits were a water glint and camera motion.
    • What was still left: 15 frames still had first reads of IDs whose texture wasn't made. None of them came in pairs, and the detector flagged none of them. That is the single first-read frame of Ocarina of Time (0400486024DEC000 v131072, Mac): parts of the scenery flash dark for a split second when the game loads a sharper version of them #1969.
    • Sample size: one session with the change and one run each way headless (n=1).
  • Ruled out along the way:

    • logical queue order and event waits;
    • the CPU running ahead of the GPU;
    • pass barrier mode (DM_NVN_BARRIER=full still showed it);
    • deferred texture handles registered in a later frame;
    • completion and fast-wake order;
    • the Metal 4 queue signal ordering;
    • the colour pass's other inputs: the screen-space shadow mask, the SH block and the light constants were normal in dark frames.

Checked

Validation, on the player's route:

  • replays of 118 s;
  • a per-frame 32x18 luminance grid, with a strict single-frame-dip rule;
  • every flagged frame checked against a picture.
Build Runs Paired dark frames
Before, no stress 3 2–4 flagged at the wall's streaming steps each run (4–5 flagged in all), in pairs; one per run checked by picture: black
Fix, no stress 5 0
Fix, with a stress that stalls the queue worker at each present (it makes streaming steps collide more often) 5 0. Without the fix, the same stress gave 18 flagged frames in 8 runs (2.25 a run)
Same build, DM_NVN_POOL_SEEN_KEEP=0 2 They return in both runs, confirmed by picture. In one run the wall, gate, rock and castle are black over 57 of the 576 grid cells

Docs

  • Reference entry Models black for one frame when a streaming step replaces their textures in docs/reference/rendering.md (Textures and formats); symptom index regenerated.
  • DM_NVN_POOL_SEEN_KEEP in docs/env_switches.tsv.
  • No shader-chain change; no game files, keys or extracted data.

🤖 Generated with Claude Code

… 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 (DM_NVN_POOL_SEEN_KEEP)

The models' shaders read their textures through the bindless pool table. When a walk writes an ID's entry it makes the ID's Metal texture if it fits the walk's room (pool_room, within pool_budget()), or, whatever the room, if a shader has read the ID (pool_seen, under POOL_REQ_BYTES); otherwise the entry is the dummy (black) and pool_seen_scan makes the texture after the first read. In Ocarina of Time the room is always 0: pool_budget() is a twentieth of Metal's working set (909 MB on a 24 GB M4 Pro) against 15-18 GB of currentAllocatedSize, so only read IDs are made ahead.

mtl_reg_tex cleared the read mark whenever the game registered another texture into an ID. The game's streaming replaces the 17 textures of the castle town wall's material set at once (3-7 MB each), finalizing the old texture first, and registers each step twice, 4-5 frames apart. With the marks cleared the replacements counted as unread, and each registration's first read found the dummy: the wall, the castle, rocks and trees drew black for one frame, twice, with the fog in front.

Now an ID that held a made texture (retired by the game, or still viewed; the finalize drops the view before the new registration arrives) keeps its mark, so the next walk makes the replacement under the read budget. An ID that held none, or is emptied, is cleared as before; DM_NVN_POOL_SEEN_KEEP=0 restores the old clearing. A texture streamed into an ID no shader has read yet still reads the dummy once, a single dark frame (issue seedds#1969).

Replays of a player's route past the wall (M4 Pro, macOS 27.0.1), every flagged frame checked by picture: 4-5 dark frames flagged per run before (2-4 at the wall's streaming steps), 0 in 5 runs with the change and 0 in 5 runs under a stress that makes streaming steps collide; with DM_NVN_POOL_SEEN_KEEP=0 the pairs return. Frame intervals and GPU time unchanged. Another user saw the same pairs on an M5 Max and none with a change of the same kind (issue seedds#1969).

t_pool_chunks: its check that an ID given another texture is unread now checks the new rule (kept for a made texture, cleared under DM_NVN_POOL_SEEN_KEEP=0), and that an emptied ID and an ID given its first texture are unread.

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

akratch commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for this fix. Our local OOT integration includes it, and the player now reports that flickering has stopped. That build combines changes, so this is supporting play evidence rather than isolated attribution.

I prepared an additional synthetic regression on this PR's exact head c7ea2af6c99a: test-only commit (two-file diff). It checks first-table replacement GPU pixels with no ordinary walk room, live replacement, real finalize-before-register, old-table entry stability, unread/empty IDs and unwritten targets. It exhausts the budget counter rather than allocating a large working set; storage is 1 MiB.

make -C dm/gfx/tests GAME=0400486024DEC000 check_t_pool_seen_replace passes 38/38 with each encoder flag setting, and 36/36 with DM_NVN_POOL_SEEN_KEEP=0, under Metal validation on M4 Max / macOS 27.2. These are direct-backend configurations, not independent replay-worker concurrency stress. C ratchet: no new findings. The earlier unmodified-clearing control in our integrated tree failed six replacement checks. No game assets are required or included.

This is test coverage offered for this existing fix; no duplicate production PR. Happy for it to be folded into your work.

Exercise a historically sampled ID with no ordinary pool-walk room, both when replacing a live texture and after Finalize drops its view. Check the first table's replacement GPU pixels, old table entry stability, unread and empty IDs, unwritten targets, and the clearing control. Exhaust the existing budget counter rather than allocating a large working set.

The fixture passes 38/38 with the fix in PR2022 and 36/36 with DM_NVN_POOL_SEEN_KEEP=0. It uses synthetic 1 MiB storage and requires no game assets. These direct-backend configuration variants do not independently stress replay-worker concurrency.

From akratch's commit 6f251e7, offered on PR 2022. Rerun on an M4 Pro (macOS 27.0.1) on this branch: 38/38, 38/38 and 36/36. Against nvn_mtl.m from before the fix it fails 6 of 38, the six replacement checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 9, 2026
@sleepyeyedjoe

Copy link
Copy Markdown
Contributor Author

Thanks, this is a good addition. It checks the replacement's GPU pixels, which t_pool_chunks does not.

It is now in this PR as d07b5a0, with you as its author. Your commit's author was set to another name and address, so I used the noreply address from your Co-authored-by line.

Rerun here on an M4 Pro with macOS 27.0.1:

  • 38/38 with each encoder setting;
  • 36/36 with DM_NVN_POOL_SEEN_KEEP=0.

Control: against nvn_mtl.m from before the fix, it fails 6 of 38, the six replacement checks.

Thanks also for the play report.

— Claude (AI agent)

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.
seedds added a commit that referenced this pull request Nov 10, 2026
… another texture into it, and loses it only when emptied: Ocarina of Time's castle town wall, Hyrule Castle, rocks, trees and Lon Lon Ranch's barn no longer draw black for one frame, twice, 4-5 frames apart, at the streaming steps that replace their textures (PR #2022)

mtl_reg_tex cleared the mark (pool_seen) whenever the game registered another texture into an ID. In Ocarina of Time the pool walk's room is always 0 (pool_budget() is a twentieth of the working set, 909 MB on a 24 GB Mac, against 15-18 GB allocated), so only read IDs are made ahead. When the game's streaming replaced the 17 textures of a material set at once (3-7 MB each, each step registered twice, 4-5 frames apart), the replacements counted as unread and their first reads found the dummy: one black frame per registration.

The mark is the ID's: shaders read an ID whatever texture it holds, so it now stays while the ID holds a texture and the next walk makes the replacement under the read budget (POOL_REQ_BYTES). A render target no pass has written stays the dummy all the same (pool_unwritten_target). The PR kept the mark only for an ID that held a made texture (retired or viewed) and added DM_NVN_POOL_SEEN_KEEP=0 to restore the clearing; this keeps it for every ID that holds a texture (also one whose texture still waits for the read budget) and has no switch.

The single dark frame at a texture's first read in an ID no shader has read yet is issue #1969, still open.

Checked: t_pool_seen_replace (akratch's GPU-pixel test, plus a read ID whose texture waits for the read budget) 44/44 with each encoder setting, 7 failures against the old nvn_mtl.m; t_pool_chunks 151/151; lint_c tidy at the baseline; docs_symptoms --check and test_docs pass. The PR's validation: 118 s Ocarina replays of a player's route, 2-4 paired dark frames a run before, 0 in 5 runs after, frame times unchanged; seen independently on an M5 Max. No game run on this form: only pool-table games reach it, and none has libraries on the current chain.

Co-authored-by: sleepyeyedjoe <353778822+sleepyeyedjoe@users.noreply.github.com>
Co-authored-by: akratch <70729875+akratch@users.noreply.github.com>
@seedds

seedds commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Landed on main as 6d9edb0, with you and akratch credited as co-authors. Thank you both: the trace, the A/B runs and the GPU-pixel test made this easy to check.

One change from the PR. The read mark now belongs to the ID. It stays while the ID holds any texture and is cleared only when the ID is emptied (if (!t) pool_seen_clear(id_)).

  • The PR kept it only when the ID "held a made texture" (retired || view). But retired says the game finalized the old texture, not that its Metal texture was ever made.
  • That condition also left out two cases where shaders still read the ID:
    • a read ID whose texture was still waiting for the read budget;
    • an ID whose memory pool was finalized while its texture was live.
  • Unwritten render targets still stay the dummy through pool_unwritten_target, which is what kept transient targets from being made ahead.
  • DM_NVN_POOL_SEEN_KEEP is gone. A switch that brings the bug back isn't worth keeping.

Tests. t_pool_seen_replace has one more case: the waiting read ID, whose replacement keeps the mark and is made in the first table.

  • t_pool_seen_replace: 44/44 with each encoder setting, and 7 failures against the old nvn_mtl.m.
  • t_pool_chunks: 151/151.

Not run. No game ran on this form. Only pool-table games reach it, and none of the local builds has libraries on the current chain. Your 118 s Ocarina replays are the game evidence. If you can, a short session on main around the Castle Town wall would confirm it.

The single dark frame at a texture's first read is still #1969, open, and #2075 is still to be reviewed.

@seedds seedds closed this Nov 10, 2026
seedds added a commit that referenced this pull request Nov 10, 2026
…s until its first read: Ocarina of Time's cliff beside the castle town wall, and other streamed scenery, no longer draws dark for one frame when the game gives it a sharper texture (PR #2075)

Once pool_budget() is spent a walk makes only the textures a shader has read (pool_seen), and an ID no shader has read held the dummy, which reads 0: the first read set its bit and pool_seen_scan made the texture for the next frame, so that frame was dark. Ocarina of Time keeps a texture set of its own for each piece of distant scenery and resolution (one level each: a BC5 texture and two three-layer BC6H / BC7 arrays) and registers a new set 12 to 969 frames before the material names it. Which registrations will be read cannot be known ahead (14,455 stayed unread in 58 s of play, 9.8 GB, against 10 first reads), and making them all is what lazy making exists to avoid, so the miss frame stays; this changes what it reads.

The entry holds a stand-in (pool_stand): the texture's own smallest levels, from the first of at most 1 KB on, or its last level cut down to at most 4 KB a layer, every n-th block of every n-th block row (nvn_deswizzle_pick; blocks as they are, so no decoder). It is read from the storage on the CPU into a small shared texture cut from a heap resident from the start, for 2D textures and 2D arrays of up to 8 layers with the view's type, layers, format and swizzle (the swizzle rule moves to treg_swizzle, which tex_by_id uses too). Only once the budget is full; at most 512 a frame, the rest written into the tables in place. DM_NVN_POOL_STANDIN=0: the dummy, as before; DM_NVN_POOL_FIRST=1 logs each first read of a texture not made and what it found, including a stand-in whose storage the game wrote since.

Rebased onto 6d9edb0's read-mark rule (the PR carried #2022's earlier form); the test's readback buffer is checked for nil (lint_c).

Checked: t_pool_standin 51/51 with each encoder setting (31/31 with DM_NVN_POOL_STANDIN=0), t_pool_chunks 151, t_pool_notes 81 (both settings), t_pool_seen_replace 44, t_view_swizzle 554, t_cpu_tex, t_stencil_view, t_pfv, t_int_float_view; t_tex 5,382 (P: picked blocks against the whole level, both GOB orders), also under ASan/UBSan; the host builds; lint_c at the baseline; docs_symptoms --check, test_docs, test_env_switches. The PR's replays of a player's route (M4 Pro): after the area load 31 of 31 first reads found the dummy with stand-ins off, 0 of 49 with them on (none stale); memory and frame times within run-to-run range. A MacBook Air M5 tester: three black flashes in Kakariko in 8 minutes before, none in 4 after. No game run here: only pool-table games reach it, and none has libraries on the current chain.

Fixes #1969

Co-authored-by: sleepyeyedjoe <353778822+sleepyeyedjoe@users.noreply.github.com>
FeReuss pushed a commit to FeReuss/nxnative-oot that referenced this pull request Nov 10, 2026
Upstream absorbed PR seedds#2022 (pool_seen read mark kept on replacement, now
unconditional, DM_NVN_POOL_SEEN_KEEP gone) and PR seedds#2075 in reworked commits;
conflicts take upstream's versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
iDams pushed a commit to iDams/nxnative that referenced this pull request Nov 10, 2026
… another texture into it, and loses it only when emptied: Ocarina of Time's castle town wall, Hyrule Castle, rocks, trees and Lon Lon Ranch's barn no longer draw black for one frame, twice, 4-5 frames apart, at the streaming steps that replace their textures (PR seedds#2022)

mtl_reg_tex cleared the mark (pool_seen) whenever the game registered another texture into an ID. In Ocarina of Time the pool walk's room is always 0 (pool_budget() is a twentieth of the working set, 909 MB on a 24 GB Mac, against 15-18 GB allocated), so only read IDs are made ahead. When the game's streaming replaced the 17 textures of a material set at once (3-7 MB each, each step registered twice, 4-5 frames apart), the replacements counted as unread and their first reads found the dummy: one black frame per registration.

The mark is the ID's: shaders read an ID whatever texture it holds, so it now stays while the ID holds a texture and the next walk makes the replacement under the read budget (POOL_REQ_BYTES). A render target no pass has written stays the dummy all the same (pool_unwritten_target). The PR kept the mark only for an ID that held a made texture (retired or viewed) and added DM_NVN_POOL_SEEN_KEEP=0 to restore the clearing; this keeps it for every ID that holds a texture (also one whose texture still waits for the read budget) and has no switch.

The single dark frame at a texture's first read in an ID no shader has read yet is issue seedds#1969, still open.

Checked: t_pool_seen_replace (akratch's GPU-pixel test, plus a read ID whose texture waits for the read budget) 44/44 with each encoder setting, 7 failures against the old nvn_mtl.m; t_pool_chunks 151/151; lint_c tidy at the baseline; docs_symptoms --check and test_docs pass. The PR's validation: 118 s Ocarina replays of a player's route, 2-4 paired dark frames a run before, 0 in 5 runs after, frame times unchanged; seen independently on an M5 Max. No game run on this form: only pool-table games reach it, and none has libraries on the current chain.

Co-authored-by: sleepyeyedjoe <353778822+sleepyeyedjoe@users.noreply.github.com>
Co-authored-by: akratch <70729875+akratch@users.noreply.github.com>
iDams pushed a commit to iDams/nxnative that referenced this pull request Nov 10, 2026
…s until its first read: Ocarina of Time's cliff beside the castle town wall, and other streamed scenery, no longer draws dark for one frame when the game gives it a sharper texture (PR seedds#2075)

Once pool_budget() is spent a walk makes only the textures a shader has read (pool_seen), and an ID no shader has read held the dummy, which reads 0: the first read set its bit and pool_seen_scan made the texture for the next frame, so that frame was dark. Ocarina of Time keeps a texture set of its own for each piece of distant scenery and resolution (one level each: a BC5 texture and two three-layer BC6H / BC7 arrays) and registers a new set 12 to 969 frames before the material names it. Which registrations will be read cannot be known ahead (14,455 stayed unread in 58 s of play, 9.8 GB, against 10 first reads), and making them all is what lazy making exists to avoid, so the miss frame stays; this changes what it reads.

The entry holds a stand-in (pool_stand): the texture's own smallest levels, from the first of at most 1 KB on, or its last level cut down to at most 4 KB a layer, every n-th block of every n-th block row (nvn_deswizzle_pick; blocks as they are, so no decoder). It is read from the storage on the CPU into a small shared texture cut from a heap resident from the start, for 2D textures and 2D arrays of up to 8 layers with the view's type, layers, format and swizzle (the swizzle rule moves to treg_swizzle, which tex_by_id uses too). Only once the budget is full; at most 512 a frame, the rest written into the tables in place. DM_NVN_POOL_STANDIN=0: the dummy, as before; DM_NVN_POOL_FIRST=1 logs each first read of a texture not made and what it found, including a stand-in whose storage the game wrote since.

Rebased onto 6d9edb0's read-mark rule (the PR carried seedds#2022's earlier form); the test's readback buffer is checked for nil (lint_c).

Checked: t_pool_standin 51/51 with each encoder setting (31/31 with DM_NVN_POOL_STANDIN=0), t_pool_chunks 151, t_pool_notes 81 (both settings), t_pool_seen_replace 44, t_view_swizzle 554, t_cpu_tex, t_stencil_view, t_pfv, t_int_float_view; t_tex 5,382 (P: picked blocks against the whole level, both GOB orders), also under ASan/UBSan; the host builds; lint_c at the baseline; docs_symptoms --check, test_docs, test_env_switches. The PR's replays of a player's route (M4 Pro): after the area load 31 of 31 first reads found the dummy with stand-ins off, 0 of 49 with them on (none stale); memory and frame times within run-to-run range. A MacBook Air M5 tester: three black flashes in Kakariko in 8 minutes before, none in 4 after. No game run here: only pool-table games reach it, and none has libraries on the current chain.

Fixes seedds#1969

Co-authored-by: sleepyeyedjoe <353778822+sleepyeyedjoe@users.noreply.github.com>
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.
Fasen24-AI added a commit to Fasen24-AI/swordboiMacNative that referenced this pull request Nov 10, 2026
…ure the game puts into it, as main's 6d9edb0 (this branch carried PR seedds#2022's narrower rule)

PR seedds#2022's version, which this branch carries, kept the mark only for an ID that held a made texture (retired or viewed). Main merged a wider one in 6d9edb0: the mark is the ID's,
so it stays while the ID holds a texture, also one whose texture still waits for the read budget, and is cleared only when the ID is emptied. Without it a streamed replacement into an
ID whose previous texture was not made yet counted as unread, and its first read found the dummy: a black frame on the castle town wall, rocks and trees. In a 17-minute session of
this branch's host the log counted 32 first reads of a texture of 256 KB or more that found the dummy. DM_NVN_POOL_SEEN_KEEP=0 still clears the mark on every change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eD437mxrMnW2UwLZhpwc7
neko973 pushed a commit to neko973/nxnative that referenced this pull request Nov 11, 2026
…s read the ID for 600 frames, so area loads late in a session no longer make every streamed texture at once: a read mark (pool_seen) stayed with its ID through every new texture (PR seedds#2022) and was cleared only when the ID was emptied, so the marks grew with every area passed through. An hour into Ocarina of Time (0400486024DEC000, v131072) the pool walk line counted 19,201 of its 33,024 IDs as read (22.7 GB of textures), and a walk makes a marked ID's texture whatever its room (pool_entry, under POOL_REQ_BYTES, 128 MB a frame), so each area load, which streams thousands of textures into IDs earlier areas had read, had them made as fast as the read budget allowed: the loads into Hyrule Field and the passages around it froze in runs of 200-670 ms frames for seconds, with 100-300 first texture uploads a frame and GPU allocated at 9.8 GB against 3.9 GB in the session's first minutes. Now pool_seen_scan takes the GPU's marks back every 60 frames (SEEN_REARM; an exchange, so a mark set meanwhile stays for the next scan) and notes the frame each mark was last found in (g_seen_at, up to 60 frames after the read); the shaders set them again at their next read (dm_pool_mark is unchanged, so the chain is not). mtl_reg_tex keeps the mark for the ID's next texture only when the ID was read within the last DM_NVN_SEEN_KEEP frames (600 by default): the streamed replacement of a material on screen keeps it as PR seedds#2022 needs, and a texture streamed into an ID left behind holds its stand-in until it is read (pool_stand). The marks are scanned in a pass of their own before the walk, which may stop at the read budget. A stale 1 read by work still running just after a take-back skips one mark, which a read after the next take-back sets. DM_NVN_SEEN_KEEP=0 keeps every mark until its ID is emptied, as before; the pool stand-ins line counts the marks kept and dropped. The reference entry (Area loads froze late in a session as read marks piled up) is new, the entry for PR seedds#2022 and Ocarina's note link it. Checked: t_pool_seen_replace (60 checks, 8 new: a read notes its frame, the GPU's mark taken back while the host's stays, a mark not read within the window dropped with the next texture, an ID read again keeps its mark; 52 with DM_NVN_SEEN_KEEP=0), t_pool_standin, t_pool_notes, t_pool_chunks, make run in dm/gfx/tests (every suite passes; the six that import dmsh_side need Python 3.10 or later), test_docs. Two sessions of play on an M5 Pro (48 GB, macOS 26.6.2), not on the same route: without the change, the runs of 200-670 ms frames above after the first hour and tens of 300-frame windows with a p95 above 40 ms; with it, 78 minutes, one such window in 903, GPU allocated 7.2-9.6 GB from the 6th minute on, following the area rather than the time played, and the area loads single frames of 102 and 155 ms (658 and 248 first uploads). 92,703 new textures kept their ID's mark and 46,614 dropped it; stand-ins made 1,235,328 in 6.19 s over the session against about 128,000 without the change (23 us of the submit thread a frame on average). Not run: an A/B on one route, regress.py check (no other game here streams into a bindless pool this way), another game, a Mac with less memory, lint_c.py, iOS. Open: whether the area-load frames of 100-155 ms left come from the stand-ins made in them or from the first uploads. test_env_switches fails on main as here, for DM_NVN_VCONV_KEEP, which this does not touch

Co-authored-by: Cursor <cursoragent@cursor.com>
UserDerezzed pushed a commit to UserDerezzed/nxnative that referenced this pull request Nov 12, 2026
…s read the ID for 600 frames, so area loads late in a session no longer make every streamed texture at once: a read mark (pool_seen) stayed with its ID through every new texture (PR seedds#2022) and was cleared only when the ID was emptied, so the marks grew with every area passed through. An hour into Ocarina of Time (0400486024DEC000, v131072) the pool walk line counted 19,201 of its 33,024 IDs as read (22.7 GB of textures), and a walk makes a marked ID's texture whatever its room (pool_entry, under POOL_REQ_BYTES, 128 MB a frame), so each area load, which streams thousands of textures into IDs earlier areas had read, had them made as fast as the read budget allowed: the loads into Hyrule Field and the passages around it froze in runs of 200-670 ms frames for seconds, with 100-300 first texture uploads a frame and GPU allocated at 9.8 GB against 3.9 GB in the session's first minutes. Now pool_seen_scan takes the GPU's marks back every 60 frames (SEEN_REARM; an exchange, so a mark set meanwhile stays for the next scan) and notes the frame each mark was last found in (g_seen_at, up to 60 frames after the read); the shaders set them again at their next read (dm_pool_mark is unchanged, so the chain is not). mtl_reg_tex keeps the mark for the ID's next texture only when the ID was read within the last DM_NVN_SEEN_KEEP frames (600 by default): the streamed replacement of a material on screen keeps it as PR seedds#2022 needs, and a texture streamed into an ID left behind holds its stand-in until it is read (pool_stand). The marks are scanned in a pass of their own before the walk, which may stop at the read budget. A stale 1 read by work still running just after a take-back skips one mark, which a read after the next take-back sets. DM_NVN_SEEN_KEEP=0 keeps every mark until its ID is emptied, as before; the pool stand-ins line counts the marks kept and dropped. The reference entry (Area loads froze late in a session as read marks piled up) is new, the entry for PR seedds#2022 and Ocarina's note link it. Checked: t_pool_seen_replace (60 checks, 8 new: a read notes its frame, the GPU's mark taken back while the host's stays, a mark not read within the window dropped with the next texture, an ID read again keeps its mark; 52 with DM_NVN_SEEN_KEEP=0), t_pool_standin, t_pool_notes, t_pool_chunks, make run in dm/gfx/tests (every suite passes; the six that import dmsh_side need Python 3.10 or later), test_docs. Two sessions of play on an M5 Pro (48 GB, macOS 26.6.2), not on the same route: without the change, the runs of 200-670 ms frames above after the first hour and tens of 300-frame windows with a p95 above 40 ms; with it, 78 minutes, one such window in 903, GPU allocated 7.2-9.6 GB from the 6th minute on, following the area rather than the time played, and the area loads single frames of 102 and 155 ms (658 and 248 first uploads). 92,703 new textures kept their ID's mark and 46,614 dropped it; stand-ins made 1,235,328 in 6.19 s over the session against about 128,000 without the change (23 us of the submit thread a frame on average). Not run: an A/B on one route, regress.py check (no other game here streams into a bindless pool this way), another game, a Mac with less memory, lint_c.py, iOS. Open: whether the area-load frames of 100-155 ms left come from the stand-ins made in them or from the first uploads. test_env_switches fails on main as here, for DM_NVN_VCONV_KEEP, which this does not touch

Co-authored-by: Cursor <cursoragent@cursor.com>
oyaboon pushed a commit to oyaboon/nxnative that referenced this pull request Nov 13, 2026
A read mark (pool_seen) stayed with its ID through every new texture (PR seedds#2022) and was cleared only when the ID was emptied, so the marks grew with every area passed through. An hour into Ocarina of Time (0400486024DEC000, v131072) the pool walk line counted 19,201 of its 33,024 IDs as read (22.7 GB of textures), and a walk makes a marked ID's texture whatever its room (pool_entry, under POOL_REQ_BYTES, 128 MB a frame), so each area load, which streams thousands of textures into IDs earlier areas had read, had them made as fast as the read budget allowed: the loads into Hyrule Field and the passages around it froze in runs of 200-670 ms frames for seconds, with 100-300 first texture uploads a frame and GPU allocated at 9.8 GB against 3.9 GB in the session's first minutes. Another player saw the same on an M4 Pro (24 GB): loads around Kakariko and Lake Hylia at about 5 fps, 17,139 IDs marked (14.2 GB) after 41 minutes (PR seedds#2344).

The host now keeps the size of the marked IDs' textures (seen_size, g_seen_kb_all). While it is above a quarter of Metal's working set (SEEN_CAP_DIV; about 2.7 GB on a 16 GB Mac, 9 GB on a 48 GB one), pool_seen_scan takes the GPU's marks back every 60 frames (SEEN_REARM; an exchange, so a mark set meanwhile stays for the next scan) and notes the frame each mark was last found in (g_seen_at, up to 60 frames after the read); the shaders set them again at their next read (dm_pool_mark is unchanged, so the chain is not). mtl_reg_tex then keeps the mark for the ID's next texture only when the ID was read within the last DM_NVN_SEEN_KEEP frames (600 by default): the streamed replacement of a material on screen keeps it as PR seedds#2022 needs, and a texture streamed into an ID left behind holds its stand-in until it is read (pool_stand). Below the cap nothing is taken back or dropped, as before: a mark makes the ID's next texture when it is registered, often hundreds of frames before its first read, and the first form of this change, which aged marks whatever their size, slowed a scripted walk out of Kokiri Forest on an M2 MacBook Air (16 GB) to 70-110 ms frames (GPU 44-60 ms) against 34 ms with DM_NVN_SEEN_KEEP=0 (dpadr's runs, PR seedds#2344). The marks are scanned in a pass of their own before the walk, which may stop at the read budget. A stale 1 read by work still running just after a take-back skips one mark, which a read after the next take-back sets. DM_NVN_SEEN_KEEP=0 keeps every mark until its ID is emptied, as before; the pool stand-ins line counts the marks kept and dropped, with the cap and the marked size. The reference entry (Area loads froze late in a session as read marks piled up) is new, the entry for PR seedds#2022 and Ocarina's note link it.

Checked: t_pool_seen_replace (67 checks, 15 new: a read notes its frame and the ID counts its texture's size; below the cap the GPU's mark stays and an unread ID keeps its mark at its new texture's size; above it the GPU's mark is taken back while the host's stays, a mark not read within the window goes with the next texture, an ID read again keeps its mark; the size goes with the marks; 50 with DM_NVN_SEEN_KEEP=0), t_pool_standin, t_pool_notes, t_pool_chunks, make run in dm/gfx/tests on 713ac08 plus this (every suite passes), test_docs, env_switches.py --check. Two sessions of play on an M5 Pro (48 GB, macOS 26.6.2) with the first form (no cap), not on the same route: without the change, the runs of 200-670 ms frames above after the first hour and tens of 300-frame windows with a p95 above 40 ms; with it, 78 minutes, one such window in 903, GPU allocated 7.2-9.6 GB from the 6th minute on, following the area rather than the time played, and the area loads single frames of 102 and 155 ms (658 and 248 first uploads). 92,703 new textures kept their ID's mark and 46,614 dropped it; stand-ins made 1,235,328 in 6.19 s over the session against about 128,000 without the change (23 us of the submit thread a frame on average). Not run: any game with the cap (neither the long session nor the Kokiri walk), an A/B on one route, regress.py check (no other game here streams into a bindless pool this way), another game, lint_c.py (no LLVM clang-tidy here), iOS. Open: whether a quarter of the working set is the right cap; whether the area-load frames of 100-155 ms left come from the stand-ins made in them or from the first uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation 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