NVN object layouts, memory pools, command memory, queues, syncs and entry points (dm/gfx/nvn/hle_nvn.c). Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.
The game allocates NVN objects with the sizes of its SDK's headers, which shrank in newer SDKs. A host record larger than the game's allocation overwrites neighbouring memory (memory corruption):
- Symptom: heap corruption; a stack smash where a function returned to
0x100000000; a crash in SHA-256 on the code address 0x16300. - Cause:
NVNtextureis 0x80 bytes in SDK 16 (heap corruption: the old 0x88 record overwrote the next heap block's links).NVNtextureViewis 16 bytes in SDK 16 (stack smash: a 16-byte local next to the saved frame pointer; the function returned to0x100000000).NVNprogramis smaller in Mario than the 0xC0 bytes innvn.h: growing the host record from 112 to 168 bytes corrupted the next object (a crash in SHA-256 on the code address 0x16300).
- Fix: keep
prog_tat 112 bytes; per-program host data goes in a side table (prog_key). - Found on: SDK 16 games (
NVNtexture,NVNtextureView); Super Mario Bros. Wonder (NVNprogram). - Check: check every host object against the new SDK:
_Static_assert(sizeof(host) <= N)next to the struct, and when a crash returns to a nonsense address, look at the 0x100 bytes belowspin the crash report for a zeroed frame.
The game may also write into an NVN object directly, without an NVN call, through code inlined from the SDK headers. Host records stored in place must leave those bytes alone (the same holds for recorded command memory: Recorded commands patched in place by the game (OP_BIND_TEX1)).
- Symptom: out-of-range vertices, NaN, a black band and flicker; seen on the iPhone, while Mac runs of the same scene looked clean.
- Cause: Mario writes the byte 1 at
NVNcommandBuffer+0x10 (main+0x4da5c,main+0xb8dec). In the host record it was the low byte ofend. Each write shrank the game's 512-byte material setup blocks, so their recordings overflowed into host segments;GetCommandMemoryUsed(thencur − begin) returned 0, the game marked the setups empty and skipped calling them, and the draws ran with the previous draw's vertex buffers (WhatGetCommandMemoryUsedcounts). - Fix:
cmd_treserves the byte (nvn_flag10innvn_obj.h). - Found on: Super Mario Bros. Wonder.
- Check: to find such writes, put the field you suspect behind a guard value and log when it changes outside the host's own code, or force overflows on the Mac by shrinking the command memory the game gets.
- Symptom: a running game with sound draws a black screen; every uniform buffer returns the same initialized address, with its allocation offset reported as its size.
- Cause: an engine can retarget an initialized buffer with direct 64-bit stores to
NVNbuffer+0x18 (offset) and +0x20 (size), then bindnvnBufferGetAddressandnvnBufferGetSize. The host previously stored offset at +0x10 and size at +0x18: the guest's offset overwrote the host's size while its address stayed unchanged. - Fix:
buf_treserves the bytes before +0x18 and puts offset and size at the guest-written offsets, with compile-time assertions. Initialization and every buffer query use that record; CPU mapping still respects the pool's CPU-access flags. The record fits the existing 0x30-byteNVNbuffer; the builder layout and recorded command layouts are unchanged. No meaning is assigned to the reserved driver bytes. - Found on: LEGO Star Wars: The Skywalker Saga, issue #723, 1.10.0, SDK 12.3.6, main build
ec593a5f9552100a9fe95c4ca9e34873(reporter's trace). This is a general object ABI correction; no game profile is needed. - Check:
t_nvn.cinitializes a buffer through the public API, retargets it repeatedly with raw byte writes, and checks address, size, mapping, pool and offset queries, builder independence and neighbouring-object guards. It also retargets a CPU-inaccessible buffer. Validation uses synthetic memory only; the reported game's rendering has not been retested.
A game may tell NVN objects apart by their bytes, without an NVN call. A host record stored in place must then keep all of its state in the bytes the game reads, and the same state must give the same bytes.
- Symptom: a game registers the same few sampler states into its sampler pool again and again: Super Mario Odyssey made 3,368 to 5,737
nvnSamplerPoolRegisterSamplercalls for 168 distinct states in two runs of about 110 s from the regress route's save, against 168 for 168 after the fix. Each re-registration rewrites a pool ID that recorded draws may still use. Its picture on that route (frames 4,500 to 6,300) was the same either way, apart from the hint video's corner, which also varies between runs of one host. - Cause: Odyssey's sampler cache (
main+0x980e20, 1.4.1) keys its entries by a CRC-16 (reflected polynomial 0xA001, from 0;main+0x96e400) of bytes 8 to 0x5f of theNVNsampler, and in its hash mode finds an entry by that CRC alone (main+0x93f730over the entry's u16 at +0x62); only its other mode callsnvnSamplerCompare.samp_theld the min and mag filters and the S and T wrap modes at bytes 4 to 7, so samplers that differed only in them had one CRC: 151,626 of 276,480 initializations met a CRC another state already had (for example CRC 0xb911 for wrap modes 6/6, 6/1, 6/7 and 1/6). Which state an ID then held depended on the order the game met them. - Fix:
samp_tkeeps every field from byte 8 on (magicand a zero word before it), andnvnSamplerInitializewrites every byte of the record (nvn_obj.h,hle_nvn.c). Bytes 0x30 to 0x5f, past the host record, are the game's own and are not written: the size of a realNVNsampleris not known for every SDK (Host records must fit the game's SDK object sizes). - Found on: Super Mario Odyssey; reported first by the S2Arm project on its copy of this layer. The builder already had its state from byte 8 on, after the device pointer.
- Check:
t_nvn.cinitializes samplers over memory with different old bytes and takes the game's CRC of bytes 8 on: equal states must give equal bytes, and a change of filter alone, or wrap alone, a different CRC (the old layout fails all three). Texture, view and state objects were not checked for the same kind of hashing.
- Symptom: sprites broke apart in some runs only, depending on the allocation order; buffers overlapped.
- Cause: host answers that size the game's own allocations count too. A size the hardware never reports as 0 must not be 0 on the host, even where the host needs no memory: Cuphead (Unity 2017.4) allocates each depth target's ZCull save area from its NVN pool allocator, which gives a zero-size request the offset of a live block. Its map of allocations then lost that block, and freeing it later released a garbage-sized range.
- Fix: the host never reports a ZCull storage size of 0 (
hle_nvn.czcull_size). - Found on: Cuphead. Found by tracking the allocator from observation gates (
OBSin itspatches.py) on its free-list push and after its map insert, checking at every event that each block allocated so far was still in the game's map.
- Symptom: a null write as soon as the main thread starts recording a command buffer (13 Sentinels: a constructor at
main+0x2fa9a0runs onthis = 0when the prologue enters its first battle). - Cause: the object came from one of the engine's ring heaps (
main+0x2d83ec), and that heap had base 0. At startup the engine takes one GPU memory pool, sized as exactly the sum of three ring heaps, and cuts the heaps out at 64 KB alignment relative to the pool's GPU address (main+0x30c118). On the Switch a pool's GPU address is big-page aligned; the host's GPU address is the CPU address, and the storage came from the game'smallocrounded only to 4 KB. Up to 60 KB was lost at the front, the third heap (16 MB) did not fit, and the sub-allocator returned 0. - Fix: the game's
mallocisnn::mem::StandardAllocator::Allocate(size); on this build the host first routed allocations of 64 KB or more toAllocate(size, 0x10000)(hle_os.c). The routing is now the profile keyalloc_big_align(hle_os.ch_mem_SAAllocate), which Unicorn Overlord (the same engine) sets too. - Found on: 13 Sentinels: Aegis Rim. Probing the heap object at the allocation (
DM_PROBE=main:2bec60@x0+8) showed the zero base directly.
- Symptom:
[RED] memory pool … created at retry N. - Cause: a memory pool created while the game maps and unmaps a large region was sometimes refused once by
newBufferWithBytesNoCopy, then accepted. - Fix:
mtl_pool_addretries up to 12 times with a short back-off and prints the red line; on a final failure it logs the VM regions around the pool. - Found on: Pokémon Legends: Z-A (its 4 GB region).
- Symptom: a buffer is created with a null pool right after a scene change.
- Cause: NVN virtual memory pools (flag 0x200) have no storage until
nvnMemoryPoolMapVirtualmaps physical pool pages in; if the host returns failure, the game treats the pool as unusable. - Fix: a virtual pool is aliased onto the physical pool named by
MapVirtualwhen all requests share one base (hle_nvn.c): Mario maps its single 128 MB virtual pool onto one physical pool in one request, so the host aliases it. Other mappings (true sparse) were then unimplemented; they are now sparse virtual pools (next entry). - Note: a null-pool buffer even with virtual pools working means the game's own heap ran out (A null-pool buffer from a game heap that ran out).
- Found on: Super Mario Bros. Wonder (all requests share one base).
- Symptom:
[RED] MapVirtual: non-contiguous mapping (true sparse) not implemented. - Cause: RE Engine's GPU heap is one 64 GB virtual pool built from separately allocated 4 MB physical pools, each mapped at a virtual offset of its own (45 at boot, over the first 180 MB). A virtual pool larger than the application pool cannot be one alias of a physical pool.
- Fix: such a pool gets an address range of its own at
MemoryPoolInitialize(reserved, inaccessible: 64 GB on the Mac, at most 1 GB on iOS), and each mapping requestvm_remaps the physical pages over its part of the range, so virtual addresses read and write the same memory, as the GPU's page table does. Each run of mapped pages is registered with the replay like a pool of its own and rebuilt when a request joins or splits runs (vpool_*inhle_nvn.c). Mario's 128 MB pool, mapped whole onto one physical pool, keeps the alias. The log reports the mapped high-water mark per 128 MB. - Found on: Monster Hunter Rise.
- Symptom:
[RED] virtual pool …: more than 1024 separate mapped runs, then a GPU hang minutes later:[RED] the host has waited 10 s for GPU work, an indirect dispatch of absurd size (283343572 x 965150577 x 1006648320 groups), and the game aborts once its 15 snvnSyncWaitexpires. - Cause: the run list of a sparse pool was a fixed array of 1,024. Past it, a new run was neither listed nor registered with the replay, so the GPU did not reach it, and runs that no longer fit fell off the list while their zero-copy buffers stayed: after a later remap such a buffer still wired the old pages, and the GPU read what had been there (the dispatch's group counts were float data).
- Fix: the list grows (
vrun_setinhle_nvn.c); a change touches only the runs it overlaps, so a new list has at most two more runs than the old. The log reports the count at each power of two from 1,024. - Found on: PRAGMATA: its GPU heap passes 1,024 runs while the title screen loads; the hang came 3 to 4 minutes into the intro.
- Symptom: in a game that maps and unmaps sparse pages while it runs: an indirect dispatch of absurd size, a GPU hang, then a crash in the game's own code reading a command list that holds another object's data.
- Cause: a run of a sparse pool is a zero-copy
MTLBufferover the pagesvm_remapput in its range, and such a buffer keeps the pages it was made over, not the address. An unmap or a new mapping changed the pages at once, while the run's old buffer was still in submitted command buffers and the residency set. The GPU then read or wrote the old physical pages, which the game may already have mapped elsewhere. - Fix: no page under a live buffer changes (
hle_nvn.c). An unmap clears the page table at once (textures read 0) but keeps the host pages until the fence after the last commit has completed (vunmap_defer,vunmap_run); then it makes the whole host pages no newer request has mapped inaccessible. A mapping over pages mapped before waits for the GPU first (vpool_overwrites,mtl_finish), and finalizing a pool with runs waits too. The log counts the waits once at each power of two ([nvn] sparse pools: … waited for the GPU first). Ported from uncraftablexd's sparse safe mode (PR #1407), without its loop caps and skip lists. - Found on: PRAGMATA.
- Symptom:
[RED] shader <key>: the buffer address the game pushed at c[24] + … is …, in no memory pool: bound to a dummy buffer, once or twice in minutes, for an address inside a mapped run of a sparse pool; the draw or dispatch of that frame reads zeros. - Cause: each run of a sparse pool is a pool of its own for the replay. A mapping that joins a run to its neighbours, or an unmap that splits one, removed the old runs (
mtl_pool_del) and then added the new ones (mtl_pool_add) on the game's thread, while the replay looks addresses up on its own thread: between the two, an address mapped all along was in no pool. - Fix:
vrun_set(hle_nvn.c) hands the runs it removes and the runs it adds tomtl_pool_replace(nvn_mtl.m), which makes the new runs' buffers first and then takes the old ones out and puts the new ones in under one hold ofg_mu, so every lookup sees the old runs or the new. - Found on: PRAGMATA: its mesh shaders
765e6d9d2588ce70and5aab38bb5ba67ef7read a buffer 0x3000000 into a sparse pool whose one run keeps growing; the miss came at frames 2971 and 4484, the frames the run was replaced in; none in a run with the fix.
- Symptom:
[RED] shader <key>: the buffer address the game pushed at c[24] + … is …, in no memory pool: bound to a dummy buffer (…; sparse pool … (+… of … reserved, … runs): page not mapped…, in no run). - Cause: none in the host. The game pushes the address of a page in a sparse pool's reservation that it has not mapped (Resident Evil 4's
0ee0c5f004239ce2at frame 5, 0x4400000 into a 1 GB pool where nothing is mapped there yet; a diagnostic host logged every mapping request before it). The GPU reads an unmapped sparse page as 0 and drops stores to it, and the host binds the zero buffer. - Fix: the case is noted once without
[RED](unmapped page of a sparse pool: bound to the zero buffer,hle_nvn.cnvn_vpool_unmapped); an address in no pool and no reservation is still red. The zero buffer is shared and 64 KB: a store through it is kept, not dropped (no game is known to store there). - Found on: Resident Evil 4.
- Symptom:
[RED] MapVirtual: non-contiguous mapping (true sparse) not implemented, on small virtual pools; streamed textures black or missing (OCTOPATH TRAVELER's village ground and plants). - Cause: Unreal Engine 4.18's NVN RHI gives each streamed texture a virtual pool of its own (0x20000 to 0x560000 bytes, flags 0x2a1), creates the texture in it before mapping anything, maps the mip tail from a 4 MB physical pool after writing its texels there on the CPU, and later maps the higher mips from other pages of that pool. Only pools larger than the application pool had an address range of their own; the others were aliased onto
physical − offsetat their first mapping, so the texture had no storage (its pool had none when it was created) and every later mapping at another base was refused. - Fix: every virtual pool gets its reserved range at
MemoryPoolInitialize(vpool_reserve; the sparse records are now one per pool, reached throughpool_t.vp, without the old limit of 4). Its first mapping request decides: one that maps the whole pool from offset 0 keeps the alias (the range is given back; Mario's 128 MB pool), anything else makes the pool sparse, with the page table and the uploads through it (next entry). - Found on: OCTOPATH TRAVELER: 221 pools by the first village; the same route with the old alias left the ground, plants and props black.
- Not checked: Super Mario Bros. Wonder (no game files on hand), whose pool should take the alias branch as before.
- Symptom: none seen in a game yet. The failure it prevents is a texture, buffer, event or command memory made on a virtual pool before its first mapping pointing into address space the host has given back (
CRASH sig=10/11at an address in no pool, or a texture that reads nothing), once a whole-pool mapping aliases the pool. - Cause: the alias moves the pool: its storage becomes the physical pool's memory and its reserved range is unmapped. NVN keeps a virtual pool's GPU addresses from creation on, so a game may make objects on it, or take its address, before mapping anything. The objects and addresses made before the mapping kept the old range.
- Fix: general,
vpool_takeninhle_nvn.c. Every place an address of a pool's own range leaves the host notes it:nvnTextureInitialize,nvnTextureGetTextureAddress,nvnBufferInitializeandnvnMemoryPoolGetBufferAddress(pool_mark_addressed),nvnBufferGetAddress,nvnBufferMap,nvnMemoryPoolMap,nvnEventInitialize,nvnCommandBufferAddCommandMemory. A whole-pool first mapping of such a pool makes it sparse instead (the physical pages remapped into its range, which keeps every address), with a[nvn] MapVirtual: virtual pool … gave out addresses of its own range before this first mappingline. An untouched pool is aliased as before. - Check:
make -C dm/gfx/tests GAME=<id> check_t_vpool_first_map: an untouched pool aliased; a pool with an address, a texture or a buffer made first stays sparse, and each reads the physical pages. Without the fix the address case crashes on the range given back. Ocarina of Time (the one local game with an aliased pool, 305 MB, flags0x2a1over a physical pool of flags0x18c) still aliases at boot. - Found on: found by reading the code, from #2513 (Super Mario Party aliases 17 virtual pools) and PR #2519. Whether any game makes objects before mapping is not known.
- Symptom:
[RED] sampler ID 4095 not registered, besidetexture ID 1048575 outside the pool … treated as an empty slot: reads 0. - Cause: a game binds the combined handle
0xFFFFFFFF(texture ID0xFFFFF, sampler ID 4095) to unused slots. The host bounded texture IDs by the texture pools' capacity but not sampler IDs by the sampler pools'. That capacity was also the 256-aligned start of the next pool's numbering (dpool_add), not the pools' own end, so a 4,000-entry pool took IDs up to 4,095 as its own. - Fix: general,
dpool_addinhle_nvn.candsamp_by_idinnvn_mtl.m: each kind records one past its pools' last ID, base plus the pool's own size (mtl_set_tex_pool_n,mtl_set_samp_pool_n, carried in captures astex_pool_nandsamp_pool_n), and a sampler ID past it is an empty slot, logged once, as a texture ID is. What is bound stays as it was (nothing); only the red line goes. An unregistered ID inside a pool is still reported, and so is one in the gap a 256-aligned numbering leaves between two pools. - Check:
check_t_vpool_first_map(a 4,000-entry pool, IDs 4095 and 300). In the game, Super Mario Party's opening,sampler ID 4095 outside the pool (capacity 4000), no red line. - Found on: Super Mario Party (
010036B0034E4000, no profile yet), #2513: one sampler pool of 4,000 entries.
- Symptom:
[RED] NVN unimplemented function called: nvnTextureBuilderGetSparseTileLayout, then an out-of-memory abort in Unreal's allocator (Failed to allocate 1879113728 bytes from Standard Allocator, onDM_DBG=1). - Cause: the call fills
NVNtextureSparseTileLayout{numTiledLevels, tile width, height and depth in texels, 16 reserved bytes}; Unreal Engine 4.18 sizes a sparse texture's mip tail as storage(all levels) − storage(numTiledLevels levels) and allocates it from its heap. Unimplemented, the count was stack garbage and the whole 1.75 GB texture counted as tail. - Fix:
nvnTextureBuilderGetSparseTileLayoutandnvnTextureGetSparseTileLayoutanswer from the host's sparse layout (nvn_tex_sparse_tiles: the levels at least one tile wide, high and deep). - Found on: OCTOPATH TRAVELER, at boot (
main+0x1f1a688).
- Symptom:
CRASH sig=10inmemmovefrompkg_upload→nvn_deswizzle, at an address 32 GB into a sparse virtual pool; textures with unaliased tails came out magenta. - Cause: the game's streamed textures (flag 0x20, packaged layout) live 32 GB into its virtual pool; it writes their tiles on the CPU into 4 MB physical pools and then maps them, with no GPU copy. NVN maps at 4 KB and the host aliases at its 16 KB page, so a request whose offsets differ modulo 16 KB cannot be aliased: the ASTC 6x6 textures' mip tail starts 0x81000 into the texture.
- Fix: every request also goes into a 4 KB page table (
vpt_*inhle_nvn.c: the physical page behind each virtual page and the generation of the request that set it). The replay reads a sparse pool's textures through it (nvn_sparse_copy, unmapped pages as 0) and uploads one again only when a mapping in its range changed or the CPU wrote a physical page behind it (nvn_sparse_since; the physical pools are dirty-tracked). Before that the untracked alias counted as written on every look: 32 textures, 18 MB, were uploaded again every frame, and those with unaliased tails came out magenta. Plain sampled textures in the pool go the same way: Sampled textures in a sparse pool uploaded through the page table. - Note: requests beyond the reserved range (iOS reserves 1 GB: the buffers stay under 0x20100000 through the opening) are kept in the table only, like unaligned ones: textures can read them, the GPU cannot address them.
- Found on: Monster Hunter Rise.
- Symptom: a texture of a sparse pool keeps showing the pages it was first read from after the game maps other pages under it, until something unrelated sends its texture ID through the slow path. And in any game with a sparse pool, every serial change (a program bound, a texture uploaded again) re-checks every texture ID and builds every texture pool table whole: the
[nvn-mtl] pool for handles traced to no slotline counts its builds undertoo many or sparse. - Cause: a mapping request changes only the pool's page table (
vpt_set): no registration, no serial change, and on a 16 KB-page Mac often no CPU alias to dirty. The cached bindings that hold a texture's resource ID (texture ID views, pool tables, tables of handles in uniform buffers, reused argument blocks) followg_reg_gen,g_ser_genand the CPU-write generation, so nothing sent the texture throughtex_mtl_again, where its upload or its tile mappings would follow the page table. To make up for that,ser_syncstarted a new epoch andpool_gpubuilt tables whole at every serial change once any sparse mapping existed: costly where nothing was remapped, and still missing a remap with no serial change after it. - Fix: for every game.
hle_nvn.crecords every request's host range by its generation (g_vnote, read withnvn_sparse_notes). At each submission's start (mtl_sub_begin),treg_sparse_notestakes the requests since the last one and notes (own_note) the serial of each registered texture whose storage one of them overlaps. The IDs registered with a sparse texture are kept on a list (g_treg_sp). The note does what a re-upload's does:ser_syncsends just that texture's IDs through the slow path,pool_gpupatches just those entries, and handle tables and argument blocks seeg_ser_genmove. A submission sees the mappings made before it, as the page-table check already did (sparse_checked); those made during its replay are noted at the next. With more requests than the record holds since the last look, every sparse texture is noted. Serial changes no longer re-check every ID or build tables whole because a sparse pool exists. The diagnosis and the first fix, which started a new epoch at every mapping instead, are PR #1721's. - Check:
make -C dm/gfx/tests GAME=<id> check_t_sparse_pool_cache: 128 program changes keep the pool table; a mapping past the texture notes nothing; a 4 KB mapping under it changes no generation itself, notes its serial once at the next submission, sends argument blocks through their check, and patches the pool table, and the GPU then reads the newly mapped page through the pool table and through a handle table. On the code before the fix 8 of its 26 checks fail, the GPU reads among them. OCTOPATH TRAVELER, whose streamed textures have their finer levels mapped as they load (it binds textures by slot, so no pool table is built), on the route into S'warkii (Apple M6, 2026-11-13, 16,200 frames each): 60 fps, submit+present 1.4-1.9 ms a frame and the same pictures with and without the fix, no red lines. - Found on: The Legend of Zelda: Ocarina of Time, in an audit of the host's caches (PR #1721); no picture on a game has been traced to it.
- Symptom:
kIOGPUCommandBufferCallbackErrorOutOfMemoryon every command buffer from the first frames, no frame presented, "GPU allocated" near the Mac's memory;large texture … in a sparse virtual poollines name textures of gigabytes. - Cause: a sparse texture in a sparse virtual pool holds only what the game maps into it, but the host made each one a whole Metal texture and uploaded all of its storage through the page table (Textures read through a 4 KB page table of a sparse pool), again on any change. Six terrain arrays of 8192x8192x68 with 12 levels (two BC6H of 5,803 MB, four ASTC 8x8 of 1,451 MB) took more memory than a 16 GB Mac has.
- Fix: such a texture becomes a Metal placement sparse texture with 64 KB pages (
nvn_mtl.msparse_make,sparse_sync), costing no memory until a tile is mapped. NVN's 64 KB tile is the standard shape that Metal's is (256x256 texels of 16-byte blocks, 512x512 of ASTC 8x8, 128x128 of RGBA8, measured on an M6), and both put the mip tail at the first level smaller than a tile. A tile the game has mapped in full (hle_nvn.cnvn_sparse_tile_state) gets a 64 KB tile of a 256 MB placement heap and its own 64 KB decoded (nvn_deswizzle_tile: a tile is one block row of128 >> block heightGOB columns, at the level's start + (row × tiles across + column) × 64 KB). A tile the game unmaps gives its heap tile back once the GPU is past the submission. The pool's mapping changes reach the texture through a log of changed ranges (hle_nvn.cnvn_sparse_log_read), so a frame with none costs one comparison, whatever the size of the pool. A tile whose physical pages the CPU writes is uploaded again: pages of sparse pools carry a flag (nvn_dirty_protect_sparse), a write to one counts (nvn_dirty_sparse_writes), and only then, at most once a second, are the texture's mapped tiles compared. A tile of an upload batch the host had to drop is uploaded again the same way. Render targets and images in a sparse pool are placement sparse too; they take their usages up front, since a whole copy made later would lose the tiles (tex_upgraderefuses one). Each layer's tail is one Metal tile, decoded from its own bytes (nvn_deswizzle_from). The mapping operations reach the queue through the record stream, ahead of the upload batch that fills the tiles (nvn_rec.hrec_queue_block). Only a 2D texture or 2D array that would take 64 MB or more made whole goes this way, whose tiles match Metal's: smaller ones keep the whole upload and sparse_view's level clamp.DM_NVN_SPARSE_TILES=0turns it off. - Found on: Mario Kart World's base game: by its flyover's first minute, the six arrays and a 6144x6144x2 RGBA8 one map 21,796 tiles (1.36 GB) where made whole they took 8.7 GB, and the flyover presents. A race then ran at a median of 96 to 117 ms: the whole-range walk of the page table each texture lookup took half the submit thread (the log above), and a whole copy of one texture took 389 MB; with both fixed and large pools in chunks, a median of 16.7 ms. Of 42,072 tiles mapped by the first race, none was rewritten in place.
- Check:
t_sparse_layoutdecodes every tile and the tail of sparse BC6H and RGBA8 textures on their own and compares them with the whole level, in both GOB orders.t_sparse_tilesgoes through the real NVN entry points: mapped tiles and the tail hold what their physical pages hold, an unmapped tile gives its heap tile back unless the GPU drew into it (Physical pages mapped at several places of a placement sparse texture), a tile rewritten in place is uploaded again, more requests than the log holds still end right, and a GPU-written texture gets tiles and no uploads. - Open: resolved for drawn tiles of consecutive physical pages (Physical pages mapped at several places of a placement sparse texture): their content stays with that memory. A tile of scattered pages, a tile cut by the level's edge, or the tail, still reads its old heap tile's content when a GPU-written texture maps it again.
-
Symptom: the placement heaps (
sparse tiles: placement heap N) keep growing through a session, to several times the memory the game maps behind its sparse textures. On a 16 GB M1 Pro, Mario Kart World's race reached 11 heaps (2.75 GB) within three minutes, wired memory 8.7 GB, and the system swapped: frames went from 25 to 50 ms. A GPU write through one place of a page is also not seen at another. -
Cause: the host gave every mapped place its own heap tile, but a game maps one physical page at several places at once. In a Mario Kart World race, 2,959 MB of mapped virtual pages sat on 976 MB of distinct physical memory, almost every page at about three places, always in textures of one format: RGBA8 SNORM 3072² and 6144² arrays (which the GPU draws into) with each other, BC6H and ASTC 8x8 terrain arrays within themselves. On hardware every place is the same memory.
-
Fix:
nvn_mtl.msp_tile. A tile whose 16 pages are consecutive physical memory (hle_nvn.cnvn_sparse_tile_phys) takes the heap tile of that memory and format (sp_ph_find), and every place mapping that memory maps the same heap tile. What a new place does depends on whether the GPU has drawn into the slot, set when a texture mapping it is an attachment, a written image, or a copy or clear destination (sparse_gpu_wrote;s->gpucannot say this, since a packaged placement texture can be drawn into too):- Not drawn: the slot is a copy of the memory. Each new place uploads it again, because the memory may have changed in ways no fault shows (the GPU writing it as a buffer), and it goes back with its last place (
sp_release). The memory saving is the sharing; the upload rate is what it was with one tile per place. - Drawn: the slot holds what the memory does not. No new place uploads it unless the CPU wrote that memory since (
nvn_dirty_sinceagainst the slot's stamp), and it stays with no place mapping it while its physical pool lives, as the memory would. A lost upload batch does not overwrite it.
A physical pool's
Finalizetakes its slots out of the table at once (mtl_sp_phys_gone), so a new pool over the same memory never finds one, even one that a texture not looked at since still holds; a place still mapping it moves off at its next look. The content is read from the physical address the slot is keyed by, not through the page table, as the game can remap the place on its own thread during a look. The unmaps of a look are issued before its maps, so a tile moving to another slot is unmapped first. Metal reads one heap tile exactly through any place of any texture of the format (every texel, at seven positions and in a second texture, for RGBA8, RGBA8 SNORM, RGBA16F, BC6H and R8 on an M1 Pro;t_sparse_tilesalso passes on an M6) only without lossless compression, which no placement sparse texture has. The tail, tiles of scattered pages and tiles cut by the level's edge (only part of such a tile is uploaded, and a whole tile of the same page would read the rest as undefined) keep a slot of their own. - Not drawn: the slot is a copy of the memory. Each new place uploads it again, because the memory may have changed in ways no fault shows (the GPU writing it as a buffer), and it goes back with its last place (
-
Check:
t_sparse_tiles: one physical page mapped at two places is one heap tile and both hold its texels; a clear through one place shows at the other and marks the slot drawn; a drawn slot keeps its content with no place and shows it at a new one with no upload, until the CPU writes that memory; an undrawn slot goes back with its last place; a packaged place behind a GPU-written one reads the CPU's fill; a new place reads what the GPU wrote to the memory as a buffer; a cut tile has a slot of its own; the physical pool's Finalize gives the kept slots back, and a new pool over the same memory reads its own content. -
Open: once a texture is drawn into, every slot it maps then counts as drawn, whichever tiles the draw touched. A page the CPU filled before a GPU-written place of a drawn texture mapped it can read undefined texels at a packaged place of the same memory, as the host cannot tell it from a drawn page. A dropped upload batch is redone only for the texture that issued it (
up_lost_take), so another place sharing its tile waits until that texture is resolved again. -
Found on: Mario Kart World: over five minutes of the same race on a 16 GB M1 Pro, 4 placement heaps (1 GB; 13,041 of 13,449 tiles shared) where there had been 11 by three minutes, wired memory 6.9 to 7.1 GB with no swap growth where it had been 8.7 GB with the system swapping, and the frame median steady at 25 to 28 ms where it had risen to 50 ms. Two Grand Prix in one session (about 30 minutes) played with the heaps at 1 GB throughout, wired memory 7.0 to 8.6 GB and swap up about 1.2 GB, then steady.
- Symptom: frames of 100 ms and more every few frames on a Mac short of memory, with no failing command buffer; a Metal System Trace shows the driver wiring and unwiring all the time (3.2 s of a 6 s trace on the game below), and the
restopline ofDM_CTLshows gigabytes of shared buffers. - Cause: Metal wires a zero-copy buffer whole while it is in the residency set, so one buffer over a large pool pins all of it, whatever the GPU reads. A pool's pages are guest memory, already resident only in part, and wiring the rest pushes other memory out.
- Fix: a pool of 512 MB or more has no buffer of its own (
nvn_mtl.mbig_lookup). A lookup gets a chunk: a zero-copy buffer over the 16 MB stride holding its address and on to the end of what it asked for, made when first asked for, so only what lookups reach is wired. The chunks overlap, as adjacent pools' buffers do at shared pages (MTL4VisibilityOptionResourceAliasis on while one exists). GPU addresses are not contiguous across chunks, so vertex, uniform and direct index lookups now pass their real lengths; one that does not know its length (an indirect draw's index buffer, a raw storage address) gets 64 MB. An upload reads from a buffer of its own held by the upload batch (up_big_src), so a one-time read leaves no chunk wired. Raw global memory, whose pool table the shader walks by itself, takes a whole-pool buffer (gmem_map_gpu).DM_NVN_POOL_CHUNKS=0turns it off. - Found on: Mario Kart World's base game, on a 16 GB M6: the GPU has touched 1,111 MB of its 2,427 MB graphics pool by the first race. Shared buffers in the residency set went from 3,864 to about 1,500 MB, wired memory stayed at 9.3 to 9.8 GB (it swung to 12.7 GB), and the 95th percentile frame of a race went from 33-85 ms to 17-33 ms.
- Check:
t_pool_chunks: a lookup's buffer says the same memory as the guest address, a read of unknown length has 64 MB past it, a chunk asked for more is lengthened, a texture's upload wires no chunk, raw global memory's table covers the whole pool, the pool's removal takes every entry, and a smaller pool stays one buffer.
- Symptom: what the GPU copies, fills or renders into a pool never shows, with no error and no red line: garbled text where the game copies its font textures inside one pool.
DM_NVN_POOL_CHUNKS=0clears it when the buffer is a chunk. - Cause: a zero-copy Metal buffer made while any of its pages is read-only drops every GPU write through it, even to pages never protected and even once its pages are writable again. One made over writable pages keeps working when they are protected later (probe on an M4 Pro). The dirty tracker protects pool pages after a packaged texture's upload and after an index range is read (
nvn_dirty_protect). A chunk of a large pool is made long after the pool, by when uploads have protected pages in its stride; a lengthened chunk and the whole-pool buffer of raw global memory are made the same way; and a small pool's first or last 16 KB page may be shared with a neighbour whose texture protected it. - Fix: every pool buffer the GPU writes through is made by
pool_buf(nvn_mtl.m) over its protected pages opened:nvn_dirty_open(nvn_dirty.c) hashes each protected page of the range and makes it writable without counting it as written, the buffer is made, andnvn_dirty_closeprotects the pages again and counts as written only those whose hash changed (a write to an open page does not fault, so the hash is what sees it). A host write into an open page (a file read) takes it as it takes a protected page: stamped and left writable, not protected again, so the system call does not fail withEFAULT. No protection starts between open and close: protection and pool retirement wait meanwhile, and a page another thread is unprotecting is waited out before the buffer is made. The page-fault path takes no lock. An upload's own source buffer (up_big_src) is only read by the GPU and stays as it was. The first fix (2d544e0d) counted every opened page as written, so every texture there was read again from guest memory, though nothing wrote it: Ocarina of Time re-uploaded 4,038 textures in its first load (about 2.2 GB more resident, median GPU time 13.3 to 22.4 ms in Hyrule Field), and in LEGO, storage the GPU drew outside the spans it tracks (lighting tables, shadow maps) was taken from stale guest memory and the 3D picture went black (both measured by contributors on issue #1591). The cost is two hash passes over the protected pages of each new buffer's range, about 7 ms for 188 MB a pass on an M6 (Ocarina of Time opens 12,032 pages once, in its load). - Found on: LEGO Star Wars: The Skywalker Saga (
010042D00D900000, 1.10.0, from a provisional profile withtex_dirty_uploadon; not in the games table; issue #1591): all its text garbled since pools came in chunks, correct withDM_NVN_POOL_CHUNKS=0and with the reporter's change tobig_new; the 3D picture black after the opening crawl with the first fix, right with the contributors' open-and-compare idea, which this fix follows. Not run here. Ocarina of Time: with the first fix, 34 textures read again on the village walk though nothing wrote them; with this one, none, and the 12,032 pages opened in its load unchanged. - Check:
t_pool_chunks: a new chunk, a lengthened chunk, the whole-pool buffer and a small pool next to a protected page each take a GPU fill made over a protected page (all four fail before the fix); the chunk leaves a protected texture's page unwritten, and so does a small pool made over its neighbour's page; and a chunk made over GPU-written storage leaves it the GPU's, and storage the CPU wrote since the CPU's.t_dirty_race: a page written while open counts as written and is protected again, a page a host write took while open stays writable (a file read into it afterwards lands), an unwritten page is protected again and not counted, and an open waits out a page another thread is unprotecting.
- Symptom: GPU memory grows with every scene change (Engage's idle title demo reached 5.4 GB of GPU memory and 3.6 GB resident in 850 s).
- Cause: a game that frees memory pools on scene changes and builds new ones (Unity does; Mario never did in its first course) hit two host structures that kept freed pools alive: the residency set still held each pool's no-copy
MTLBufferafternvnMemoryPoolFinalize, and the storage-sharing texture table (g_alias, keyed by pool object, offset and description) never evicted, so the old pools' render targets stayed, and linear textures built on a pool buffer kept the whole buffer alive. - Fix: pool buffers are retired through deferred release, and finalizing a pool evicts its sharing-table entries (
mtl_alias_drop_pool). - Found on: Fire Emblem Engage: it now stays at 1.2 to 1.4 GB.
- Note: the
memory up to frameline also reportsserial objects(host objects by serial),freed but alive(finalized pool buffers still alive) andshared textures(sharing table size and evictions); growth in GPU memory with those counts flat points to an allocation outside them.
- Symptom: the memory line's
freed but alivecount only grows, while no host table holds those buffers; "GPU allocated" is far above resident memory (Ghost Trick: Phantom Detective, 125 to 146 buffers and 6.6 to 8.7 GB of GPU allocations against 2.2 GB resident). - Cause:
mtl_pool_delruns on the guest thread that finalizes the pool, whose implicit autorelease pool never drains, and adding the buffer to the weak tableg_dead_pools(the table behindfreed but alive) left it autoreleased there: every finalized pool's zero-copy buffer stayed alive for good, wrapping freed guest memory. A temporary census of every host table that can hold a texture over a pool buffer (by serial, finalized serials, the sharing table, texture ID views, view caches, the deferred queue, the residency set) found no holder at all.containsObject:andremoveObject:on a weakNSHashTabledo the same (a standalone test: the object outlived its last strong reference until its thread ended), andres_add/res_removecall them on guest threads too. - Fix:
mtl_pool_del,res_add_atandres_remove(nvn_mtl.m) each have their own@autoreleasepool, asdefer_drainand the memory line's count already did (Deferred-release drain without an autorelease pool). - Found on: Ghost Trick: Phantom Detective: 0 buffers freed but alive with the fix; GPU allocated 1.7 GB instead of 8.3 GB 300 frames into the boot and 2.9 GB instead of 9.9 to 11.7 GB in Chapter 1, resident memory, frame times and pictures unchanged (zero-copy buffers add no memory of their own). Pokémon Sword: 8 to 0 at the title after 90 s. Earlier Mac logs show the count in most games, growing with play: Animal Crossing: New Horizons 3,113 after 27,900 frames, Pokémon Sword 2,050, Super Smash Bros. Ultimate 1,279, Pokémon Legends: Z-A 932 (small pools there: 100 to 300 MB).
- Symptom: GPU and resident memory keep growing during texture streaming while pool, residency and serial-object counts stay nearly flat, with no freed pool buffers alive.
- Cause: texture descriptors copy their NVN texture metadata, so they remain readable after the API object's
Finalize. Their cached Metal views, however, were released only when the descriptor ID was registered again. A game that streams textures into new IDs left those uploads retained indefinitely after their serial and shared-view entries had been removed. - Fix: index descriptors by texture serial and retire their cached base, compatible-type and image views at
mtl_tex_release, through deferred release behind the GPU fence. Preserve the descriptor metadata: a late read resolves it throughtex_mtland caches its views for that submission, then retires them at commit.t_cpu_texchecks multiple descriptors, ID replacement and pixel-correct recorded replay after finalization and API-object reuse. - Found on: Xenoblade Chronicles: Definitive Edition.
- Symptom: the submit thread spends milliseconds a frame in
pool_gpu→tex_by_id→tex_mtl/tex_get(asampleof the process), and the[nvn-mtl] pool for handles traced to no slot … built in X msline shows a rebuild of the texture pool table every frame at about 2 ms. Fire Emblem Fortune's Weave's battle map: 1.9 ms a build, three builds a frame, about 14 % of the main thread. - Cause: a texture ID's cached view was valid while
g_ser_genstood still, and every change to the serial table bumped it. A game that changes a few serials a frame (the per-stage program ring inbind_stagesdoes) therefore sent every registered ID down the slow path at the next lookup, andpool_gpuwalks all of them (about 5,000 in Fortune's Weave). Packaged (CPU-written) IDs took the slow path again on any guest write anywhere. - Fix: for every game (the generation and its notes are the resource owner's,
nvn_owner.m; the readers arenvn_mtl.m's). A change to one serial's object bumpsg_ser_genthroughown_note, which records the serial; ID views are valid for an epoch (treg_ep), andser_syncinvalidates only the IDs registered with a noted serial (g_treg_serial). A bump without a note (the sharing table, upgrades, a dropped upload) or more than 64 notes starts a new epoch, which re-checks every ID as before. A sparse mapping comes as notes of the textures it changes (Sparse mappings under cached texture bindings). A packaged ID whose pages nobody wrote since its upload stamp keeps its view (the testtex_mtl_makes), andtex_fast_ridreturns the cached resource ID of a viewtex_by_idkept. Other users ofg_ser_gen(argument blocks,pool_gpu's own key) are unchanged. - Check:
make -C dm/gfx/tests GAME=<id> run(t_cpu_texamong them). Fortune's Weave's battle map (975 draws a frame, MacBook Pro M1 Pro): pool build 1.9 → 1.2 ms, submit 52.5k → 42.8k instructions per draw. - Found on: Fire Emblem Fortune's Weave.
- Symptom: the
[nvn-mtl] pool for handles traced to no slotline counts thousands of builds while the game streams, and the submit thread spends milliseconds a frame walking the texture pool table again. Ocarina of Time, a seven-minute walk through Kokiri Forest: about 61,000 builds of its 33,025-entry table, 53 s in all. - Cause:
pool_gpuwalked a table whole at any change ofg_reg_genorg_ser_gen, though each change reaches only a few of its IDs: a texture or sampler registration (the game registers something between most of its draws), a memory pool added or removed (a game that maps and unmaps virtual pool runs as it streams,vrun_set, does so dozens of times a second), and a storage takeover (alias_take_over, about 8,400 in six minutes in Kokiri Forest), which bumpedg_ser_genwith no note and so also sent every texture ID down the slow path. Past 2 ms of walks a submission (POOL_SUB_MS) a table stood until the next one, so reads of what had changed found the dummy for the rest of it. A table that held a view of a retired registration (a finalized texture whose descriptor is still registered) was also kept past the submission whose end drops that view (treg_finish_submission). - Fix: in
nvn_mtl.m, for every game. A table takes again only the IDs a change can reach, by the walk's own rules (pool_entry); it is kept when none of their entries changes, and copied and patched otherwise, as draws already encoded read the old one:- A registration records its generation and ID (
g_reg_log), and a storage change its serial (g_ser_log, beside the notesser_synctakes); a table takes again the IDs they name. - A pool change records
REG_POOLand no ID. It changes no registration, only which storage exists, so a table takes again only the IDs it found without storage (pool_snap.dead).nvnMemoryPoolFinalizemarks a pool's IDs gone before it removes the pool, andtreg_gonenotes each of them. Pool changes come from threads that do not holdg_rmu, so a note is a sequence lock: its generation is cleared before what changed is written and stored after it, and a reader takes the note only if the generation matches before and after it reads. - A table keeps the IDs whose entries are views of retired registrations (
pool_snap.trans), those the read scan writes in place (pool_seen_scan) among them; the next submission takes only those again, and such a table does not stand past its submission. - A takeover records a note that names no texture (
own_note(0)): it changes no serial's texture, as an entry a live serial holds is not evicted (serial_table_holds) and one a finalized serial holds throughg_ghostis only unref'd. A live sRGB / linear twin holds a view of the shared texture, which counts as holding the texture (A view's entry did not hold its parent). A retired ID's view goes at the submission's end, and the next submission takes those IDs again. - A patch makes textures within what the frame may still make (
pool_room), as a walk does; a texture left out counts in the table'sleft, and the walk goes on next frame as before. - Past
POOL_IDS_MAXIDs on a list, a note the ring has overwritten, a bump without a note, or a pool that grew, the table is built whole as before. A sparse mapping is a note of each texture it changes (Sparse mappings under cached texture bindings). The log line says why tables were built whole and how many were patched or kept; the power lines gain[nvn] pool tables up to frame ….
- A registration records its generation and ID (
- Check:
make -C dm/gfx/tests GAME=<id> check_t_pool_notes: a pool change, a finalized pool, a retired ID across submissions, a takeover, a registration past the budget and a read written in place each patch or keep the table, and the patched tables hold what tables built whole do. The notes and the patching were measured on the host of PR #1387 (MacBook Pro M5 Pro, a six-minute walk through Kokiri Forest): tables built whole about 9,600 (26 s) with only pool changes noted, 14 (0.3 s) with all three. Open: the counts on this walk, which also has the read bitmap and the budget, are not measured yet. The table patching and the first notes are PR #1299's; the unnoted takeover was found in issue #1326. - Found on: The Legend of Zelda: Ocarina of Time.
- Symptom: while a game streams an area in, frames of 100 ms to about 1 s with the GPU at its usual time, no new pipelines and no texture uploads in them: the
[longframe]lines put the whole frame insubmit+present, and a long session falls into slow motion for a minute or two at a time. In the[nvn-mtl] memory up to frameline,finalized textures still referenced 512 (small table, N put in total)stays at 512 and N jumps by millions in one report (651 to 3,669,055 in 300 frames at an area transition; 43 million in one window riding into Hyrule Field), anddeferred-release queuereaches tens of thousands. After the same game's pause menu, which it streams behind, 50 to 55 fps for about five seconds with[nvn] pool tables … built wholeat 1.4 to 1.6 a frame (9 ms each) and the pool line'sa new submission over too many retired IDscounting nearly every build. - Cause: two limits sized for a few finalized textures in use at once.
- A texture ID registered for a texture the game has since finalized (a retired registration) has its view dropped at the end of each submission and resolved again in the next (
treg_finish_submission), through the table of finalized serials (g_ghostinnvn_owner.m, which a replayed record resolves through too). That table was a ring of 512 whose oldest entry went when a new one came, whatever its use. With more than 512 resolved in a submission, each resolution evicted an entry the next would need, the eviction's note (own_note) sent that serial's IDs to be resolved again, and the ring never settled: every lookup missed and made the texture again, with a deferred release and two notes each. - A pool table lists its views of retired registrations to take them again in the next submission (
pool_snap.trans, Pool tables patched instead of built whole). The list held 1,024 IDs (POOL_IDS_MAX), fewer than one patch takes (8,192); past that each submission built the table whole.
- A texture ID registered for a texture the game has since finalized (a retired registration) has its view dropped at the end of each submission and resolved again in the next (
- Fix: for every game.
nvn_owner.mkeeps the table by use: each entry's last lookup or put (own_resolve_ref,tex_put: the frame fromown_hook_frameand the submission) in a list from least to most recently used. PastNGHOST(512) entries, the least recently used goes only once it is idle, unused forGHOST_IDLE(16) frames orGHOST_IDLE_SUBS(1,024) submissions, so a working set larger than 512 stays while it is used. Each put evicts up to two idle entries and each submission's commit up toGHOST_TRIM(32, under the 64 notes a texture ID cache takes at once;own_ghost_trim), so the table drains back to 512 once its textures idle. The window is in frames because a game may make many submissions a frame (about 25 here: a window of 16 submissions, tried first in PR #2389, left 99.6 % of the evictions coming back within a frame); the count of submissions bounds a stretch with no present, when the frame stands still. A stale view's entry leaves the list with it (own_view_drop_stale), andown_check_lockedchecks that each ghost serial has one place in the list, in order of use.POOL_IDS_MAXis 8,192 innvn_mtl.m, the IDs one patch takes, which it now names too.
- Check:
t_serial_owner(the owner alone): past 512 entries in use none goes; once idle the least recently used goes at the next put, passing over one used again; 2,048 serials resolved every frame for 32 frames at 25 submissions a frame are all found with no put, then a commit evicts 32 of them and notes as many, and commits take the table back to 512, least recently used first; with the frame standing still, entries go after 1,024 submissions and not one before; a serial put again after its stale view went has one place and goes in its turn.t_cpu_tex --metal's hand-over and cross-thread cases evict their ghost entries once idle. A seeded differential driver gave main's ring and this table the same sequences of owner calls (13 seeds, up to 60,000 calls, about 300 entries): the same lookups, drops, notes and counts belowNGHOST. In play (Ocarina of Time on an M6, the village walk from its note's save, the chain header pinned to the libraries on disk): with the table cut to 8 entries in both hosts, the ring made 4,876 puts by frame 7,800 and this table 202 (a 64-entry ring makes 231 over the whole walk), growing past 8 while its textures were used and draining back; at 512 it does what main does on this walk (72 to 266 puts, 42.0 M submit instructions a frame either way). That walk falls into a slow mode after the load (frames of 60 to 120 ms, GPU 25 to 35 ms) in 2 of 4 runs on either host, once with 73 puts: it is not the table's. The diagnosis, the window in frames and the pool limit are PR #2389's, which measured on a MacBook Pro M4 Pro, at the same area transition with an earlier form of this change on an older revision: worst frame 490 → 36 ms (one frame; the rest under 20 ms), puts in the run 3,669,055 → 3,733, 60.0 fps in every report of the transition. The ring's thrash was reproduced without the game in GitHub issue #2213 (600 finalized IDs resolved a submission: 600 puts every submission). Open: the reporters' long sessions with this change (the slow motion took an hour or more of play to reach); the retired views are still dropped and taken again every submission, which costs submit time in proportion to their number; more than 8,192 of them would build a table whole as before. - Found on: The Legend of Zelda: Ocarina of Time.
- Symptom: a pool table keeps naming an sRGB / linear twin's view after the texture it is a view of left the sharing table and, 17 submissions later, the residency set: a texture ID with no residency behind it, which gives GPU address faults in the shaders that sample it (as in issue #1326), and a view that no longer shares its storage's bytes with anything. Seen in
t_pool_notesonly, in no game. - Cause: a texture that differs from a shared texture only by the other format of an sRGB / linear pair is a view of it (
tex_mtl_,srgb_twins), and the twin's serial entry holds the view.g_by_serial_heldcounted the view alone, so the shared texture looked unheld once its own object was finalized:alias_take_overevicted it under the live twin, anddefer_draintook it out of the residency set (entry_holds). Since the takeover's note names no serial (Pool tables patched instead of built whole), nothing sent the twin's texture IDs throughtex_mtl_'s stale-view check, which a twin only sampled through its IDs does not reach otherwise. Before that note every takeover sent every ID there, which hid the eviction. A texture placed in a recycled heap (rh_texture: a render target in a GPU-only pool) stays resident through its heap, so only the stale bytes applied to it. - Fix: in
nvn_mtl.m, for every game. An entry that holds a view holds the textures it is a view of as well (count_add), in the serial table and the ghost ring: a live twin keeps its parent from a takeover as a live texture of the parent's own format does, and a parent retired another way (its pool finalized, an upgrade) stays resident until the last view's entry goes. Those two bumpg_ser_genwith no note, so every ID reaches the stale-view check as before. The twin path checks underg_smu, after its entry is in the table, that the parent is still the shared texture of its key, and drops the view otherwise (a takeover on another thread between the lookup and the entry). - Check:
make -C dm/gfx/tests GAME=<id> check_t_pool_notes: with the note of the twin's first resolution taken, a texture of another description over the storage leaves the twin's parent shared, held and resident and the table's entry unchanged, with no table built; once the twin is finalized the next such texture evicts the parent. The same case fails three checks on the host before the fix. Not checked: a game (none is known to keep a twin live past its parent's object); the check after the entry, which needs a second thread. - Found on: no game: reported from the code and the fixture in issue #2125.
- Symptom: surfaces that read textures by handle through a texture pool come out black or unlit. PRAGMATA: Cabin's monitor in the Shelter stays black (the console shows a cyan animated face), and every cube map read through the pool samples zero (environment reflections and light). No
[RED]line. - Cause: a pool table (
pool_gpu) is one per element type a shader declares, andpool_entry/pool_seen_scangave it only textures whose Metal type was the table's. To Maxwell 2D, 2D-array, cube and cube-array textures are all layers, and a shader's texture instruction carries its own dimension; the host also keeps cubes and cube arrays as 2D arrays and makes cube views only for slot binds (tex_fit). So a cube read through a cube table held the dummy (Metal type 2D array, not cube), and so did PRAGMATA'sCharacter_CabinDisplaymaterial (pixel shaderecd01ea9b68f5390), which samples its five sprite slots (2D textures,Ch06000FaceMonitorUserData'sfms*.tex) through atexture2d_arraytable. - Fix: in
nvn_mtl.m, for every game.- The read bitmap has one section per table type, and a table's head names its own (
pool_seen_gpu_t; 2D tables keep the bitmap's start), so the host knows which type a shader read an ID through (pool_seen_t). - A table of another layered type holds a view of the ID's texture when a shader read the ID through that type (
pool_fit), with the layersfit_layersgives, astex_fitdoes for a slot: a 2D texture as a one-layer 2D array, a 2D array's layer 0 as 2D, a cube as a cube and as six layers, a square 2D array of 6n layers as cubes, a cube array as layers or cubes. - The view is cached apart from the slot binds' (
palt), one per table type, and goes with the ID's views. One per type because a texture read through two other types (a cube as 2D and as a cube, a cube array as a cube and as a cube array) is in tables of both at once, and a table stands over submissions while no registration or storage changes: a single view per ID, replaced when the second table took the ID, left the first naming a released view. What no view serves (a 2D or 3D texture read as a cube, a non-square array as a cube) holds the dummy as before, astex_fitbinds a zero texture; GL and Vulkan leave such a read undefined. Each such ID read is reported once ([RED] [pool-type] … left out, at most 256 a run).
- The read bitmap has one section per table type, and a table's head names its own (
- Check:
make -C dm/gfx/tests GAME=<id> check_t_pool_typebridge: each pair, read on the GPU through the table at a given layer or face; an ID read only through its own type gets no view; an ID read later through a second type goes into that table in place; a table's view of an ID stays when a table of a third type takes the ID; the view is kept and goes when the ID is emptied; the unserved reads are reported once. Without the bridge 10 of its 32 checks fail; with one view per ID for every type, 2. In play, PRAGMATA's Shelter on the Mac: Cabin's face and the reflections match the console. - Found on: PRAGMATA.
- Symptom:
[RED] [pool-type] texture ID … (ser=… 1x1 format 0x25 target 1) is Metal type 2, read through a type 5 texture pool table: left out (dummy), no view serves it, or a 3D textureread through a type 2 texture pool table, in the frame after a scene change, with a different ID each run. - Cause: the read bitmap's sections (see Pool tables that held only their own texture type) are an ID's, and stayed when the game put a texture of another type into the ID. The marks were the old texture's: the next walk found the new texture marked as read through the old one's type and reported it, though no shader had read it so. Resident Evil 4 puts its 1x1 default 2D texture into the IDs of the 256x256 cubes it lets go, and a 3D texture into an ID a 2D texture had; a diagnostic host showed every report in the frame after such a change, with only the old type's mark set.
- Fix:
mtl_reg_texclears an ID's typed marks, on the host and in the GPU's bitmap, when it registers a texture of another NVN target (the view's, if it sets one) there (pool_seen_retype). The read mark itself stays, for the read budget (PR #2022), and so do the typed marks when the type is the same, which keeps the bridges of a game that cycles textures of one type through its IDs. A shader that does read the new texture through another type marks it again, and that is reported. - Check:
make -C dm/gfx/tests GAME=<id> check_t_pool_typebridge: a cube replaced by a 2D texture loses its typed marks and keeps its read mark; the cube table then holds the dummy and reports nothing; a cube read of it after that is reported; a 2D array replaced by another keeps its 2D mark. Without the fix 2 of its 40 checks fail. Resident Evil 4's opening to the forest path on the Mac (Apple M6, 2026-11-13): nine such lines before, none after. - Found on: Resident Evil 4.
- Cause: the memory-pool tracking table had a fixed 4,096 slots; a pool past the end would have its textures re-uploaded at every use.
- Fix: it grows in blocks of 1,024 (
nvn_dirty.c, up to 262,144). - Found on: The Legend of Zelda: Breath of the Wild: the phone reached 4,006 pools (found while fixing GitHub issue #9: Deferred-release drain without an autorelease pool).
- Symptom: content kept by its dirty-page stamp is used again after a load although the game wrote new data over it: the game destroyed a memory pool, wrote the next area's data into the same memory and made a new pool over it, and a copy taken from the old data stays, with no red line. A cold start into the same place is right. Not seen in a game on the Mac or iPhone yet; a fork that keeps vertex and index bytes on the GPU by these stamps drew Skyward Sword HD's Skyloft with the opening's meshes after a new game (crates one flat grey shape or missing, large flat green, grey or magenta shapes, flat grey sky).
- Cause:
nvn_dirty_remove_rangemakes a destroyed pool's pages writable and retires its range, so the game's next writes there fault nowhere. A pool registered over that memory reuses a slot with every page's generation back at 0, so a stamp taken in the old pool's time, or while no pool held the memory, saw no newer generation, andnvn_dirty_sinceand its variants answered "not written". On the Mac a caller that keeps a stamp past the pool's end meets it: the vertex conversion's kept index ranges (nvn_mtl.mix_range) are keyed by address and outlive their pool. - Fix: each range keeps the clock at its registration (
born,nvn_dirty.c), andnvn_dirty_since_cached(behindnvn_dirty_sinceandnvn_dirty_since_span) andnvn_dirty_since_exceptcount a stamp older than it as written, through a kept handle too. A stamp taken after the registration is unaffected. - Check:
make -C dm/gfx/tests GAME=<id> t_dirty_slots: a pool destroyed, a write with no pool registered, and a new pool over the same memory: the older stamp is dirty throughnvn_dirty_since, a kept handle andnvn_dirty_since_except(all three clean without the fix), and a stamp taken after the new registration is clean. - Found on: The Legend of Zelda: Skyward Sword HD, from a new game into Skyloft, in a fork whose geometry cache keeps vertex and index bytes by these stamps; no game on the Mac yet.
- Symptom: the serial table grows by about 10 entries a frame (276,000 after 25,000 frames).
- Cause: a game that records transient render targets and finalizes them before it submits: replay put the dead serials back into
g_by_serial. - Fix: dead serials go to a table of their own (
tex_put,g_ghost; the memory line'sfinalized textures still referenced), which keeps 512 once idle (More finalized textures in use than their table held). - Found on: The Legend of Zelda: Breath of the Wild (the HUD captures, the 640×360 buffers; found while fixing GitHub issue #9).
- Note: the tables are
nvn_owner.m's (dm/gfx/README.md).t_serial_ownerholds them to their behaviour, linked with the owner alone: live registration, replacement and removal, wheretex_putsends a serial (live table for a live texture object or an external texture, ghost ring for a finalized one), finalizing, eviction by use past 512, texture and program pins, a view's entry counting its parent, and the held counts (g_by_serial_held,g_ghost_held, pins included) against the tables after every step.
- Symptom: none seen on a game. A finalized texture could resolve to an sRGB / linear view of storage that no longer held its bytes.
- Cause: two paths of
tex_mtl_published or kept such a view and took it back withserial_del, which ends a live entry only (what a finalize wants: a replayed record may still resolve the finalized serial through the ghost ring). The twin path published the view first (tex_put, which sends a finalized serial to the ghost ring) and checked its parent's place in the sharing table afterwards, so a parent evicted on another thread in between (a pool's finalize on the game's thread) left the view published until the check, and for a finalized serial in the ghost ring after it. The stale-view path (a view whose parent left the sharing table since) dropped it the same way. Each time the serial's nexttex_putreplaced the ghost entry a few lines later, unless making the texture's own one failed (tex_mtl_returns nil); and any thread resolving the serial in between got the stale view. Reproduced by the old sequence int_serial_owner(synthetic, at the table level: the view resolves between the publish and the check, and afterserial_del); not seen on a game. - Fix: a view's validity is decided in the same hold of
g_smuas its entry (nvn_owner.m).own_view_putpublishes a view only while its parent is still the shared texture of the parent's key (and marks that key looked up in the same hold), so the twin path never publishes one whose parent has gone or been upgraded and has nothing to take back.own_view_drop_staleis the invalidation, distinct from a finalize: a view whose parent is neither shared nor pinned (and that is not pinned itself) loses its live entry, or with none its ghost entry and that entry's place in the table's list (a ring slot then), and only while the entry still names that view (a replacement made meanwhile stays).serial_delis unchanged. Each ghost serial now has exactly one ring slot (own_check_lockedchecks it): a slot left behind by a removed entry would evict the serial's later entry early.t_serial_ownercovers publication against a shared and an evicted parent, pinned views and parents, a replaced entry, a live entry beside a ghost one, each order of finalize, publish, eviction and resolution, a parent's upgrade, the ring after a removal, and teardown;t_srgb_aliasruns a finalized twin whose parent is evicted through the realtex_mtl_. - Found on: no game; found in a review of the serial tables.
- Symptom: none seen on a game. Windows in which a texture serial could resolve to a texture the sharing table had already let go, or two serials over one storage to two textures.
- Cause:
tex_mtl_, which also runs off the submit thread (it checkson_sub_thread), changed the sharing table and the serial tables in separate holds ofg_smu. A lookup let go of the lock before itstex_put, so a pool's finalize in between (the game's thread) published an evicted, retired texture under the serial. A new texture's takeover and entry were made in one hold and itstex_putin another; a second texture registered under a key already present replaced the entry in the map alone, so the first stayed in the set of shared textures (kept resident as shared, never retired) and its serials kept a texture no longer shared with the storage's other serials. An upgrade renamed the tables in one hold and published the twin's view made again in another, so a reader in between found the old view of a parent no longer shared (which the stale-view check then dropped). - Fix: the sharing table moved into the resource owner (
nvn_owner.m), and each change it makes with a serial's publication is one operation under one hold:own_alias_take(lookup, last-lookup mark, publication),own_alias_publish(takeover, entry, publication; a key registered meanwhile by another thread is taken and the caller retires its own),own_view_put(the parent still the key's texture),own_upgrade(the rename of the serial tables, pins and sharing table, then the asking serial's object). A replaced entry is retired with its set membership (alias_put).own_check_lockedchecks the sharing table's index, set and last lookups against it. Matching (the key,ALIAS_IDLE, which entries a takeover spares) is unchanged. - Check:
t_serial_owner(owner alone): lookups and registrations against a shared and an absent key, a second registration under one key, a takeover sparing held and lately looked-up entries and taking a ghost-held one, a pool drop, a twin refused after its parent's pool drop or upgrade between lookup and publication,own_upgradethrough a twin and through the parent; seven deliberate breaks of the owner each fail it.t_alias_takeover,t_pool_notes,t_srgb_aliasandt_upgrade_aliasthrough the realtex_mtl_. Not checked: two threads at once (the cases replay each interleaving in order). - Found on: no game; found while moving the sharing table into the owner.
- Symptom:
nvn_replayaborts on a game's capture with-[NvProg width]: unrecognized selector(fromrt_dims) or-[NvProg mipmapLevelCount]: a render target resolves to a program. - Cause: a capture's textures and programs keep the live run's serials, while the replay layer's own objects (the combined program
bind_stagesmakes when a game binds its shader stages from separate programs) took theirs from the replayer's counter, which starts at 1. Textures and programs share the serial table (tex_get,serial_put), so a combined program could take an early texture's serial and replace it there. - Fix:
nvn_replayraises the counter to 2^61 before it loads a capture (nvn_serial_floor); a live run's serials stay far below that, and external textures take 2^62 and up.check_t_capture_livebinds two programs to separate stages to cover it. - Found on: Pokemon Scarlet (frame 3000 of its regression route).
- Symptom:
DM_NVN_CAPTUREreportsa record names … (0 bytes), outside every pool … texture registry entry …for some texture IDs, with addresses that look like data; one capture stopped witha sparse virtual pool (step 1 does not capture page tables): the capture is incomplete, another withstraddles a region. - Cause: the game finalizes a texture's memory pool, frees the pool object and reuses its memory, and leaves the texture ID registered. The host marks such an ID
gone(treg_drop_pool_views: nothing makes its texture until the ID is registered again), but the capture did not record that state. It walked the entry's texture fields and read the dead pool object throughtex_t.pool: the bytes now there (Scarlet: a texture's data, such as0x2492492492497f80) became the pool's address, size and mode. - Fix: a registry entry carries
gone(cap_treg), and a gone entry keeps neither its pool nor its storage address and is not read back.nvn_replayregisters it and then marks it gone (mtl_reg_tex_gone), as the live run left it. Captures made before keep working (the byte was padding, 0).check_t_capture_liveregisters a texture on a pool of its own and finalizes that pool, then fills the pool object with such bytes. Without the fix the capture is incomplete. - Found on: Pokemon Scarlet (texture IDs 1726 to 1786 in the bedroom, each texture with a pool of its own).
- Symptom:
CRASH sig=11 … pc=nvn_sparse_protect+0x40, an address that looks like data,x88 below it, after a long session; the backtracetex_by_id←tex_mtl←pkg_upload. The pool need not be sparse. - Cause: a texture ID keeps a copy of the texture, and with it
tex_t.pool, the game's memory pool object. The game may finalize the pool, free the object and leave the ID registered: a descriptor costs nothing until a shader samples through it.tex_by_idstill resolved such an ID, markedgoneor not, andtex_mtl_read the object: garbage in itsvpmade the texture look sparse, and the upload'svpool_atreadv->basefrom that value. The fault is onpool->vpitself, before any page table is read, so no check of a page table pointer can stop it.tex_storage_liveguarded onlypool_gpu. - Fix: nothing is made or refreshed from a texture whose pool is gone, on two checks. An ID marked
gone(tex_by_id,img_fit_upgrade,image_prepare) cannot race the finalize and stays right when a new pool takes the old object's address:nvnMemoryPoolFinalizenow marks it underg_rmubefore it tears anything down, not after. Every other route passestex_mtl_, which checks the object's magic, the only sign left when a game overwrites the object with noFinalize. A texture the host already made keeps its host texture without a refresh (tex_made), as in-flight work may still read it; one it has not made reads as an empty slot. Each case logs its ID or serial. A hand-builtpool_tin a test therefore needs its magic.t_readback(stale_pools) faulted at this instruction before the fix and covers each case, sparse pools included. Which route the game took is open: the report has no backtrace, so if a host bug put a dead pool's texture in a live slot, this stops the crash and that bug remains. - Found on: FINAL FANTASY RESONANCE, Asian release, issue #934, after 22 minutes of play (not reproduced on the game: the fault was reproduced by
t_readback); once in Mario Party Superstars, in the first pool walk.
- Symptom:
regress.py capturestops withframe N was not captured within … salthough the game reached the frame before it; a capture takes 5 to 20 minutes where an earlier one of the same frame took 2 to 3, and a sample of the host shows the submitting thread inlzfseEncodeBaseundernvn_cap_submit. - Cause: the capture's steps run on the thread that submits or presents, and that thread can hold a time-constraint (real-time) policy declared for a few milliseconds a frame (time-constraint computation declared below the thread's peaks). A step that runs for minutes far exceeds the declaration, and the kernel lets the thread run only in short slices: Pokemon Scarlet's pool bytes took 1,175 s to compress where the same data takes 55 s outside the host. The harness also counted the capture step's 300 s without a frame as a hang.
- Fix: each capture step runs under the standard policy, and the thread gets its own policy back after it (
cap_policy_standard,cap_policy_restoreinnvn_capture.inc). Scarlet's frame 3000 now takes 53 s to capture (1,178 s before), Breath of the Wild's frame 5000 34 s (124 s). The log gives each step's time (submit N: registries and their textures …, records …, pool bytes …).regress.py capturebounds the step by--capture-limitinstead of the hang time. The pool bytes are also copied last in the step, as the live replay of the submit reads them, and once. - Found on: Pokemon Scarlet and Breath of the Wild (their regression route captures).
- Symptom:
nvn_replaystops withprogram #N (a key …) stage S: the translated shader's bindings or inputs differ from the ones the records were captured against, andregress.py replayreports every game's capture as one the candidate cannot replay, while the shaders' reflection (<key>_b.json) is the same in both chains apart from the chain field. - Cause: the capture compares each program's interface with the replayer's through a hash (
cap_iface), and that hash was of the reflection structs' bytes (nvn_rbind, the vertex inputs). 682fdd26 widenednvn_rbind.nto 16 bits and addedsoff, so every shader's hash changed although no binding had. - Fix: the hash is of the values, field by field, and a field at 0 adds nothing, so a field widened or added later keeps the hash of every shader that does not use it;
t_capture_livepins the hash of one reflection. A capture records its hash kind (headeriface 2), andnvn_replayrefuses one of another kind withcapture again: the captures made before have to be recorded again once. - Found on: the regression captures of Breath of the Wild, Pokemon Scarlet, Tears of the Kingdom and Super Mario Bros. Wonder (2026-10-30, all four refused at f5aee5aa).
- Symptom:
texture object no longer valid, followed byCRASH sig=10innvn_sparse_poolthroughsparse_gpu_wroteandattach. In Rhythm Heaven Groove's frog stage, the supposed texture contained a texture handle followed by anOP_DRAW_IDXrecord. - Cause:
tex_snapstores a texture description inside the command recording. The queue'sR.rtkept pointers to those descriptions across submissions, even after the recording had completed and its memory could be reused. A write watch caughtnvnCommandBufferBindTexturereplacing a valid snapshot at the same address; the next render pass read the new command bytes as a texture and memory-pool pointer. Checking the texture's magic intex_mtldid not protect the subsequent sparse-pool lookup. - Fix:
rt_preserve_boundcopies the still-bound color and depth descriptions into fixed storage owned by the queue after CPU replay, before the submission can publish completion. The standalone replay path has its own storage. Already-owned descriptions are reused; all sources are read before any destination is written, so reordered or aliased targets remain intact. Views and subresource selections stay withR.rt. No texels are copied, no texture is reloaded, and Metal objects retain their existing fence-based lifetimes. - Check:
t_rt_lifetimereuses completed command memory through the NVN interface and checks real color/depth readback. It covers bindings inherited by a later submission, alternating queues, a nested command list, mip/layer views, depth-only targets and the eighth color attachment, with synchronous and deferred Metal encoding. The inherited-target case crashed before the fix. - Found on: Rhythm Heaven Groove, base 1.0.0,
kaeru(localized name labelsI_KAERU_00andI_KAERU_01).
- Symptom:
[RED] NVN unimplemented function called: nvnCommandBufferGetControlMemoryFree(or…GetControlMemorySize) once, after the game gives a command buffer control memory. - Cause:
AddControlMemorywas a no-op, and the size and free getters reached the unimplemented stub, which returns 0, so a game that checks the free space sees none left. - Fix:
cmd_trecords the lastAddControlMemoryblock;GetControlMemorySizereturns its size,GetControlMemoryUsedthe bytes of control entries the host wrote into it (A recording's end and links are in control memory; 0 until then, when the host used none), andGetControlMemoryFreethe rest.t_nvnchecks the values before any block, after one, after a recording and after a smaller block replaces it. - Found on: South Park: The Fractured But Whole 1.05, whose port is PR #662 (not yet in the games table). With the getters implemented it reached the title, menu, a save and character customization on the Mac; with them returning 0 it also progressed, but submitted commands at a different rate.
- Symptom:
[RED] NVN unimplemented function called: nvnQueueBuilderGetCommandMemorySize(or…GetComputeMemorySize,…GetControlMemorySize,…GetRayTracingMemorySize,nvnQueueBuilderSetRayTracingMemorySize), once each, as the game makes its queue. - Cause: the calls were not implemented. The driver's getters write the size to
*sizeand return whether it was set (ldr w8, [x0, #0x2c]; str w8, [x1]; ldrb w0, [x0, #0x24]); the stub returns 0 and writes nothing, so a game that copies the sizes into its own queue description keeps what its fields held. - Fix: the queue builder keeps each size with a set flag (
qb_set,qb_getinhle_nvn.c); the getters answer from them, andGetQueueMemorySizetakes the device defaults for the sizes not set, as before. The ray tracing size is kept and reported only: the host traces no rays. - Found on: PRAGMATA, which reads all four after
SetDefaultsand its setters.
- Symptom:
[RED] NVN unimplemented function called: nvnCommandBufferSetFloatBlendClamp; now[RED] nvnCommandBufferSetFloatBlendClamp 1 (a float blend result that overflows becomes the largest finite value): Metal stores infinity, once. - Cause: an NVN2 call the host did not have. The driver writes its flag into 3D method 0x10b0, which NVIDIA's class headers list only as a reserved software method (
clb197.h,clc797.h:SET_RESERVED_SW_METHOD03). The Tegra kernel driver names it:SET_BES_CROP_DEBUG4(nvgpugr_gp10b_set_bes_crop_debug4, also the handler of the later chips), whose bit 0 sets theclamp_fp_blendfield of the register: what a floating-point blend result that overflows becomes, infinity (0,CLAMP_FP_BLEND_TO_INF) or the format's largest finite value (1,CLAMP_FP_BLEND_TO_MAXVAL). - Fix: the call exists (
hle_nvn.c). Metal has no such setting: a blend result above a 16- or 32-bit float target's range is stored as infinity, which is mode 0, so mode 0 is silent and mode 1 logs the red line once. Mode 1 would need every blending pixel shader to read its target and clamp there (a platform limit, left as it is): it differs only where a blend overflows, about 65,504 in a half-float target. - Found on: PRAGMATA, which sets 1; no frame has been seen with an infinity in a float target. Resident Evil 4 sets 1 too: over its opening, title to the forest path (19,261 frames, every pass's colour targets scanned every 30th frame with
DM_NVN_MAGSCAN, Apple M6, 2026-11-13), no target held a non-finite pixel, so no blend overflowed and mode 1 would change nothing there.
- Symptom:
[RED] NVN unimplemented function called: nvnCommandBufferSetRepresentativeFragmentTest, once. - Cause: an NVN2 call the host did not have: NVIDIA's representative fragment test (
GL_NV_representative_fragment_test,VK_NV_representative_fragment_test). Enabled, the GPU may skip a primitive's remaining fragments once one has passed, in draws made only for visibility (no colour or depth writes). - Fix: the call exists (
hle_nvn.c) and is logged once. Skipping is allowed, never required, so running every fragment, which is what Metal does, is a valid result of either setting. - Found on: Resident Evil 4.
- Symptom:
FATAL NVN command memory: the game's pool is empty…. - Cause: our command records are larger than real NVN commands, so command buffers the game sizes for hardware overflow; when the game's out-of-memory callback declines to add memory, the host lends a segment. Many small command buffers each overflowing once per frame exhausted a fixed pool of 64 × 1 MB.
- Fix (
hle_nvn.c): the pool now grows on demand (256 KB segments, abort only beyond 1 GB;hostseg_take). A segment is released only when another recording writes records over the guest bytes of the recording that overflowed into it (hostseg_written; a grant alone does not release it: A command memory grant over a list whose tail is lent): the game records setup blocks once and keepsCallCommands-ing them for many frames, so recycling segments after a fixed number of frames would overwrite live recordings. An empty-pool grant of at most 4 KB outside a recording gets a 4 KB segment, a record larger than 256 KB gets a segment that holds it, and the cap counts every segment's bytes (hostseg_size). Such a grant has no guest range, so its segment is never reclaimed. On hardware a null pool cannot be written to, so a game that grants one has run out of the command memory it measured for itself: the host's records were larger than the driver's (Model heaps sized by the host's command record sizes). That is fixed in the record sizes; the host does not try to recycle these segments by guessing which recording a grant replaces. - Found on: Super Mario Bros. Wonder (the many small command buffers: its opening course, per the comment at
HOSTSEG_SZinhle_nvn.c), Super Mario Odyssey (it hands out many 512-byte grants before recording, and with no recording to reclaim them by, 256 KB each reached the cap entering the Wooded Kingdom: the 4 KB segments; 1.4.1 still reached it after 11 minutes of play, about 260,000 4 KB segments, because its display-list heap was full and each re-recording took a new one; fixed by the cause, Model heaps sized by the host's command record sizes).
- Symptom: a recorded list that the game keeps calling replays another list's records from some point on: wrong uniform buffers or state bound, a scene that alternates between right and wrong lighting frame by frame. Only once the host lends segments (the line
the host lends a segmentin the log). - Cause: the host freed a lent segment as soon as the game granted command memory overlapping the guest bytes of the recording that ran into it. A grant is not a write: games grant at least 512 bytes for a list measured smaller and write only its records (Tears of the Kingdom's model lists,
max(S, 512), int_nvn's fixed-slot test; Splatoon Raiders' agl does the same), so the grant for one list can cover the next list, which is still called. Its tail went to the next command buffer that ran short and was recorded over. - Fix (
hle_nvn.c): a segment is freed when another recording writes records over its recording's guest bytes (hostseg_written, at each piece of records a recording closes:ctl_chunk_closeandcmd_alloc); a recording's own segments carry its serial (cmd_t.rec_id) and its own writes leave them. With no segment lent, the check returns at once.t_nvn("live list keeps its lent segment after a wider grant", "re-recording A in place frees its segment"). - Found on: Splatoon Raiders, whose agl grants more command memory than it writes; with records large enough to run short, the terrain's 4,672-byte lighting buffer would be bound as a 256-byte material buffer on alternate frames. This host lends Raiders no segment (21,300 frames from a new save on 2026-11-14), and no logged run of another game lent one from a game pool, so the fix changed no picture here.
Games read this count as a position in their own command memory and size their heaps and slots from it, so it must be what NVN counts.
- Symptom: frames stop after about 15 s; a game thread spins in its command-memory callback (a ring allocator at
main+0x9cc120waiting for fences that have all signalled). - Cause:
- NVN counts used bytes from the last
AddCommandMemory; the host counted fromBeginRecording. KTGL gives its main command buffer one 16 MB chunk of a ring and, in its callback (main+0x9ca960), takes chunk start +GetCommandMemoryUsedas the position the GPU has reached, so the position stayed at one frame's size and the ring never freed. GetCommandMemoryUsedmust count everything the recording used, including the parts that went into host segments. Mario marks a recorded block as empty when the count is 0 and then skips calling it (how that happened: Inlined SDK code writes intoNVNcommandBuffer+0x10).- Games also size their own heaps and slots from this count: Model heaps sized by the host's command record sizes (Breath of the Wild's model heaps) and Prerecorded lists in slots of their measured size (next entry).
- NVN counts used bytes from the last
- Fix: the count now resets at
AddCommandMemory(hle_nvn.c). - Check: Super Mario Bros. Wonder and Super Mario Odyssey checked unchanged after the reset.
- Found on: Fire Emblem: Three Houses (the count from
AddCommandMemory); Super Mario Bros. Wonder (host segments counted).
- Symptom:
CRASH sig=10inmtl_execafter minutes of play, the fault address equal tox19and shaped like0x1000000003. - Cause: the fault is the record-header load at the top of
mtl_exec's loop,x19is the replay pointer, and the address is itself a record header ({op 3 = OP_CALL, size 16}): a JUMP's target had been overwritten by the next recording. Tears of the Kingdom gives each model's prerecorded lists fixed slots of the size a recording of that list measured (GetCommandMemoryUsed), slot k at base + size × k, and passesAddCommandMemorymax(size, 512) (main+0xbb120); its memory callback is empty. The host kept 24 bytes free after every record for "a JUMP or an END", so a 496-byte list's END went to a host segment and the 16-byte JUMP at 488 reached 8 bytes into the next slot. - Fix: first the host kept 16 bytes (one JUMP) instead of 24 and let the END take a segment's last bytes. Now command memory holds nothing but the records: a recording's end and the links between its pieces are entries in control memory, so nothing ever stands past what
GetCommandMemoryUsedreported (A recording's end and links are in control memory;t_nvn"slotted lists"). - Note: to read such a crash,
x8holds the jump-table target of the previous record's case, and the registers a callee saved belowspgive the previous replay pointer (x19) and payload (x26). - Found on: The Legend of Zelda: Tears of the Kingdom (1.4.3, GitHub issue #61).
- Symptom: a model load runs out of its resource heap:
[RED] BufferInitialize: pool has no storage (pool 0x0 …), thenCRASH sig=11inh_nvnMemoryPoolGetFlags(Model heaps sized by the host's command record sizes). - Cause: in NVN, command memory holds only GPU commands. What strings a recording together is in control memory, which the driver reads on the CPU, at
CallCommandsand at submit: the end of a recording, the link to its next piece of command memory when it ran out, and the pieces of a recording it calls. The handle points there, and the device reports control memory aligned to 8 bytes and at least 256 of it (items 0x14 and 0x16,hle_nvn.c). The host wrote all of these as records into command memory instead and counted them inGetCommandMemoryUsed: 8 bytes of END per recording, a 16-byte JUMP per new piece and a 16-byte CALL per call, while it reported no control memory used. Engines that size a model's prerecorded commands by measuring scratch recordings, one per mesh and per shape and pass, against a fixed resource budget paid 8 bytes per recording more than the driver's commands. - Fix (
hle_nvn.c,nvn_obj.hctl_e,nvn_queue.incnq_flatten): command memory holds only the records. Each recording's control list is 8-byte entries in the game's control memory: a piece of records (address and length), or a link to more control memory, the last one marked. The handle is the first entry.GetControlMemoryUsedandGetControlMemoryFreecount the entries. A non-final entry is written only with 8 bytes left after it, so a list cut off by new control memory (from the game's out-of-control-memory callback or on its own) is always linked on from where it stopped. When the game's callback gives none, the host lends 64 KB blocks that are never reclaimed.CallCommandscopies the called list's pieces into the caller's list, as the driver copies a command set's GPU FIFO entries, so the called recording's control memory may be given out again once the call is recorded. Its pieces still point into its command memory, which they read at submit: a game that records over it first gets what is there then (A called recording recorded over before its caller is submitted).CopyCommandscopies the records into the caller's command memory, so the source may be recorded over at once. It was recorded as a call before.- The queue's flatten walks the list. Replay, capture and the capture format are unchanged, since they see the flattened records.
- Check:
t_nvnchecks the counts per recording (no END), the handle as a one-piece list, an empty recording (no command memory, one entry), slotted lists, the host-segment piece split, a call that replays after the called list's control memory is overwritten, a copy that replays after both of the source's memories are overwritten, and a list linked across three control blocks from the callback. The out-of-control-memory callback was measured on Cuphead (Unity): it gives one 128 KB block at boot and another each time the callback asks. - Found on: reported for Super Mario Bros. Wonder's Nintendo Switch 2 Edition (
NpcCommonerAneeded0x5424bytes of a0x5000budget,PlayerPeachElpWM0x400cof0x4000) and for Drag x Drive (040041601D83A000, 5 s after boot), in PR #1609 and issue #1580. Neither game is on this Mac, so neither was run with this fix. Donkey Kong Bananza gave the calls their copy: its control memory comes from one arena reset twice per arena of command memory, and while a call pointed at the called list, the caller replayed whatever the next recordings had written there (FATAL invalid NVN command sizewithin 100 s in about one run in six). Mario Golf: Super Rush needs the copy ofCopyCommands: it copies each model's set-up out of one scratch recording it records over at once, and without the copy the course had no golfers, trees or buildings (issue #814's reporter).
- Symptom:
FATAL NVN command list 0x…: record {op 8, size 504} at 0x…, 0 bytes into its piece of 16 at 0x…, thenCRASH sig=6innvn_queue_submit; now[RED] NVN command list 0x…: record {op …, size …} at 0x…, … bytes into its piece of … at 0x…, does not fit: the piece was recorded over after it was called. - Cause: a list calls a recording, and before the list is submitted the game records into that recording's command memory again. The caller's control list still holds the called piece's address and length, as the driver's GPU FIFO entries do, so at submit the piece holds the head of other records. Advance Wars 1+2's Unity rendering plugin (render-event handler
main+0x1545800) rotates three command buffers, each with its own 1 MB block of one pool. For each render event it waits withnvnSyncWait(sync[i], ∞)on the fence it recorded at the end of buffer i's previous recording, re-adds block i from its start (AddCommandMemoryatmain+0x15458cc), records its work (or nothing) and aFenceSync(sync[i]), and hands the handle to Unity, which calls it into the frame's list without submitting (main+0x96bb00). A frame normally has two events. One with four (the reporter's list: B's 16-byte fence-only recording, C's work, A's fence, then B's work) waits on a fence in the frame's own list, not submitted yet. The host answers at once (a recordedFenceSyncfences the sync only at submit). The console must too, since a wait that held there would stop the game. So B's block is recorded over while the list still calls its first recording: the 16-byte piece became the head of a 504-byteSET_RT. On the console the GPU runs whatever bytes are there, here the head of the plugin's render-target setup, which it sets again anyway. Before f02d0474 (PR #1609) a call was a CALL record to the handle, and the replay ran whatever recording stood there to its END (B's work, twice), so this went unnoticed. Since then the flatten checked each record against its piece and aborted. - Fix (
hle_nvn.cnvn_piece_record, used bynq_flatten,nvn_rec_linearandCopyCommands): a piece runs what its command memory holds at submit, as the GPU runs a command set's bytes: the whole records that fit in it. The first record that does not fit, or a header that is no record, ends the piece with a[RED]line (the first 8, then every 1,000th) instead of aborting. Where the GPU would run part of such a record, the host runs none of it: here that drops the head of aSET_RTthat B's own piece runs whole later in the list. - Check:
t_nvn5b''' records over a called recording before submitting its caller, once with a longer record (the piece is skipped, the caller's own record runs, the linear copy holds only that) and once with a record of the same size (it runs). - Found on: Advance Wars 1+2: Re-Boot Camp, when the enemy uses a CO power, about a minute into the reporter's save (issue #2437, bisected to f02d0474). The plugin's rotation and the two events per frame were traced on the Mac; the four-event frame is read from the reporter's register dump, not reproduced here.
- Symptom: a jump to pc=0 on a worker thread, with the backtrace
0x0←cmd_alloc_keep←h_nvnCommandBufferCopyBufferToTextureandlrin the game'smain, just after the instruction that called the game's command memory callback. - Cause:
SetCopyRowStrideandSetCopyImageStridewere recorded as 24-byteOP_COPY_STRIDEcommands. In NVN they are command buffer state that a copy reads when it is recorded (GetCopyRowStridereads them back), and setting them uses no command memory. A game that sizes one upload's command memory for the driver ran out on the host. nn::gfx'sCommandBuffer(the NVN backend) always installs its own callback (main+0x31be0in Smash) and calls the out-of-command-memory or out-of-control-memory handler the game set (+0xd0/+0xd8of the object). The game never sets a handler on a command buffer that should never run out, so the callback called address 0. Smash records each texture upload from a stack command buffer with 0x100 bytes of command memory (main+0x3664960andmain+0x3794430): the two strides (48 bytes), then the copy (200 bytes with its texture snapshot, plus 16 kept for a JUMP) came to 264 bytes. - Fix: the strides are fields of
cmd_t(copy_row,copy_image), set and read back bySet/GetCopy*Stridewithout a record. A buffer ↔ texture copy carries them after its texture snapshot (p_copy_strides,rec_copyinhle_nvn.c) only when one is set, so a copy with tight strides keeps its size. The replay reads them from the record (copy_stridesinnvn_obj.h);OP_COPY_STRIDEis still replayed from older captures, and the capture layout gained a line, so an older replayer refuses a new capture.t_nvn(5c) records Smash's sequence in 256 bytes with a callback that counts any call. - Note: the game's
nu::VirtualAllocHook/nu::VirtualFreeHookare undefined weak imports bound to 0 at boot, as Horizon's rtld binds them. Every one of their 6,155 call sites in Smash'smaintests the GOT entry first, so they were not the null call. - Found on: Super Smash Bros. Ultimate 13.0.5 on the iPhone, opening Adventure (World of Light).
- Symptom:
CRASH sig=11 addr=0x50on a game thread, atpc=main+0x385201cin Super Smash Bros. Ultimate (ldr x0, [x8, #0x10]withx8 = 0x40), as a battle starts. Rare: it crashed on 1 of 2 battle starts in one run on a Mac mini and on none of 11 in another. - Cause: the game's two
render dispatchthreads take command buffers from a pool under a lock (main+0x3851aa0) and, when the pool runs out,std::sortit bynvnCommandBufferGetCommandMemorySize(main+0x3851e80; the proc table's+0x428) while the other thread is still recording into buffers it took. The host summed the size at each call (used + end - cur): a record written between the two loads, or betweencur += sizeandused += size, could give the same buffer two different sizes within one sort, and libc++'s unguarded partition can then walk off the vector. A diagnostic host that compared the sum with a stored value counted 22 torn sums during boot and 0 to 3 per battle start, all on buffers in the middle of a recording. The link from a torn size to this crash is read from the disassembly: no crash was caught with the diagnostic host. - Fix: the size is what NVN defines it as: the bytes the game last passed to
AddCommandMemory, a field ofcmd_t(cmd_size) stored there with one atomic store and read with one atomic load. Records never change it, and neither does a segment the host lends when the game's memory runs out (that memory is not the game's), so only the game's ownAddCommandMemorymoves it, as on the Switch. In the game's memory it equalsused + free(less the bytes the host aligns past at its start); in a host-lent segmentusedgoes on past it. DRAGON QUEST XI S, which first needed the call, was checked: nothing in itsmainpoints at the name (its loader walks a table of NVN names), and its bring-up added the call because it was missing, not for the sum.t_nvn5d reads it for 200 ms, about 200,000 reads, while another thread records into the buffer: the summed version gave 96 to 227 torn reads per run, the stored one none. - Open: the size still changes when the game's callback adds memory of another size while another thread sorts, as it can on the Switch; the host's larger records call the callback more often than the Switch does. Not measured.
- Found on: Super Smash Bros. Ultimate 13.0.5 on the Mac (GitHub issue #768 reports the same
pcand fault address in its earlier run): 13 battle starts over three boots on a Mac mini without the crash, with an earlier form of the fix applied to 88c7e03f (the size stored asused + freewhere each segment starts, host-lent ones too; the same value in memory the game added at an 8-byte boundary). The final form was checked byt_nvn, not in the game.
- Symptom: the title showed one stretched 64×64 icon over the 3D scene, a new game a white screen; post-process passes with stale texture handles;
[RED] bind record … overwritten after recording. - Cause: the game may write into recorded command memory directly, and host records stored in place must leave those bytes alone. The engine records a command buffer, then rewrites texture handles in it before submitting, as the hardware lays the command out: before
nvnCommandBufferBindTextureit takes the command memory base +GetCommandMemoryUsed(main+0x7c8964, the only reader of that NVN entry point), and every frame it stores a 32-bit handle at that address + 0x10 (main+0xb7d0d4, on a job worker). In a host bind record that word was the count, and the replay read 535 million handles from whatever followed, so the post-process passes kept stale texture handles in their slots. - Fix: a single texture bind is recorded as
OP_BIND_TEX1, its handle 16 bytes in, in the hardware's layout (nvn_obj.hp_bind_tex1, read throughbind_rec); every game. - Found on: Luigi's Mansion 3. Found with a check that reports a bind record holding more than 32 handles (
[RED] bind record … overwritten after recording), thenDM_PROBE_WATCHon that word. - Note: any other command a game patches this way needs the same: find the readers of the
GetCommandMemoryUsedslot first.
- Symptom: intermittent scene-wide lighting flashes and a persistent pale outline around a character. Extra GPU readbacks or synchronous screenshots can make the flashes disappear temporarily. Separately: twice the render passes and attachment loads a frame, with a correct picture, in a game that signals events inside its passes.
- Cause:
nvnCommandBufferWaitEventwas registered as a no-op. Serializing NVN submissions onto one Metal queue only orders them by CPU arrival; a consumer can be submitted before its producer on another NVN queue. Its acquire must park that queue until the producer releases the event. The native SDK implements exact equality and wrapping greater-or-equal ((uint32_t)(current - target) < 0x80000000), and its CPU event signal publishes earlier writes with a store barrier. Ignoring either dependency lets a consumer read cleared or stale shared data. - Fix:
nvn_queue.incretains submitted command streams and replays ready continuations. An unresolved event or sync wait parks only its NVN queue; another queue can submit and execute its producer. GPU event signals publish after the completion writes of the submission they are in, including render-target readback. CPU signals publish with release ordering. Logical queue receipts remain separate from physical Metal fences: a fence on parked work cannot accidentally inherit another queue's completion, and a queued sync wait retains its original target through later refencing. Presents remain in queue order. Descriptor snapshots and retained renderer objects survive deferred replay; the caller still owns backing-memory lifetime. - Waits on a queued signal: a wait the last GPU
WRITEsignal of its event satisfies, still in flight, is released when the host encodes it: its continuation runs after the writer on the one ordered Metal queue, as a later submission of the same NVN queue would, and the reports and readbacks the writer's completion lands are handled as for any in-flight submission. Waiting for the writer's completion instead left the GPU idle while the host encoded the next submission (Ocarina of Time: about 25 waits a frame, 2 ms of a 14.5 ms frame, which its dynamic resolution counted as GPU time: Dynamic resolution at its floor). AnADDsignal's result depends on the writes landing before it, so a wait on it still waits for its completion. - Signals do not cut a submission: each piece the queue replays is a Metal command buffer of its own, so a cut ends the open render pass, which then loads its attachments again. Only what must wait or be waited for cuts: an event wait, a sync wait, a recorded fence. A signal stays among its commands, and the piece keeps every signal it records: each is published when the piece completes, and a wait on any of them, in flight, is ordered by the piece's fence as above. Cutting at each signal doubled the passes of a game that signals inside them: BioShock Infinite (Unreal Engine 3) signals an event per occlusion query, and its title screen went from 88 to 189 render passes a frame, from 734 MB to 2.58 GB of attachment loads and from 3.8 to 7.1-8.4 ms of submit CPU (#2112, measured by its reporter). A waiter on another queue is released after the rest of the signalling piece, not after the signal's prefix; no wait can sit in the same piece as its signal, since waits cut.
- Waits on a sync not yet fenced: a queue or command-buffer
WaitSyncon a sync that no fence had reached when the wait was submitted passes at once: the wait keeps the sync as it was then, and there is nothing to wait for. Parking it instead hung any game whose wait comes before its first fence of that sync. Sparking! ZERO, on a single NVN queue, waits on such a sync at its first load after the title, a wait that runs on the console. Its queue's own fence, behind the parked wait, never published. The RHI interrupt thread'snvnSyncWaitthen blocked, then the render thread and the game thread: the picture went black with sound going on, and Unreal's GPU-hang check aborted a minute later (#1808). - Check:
t_wait_eventexercises public NVN calls, CPU and GPU producers, wrapping predicates, a consumer submitted before its producer, a wait on an in-flightWRITE(encoded before it completes) andADD(parked until it lands), two signals with commands between them as one submission with a wait on the first encoded behind it, a queue and a command-buffer wait on a never-fenced sync (the work behind it runs), and logical sync lifetime.t_queue_lifetimechecks submitted program/texture descriptor reuse and inherited queue bindings. The focused event, receipt, descriptor and lifecycle checks pass; combined gameplay and performance validation are still pending. - Found on: The Legend of Zelda: Ocarina of Time, M4 Pro. Its traced lighting producer and ambient-cache consumer have an explicit event dependency; this is a general queue contract, with no shader-key or game check in the fix. The wait on a sync not yet fenced: DRAGON BALL Sparking! ZERO. The signal cuts: BioShock Infinite, which has no profile here (#2112).
- Symptom: choosing X-2 after the collection launcher crashes in
keys_hash, reading control address0x8000000000; the program object now contains command-buffer data. - Cause: the replay kept one global binding state across queue destruction and creation. X-2 binds its fragment program first, so combining stages dereferenced the launcher's freed vertex program. Distinct live queues could also inherit each other's bindings.
- Fix: give every queue initialization a serial. Save and restore draw state and render-target state when submissions switch queues, resolve composite programs again, and invalidate encoder caches. Finalization discards that queue's saved state. Reusing the queue's storage starts with defaults and cannot recover old guest pointers.
- Found on: FINAL FANTASY X/X-2 HD Remaster, launcher-to-X-2 handoff.
- Check:
t_queue_origintests independent state, switching back, finalization and address reuse with a poisoned old program pointer, alongside its real GPU culling tests.
- Symptom: about 23 s after launch, when the title screen hands over to the opening demo, a crash in
keys_hash(prog_key←prog_get←bind_stages←prog_resolve←mtl_queue_activate), reading a control address made of float data; the program object now holds other data. - Cause: NVN reads a program only when it is bound, so a game may finalize a program a queue still has bound and reuse its memory. The host kept the pointer in that queue's bindings (
S.bst,S.prog,S.cprog); it went into the queue's saved state when another queue submitted, and switching back combined the stages from the reused memory. - Fix:
nvnProgramFinalizedrops the program from the active bindings and from every saved queue state under the submission lock (mtl_prog_forget). When the active queue's effective program is a composite of its stage and another program's (stages bound apart), the composite is taken again from the stages left: it holds its own copy of each stage's shader, so clearing the stage alone kept drawing the finalized program's shader. A saved queue's composite is taken again when the queue is switched back to (prog_resolve). It logsfinalized program … dropped from queue bindingsthe first time and at powers of two. - Found on: Fire Emblem Fortune's Weave, left on the title screen until the opening demo: 5 of 5 runs crashed before, 0 of 7 after, each logging one drop from the active queue.
- Check:
t_queue_originfinalizes a program bound in the active queue and in a saved one, poisons its memory and switches back: both lose it; finalizing the fragment program of a composite leaves the vertex stage alone as the effective program.
- Symptom:
nvnCommandBufferFenceSyncunimplemented, everySyncWaittimed out at once. - Cause: Pokémon Legends: Z-A fences once per frame from a command buffer and waits on it; Unreal's RHI records the call in its command buffers too.
- Fix: a record (
OP_FENCE_SYNC) establishes a logical receipt when submitted. Replay publishes its physical fence only after the preceding queue commands have been encoded;SyncWaitthen waits for that fence and its CPU-visible completion writes. An earlier unresolved event keeps the receipt pending even if another queue completes newer physical submissions (GPU event waits). - Found on: Pokémon Legends: Z-A (19.3.11; one boot); Octopath Traveler 0 (new with Unreal).
- Symptom:
FATAL submit re-enteredin the first frames. - Cause: the game initializes two NVN queues and submits on both from two threads, while the Metal replay keeps one command buffer for the submission in progress.
- Fix: ready queue continuations replay under one lock (
g_sub_muinhle_nvn.c); submission copies its command stream and descriptors under the same lock. Queue dependencies determine which continuation can run (GPU event waits). Replay takes the lock before the graphics gate (Going to the background during the prepare step). - Found on: Fire Emblem: Three Houses.
- Symptom: going to the background (iOS
sceneDidEnterBackground, minimizing the Mac window) while the "Preparing shaders" or "Preparing pipelines" screen is up blocks the main thread until the step ends: minutes on a first start, long enough for iOS to end the app. The log's[nvn] graphics suspendedline comes only after thepipeline prepare:line. - Cause: the prepare step (the prewarm and the pipeline prebuild,
mtl_prepareinnvn_mtl.m) runs in the game's firstnvnQueueSubmitCommands, which held the graphics gate across it.dm_gfx_suspend(1)closes the gate and waits for every submit and present inside it to leave. The step loads shaders and builds pipelines and submits no GPU work, so it never needed the gate. - Fix: the submit entry runs the prepare step under the submit lock (
g_sub_mu) but before it enters the gate, marked as host work for the guest scheduler (dm_host_work). If the app went to the background meanwhile, the submission waits at the gate for the foreground like any other. The lock now comes before the gate, taken withdm_host_mutex_lock: with the gate first, a second submission waiting for the lock counted as in flight, so the suspend waited for it while the first, done preparing, waited at the closed gate holding the lock.mtl_sub_beginstill callsmtl_prepare, a no-op by then, for the replay tool (nvn_replay), which has no gate. - Check: the CPU-only
t_gfx_suspendruns the real submit entry on two threads with a prepare step held by semaphores. It checks that the step runs outside the gate as host work, that the queued second submission is not in flight, that the suspend returns while the step holds the lock, that nothing commits in the background, and that both queued submissions execute after resume. It also suspends with an unresolved event, releases that event in the background, and verifies that its continuation executes only after resume. With the gate taken before the lock it fails before suspending. - Found on: no game named in the source (general: every NVN game with a prewarm list; a first start without the Metal cache takes longest). Open: not yet run by going to the background during a real game's prepare step on an iPhone.
- Symptom: frames stop, no crash, the main thread sits in
nvnSyncWait(sync, -1), and the watchdog's wait-state line shows the main fence one short (main fence submitted N / completed N-1). - Cause: a GPU command buffer never completes.
- Check: GPU load from outside:
ioreg -r -d 1 -c IOAccelerator | grep 'Device Utilization'at 100 % means a shader is looping forever. Find the shaders first used in the last frames (DM_NVN_PASSDUMP, compare theKkeys) and look at their.metalfor an emptydo {} while (true);with noreturn(one such case: Exact program start for the decoder). - Found on: no game named in the source (general).
- Symptom:
game called nn::diag abortright after a screen change (PRAGMATA: the Initial Settings menu's Next), with nothing slow before it. - Cause: the game initializes its two frame syncs again and then waits 15 s on the one it has not fenced yet, aborting on
TIMEOUT_EXPIREDorFAILED(main+0x4a8cf0) once two frames are in flight. It is a shipped path, so on the console that wait does not expire. The host answered a never-fenced sync with a timeout, a rule from its first version with no game behind it. - Fix:
nvnSyncWaitanswersALREADY_SIGNALEDfor a sync never fenced since itsInitialize: no GPU work stands behind it. A wait that does expire after a second or more is a red line naming the sync's state (sync_expired), since that is what a game's GPU watchdog acts on. - Found on: PRAGMATA.
- Symptom: the frame's blocking SyncWait on its newest fence wakes about 0.26–0.29 ms after the GPU end (M2 Max; the wait ledger,
DM_NVN_WAITS=1), on the critical path of every frame. - Cause: the waiter slept until the commit feedback handler of the fence's submission had run, which comes that long after the GPU end.
- Fix: the game's waits (newest fence, older fence, acquire sync) wait on
g_ev, which the queue signals after each submission, and then make the CPU-side writes of every submission up to the fence that no handler has made yet (post_early: events, samples-passed and counter reports; a ring ofPOST_RINGrecords, each run once by whichever comes first). A counter report of the fence's last segment takes the wake as that segment's end.DM_NVN_FAST_WAKE=0turns it off. - Note: the commit feedback handler's other blocks (diagnostics such as
DM_NVN_FUSE_CHECK) still run when the handler does, after a fast-woken wait has returned. - Resolved in review of #158: claiming a record is not completion. The original early path skipped records a handler or another waiter owned and advanced
g_donebefore their writes finished; a delayed callback also retained a recyclable slot pointer.post_claimnow holdsg_post_muthroughpost_run, and both contenders callpost_completewith their fence to apply earlier records in order. A slot is reused under that lock only after its writes finish; an old fence cannot claim its new generation.g_doneandg_fenceare atomic. - Check:
make -C dm/gfx/tests GAME=010015100B514000 check_t_perf_ordercovers a paused completion owner, a competing waiter, recycled-slot generations, event order and exactly-once writes, with direct/deferred encoding and fast wake disabled.t_perf_order --cpuandperf-order-tsanrun the completion cases without a GPU in CI. The original harness reproduced premature completion; the corrected CPU cases pass ThreadSanitizer. Current gameplay coverage and timing limits are recorded in BotW and Metroid Dread; 13 Sentinels has additional menu/report traffic coverage. - Finite timeout budget: when the event wait times out, the feedback wait uses only the remaining requested time. Previously an expired 20 ms event wait could be followed by another full 20 ms feedback wait. Elapsed time is taken from the monotonic wait clock independently of wait diagnostics. Zero/sub-millisecond polls, successful CPU completion, and the existing per-stage one-hour cap for
UINT64_MAXare unchanged. This bounds the time assigned to the two waits, not scheduling delays or the CPU completion lock. - Budget regression: the
--cpupath also uses a synthetic event, clock and condition-wait deadline to exercise the actualmtl_wait_atfunction without a Metal device. Run withDM_NVN_FAST_WAKE=0/1andDM_NVN_WAITS=0/1; no gameplay or FPS improvement is inferred from these timeout cases. - Found on: The Legend of Zelda: Breath of the Wild: it reads 5 timestamp reports a frame right after the wait (its interval controller and dynamic resolution), which is why the waiter makes them itself. Wake after the GPU end 0.29 → 0.08 ms on an M2 Max, 0.147 ms on an iPhone 17 Pro Max.
- Symptom:
[nvn-mtl] FATAL post ring: the writes of fence <N> never ranat a submission's commit (mtl_sub_commit), with the new fence exactly the ring's size past<N>, in a game that makes many submissions a frame. - Cause: every submission keeps a record of its CPU-side writes (events, samples-passed and counter reports) in a ring of
POST_RINGslots, indexed by its fence, until its feedback handler or an early waiter runs them (The game's SyncWait wakes on the GPU's own signal of its fence). The ring held 128, enough while each submission took an allocator of its own (8 in flight). Since the submissions of one frame share an allocator (PR #2090),ALLOC_MAX * ALLOC_SHARE= 512 can be in flight beforemtl_sub_beginwaits, and the 128th submission after one whose feedback had not come yet found its slot still unrun. - Fix: the ring is sized from that bound (
POST_RING = SUB_FLIGHT_MAX,ALLOC_MAX * ALLOC_SHARE): a slot is reused only after the allocator wait has seen its previous submission complete, and completion runs every record up to a fence before it raises the completed fence, so the abort stays only as the check of that invariant (PR #2469). - Found on: Super Mario Party (
010036B0034E4000, provisional profile, Apple M1 Pro): 70-80 submissions a frame; two crash logs aborted at fences 166777 and 442682 with 166649 and 442554 unrun.
- Symptom: loading screens many times slower on the iPhone than on the Mac (Three Houses' first loading screen 78 s on the iPhone, 5 s on the Mac).
- Cause:
mtl_finishcalledmtl_present_flushfirst, and with a window that waits until the display link has taken every queued present: one to two display frames per call. A loader that follows each upload copy withnvnQueueFinishpays that hundreds of times per loading screen. The windowless Mac runs present at once, so they never showed it;DM_WINDOW=1does (one step took 20 s instead of 1.7 s). - Fix:
nvnQueueFinishwaits only for the GPU work submitted so far, as on the Switch, where it does not wait for the display. A swapchain texture is still not handed back before its previous present is processed (AcquireTexturewaits for that ticket), andmtl_drainkeeps its flush. - Found on: Fire Emblem: Three Houses: KTGL's loader (effect banks, face models). Windowed Mac run after the fix: first load 6.1 s, frame pacing unchanged.
- Note: how it was found on the device:
Documents/env.txtwithDM_WATCH=50(thread table at 50 s: only the yield-polling main thread used CPU) andDM_FSLOG=<absolute path>(the reads came in bursts, with stalls of 4 / 24 / 13 s right after the effect banks).
- Symptom: CPU texture uploads spend time recalculating block-linear addresses and calling variable-length
memcpyfor every 16-byte segment. - Cause:
movecallednvn_bl_offsetinside its innermost loop, repeating slice/row calculations and division by runtime block dimensions. - Fix: hoist slice and row offsets, express power-of-two block dimensions as shifts/masks, and use the four fixed x offsets and eight fixed y offsets within a GOB. Full segments use constant-size 16-byte copies; byte counts not divisible by 16 retain a tail copy. ASTC blocks are always 16 bytes irrespective of their texel dimensions. Both copy directions preserve the layout, sparse row padding and mip/layer offsets.
- Found on: The Legend of Zelda: Breath of the Wild, reported in PR #261.
- Check:
t_texcompares both directions against the byte-address formula for 1,029 deterministic layouts, including 1D/2D/3D, arrays/cubes, packaged block dimensions, sparse mip tails, 1/2/3/4/6/8/12/16-byte formats, padded pitches and partial segments. All 5,334 checks pass, including under AddressSanitizer and UBSan. A local arm64 Mac comparison against the old implementation, Apple clang 21 at-O1(the host's level then), measured 7.4–7.7 GB/s before and 35.1–37.6 GB/s after (4.6–5.1x) across 1024x1024 sparse ASTC 6x6, 2048x2048 BC7, 1920x1080 RGBA8 and 1280x720 RGBA16F. Each result is the median of seven runs copying about 128 MiB, with implementation order alternated. This is a CPU microbenchmark; no new gameplay run here.
- Symptom: a Switch 2 game's textures from its files keep their shapes but break up along every edge: misplaced 4×4 blocks (16-byte pieces) in a regular weave, invisible in flat areas. Normal maps make lit surfaces look woven; opacity masks punch holes.
- Cause: the GOB (64 bytes × 8 rows) keeps its size and place, but the Switch 2 orders its 16-byte pieces x5 y2 x4 y1 y0 from the GOB's start, where the Tegra X1 orders them x5 y2 y1 x4 y0: offset bits 5 and 6 exchanged, (x%64/32)·256 + (y%8/4)·128 + (x%32/16)·64 + (y%4/2)·32 + (y%2)·16 + x%16. Block heights, sizes and level offsets are unchanged (the BNTX headers' block heights and sizes match
nvn_tex_layout). - Fix:
nvn_gob_switch2(dm/gfx/tex/nvn_deswizzle.h), set atnvnDeviceInitializewhen the SDK targets the Switch 2 (dm_sdk_ounce): the CPU address formula, the CPU copy tables and both Metal untiling kernels take the Switch 2's order. - Check:
t_texchecks the Switch 2's offsets by hand (B2) and both orders where they differ;t_deswz_gpuruns every case in both orders, the GPU kernels against the CPU copy. - Found on: Donkey Kong Bananza: level 0 of its BNTX textures (BC7
BaseRock000_Pnrm, BC4ArenaLevelShadowMaskSunbeam00_Smsk) decoded with every order of the five address bits; the X1's left the weave, this order alone gave clean images (mean neighbour difference 19.5 → 14.9 on the rock normal map, 21.0 → 15.7 on the BC4 mask).
- Symptom: driver comparisons report the wrong block dimensions for packaged 2D / 3D textures, or too little storage for linear block-compressed textures (64x64 BC1 with stride 128: 2048 bytes instead of 8192).
- Cause:
SetPackagedTextureLayoutbyte 0 holds log2 block height in its low nibble and log2 block depth in its high nibble. The host treated the whole byte as a 2D block height and ignored it for 3D. Linear storage was sized by compressed block rows, while NVN counts pixel rows. - Fix:
nvn_tex_layoutdecodes both nibbles for 3D and only the height nibble for 2D; a present byte of zero overrides automatic dimensions too (pkg_layoutstores byte 0, with the width nibble of byte 1 above it, + 1). Level offsets and CPU / GPU deswizzling use those shared dimensions, reduced for smaller mips. Both linear flags use stride times pixel height, preserving row alignment and explicit strides. - Check:
t_tex_layoutcovers nine driver-derived shapes; four fail before the fix.t_texalso decodes packaged 3D markers at independently calculated addresses and checks mip dimensions and offsets;t_deswz_gpuchecks both packaged 3D layouts across six mips on Metal. For flag 0x200 see Minimal texture layouts; linear compressed-texture uploads are outside these storage checks. - Found on: The Legend of Zelda: Tears of the Kingdom: digitalhivehub's driver probes in PR #11, ported through MacNativeGames
3c482a7and PR #201. Validated here with CPU and synthetic Mac GPU tests; packaged 3D / linear BC gameplay and the iPhone have not been rechecked.
- Symptom: huge stretched terrain walls and ceilings, flickering, while characters and the HUD are right:
WriteTexelsinto a texture overwrote the mesh data stored right after it. - Cause:
nvn_tex_layoutignoredMINIMAL_LAYOUT(0x200) and laid every such texture out with the automatic block height. The driver keeps the automatic block height and depth only when the whole layer (every level and the layer alignment) is no larger than with 1-GOB blocks; otherwise it takes 1-GOB blocks. A game that packs resources by the driver's sizes then gets textures larger than their slots. - Fix:
nvn_tex_layoutcompares the two layer sizes (layer_bytes) and takes 1-GOB blocks when they differ, so storage size, level offsets and CPU / GPU swizzling agree. A packaged layout and a sparse texture's tiles keep their own blocks. This is the rule digitalhivehub measured on the game's own driver in PR #11 (a sweep with no difference left outsideSPARSE), which the port of that PR left out. - Check:
t_tex_layouthas PR #11's six driver shapes with the flag (320×320 RGB565, 260×260 RG8 with 9 levels, 8×1920 R8 with 11, 3D 64×64×8 and ×9, 64×128 RGBA8 that keeps its block) and three from issue #183 (RGB5A1 320×320, a 2D array with mips, a packaged layout over the flag): 23 of 23 match, 17 before.t_nvnwrites two adjacent 320×320 textures withWriteTexelsand checks the bytes after each and the texels read back;t_deswz_gpudeswizzles the Depths shape and the array on Metal (19 cases pass). - Found on: The Legend of Zelda: Tears of the Kingdom (GitHub issue #183, the Depths): two 320×320 RGB5A1 / RGB565 textures before mesh data in one resource take 204,800 bytes each; the host laid them out in 245,760. Found by the issue's reporters (M1 with 1.2.1, replaying the wrong writes reproduced every changed byte of the resource; checked on M4 with 1.4.3).
- Symptom: menus and dialogue boxes draw but contain no text, while images and pictogram digits are fine; the sky is black (CPU-written linear colour ramps).
- Cause: CPU writes into a linear texture in a CPU-visible pool must be visible to the GPU, and a private Metal texture never sees them. Mario rasterizes glyphs on the CPU into a linear texture in a CPU-visible pool (2048×8192 R8, flags IMAGE|LINEAR), and its sky reads four 64×1 RGBA16F colour ramps written the same way.
- Fix: a texture with
NVN_TEXTURE_FLAGS_LINEARorLINEAR_RENDER_TARGETin a CPU-visible pool is created on the pool'sMTLBuffer(tex_linearinnvn_mtl.m), so CPU writes (Mario's glyph cache) are visible to the GPU. Since Rhythm Heaven Groove a linear texture is made on its pool's buffer in any pool (A pool's storage at creation is its textures' content). - Note: block-linear CPU-written textures still need the packaged-texture re-upload path (next entry).
- Check:
DM_NVN_TEXCPU=1lists non-packaged textures in CPU-visible pools. - Found on: Super Mario Bros. Wonder: its glyph cache (the text texture) and its four 64×1 RGBA16F sky colour ramps.
- Symptom: a trap during graphics initialization in
SetupSurfaceInfoMemoryInside, assertingctx.memorySize <= localmemsetupinfo.memorySize. - Cause:
LINEAR_RENDER_TARGETspecifies linear storage independently ofLINEAR. The Metal backing path recognized either flag, but the storage-size and deswizzle helpers tested onlyLINEAR, underallocating some render targets and treating their rows as block-linear. - Fix:
nvn_tex.crecognizes either flag for linear sizing and excludes both from block-linear level offsets, GOB columns and sparse layout.nvn_deswizzle_geomrejects both.tex_linearuses the same calculated row pitch, including implicit alignment, asGetStorageSize. Render-target rows align to 128 bytes; ordinary linear rows align to 32. Explicit strides are preserved.t_texcovers the layout andt_linear_readbackchecks a GPU clear against padded CPU-visible rows. - Found on: Persona 5 Royal: a 16x16 RGBA8 target with flags
0x44and stride 128 needs 2 KB; block-linear sizing returned 1 KB.
- Symptom: on an iPhone only, a scene's backgrounds are solid magenta and magenta frames flash, while the Mac is right. The log has
[RED] linear texture offset … / row pitch 32 not aligned to 64 bytes; created as a private texture (CPU writes invisible), then[RED] deswizzle failed format 0x25 target 1. Or movie backgrounds are grey and flicker, or show green and magenta blocks, afterlinear texture ser=… 736x480 format 0x1 flags 0x14: offset … / row pitch 736 not aligned to 64 bytesand[RED] linear texture 8x8 format 0x9a flags 0x14: rows of 32 bytes no view can take, a private texture the CPU's writes do not reach. - Cause: Metal makes a texture on a buffer only at
minimumLinearTextureAlignmentForPixelFormat, and the minimum depends on the device: 64 bytes for RGBA8 on an A18 Pro, less on the Macs here. Ordinary linear rows align to 32 bytes (the entry above), so an 8×8 RGBA8 linear texture has 32-byte rows.tex_linearrefused it, and the texture became a private one. Its first fill (tex_init_fill→pkg_upload_) went through the block-linear deswizzler, which rejects linear storage, so it stayed empty and never saw the CPU's writes. - Fix:
linear_fit(nvn_mtl.m) tells a linear texture that no view can take apart from one that is not linear.tex_mtl_makes such a texture a CPU-written packaged one, uploaded at its first use on the submit thread and again after CPU writes to its pages, when its storage holds its content (linear_storage_ok). The storage then holds its content, as for the other uploaded textures:pkg_upload_copies it by rows of its stride instead of deswizzling it, with no GPU deswizzle or large-pool staging buffer;- a GPU copy into it is written into the storage too (
tex_storage_dst;write_region_linearinhle_nvn.c), so the next upload carries it; mtl_read_texelsreads it from the storage by the same rows, on whatever thread asks. So a read before its first use does not make it a private texture of zeros, and a read after a CPU write does not upload it off the submit thread. Textures that a view can take are unchanged.
- Fix, render targets and images:
LINEAR_RENDER_TARGETandIMAGEsay only that the GPU may render or store into a texture, not that it does; Hades II's Bink movie planes areIMAGE | LINEARand only sampled. Such a texture's storage holds its content while the GPU has not written it:linear_storage_okasksgws_owned, which knows the GPU writes the commands record (render targets, clears, copies, image bindings:mtl_tex_gpu_written). One taken at its first use is registered withcpu_unc_take, so the GPU's next write into its storage makes it a GPU texture with the content it has (cpu_unc_drop), as for the uncompressed textures taken from their storage.mtl_read_texelsreads it from the storage while it is CPU-written, or before its first use while the GPU has not written there. - Limits: a linear render target or image that no view can take stays a private texture when the GPU wrote its storage before its first use, after the GPU writes it, or in a pool the CPU cannot write (there no GPU write is recorded). Its first fill takes its storage, but the CPU's later writes do not reach it (
[RED] linear texture … rows of 32 bytes no view can take, a private texture the CPU's writes do not reach). A GPU copy into a taken one makes it a GPU texture, where aLINEARone keeps the copy in its storage. - Check: on a Mac,
DM_TBUF_ALIGN=64reproduces the phone's alignment for these textures too.make -C dm/gfx/tests GAME=<id> check_t_linear_readbackruns with it, on 6×6 RGBA8 textures in a pool made through NVN (write-tracked, as a game's):- a
LINEARone with 32-byte rows is not a view. Its first upload andnvnTextureReadTexelshold its pixels at the storage's stride. After an NVN copy into it, a read on another thread has the copied rows. After a CPU write, it has that row as well, and the next upload carries both; - the same with 64-byte rows stays a view;
- one read on another thread before its first use has its pixels, and is still uploaded at that first use;
- an
IMAGE | LINEARone with 32-byte rows, only sampled, is CPU-written: a CPU write is read on another thread and uploaded at the next use, and a recorded GPU write into its storage then makes it a GPU texture that keeps its content; - an
IMAGE | LINEARone whose storage the GPU wrote before its first use stays a GPU texture, filled at the stride; - a
LINEAR_RENDER_TARGETone with 32-byte rows, only sampled, is CPU-written. Each part is needed: - without the storage read, the reads come back empty;
- without the
gws_ownedtest inlinear_storage_ok, the texture the GPU wrote first is made CPU-written; - without
cpu_unc_take, the image stays CPU-written after the GPU's write; - without the storage read for an image, ReadTexels on another thread after a CPU write misses all 6 rows;
- reading the resolved host texture instead uploads it on the reading thread after the CPU write, and
up_owner_checkstops the test. Copying the rows at the pixel pitch fails 5 of 6 rows. Axiom Verge's opening logslinear texture ser=28 8x8 format 0x25 flags 0x10: offset 0x3f8000 / row pitch 32 not aligned to 64 bytesandCPU-written texture ser=28 …, the same on the Mac with the switch and on both iOS devices without it. On the Mac its picture matches a run without the switch.
- a
- Found on: Hades II v0 on an iPhone 15 Pro Max (GitHub issue #1956): the title screen's movie background grey and flickering, green and magenta blocks behind the options menu; 736x480
IMAGE | LINEARR8 planes in aCPU_UNCACHEDpool, rows of 736 bytes (the Mac asks for 16). Checked byt_linear_readback; not yet on the iPhone. Axiom Verge 1.0.3 (0100052004384000, SDK 4.4.0, MonoGame; its profile is a separate change) on an iPhone 16 Pro Max, iOS 26.0: the opening's sky and mountains are magenta. Its 8×8 RGBA8 linear textures sit in aCPU_UNCACHEDpool. An iPad Pro with an M4 asks for 64 bytes on iPadOS 26.0 as well, while macOS 27.0 on an M4 Pro asks for 16. With the fix, both iOS devices log the four textures as uploaded, and the iPhone's opening is right.
- Symptom: video frames, or a game's built-in default textures, are all zero.
- Cause: the texture is block-linear, has no packaged layout and is written from the CPU with
nvnTextureWriteTexels, so its content lives in guest memory, not in a GPU texture (Engage's video frames, Unity's movie frames: Y planeR8+ UV planeRG8, every frame; Mario's and Unity's 4×4 fallback textures, which hold black, white, transparent and red texels). The host uploaded only packaged textures from guest memory and made everything else a GPU-only texture. - Fix:
WriteTexelsnow records the texture, and it resolves like a packaged one: uploaded from guest memory, re-uploaded when its pages are dirtied (mtl_tex_cpu_written). - Note: Mario's frames got slightly brighter where materials sample the default white texture.
- Found on: Fire Emblem Engage (video frames and default textures), Super Mario Bros. Wonder (fallback textures), Unity games (fallback textures; the source names no other).
- Symptom: after a few minutes of play the main thread spends whole frames in
WriteTexels(Metroid Dread's frames stopped at about 175 s). - Cause: a game that writes a 1×1 region per call (Metroid Dread's set-pixel helper
main+0x160e80, probably the map / minimap mask); the host decoded the whole level, patched it and encoded it again for every call. - Fix: non-3D regions are written in place by block-linear address (
write_region_blinhle_nvn.c). - Found on: Metroid Dread.
- Check:
t_nvnchecks 15 regions in RGBA8, BC1 and a two-layer RGBA16F array, from 1×1 to the whole level, byte for byte against the old whole-level path.
- Symptom: a movie in native-texture mode draws black (or a texture the game fills with
WriteTexelsstays empty), and the texture isLINEARorLINEAR_RENDER_TARGET. When only part of the texture is written, the log has[RED] WriteTexels deswizzle failed. A write of the whole texture logged nothing. - Cause:
write_texels(hle_nvn.c) wrote every texture by block-linear address.nvn_deswizzle_geomrefuses a linear layout, so the in-place write (write_region_bl) failed. The whole-level fallback then failed too:nvn_swizzlerefuses linear storage for the same reason, and its result was ignored. The storage kept what it held before. The host's native-texture movie output (nvn_host_write_texture) goes through the same function, so a decoder output texture withLINEAR | VIDEO_DECODEdrew black. ReadTexels already read such a texture by rows (mtl_read_texels). - Fix: a linear texture is written as one 2D level of rows of its stride (
tex_linear_row, the strideGetStorageSizecounts). This is how ReadTexels reads it and how the upload reads it (gsb_geom). A region outside those rows, another target or level, or a block-compressed format is refused with a[RED]line, as is a block-linear write thatnvn_swizzlerefuses. After the write,mtl_tex_linear_cpu_writtenleaves a texture viewed on its pool's buffer (linear_fit) as that view, since the view reads the storage directly. Making it CPU-written would turn it into a full upload at every write. Any other linear texture is made CPU-written, astex_mtl_makes one at first use. - Found on: TSUKIHIME -A piece of blue glass moon- (
01001DC01486A000, no profile in the repository, nxnative PR #1983). The title screen's backdrop is a 1920x1080 RGBA8 movie texture with flags0x12. It was black, and with the fix the author's Mac run shows the backdrop, the opening movie, in-game event movies and gallery CGs. - Check:
t_nvn6c2b writes a 37x5LINEAR | VIDEO_DECODEtexture with a stride of 256 and a 16x16LINEAR_RENDER_TARGETtexture with a stride of 128. Each gets four regions (whole, part, one texel, one row or column), some with a padded source stride. The check compares the storage byte for byte, including the row padding and the bytes after the texture, then reads the texture back with ReadTexels. It also checks that a region past the last row writes nothing, and that no CPU-written texture is added for these buffer views. Without the fix, three of these checks fail; withmtl_tex_cpu_writtenin place of the view rule, the last one fails. - Open: a
LINEAR_RENDER_TARGETorIMAGElinear texture that the GPU drew into as a private texture (cpu_unc_drop) and the CPU then partly writes is uploaded from its storage, as a block-linear texture is. No game is known to do that.
- Cause: a texture created with
TextureBuilderSetPackagedTextureDatagets that data copied into its storage by NVN atTextureInitialize(a compressible pool cannot be written by the CPU). - Fix: the host copies it and marks the texture CPU-written, so it is de-laid-out and uploaded like a packaged texture. Linear textures are excluded: in a CPU-visible pool they are read straight from the pool buffer.
- Found on: 13 Sentinels: Aegis Rim (every texture is created this way; the linear ones are the save thumbnails).
- Symptom: black screen with every geometry right (
DM_NVN_HILITEshows each sprite and glyph in place); the uniform buffer right (aCBDUMP), so every texture read 0. - Cause: NVN keeps what a pool's storage held at
nvnMemoryPoolInitializeas its content, but the host uploaded from guest memory only packaged,WriteTexelsor flushed textures, and made a linear texture on its pool's buffer only in a pool the CPU can see. Rhythm Heaven Groove reads each texture file into memory and makes aCPU_NO_ACCESS | GPU_CACHEDpool (flags 0x21) on it, so its textures sit inCPU_NO_ACCESSpools made on memory the files were read into, whose initial content the host did not take; its sprite shader discards a fragment whose alpha is 0. - Fix: a new texture in a
CPU_NO_ACCESSpool whose storage holds data starts with that content, uploaded once (tex_init_fillinnvn_mtl.m); it stays a GPU texture that can be rendered into, with no re-upload on page writes. A linear one is made on the pool's buffer in any pool (tex_linear). Left out: storage of zeros,COMPRESSIBLEtextures in aCPU_NO_ACCESSpool (render targets in Rhythm Heaven Groove, in their own 0xa1 pools; their memory means nothing without the compression tags — a pool the CPU writes is different), multisample, depth / stencil and sparse ones. - Found on: Rhythm Heaven Groove (SDK 21.4.0): the sprite shader (
40adb3021c79b155) computes texture × colour multiply + colour add and discards a fragment whose alpha is 0 (theCBDUMP: multiply 1, add 0); 245 pools by the first screens, each holding one texture at offset 0, 17 block-linear BC7 (flagIMAGE, no packaged layout) and 228 linear R8 (IMAGE | LINEAR). - Check: found by a census of every texture's pool flags, flags, format and whether its storage held data at creation (a temporary line under
DM_NVN_LOG).
- Symptom: a game draws its UI and text but every image is black;
packaged textures: first uploads 0whileTextureInitializecounts the textures the game created (SD GUNDAM G GENERATION CROSS RAYS: 74, of which the 10 BC3 ones hold data and none is uploaded). After the fill works, one overlay still comes up with its text over nothing: the panel's background is missing and the text of the next block sits on top of it. - Cause: the game sets
NVN_TEXTURE_FLAGS_COMPRESSIBLE(0x8) on every texture it creates. That flag is a Switch memory-compression hint, not a data format: in a pool the CPU writes (CPU_UNCACHED, flags 0xa2) the texels are plain. Two host paths skipped such a texture —tex_init_fill(its content at creation, the pool's storage at creation) andft_add(hle_nvn.c, the CPU-write tracking thattex_flush_uploadandtex_dirty_uploadrely on). The second one is why an overlay generated after creation — zero atnvnTextureInitialize, written by the CPU a frame later — never appeared: the texture was never registered, so no page fault reported the write, andwrite-protection faultsstayed 0. - Fix: the
COMPRESSIBLEexclusion applies only in aCPU_NO_ACCESSpool, where the game never writes the storage and the tags may really be there (Rhythm Heaven Groove's render targets, flags 0xa1). Bothtex_init_fillandft_addmake that check after the pool is known; a texture in a pool the CPU writes is filled and registered as any other. - Found on: SD GUNDAM G GENERATION CROSS RAYS (
010055700CEA8000, #1052, no profile onmainyet) (SDK 9.4.1): 74 textures in 0xa2 pools, all withCOMPRESSIBLE; the 10 that hold data at first use are block-linear BC3 (0x44). With both paths fixed,first uploadsgoes 0 → 10,write-protection faults0 → 94, the title, the menus and the battle screens draw, and the stage-start overlay shows its panel with the tutorial text on it. - Check:
DM_NVN_TEXINFO=WxHprints the pool flags and the non-zero byte count at first use, andinitial content:in the log names the pool kind each fill came from.write-protection faultsstaying at 0 while a texture'sany=is 0 at creation means its CPU writes are not being tracked — theft_addpath.
- Symptom: a texture the game filled with
memcpyand flushed reads as zeros (BotW's sky brownish grey, the distant haze brown). - Cause: a game that makes a CPU-cached pool, creates the textures, then copies data into the pool and flushes it: the host created the texture as a GPU texture of zeros at its first use.
- Fix: every game now fills a new GPU texture from its storage once when the game flushed that storage before the texture's first use, as it already did for a pool the CPU cannot access (
nvn_flushedinhle_nvn.c,tex_init_fillinnvn_mtl.m; previous entry): the GPU has not written such a texture yet, so its content is what the CPU put there. Unlike the profile'stex_flush_upload(next entry) it never re-uploads, so a texture the game renders into afterwards stays a GPU texture.DM_NVN_FLUSH_FILL=0turns it off. - Found on: The Legend of Zelda: Breath of the Wild, GitHub issue #52 (Mac and iPhone). The sky is precomputed atmospheric scattering: each frame a pass (
08fe25061d583637,uking_pass_shader.sharcb) turns a 256×32×16 RGBA16F 3D table of in-scattered light into a 256×256 sky map, which the sky draw (8110c74078548172) and the materials' aerial perspective sample. The table and two 2D ones (transmittance 256×64, irradiance 64×64) come fromSystem/KSys/sky.skybininPack/Bootup_Graphics.pack(1,212,416 bytes, the three textures' storage back to back). The game makes aCPU_CACHED | GPU_CACHEDpool for each texture, creates the textures, copies the file's data in withmemcpyand flushes (main+0x1257d50, once, at the sky's setup; the flush goes throughmain+0xebf4c4, which flushes only CPU-cached pools). With zeros the sky had no scattered light: only the clouds and the sun's colour were left. In the Great Plateau save the fill covers that one texture; the flushes (about 15 a frame) merge into about 530 ranges, and frame times did not change. DRAGON QUEST XI S (title, free play, menus; 300 s) filled none. - Note:
DM_NVN_TEXRAW=212,214(all zero) andDM_NVN_TEXINFO=256x32(storage 1048576 bytes non-zero 781567at first use,swizzled=0) showed it.
- Symptom: magenta (ASTC's error colour) where UI images belong: a title logo and menu panels, while the 3D scene and the text draw;
DM_NVN_ZBLOCK=1reports nothing (the textures are not uploaded on the CPU at all);DM_NVN_TEXINFO=WxHshows storage holding data at first use (non-zero …) in a pool with flags 0x22 (CPU_UNCACHED | GPU_CACHED). - Cause: in a
CPU_UNCACHEDpool the CPU's writes reach memory with no flush, and the GPU reads that memory: what the storage holds is the texture's content. The host took a CPU-visible pool's storage only when the game had flushed it (previous entry), and a game that writes an uncached pool never needs to. Withtex_flush_uploadandtex_dirty_uploadthe logo stayed magenta too, so its texels were apparently in the storage before the texture was registered, where no later write marks it. - Fix:
tex_init_fill(nvn_mtl.m) also fills a new texture in aCPU_UNCACHEDpool from its storage once, at first use, when the storage holds data; packaged layouts, multisample, depth / stencil and sparse ones stay out. The log saysinitial content: … a CPU_UNCACHED pool's. A texture the CPU writes again after its first use still needs the profile's keys. - Found on: Donkey Kong Country: Tropical Freeze: the 912×512 ASTC 5×5 title logo (242,374 of 376,832 storage bytes non-zero at first use, no packaged layout) and the menus' ASTC panels; with the fill and neither profile key, the title and every menu draw.
- Also found on: NieR:Automata US 1.0.3: 9S's 512×512 ASTC 6×6 sRGB face texture (ten mip levels) uses pool flags
0x22; the GPU has zero blocks while 151,736 of 244,224 guest bytes are nonzero and decode to the correct face. His hair uses the same pool flags. GPU fixtures (dm/gfx/tests/t_initial_uncached.m) sample three independently colored mip levels in linear and sRGB ASTC. - Superseded for block-compressed formats: such a texture is now made CPU-written at first use instead of being filled once (next entry), so a later CPU write reaches it too. The one-time fill is left for uncompressed formats.
-
Symptom: a picture that should change with a selection is black, or shows the previous item's picture while the item's name and description are right.
DM_NVN_TEXINFO=WxHshowsswizzled=0at first use with storage holding data, no packaged layout, in aCPU_CACHEDorCPU_UNCACHEDpool, andDM_NVN_DIRTYCHECK=1reports no missed write. -
Cause: two gaps, met one after the other. (1) The CPU wrote the texels before
nvnTextureInitializeand never flushed them.tex_dirty_uploadstamps a texture only when it is registered, so it never sees those writes, andtex_init_filltook aCPU_CACHEDpool's storage only when the game had flushed it: the texture started as zeros. (2) A one-time fill (tex_init_fill, any pool) makes a GPU texture, which goes into the sharing tableg_alias(pool, offset, description). When the game writes the next image into the same storage and builds a new texture object with the same description over it, the table hands back the earlier GPU texture with the earlier picture, and nothing tracks writes to it. -
Fix: every game. At first use (submit thread), a texture is made CPU-written (
tex_cpu_contentinnvn_mtl.m, thenmtl_tex_cpu_written) when all of these hold:- a block-compressed format, or any format with target 3D, or an uncompressed 2D texture in a pool the GPU has not written (next entry)
- no packaged layout
- not
COMPRESSIBLE,LINEAR,LINEAR_RENDER_TARGETorSPARSE, notIMAGEunless block-compressed (shaders cannot write a block-compressed texture), and not a buffer or multisample texture - in a pool the CPU can write (not
CPU_NO_ACCESS, not virtual) - its storage holds data
It is uploaded from the storage, re-uploaded when its pages change, and never enters the sharing table. Until the GPU first uses a texture it has not written it. A block-compressed one it can never render into, and GPU copies into it are written into its storage too (GPU copies into textures uploaded from their storage). So what its storage holds is what the CPU wrote, flushed or not, before or after
nvnTextureInitialize. Uncompressed 2D textures cannot be told from render targets (NVN has no flag for that, andtex_descgives them render-target use), so they keep the flushed-range and uncached-pool fill (entries above). An uncompressed 3D texture with neitherIMAGEnorCOMPRESSIBLEis taken from its storage as well: the 3D textures Xenoblade Chronicles 3 renders or stores into carry one of them. That is what the games seen so far do, not an NVN rule. In Breath of the Wild and Tears of the Kingdom no 3D texture the rule takes is drawn into, cleared or read back; their flag-0 3D textures the GPU draws into are empty at first use or already CPU-written by an earlier path, and Breath of the Wild's CPU-filled 256x32x16 RGBA16F table moves from the flushed-range fill to this path. A 3D render target with neither flag and data in its storage at first use would still be drawn into (attachgives it render-target use), but aReadTexelsof it, or a second texture object over the same storage, would read the storage rather than what the GPU drew. The log saystexture written by the CPU before its first use ….DM_NVN_STORAGE_FIRST_USE=0turns it off, to measure it. GPU fixtures:dm/gfx/tests/t_initial_uncached.m(an unflushedCPU_CACHEDpool, a later CPU write, and a new object over rewritten storage). -
Found on: The Great Ace Attorney Chronicles, Japanese release
0100D7F00FB1A000(GitHub issue #568). Its court-record evidence and profile pictures are 256x256, 512x512 and 2048x2048 BC1 (format0x42) with no packaged layout, in itsCPU_CACHED | GPU_CACHED | COMPRESSIBLEpool (flags0xa4). At first useTEXINFOshowed 31,800 of 32,768 bytes non-zero. They were black, and once filled once, the autopsy report showed the school badge's picture. The 2048x2048 BC1 and BC7 chapter title cards (UI_opdemo00_jpn.arc,opTitle0_BM_NOMIP_HQ) are written the same way (GitHub PR #167). -
Also: Pokémon Pokopia gives nearly every texture
IMAGE(flags 0x4), its icons BC1 / BC7 in aCPU_UNCACHEDpool: withIMAGEexcluding them, a new icon over storage another icon had used got that icon's picture (in the store and the Pokédex, once storage was reused).IMAGEnow keeps only uncompressed textures out. Found and fixed by xFractals/pokopia-native (issue #1). -
Also found on: Xenoblade Chronicles 3 (GitHub issue #1011): its particle steering fields are 10x10x10 RGBA8 and RGBA16F 3D textures, flags 0, that the CPU writes before first use. As GPU textures of zeros they steered every send-off particle along (-1, -1, -1), so the particles sank diagonally into the ground instead of rising.
t_initial_uncached.malso covers a 4x4x4 RGBA8 3D texture (each slice holds the CPU's texels at first use) and a 3DIMAGEtexture (left alone).
- Symptom: a movie plays black (Age of Calamity: its intro movie under the "+ Skip" prompt).
- Cause: the decoder's NV12 output is copied by the game into textures in a CPU-visible pool and published with
nvnMemoryPoolFlushMappedRange, which the host did not take as an upload. - Fix: the profile key
tex_flush_upload. Buffer textures stay out of that path: they are views onto the pool buffer, and an upload would have sent them past Metal's 2D size limit. - Found on: Hyrule Warriors: Age of Calamity: a 1280×720 R8 and a 640×360 RG8 texture; the buffer texture kept out is a 1,048,576-texel R32F one.
- Also found on: NieR:Automata The End of YoRHa Edition: the title background; the game note records the controlled comparison.
- Symptom: open-world ground is one flat colour on a flat plane with blocky seams, or water and sand are missing, while course meshes, rocks and the kart look right.
DM_NVN_TEXINFO=WxHshows uncompressed textures withswizzled=0, no packaged layout and storage holding data in aCPU_CACHEDpool. - Cause:
tex_cpu_contenttook a texture's storage at first use only for block-compressed formats and 3D targets, because an uncompressed 2D texture cannot be told from a render target by its flags (Block-compressed storage the CPU wrote before first use).tex_init_filltakes aCPU_CACHEDpool's storage only when the game flushed it. So an uncompressed texture the CPU wrote with no flush became a GPU texture of zeros. - Fix: every game. Storage the GPU has not written holds only what the CPU wrote, whatever the texture's flags say, so
tex_cpu_contentalso takes an uncompressed 2D texture's storage there. Storage counts as written once a render target, a texture clear, a copy or downsample into a texture, or a shader image has been recorded over it; copies count, so a game that fills textures by staging copies behaves as before. Once the GPU writes the storage of a texture taken this way, it becomes a GPU texture with the content it has, and CPU writes to it are no longer uploaded (cpu_unc_drop). Over storage the GPU writes, the earlier rule stays. This was decided per pool at first; it is now decided per address range (next entry). Display, video-decode, depth,IMAGE,COMPRESSIBLE,LINEAR, buffer and multisample textures stay out, and so does storage of zeros.DM_NVN_STORAGE_FIRST_USE=0turns it off. The log says(uncompressed, in storage the GPU has not written)andstorage the GPU writes: … GPU textures from now on. - Found on: Mario Kart World: terrain tiles (260x260 R16 height, RG8 with 9 levels, RGBA8, R11G11B10F, and 68x68 RGBA16) in a
CPU_CACHED | GPU_CACHEDpool (flags 0x24), 50 to 90% of their bytes non-zero at first use and all zero on Metal, so every tile was a flat plane at its base height. Pull request #1109 found this on update 1.8.0. - Check:
make -C dm/gfx/tests GAME=<id> check_t_cpu_storage.
- Symptom: after some walking, the ground opens in holes (black, or the water and sky below showing through) and the hole spreads to areas loaded later, including ones that were fine before; saving and loading the game makes it right again for a while. A diagnostic line at the GPU texture's creation shows uncompressed terrain textures in a
CPU_CACHED | GPU_CACHEDpool (flags 0x24) whose storage holds data, not flushed, made as GPU textures of zeros. - Cause: the previous entry's rule was per pool: once any texture of a pool was 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. A game that keeps CPU-filled textures beside textures it draws, in one pool, then gets zeros for every CPU-filled one it creates after the first draw, when the CPU wrote it before
nvnTextureInitializeand did not flush (neithertex_dirty_upload, which stamps at registration, nor the flushed-range fill sees that write). - Fix: every game.
tex_cpu_contentasks whether the GPU wrote the texture's own storage, not its pool: the host keeps the address ranges a render target, a clear, a copy into a texture or a shader image was recorded over (mtl_tex_gpu_written,gws_spaninnvn_mtl.m), dropped with their pool. 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 into it afterwards makes its storage the content again; a texture taken that way becomes a GPU texture at the next GPU write there (cpu_unc_drop). Only whole pages count, as a range's first and last pages are shared with whatever lies beside it. Ranges do not merge: a GPU write over part of one cuts it, and the rest keeps its own stamp, so a CPU write there since stays seen. Storage the CPU cannot write (aCPU_NO_ACCESSor virtual pool) gets no range, as no texture there is taken from its storage; most render targets lie there, so they are neither protected nor looked at. The GPU's own write into such a range (GPU textures in storage the CPU can read) goes through the pool's buffer, which page protection does not see, so it is not a CPU write. Open: GPU writes are stamped when recorded, so a command buffer submitted again does not stamp them again, and storage the CPU wrote since counts as the CPU's until the next recorded GPU write there. - Found on: The Legend of Zelda: Ocarina of Time (the pool rule's part first by aCidcOde, GitHub issue #1326): its terrain tiles' height maps (260x260 R32F) and blend and hole maps (260x260 RGBA8, one and two layers) share one pool with the tiles' normal maps (260x260 RG8, drawn by the GPU); after the first normal map was drawn, every tile streamed in came out as zeros, and the ground opened while walking out of a cave and back into it.
- Check:
make -C dm/gfx/tests GAME=<id> check_t_cpu_storage: 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 until the GPU writes there again, a GPU write over another part of that storage leaves the rewrite seen, and storage the CPU cannot write gets no range.
- Symptom: after some walking, a whole terrain tile is missing (the ground vanishes, or the tile sits flat and low) or shows another surface with a hard square edge (a square of grass across a cobbled path, a grass patch on a mountain trail); a reload makes it right for a while. The host's copy of the tile's 260x260 R32F height map or RGBA8 hole map is all zeros while the game's memory holds data (
DM_NVN_TEXINFO=260x260shows storage non-zero at first use; atexrawdump of the host texture is zero). - Cause: the previous entry's question, whether the GPU wrote a texture's storage and the CPU has not rewritten it since, is asked of every span that overlaps the texture through its whole pages (
gws_cpu_since): a span's first and last pages are shared with whatever lies beside it. An overlap that holds no whole page of the texture cannot be answered that way, and its silence was taken as the GPU's. Two slivers do that to Ocarina of Time's tile maps: the span of a 260x260 RG8 normal map (laid out at 214,528 bytes) reaching 1,536 or 13,824 bytes past the texture's start, since textures are 512-byte aligned and a span outlives the texture it was recorded for, so the game hands part of that storage to the next tile's map; and a 2,560-byte remnant of an old span left between two newer ones (gws_setkeeps the parts outside a new span). Either made the map a GPU texture of zeros at its first use. - Fix: every game.
gws_sliver: a span whose overlap with the texture holds no whole page of it, and which does not cover the texture, does not decide ingws_owned. A span that covers the whole texture keeps the GPU's, so a texture smaller than a page over storage the GPU wrote stays a GPU texture, and a neighbour's CPU write in the same page is not its. - Check:
make -C dm/gfx/tests GAME=<id> check_t_cpu_storage: a neighbour's span reaching less than a page into a texture the CPU filled leaves it taken from its storage while the drawn neighbour stays the GPU's; a sub-page remnant of an old span inside a texture the CPU rewrote does not keep it the GPU's; a texture smaller than a page over storage the GPU wrote stays the GPU's though a neighbour in its page is CPU-written. - Found on: The Legend of Zelda: Ocarina of Time on the Mac (M4 Pro, macOS 26.6; GitHub issue #1566's reports from Death Mountain Trail and Kakariko are the same class): a first-use trace of every 260x260 texture logged 12 of 620 CPU-filled maps resolved as GPU textures in 26 minutes of play, all with a sliver as the only unanswered span, and whole tiles missing in Hyrule Field, a grass square across the path from Hyrule Castle Town, a grass patch on Death Mountain. With the rule, 1,498 of 1,510 tile maps in the next 26 minutes were taken from their storage (the other 12 were still zeros at first use, filled later), 162 of them past a sliver that would have made them GPU textures, and none came up as zeros (diagnosis and fix by nawangwe, PR #1920; PR #1876 found the remnant case independently). A second Mac (M5 Max, macOS 26.6.2) had tiles missing in Hyrule Field within 2 minutes without the rule, and none in 5.2 minutes with it.
- Symptom: magenta surfaces (Metal decodes an all-zero ASTC block as the error colour (1, 0, 1)); a zero BC block decodes black, which looks like missing light; black screen while the UI (or a dialog box) draws in the right places under
HILITEor in the pass dump;packaged textures: first uploads 0. - Cause: the game writes texels into a texture's storage after
nvnTextureInitializewith nonvnMemoryPoolFlushMappedRangeorFlushTexelsover them, so neither the flush path (tex_flush_uploadnever marks them) nortex_init_fill(which only takesCPU_NO_ACCESSpools) sees them, and they stay GPU-only with content 0. Tears of the Kingdom creates textures on zeroed memory and then fills them from the CPU (its cache maintenance runs natively). Writes to an uncached pool need no flush (FINAL FANTASY TACTICS); a game with no cache maintenance instruction at all gives the host no flush to take as the signal (The Great Ace Attorney Chronicles). - Fix: profile key
tex_dirty_upload:ft_addwrite-protects a registered texture's pages, and on first use (submit thread) a write since then makes it CPU-written, so a texture whose pages the CPU writes after creation is uploaded at its first use. ACOMPRESSIBLEtexture in a pool the CPU writes is registered too (a pool the CPU writes). FINAL FANTASY TACTICS and The Great Ace Attorney Chronicles set it together withtex_flush_upload; withtex_flush_uploadalone The Great Ace Attorney Chronicles stayed black. Textures written after their first use andReadTexelsbefore any draw: next entry. - Check:
DM_NVN_TEXINFO=WxHtells the cases apart: it prints how many bytes of the texture's guest memory are non-zero at first use. - Found on:
- The Legend of Zelda: Tears of the Kingdom: the cave was magenta; the terrain draws sample two 121-layer ASTC 8×8 arrays (42 MB each) filled this way.
- FINAL FANTASY TACTICS - The Ivalice Chronicles: the UI reads its textures from
.texfiles the engine loads straight into aCPU_UNCACHED | GPU_CACHED | COMPRESSIBLEpool (flags 0xa2): 231 textures by the title screen, no packaged layout, noWriteTexels, no copies. - The Great Ace Attorney Chronicles: a dialog box sampling 256x256 and 1024x256 textures of format 0x4d;
DM_NVN_TEXINFO=1024x256showed storage that already held data at first use (226,555 of 262,144 bytes non-zero): no packaged layout, block-linear, in a 1000 MBCPU_CACHED | GPU_CACHED | COMPRESSIBLEpool (flags 0xa4). A log of the pool flushes, texture creations and file reads (temporary lines underDM_NVN_LOG) showed every.texread into staging buffers (512 KBReadFiles, none into the pool) and the texels copied by the CPU into the texture's storage; only the four linear 1024x64 text textures are flushed, every frame.mainhas no cache maintenance instruction at all. - Kirby Air Riders: magenta props and panels in races, one of them a 512x512 ASTC texture all zeros when dumped; with both keys none of the 378 ASTC textures bound on one frame had a zero block.
- Symptom: a texture the game builds from CPU-filled textures read back with
nvnTextureReadTexelscomes out zero. - Cause:
tex_dirty_uploadonly marked such a texture CPU-written when the submit thread first resolved it, soReadTexelson another thread before any draw returned the never-uploaded host texture: zeros. A texture filled after it was first bound was never marked at all. - Fix:
mtl_read_texelsreads any texture whose content is in guest memory there (a packaged layout, a CPU-written texture, or atex_dirty_uploadtexture written since registration), on any thread, without an upload; andnvn_ft_poll(hle_nvn.c, at each submission after guest memory changed, at most 128 MB of textures per look, going on where it stopped) marks registered textures written after their first use. Textures the GPU renders into or writes (mtl_tex_gpu_owned) leave that list without an upload. - Found on: The Legend of Zelda: Tears of the Kingdom (Mac and iPhone): the stone blocks and steps of the opening cave got no torch light (on the Switch they are grey stone lit warm on the side facing the torch; here they kept their texture and the ambient light but stayed black). Their fragment shader
54e8c08998050e79takes its roughness from a 5120×5120 BC4 atlas (roughness = the square root of its red, packed into the upper six bits of blue in colour target 3, which the tile-light shaders read); the atlas was all zero, and roughness 0 gives no point light. TOTK fills 1024×1024 BC4 material textures from the CPU with no NVN flush, reads them back withnvnTextureReadTexelson its own threads before any draw uses them, and copies the blocks into the atlas, which itself is filled after it was first bound. - Note: found by running MacNativeGames' build of the same game on the same save: their atlas held data and their blocks were lit (their
ReadTexelsreads guest memory). Traced withDM_NVN_TEXDUMP(the blocks' roughness bits in colour target 3), the shader's MSL,DM_NVN_TEXRAWon the atlas andDM_NVN_TEXINFO(guest bytes), then lldb reading the atlas memory inside their process. Fast loop: the in-cave autosave (run/save, keysf2500:A,f2800:A,f3100:A,f3600:A, shot at frame 4500);DM_DETCLOCKcannot be used (TOTK stops at frame 1 waiting for guest time).
- Symptom: with
tex_dirty_upload, a texture the CPU fills again after its first use keeps its old content: Ocarina of Time's ground opened flat blue holes with grass and rocks floating over them (Kokiri Forest, the village floor, the Deku Tree) after an hour of play, as terrain tile slots were reused; no red line, and the frame'stexture uploads … re-uploadsstays at 0 while the pool's pages change. - Cause:
nvn_ft_poll(hle_nvn.c) leaves a registered texture to the GPU when its host texture can be rendered into or written by shaders (mtl_tex_gpu_owned), so that a CPU write to pages it shares does not replace what the GPU drew. But NVN has no flag for a render target, sotex_descgives every uncompressed texture made without a CPU fill render-target use from the start, and the test held for every such texture whether or not the GPU had ever written it: a texture bound before the CPU filled it and filled later was never uploaded. Only block-compressed textures, which take no render-target use, came through (Tears of the Kingdom's BC4 atlas and ASTC arrays, where the poll was found to work). Ocarina's terrain tiles (260×260, in oneCPU_CACHED | GPU_CACHEDpool: R32F height, RGBA8 and 2-layer RGBA8 blend and hole maps, block-compressed detail, RG8 normals with 9 levels) are bound at creation and written from the CPU as tiles stream into their slots. - Fix: the poll asks
mtl_tex_cpu_rewritten, which takes a texture from its storage by the rule textures follow at their first use (uncompressed ones taken from their storage): the same flags and formats (tex_storage_ok: no depth, display, video-decode, compressible or multisample texture, no uncompressed image), and an uncompressed 2D one not while the GPU has written its storage and the CPU has not rewritten it since (gws_owned, stamped when a GPU write is recorded). A texture the GPU never wrote, or wrote before the CPU's write, is uploaded, and joins the texturescpu_unc_dropmakes GPU textures again at the GPU's next write there, so a later drawing into it is not replaced by the next upload. One the GPU wrote after the CPU's write stays registered with a new stamp, so the CPU's next rewrite is asked about again (once left to the GPU it used to leave the list). The normal tiles stay the GPU's, rightly: the CPU zeroes their level 0 and the GPU then draws every level. A block-compressed texture is uploaded whatever the GPU wrote: a GPU copy into a registered one is written into its storage too (GPU copies), so a GPU-written span over it does not make the storage stale, and a span as the test would have kept a CPU rewrite followed by a copy from being uploaded. A texture the rule leaves out keeps the usage test, as before. - Check:
t_cpu_storage(a rewrite of storage the GPU never wrote is uploaded and becomes a GPU texture again at the GPU's next write; one the GPU wrote over afterwards is the GPU's until the CPU rewrites it, also through a second texture object over the storage; a block-compressed rewrite is uploaded though a GPU copy followed);t_cpu_texwith atex_dirty_uploadprofile (a texture left to the GPU stays registered, and the CPU's next rewrite is uploaded). In the log,registered texture written by the CPU after its first use: uploaded from its storagefor the first four such uploads and… left to the GPU until the CPU rewrites itfor the first four left to the GPU. - Found on: The Legend of Zelda: Ocarina of Time on the Mac: loading a Kokiri Forest save and turning the camera (M3 Pro, the first version of the fix, which kept textures by the span alone), 127–263 textures uploaded by the poll in the first 6,600–10,600 frames, nearly all terrain tiles, and 4 kept (RG8 normal tiles the GPU had drawn; a diagnostic showed the CPU writing zeros to their level 0 only); before, every one of them was dropped. With this fix (M4 Pro, the autosave and a walk onto the village floor, 160 s): about 250 uploaded by the poll (small lookup textures, 3×3×6 R8 arrays, the tiles' 260×260 height, blend and hole maps, and RG8 normal tiles caught between the CPU's zeroing and the GPU's drawing), 17 of them made GPU textures again when the GPU drew into them, the picture whole at 60 fps; 36 long frames against 57 on main.
- Symptom:
[RED] upload batches belong to the submit thread only: up_begin called on thread '…', then an abort, withpkg_upload←tex_mtl←mtl_read_texelson the stack; orCRASH sig=10innvn_deswizzle←mtl_read_texels, at an address in a sparse virtual pool's reserved range. - Cause:
mtl_read_texelsread a texture from its storage only when it had a packaged layout or was CPU-written, whiletex_mtl_also takes a sampled texture of a sparse virtual pool from its storage. Such a texture went throughtex_mtl, which uploaded it (first use, or pages written or mapped since) on the game's thread, where the upload batch must not be used. And a texture of a sparse pool that did take the storage path was read at its virtual address directly, not through the page table: unmapped pages, and pages mapped at offsets the host cannot alias, are inaccessible there. - Fix: one predicate,
tex_from_storage, decides for bothtex_mtl_andmtl_read_texelswhich textures the host takes from their storage, andmtl_read_texelsreads a sparse pool's texture through a copy gathered from its page table (nvn_sparse_copy, unmapped pages as 0), aspkg_upload_uploads it. Neither touches the host texture, so the read works on any thread. Theup_owner_checkabort stays: a texture upload on another thread is still a bug to find. - Check:
t_readbackreads a sparse texture over mapped and over unmapped pages, first on another thread and then on the submit thread. Before the fix, a profile withouttex_dirty_uploadaborted inup_begin, and TOTK's profile crashed with SIGBUS on the unmapped texture. - Found on: The Legend of Zelda: Tears of the Kingdom on an iPad Pro (M5), GitHub issue #471: an abort a few seconds after the app came back from the background (switching between Stage Manager and full screen triggers it), on thread
bake::ModuleMgr. The reporter traced it tomtl_read_texels.
- Symptom: the same abort as above,
[RED] upload batches belong to the submit thread only: up_begin called on thread '…'withpkg_upload←tex_mtl_←mtl_read_texels, on a texture of an ordinary pool; at random, from minutes to hours into a session (after a shrine, loading a save, entering a sky island), more often with less memory. - Cause: whether the host takes a texture from its storage changes on the submit thread at any time (
mtl_tex_cpu_writtenat its first use, or whennvn_ft_pollfinds a rewrite), andmtl_read_texelsandtex_mtl_each askedtex_from_storageon their own. A reader on another thread found the texture not taken from its storage, the submit thread marked it CPU-written, andtex_mtl_then took the packaged path on the reader's thread: the first upload of a texture with no host texture, or the rebuild of a GPU texture as packaged, both in the upload batch the submit thread owns. Two more paths oftex_mtl_used that batch off the submit thread: the tile sync of a placement sparse texture (sparse_look,sparse_gpu_look). And a texture no thread had resolved was made there with no content (tex_init_fillandtex_zero_fillrefuse other threads) and kept that texture: its first use on the submit thread, which takes CPU-written storage (tex_cpu_content), never came. - Fix: off the submit thread,
tex_mtl_decides on its own single look and never uploads, syncs or makes a host texture it would have to fill. Asked bymtl_read_texels(itsin_storageargument), it answers "the storage" for a texture taken from its storage, and for one no thread has resolved and nothing shares thatnvn_deswizzlecan read; the reader then reads the storage, as the console does. Textures that share another's storage (the sharing table, an sRGB twin) and GPU textures are still read back from the host texture. Any other thread (the present thread) gets an uploaded package as it is, and nil with[RED] texture ser=… taken from its storage resolved first off the submit threadrather than an upload. Theup_owner_checkabort stays. - Check:
t_read_ownerputs a reader on a thread that is not the submit thread and givestex_mtl_the classification the submit thread left: a CPU-written texture never resolved, a GPU texture then CPU-written, and a texture never resolved or classified. Each must give the storage's bytes without an abort, and the last must stay unresolved, so that its first use on the submit thread takes it from its storage. On the code before the fix, the first two aborted inup_beginwith the issue's message, and the third read the wrong bytes and kept a host texture without the CPU's content. - Found on: The Legend of Zelda: Tears of the Kingdom, GitHub issue #1814, on thread
bake::ModuleMgr: Mac mini (M6) entering the Starview Island shrine, iPhone 17 Pro Max loading a save (only without the increased-memory-limit entitlement), Mac mini (M4) after a shrine. volkanayd traced the race to the two looks; their caller-side guard went from three aborts in an afternoon to none in 14.8 hours on an M1 Max.
- Symptom: a streamed texture's small mip levels hold another texture or zeros while its large levels are right (
texraw <serial>.<level>), so surfaces sampled with a mip bias or at a distance show bright speckles, dark blotches and sharp detail where the Switch shows soft texture (Yoshi's felt), from the moment finer levels stream in (the opening going dark). - Cause: the game fills a texture partly from the CPU and partly with GPU copies. Unreal's streamer re-creates a texture with more levels, copies the resident levels from the old texture on the GPU (
RHIAsyncReallocateTexture2D:nvnCommandBufferCopyTextureToTexture, a level at a time) and writes the new, finer levels from the CPU. The host uploads a CPU-written texture whole from its storage, where the copied levels were never written: they held what the pool had there before. - Fix: what a copy writes into a texture whose storage the host uploads from (a packaged layout, CPU-written, or registered by
tex_flush_upload/tex_dirty_uploadand not resolved yet) is written into the storage as well, as the hardware's copy would, so that a later upload from the storage carries the copied levels: on the GPU, in the same command buffer right after the copy (GPU textures in storage the CPU can read describes the store). A buffer-to-texture copy is stored from its own source bytes, which are already in the storage's format and order (gsb_store_buf, fromcopy_b2t); a texture-to-texture copy, a format-converting, scaled or mirrored one drawn bycopy_t2t_drawand a multisample resolve are stored from the destination's host texture after it (gsb_store, fromcopy_t2t). Block-compressed formats are stored in block rows, a 3D texture's region into the depth planes it covers (the planes interleave by the level's block depth), aLINEARtexture's into rows of its stride. Being in command order, a copy's store comes after every earlier GPU write to its source, a copy from a texture whose own store is in the same command buffer reads the host texture, and a later write's store lands after it. Depth and stencil formats, multisample textures and three-component formats (no host texture) are left out.- Before (2026-11-10), the host wrote the copy into the storage on the CPU when it encoded it (
nvn_tex_mirror_b2t/nvn_tex_mirror_t2t), from the source's storage when that held its content, and otherwise read the region back into a shared buffer and wrote it in the submission's completion handler (mirror_readback); a pending-readback table (mrb_pending) kept the two in order.
- Before (2026-11-10), the host wrote the copy into the storage on the CPU when it encoded it (
- Check:
t_nvn6c3 runs region and whole-level copies through the queue between textures of different block heights (RGBA8, BC1) and from a buffer with a padded row stride, and compares the whole pool with a level round trip, byte for byte; 6c4 the same into 3D textures (buffer copies into a BC6HMINIMAL_LAYOUTvolume with padded strides and into level 1 of an RGBA8 volume with a block deeper than one GOB, a 3D → 3D copy), with a 3DWriteTexelsregion on the CPU.t_readbackcopies a cleared render target into a packaged texture and reads the clear colour from its storage, copies a buffer the submission writes on the GPU into slices 1 and 2 of a packaged 3D texture, copies a buffer into a block region of layer 1 of a packaged BC1 array and into a packaged RGB565 texture (the guest's order), and orders a clear, a copy over half of it in the next submission and a copy from it. WithDM_NVN_GPU_STORAGE=0each fails. - Found on: Yoshi's Crafted World: the Yoshis' 1024² DXT1 base colour and BC5 normal textures, 11 levels; level 4 of one held a pink felt texture, of the others zeros; the speckles began when the opening went dark. LEGO Star Wars: The Skywalker Saga (
010042D00D900000, no profile yet; GitHub issue #1856) for 3D textures: it fills its BC6H light-probe volumes a slice at a time with buffer copies, then moves them into new textures by copying their storage; with 3D left out the moved volumes held the pool's old bytes, and every dynamically lit object came out at the half-float maximum (65504), white on Episode Select.
- Symptom: while a scene streams in, frames of seconds whose
[longframe]line has hundreds of re-uploads and gigabytes (texture uploads 98 first + 280 re-uploads, 12287.6 MB / 8091.3 ms); picture, sound and controller drift behind. WithDM_NVN_REUPLOADS=1each re-upload follows one GPU copy into the texture, which wrote a few of its thousands of pages. - Cause: a GPU copy into such a texture is written into its storage as well (previous entry), and that write marked the pages as a CPU write does. At the texture's next use, the next copy's own lookup of its destination, the host uploaded all of it from the storage again, though the copy, encoded on the GPU, had already written the same bytes into the host texture: one whole-texture upload per copy for a game that fills a large texture a level at a time.
- Fix: the copy's write into the storage is made on the GPU (previous entry), which page tracking does not see: the storage holds the copy and the texture keeps its host texture, which the same copy wrote. Any CPU write still uploads it at its next use. For every game.
- Before (2026-11-10), the copy was written on the CPU, and
pkg_copy_absorbstamped and protected the storage again after it and kept the host texture when no page wholly inside it had been written but those the copy wrote (nvn_dirty_since_except) and its bytes in its edge pages were as kept; a copy read back after it landed still uploaded the texture again.DM_NVN_COPY_ABSORBand thepackaged texturesline's count of absorbed copies are gone with it.
- Before (2026-11-10), the copy was written on the CPU, and
- Open: none.
- Check:
t_readback(copy_stream): nine buffer-to-texture copies into a 64x64x4 RGBA8 packaged array, a level of a layer each, after a CPU write into another layer, upload it once (the CPU write) instead of 10 times, are stored nine times on the GPU, and the host texture (read on the GPU) and the storage hold every byte as copied; a CPU write after the copies is uploaded; after a copy into the storage's shared first page, a CPU write beside the texture there is no upload and a write into its own byte there is one;copy_stream_3d: eight slice copies into level 0 of a packaged 32x32x8 RGBA8 volume upload nothing, the host texture and the storage hold every slice, and a CPU write into level 1 after them is uploaded. - Found on: Star Fox (measured on a build of our fork, not
main): its streamer fills BC7 2D arrays (512x512x128 with 10 levels, 43 MB; 1024x1024x64 with 6, 85 MB) withnvnCommandBufferCopyBufferToTexture, a level of a layer per copy, at each scene change of the prologue (prologue_part2_stage…part5_stage). Headless on an M5 Pro, 240 s of the same route with the switch off and on: 986 re-uploads before (each of the 400 logged followed one copy that had written 1-17 of the array's 2,752 16 KB pages), three frames of 280 re-uploads (12.0-12.3 GB, 2.1-7.6 s); none after (986 copies absorbed). Frames over 1 s went from 5 to 1, the worst from 7.6 s to 1.6 s; what is left is first uploads (117 new textures, 400 MB). The player's log had a 10.0 s frame (280 re-uploads, 12.3 GB).
- Symptom: what the GPU drew into a texture is lost later: a texture re-created from the same memory, or one the game moved with
memcpy, comes out zero; on the Switch it holds the drawing. Tears of the Kingdom: the grass of a meadow is drawn with no length and the ground under it goes dark, a few seconds after loading, tile by tile around Link. - Cause: a texture the host uploads from its storage (a packaged layout, CPU-written: a flush over its range is enough) can also be a render target. The hardware's render pass writes the storage; the host's wrote only the host texture, and the storage kept what it held before. Tears of the Kingdom creates the normal maps of its terrain tiles (260×260 RG8, 9 levels,
MINIMAL_LAYOUT, in aCPU_CACHED | GPU_CACHEDpool) on zeroed memory, flushes a range covering them (which made them CPU-written), then draws each level. Its resource defragmentation (main+0x5bc988, terrain resources with the magicTERA) later moves each tile withmemcpyand re-creates the texture at the new address: uploaded from there, the tile was zero. The grass vertex shader scales each blade by how flat the ground is, from that normal map: a zero normal is horizontal, so every blade got length 0, and the terrain's lighting lost its normals too. The 226 tiles drawn at loading were moved within the first half minute; the 410 the defragmentation created afterwards were never drawn again. Such a texture was also attached for rendering without Metal's render-target usage, which Metal's validation stops at. - Fix:
attach(nvn_mtl.m) gives a texture made without render-target use that use (tex_upgrade, asclear_texdoes for a clear). What a colour attachment whose texture's storage the CPU can read draws is stored into that storage when its pass ends, on the GPU in the same command buffer (rt_mirror_add/rt_mirror_flush, throughgsb_store, as for GPU copies): its whole level, and in a layered or slice-merged pass every layer the pass draws (rt_mirror_layers, fromrenderTargetArrayLength); of a 3D texture the depth planes from its attached plane on; the copy encoder the stores open is closed again beforerp_endreturns, since its callers may open an encoder of their own next (left open, the opening of a new game stopped presenting at frame 1983). Page tracking does not see the store, so it is no change to upload: the four CPU-written targets Tears of the Kingdom draws into every frame keep their host textures. While the command buffer being encoded holds a store into a texture, the texture is not uploaded (gsb_held): an upload batch runs before that command buffer, so it would read the storage without the drawing; the texture keeps its host texture, which has it, and a CPU write is uploaded at its next use. A re-upload of such a texture (a CPU write into it) keeps the render-target use its last texture was upgraded to (pkg_upload'skeep); made without it, every re-upload was followed by another upgrade, a second texture and a copy of every level, at its next attachment. A texture clear (nvnCommandBufferClearTexture) is stored the same way (clear_tex). The same store serves GPU textures.- Before (2026-11-10), the region was read back into a shared buffer and written into the storage on the CPU in the submission's completion handler (
mirror_readback/mirror_rb_land), which then stamped and protected the storage again and kept the texture's edge bytes so that the next resolution kept the host texture (pkg_absorb_note/pkg_absorb_take; without that, 31,249 uploads in 200 s of Tears of the Kingdom instead of 19), and held the texture while a readback was pending (pkg_absorb_pending). A CPU write made between the pass's replay and the landing was kept out of the host texture. Measured on a bench (M4 Pro, warm caches), the landing alone took 159 µs a frame for Tears of the Kingdom's four targets at 4 bytes per texel (316 µs at 8), on the completion thread before the fence was published, without the protection calls and edge copies.
- Before (2026-11-10), the region was read back into a shared buffer and written into the storage on the CPU in the submission's completion handler (
- Open: not stored: block-compressed targets drawn through a shadow (
bcrt_attach; the shadow is copied back into the host texture only) and depth targets. - Check:
t_readbackclears a packaged texture through a render pass, reads the clear colour from its storage, checks that resolving it does not upload it again, then copies the storage to another address withmemcpyand reads the colour from a texture created there; without the store both reads fail. It also clears depth plane 2 of a packaged 3D texture and finds the colour in that plane of the storage, the others untouched, and clears a packaged texture with a texture clear and finds the colour in its storage;copy_streamchecks the hold: a CPU write while the command buffer holds a store keeps the host texture, and after the commit it is uploaded with the store in the storage. - Found on: The Legend of Zelda: Tears of the Kingdom on the Mac: meadows lost their grass and went dark within half a minute of loading; with the fix the grass stays through three minutes of standing and walking. Uploads of textures that changed in the pool, same script: 40 before, 449 after, almost all while loading; frame times the same (p50 about 18 ms).
- Symptom: after several area changes (dungeon entries and exits), terrain is covered in crossing stripes ("plaid"), tile by tile, and stays so until the game is restarted; loading a save alone does not bring it. A texture re-created over storage the game moved with
memcpycomes out zero although the GPU drew its source.texrawof the affected textures (Ocarina of Time: 260x260 RG8 normal tiles, format0xd, 9 levels) is all zero, and the log names themtexture written by the CPU before its first use … (uncompressed, in storage the GPU has not written)although the CPU only copied them. - 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 (GPU copies, render passes): Tears of the Kingdom's terrain normal maps, which a flush had made CPU-written. A texture that stays a GPU texture kept its drawing in the host texture alone, its storage holding what the CPU last wrote. Ocarina of Time has the same terrain defragmentation (
SeqExecuteDefragmentationForTexture; the block movesmemcpyinto the pool's mapping and re-create the texture) but does not flush its normal tiles: the CPU zeroes them, the GPU draws each of the 9 levels in a pass of its own, and they stay GPU textures. The defragmentation then copied zeros, and the texture made at the new address was taken from them (storage the GPU has not written): a zero normal. - Fix: every game. The GPU writes the storage of a texture the CPU can read as the hardware does: one the host uploads from its storage (
tex_storage_dst; the two entries above) and a GPU texture whose storage could be its content (gsb_targetinnvn_mtl.m:tex_storage_ok, so no depth, display, video-decode,COMPRESSIBLE, uncompressedIMAGE,LINEAR, multisample or buffer texture, in a pool the CPU can access and that is not virtual, and not CPU-written). After each render pass (rt_mirror_flush, every layer it draws), texture clear (clear_tex) or copy from a buffer or a texture (copy_b2t,copy_t2t; their region) into it, the region written is stored into the pool's buffer by theswzkernel (dm/gfx/msl/deswz.metal, the inverse ofdeswz): from the host texture through a linear area of the frame arena (gsb_store, the RGB565 family put into the guest's order there,rb_swap_gpu; a host format of the storage's texel size, of a block-compressed format a block-compressed one), or for a buffer-to-texture copy from the copy's source bytes (gsb_store_buf). Only the region's bytes are written, so GOB padding and a neighbour's bytes keep what they hold; block-compressed formats go by block rows, a 3D level's planes by its block depth, linear storage by its row stride. It is in the same command buffer, ordered by the copy encoder's barriers, so it lands before any fence after the write, as on the hardware, and a later write or copy in the submission comes after it. A GPU write is not seen by page protection: a GPU texture's storage stays the GPU's (gws_owned) and the texture a GPU texture (nvn_ft_poll,nvn_ft_written), a texture uploaded from its storage keeps its host texture, and amemcpyof it by the game writes the destination, which is then taken from its storage. An upload batch runs after every command buffer committed before it and before the one being encoded, so while that one holds a store into a texture the texture keeps its host texture (gsb_hold/gsb_held, cleared asg_cbgencounts the commit).DM_NVN_GPU_STORAGE=0turns the stores off (to measure). The log saystexture in storage the CPU can read ser=… : what the GPU wrote is written into its storage on the GPUfor the first four.- A first version (PR #2077) read the region back on the CPU when the submission completed and, to bound the cost of targets drawn every frame, held a level written in two nearby frames until two frames passed without a write. A copy of the storage within that time got the older content, and the late landing needed guards against storage the game had reused meanwhile (its span, CPU-write stamps, a compare after writing, a restamp under the registered textures' lock). Written on the GPU, every write is in the storage when its fence signals, and none lands later. Textures uploaded from their storage moved onto the same store on 2026-11-10 (their CPU readback, its absorb tables and the copies' CPU mirror removed).
- Open: not written into the storage: three-component formats (no host texture), a host texture of another texel size (
RGB10A2integer as float) except through a buffer copy, depth targets,COMPRESSIBLEor uncompressedIMAGEtextures, and what a shader writes through an image. A target drawn in many passes of a submission is stored after each pass; the cost is the region's bytes twice on the GPU (a 1920x1080 RGBA8 level is 8 MB). Ocarina of Time has not been run with the store written on the GPU (its build's libraries are of an older chain). - Check:
make -C dm/gfx/tests GAME=<id> check_t_cpu_storage: a cleared GPU texture's storage holds the clear and the texture stays the GPU's; a texture made over amemcpyof it holds the clear; writes in consecutive frames are each in the storage when their submission completes, and amemcpyright after the second gives the second; a cleared region writes only its texels; 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; an image's clear is not stored; a texture smaller than a page is stored; every level of a 260x260 RG8 tile with 9 levels (rows of 520 bytes, smaller block heights in its small levels) is stored, every texel.check_t_readback(gpu_storage): a render pass, a texture copy and a texture clear into GPU textures of aCPU_CACHED | GPU_CACHEDpool reach their storage, nothing is uploaded afterwards, andReadTexelsof a texture made over amemcpyof the drawn one returns the drawing;store_kindsandt_nvn6c3 / 6c4 cover block-compressed, 3D and padded-stride stores byte for byte (GPU copies). WithDM_NVN_GPU_STORAGE=0, 33 oft_readback's checks and 9 oft_cpu_storage's fail. - Found on: The Legend of Zelda: Ocarina of Time (GitHub issue #1865) on the Mac (M4 Pro, 2026-11-09). A 32-byte tag written into each drawn normal tile's storage was later found at another address 172 times in one play session with dungeon entries and exits (the copies; 47 more stale in place), the earliest 12 frames after the tile was drawn, half of them 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' sampler 3 among them. Before the change, a count of what the rule would read in a route of one load and three reloads (8,500 frames): only those tiles, 1,099 level readbacks in 20 frames, 72 levels and 1.4 MB in the worst; no other texture of the game is reached (its render targets lie in a pool the CPU cannot access). With the first version (the CPU readback), a play session with dungeon entries and exits (7,800 frames) showed no stripes: 85 normal tiles were made from storage the game had copied, none of them with an empty level 0 (153 such copies held only zeros before), no landing was refused, and of the 44 registered normal tiles one host texture was all zero, a leftover object whose storage two newer tiles had taken; frame times as before. Written on the GPU (2026-11-10, M4 Pro, main at 6c676444 with the change): Minecraft Dungeons II, title to first control in Squid Coast, with
DM_NVN_GPU_STORAGE1 and 0 in turn, three pairs: 465-479 regions (116-125 MB) in the first 600 frames (loading), about 280 regions (47-48 MB) entering the level, none once playing; 60 fps in play with either; the level's load window (frames 2100-2400) at 48.7, 55.5 and 49.7 fps with the store and 53.1, 52.4 and 47.9 without, the Mac busy with other builds; the host's CPU share in play higher with the store in two pairs and lower in the third; the same picture.
- Symptom: the
[nvn] residency set up to frame …line shows hundreds of additions frommirror_readback's line innvn_mtl.mevery 300 frames, and as many removals, with the residency commits' time up. Tears of the Kingdom: about 1,500 every 300 frames on an iPhone 17 (1.4.2, seedds/nxnative#931), about 370 on a Mac. - Cause: each readback into a texture's storage made a shared buffer of its size, put it into the residency set, and released it after its submission, which took it out again: a residency commit each way, for about five render passes a frame in Tears of the Kingdom.
- Fix: resolved for good on 2026-11-10: what the GPU writes into a texture's storage is stored there on the GPU (GPU textures in storage the CPU can read) through the frame arena, and no readback buffer is made. Before,
mrbuf_get/mrbuf_putkept landed readback buffers by power-of-two size (4 KB and up, at most 16 MB) for the next readback. - Check:
nvn_mtl.mhas no readback into storage left; the[nvn] residency setline has no additions from it. - Found on: The Legend of Zelda: Tears of the Kingdom: its four CPU-written render targets (256×256, 192×192, 96×96, 500×400) are read back every frame. On the Mac (Apple M4 Pro, 2026-11-03, the regression route) 390-418 buffers every 300 frames on
mainat12915a31, none with the fix; the residency numbers of both fixes together are under Snapshot textures taken by format and size.
- Symptom: textures that should hold CPU-written data come out magenta (ASTC's error colour) or black.
- Cause: textures in the sparse virtual pool hold what the CPU wrote into physical pages before mapping them: the streamed textures (flag 0x20, packaged layout) and plain sampled ones with no packaged layout. The replay took the plain ones for GPU textures, so their Metal copies stayed 0.
- Fix: a sampled texture in a sparse pool (not a render target, image, linear or buffer texture) is now uploaded like a packaged one, through the pool's page table (Textures read through a 4 KB page table of a sparse pool).
- Found on: Monster Hunter Rise: the hunter's innerwear (ASTC 6x6) came out magenta and the face black.
- Symptom: the submit thread spends most of its time in
nvn_dirty_sinceundernvn_sparse_since, called fromtex_mtl(render-target and texture lookups of textures in a sparse pool). - Cause: a sparse texture is checked for CPU writes by walking its range of the pool's page table and asking the dirty tracker about each run of physical pages. The check was skipped only when nothing had been written anywhere and nothing remapped since the last one; a game that streams pages remaps every frame and writes some page between nearly every two draws, so every lookup walked again. Each run then searched every registered range (
find_span; about 4,300 in a Mario Kart World race). - Fix: a texture is checked once per submission: a submission sees the CPU writes and the mappings made before it, as NVN's (a map is a CPU call, not a command), so a second check in the same submission can only find writes the GPU work need not see. A skipped check leaves the write and mapping generations of the first, so what another thread writes or maps during the replay is found at the next submission's check. The walk keeps the last range it found (
nvn_dirty_since_cached). - Found on: Mario Kart World 1.0.0 (#900; its terrain render targets are sparse, up to 16384x16384): in a race on an M4 Pro, translation 9.7 → 6.25 ms a frame, frame time p50 18.2 → 16.66 ms and p95 23.4 → 17.0 ms. Measured on its fork, where those targets are Metal placement-sparse textures; the check and the walk are the same here.
- Symptom: some textures are re-uploaded every frame (several MB per frame).
- Cause: the dirty-page tracker registers each memory pool rounded to 16 KB host pages, but NVN pools are only 4 KB aligned, so neighbouring pools share their first / last host page and one address can lie in several registered ranges. Taking the first range that contains an address judged textures in the second pool untracked (always dirty), released only one range on a write fault, and could unregister the neighbour when a pool was destroyed.
- Fix:
nvn_dirty.cnow uses the range that holds the whole span, releases every range covering a faulting page, and removes a range by the pool's exact start. - Found on: the source names no game.
- Check:
t_dirty_slotscovers it.
- Symptom: a CPU-written texture stays stale although its storage changed while the host made a zero-copy pool buffer.
- Cause:
nvn_dirty_openhashed and opened each pool range in turn. Adjacent 4 KB-aligned pools can share a 16 KB host page. Opening the first pool made the second pool's bytes writable before the second pool's hash was taken. A CPU write between those steps changed the first range's hash, but the second range's before/after hashes matched, leaving its texture clean with stale content. The race reaches only the host pages two pools share (a pool's first or last page): a write that also touches a texture's unshared pages faults there and marks it written, so only a texture, or a write, that lies wholly in a shared page is missed. - Fix: take and hash all protected ranges before making any of their pages writable. Closing still re-protects the pages and stamps only changed content; unchanged pages do not force texture uploads.
- Found on: a synthetic reproduction while investigating Ocarina of Time issue #2086. Open: that issue's stale terrain map spans about 33 host pages, more than this race can reach by itself, so its cause is not found; one M2 Max run of 8 minutes saw Goron City's strobing ground stop with the fix, with no miss counts (PR #2136).
- Check:
t_dirty_raceplaces two adjacent pools in one host page and makes a real store to the second pool immediately after the first becomes writable. Without the fix its texture is incorrectly clean; with the fix it is dirty. Opening and closing without a write stays clean. The existing fault, host-write, busy-page and pool-retirement tests also run without game files.
- Symptom: packaged textures re-uploaded at every use with unchanged content; upload staging and frame times rising.
- Cause: the game keeps each texture of its models in a memory pool of its own, the pool's storage 4 KB after its own object, in the same 16 KB host page, and writes there every frame. The dirty-page tracker marks whole host pages, so every such texture counted as changed at each use; hashing showed every re-uploaded content unchanged.
- Fix: a packaged texture keeps the bytes of its storage that lie in its first and last host pages when they are shared (
pkg_edges_take); when only those pages were written and the bytes are as uploaded, it is protected again instead of re-uploaded (pkg_edges_clean, counted in thepackaged texturesline aswrites beside a texture only). A GPU store into the storage (GPU textures in storage the CPU can read) changes the texture's bytes in those pages without a page fault, so from a texture's first store on its edge bytes live in a shared buffer (pkg_edges_gpu) and theswzkernel writes each byte it stores there as well, in the same order: the bytes compared are those the host texture holds. Kept from the upload only, the next write beside a stored texture uploaded it again: 17 small textures in two minutes of Minecraft Dungeons II (2026-11-10), and a full-screen target beside a page written each frame would be uploaded each frame. - Found on: Pokémon Sword: 73,232 re-uploads in 70 s from a save, 12 GB of upload staging in 8 minutes, frame times rising to 75 ms; the same 70 s with the fix: 0 re-uploads and 72,584 such checks.
- Check:
t_nvn6c1 covers it;t_readback(copy_stream) covers the edge bytes after a store.
- Symptom: with
tex_dirty_upload, textures the CPU never writes (window textures, render targets) are logged asCPU-written texture …orregistered texture written by the CPU after its first use: uploaded from its storagesoon after boot. The present thread then re-uploads a window texture from its storage, and the host aborts:[RED] upload batches belong to the submit thread only: up_begin called on thread 'nvn-present', withpkg_upload←tex_mtl_←present_nowon the stack. - Cause: a registered texture (
ft_addinhle_nvn.c) counted as written by the CPU when any of its host pages was written since its stamp, at first use (nvn_ft_written) and at each poll (nvn_ft_poll). Its storage need not start or end on a 16 KB host page, so its first and last pages are shared with whatever lies beside it, as for packaged textures. A texture whose flags leave it out of the first-use rule (tex_storage_ok: display, compressible) and that has no host texture yet then fails the usage test too, and is made CPU-written. - Fix: every game with
tex_dirty_upload. A registered texture keeps 64-bit hashes of its bytes in its first and last host pages, taken right after its stamp and protection (ft_edges_take; dropped when a write reached them meanwhile). When only those pages were written and the hashes match, it is stamped and protected again instead of taken as written (ft_written, for both the first-use check and the poll; logged asregistered texture: only the pages it shares with other data were writtenfor the first four). A texture left to the GPU by the poll (MTL_REWRITE_GPU_NOW) takes new hashes with its new stamp. The storage is the whole edge when it lies within two pages. Hashes, not copies as for packaged textures: Tears of the Kingdom registers more than 9,000 textures at boot, up to 32 KB of edges each. - Check:
make -C dm/gfx/tests GAME=<a tex_dirty_upload profile> check_t_cpu_tex(poll_beside): writes beside a texture 0x200 past a page, in its first and last pages, leave it registered and not CPU-written at the poll and at first use; a write of its own bytes in its last page, or of a page wholly inside it, is still taken as written; aCOMPRESSIBLEtexture resolved as a render target stays the GPU's after a write beside it. Without the fix 4 checks fail. - Found on: The Great Ace Attorney Chronicles (2026-11-07, on main since at least
e2b76d4e~1): the abort about a second into boot. Its three window textures (1920x1080 RGBA8, flagsDISPLAY | COMPRESSIBLE) and its 1920x1080 render targets lie in its 1000 MBCPU_CACHEDpool at 0x200 past a page, beside textures it fills from the CPU (a 256x256 one shares the first window texture's last page). A temporary log at each poll hit showed no page wholly inside any of the 20 textures written: every 1920x1080 one was made CPU-written at the first poll.
- Symptom: about one run in four stalled early (around the opening movie or the title), with no log output, not even the time-limit report.
- Cause: two candidates. First, the stalls happened before the real thread pause for IL2CPP's GC (Pausing threads and reading their context for garbage collectors): without the pause the collector ran while other threads kept changing the heap. Second, fixed later: a race in the dirty-page tracker (
nvn_dirty.c, ported from d2r-mobile) could leave a page read-only while marked writable, so a game thread writing it spun forever in the fault handler; Engage's movie decoder writes frames into texture storage from a host thread while the replay thread protects pages. - Fix: the thread pause for the GC, and the tracker race fixed;
tests/t_dirty_race.creproduces the race. - Found on: Fire Emblem Engage: probably fixed; since the pause, 12 runs (eight of them 200 s runs through the movie and title, four of 330 to 850 s) have not stalled once, where one in four used to.
- Note: if a stall comes back, sample the process when its log stops growing (
sample <pid> 3) to see where every thread waits. The crash handler writes one line withwrite()and exits if the stderr lock stays taken for 1 s, so a stall that holds that lock shows up as such.
- Symptom: a CPU-written texture stays stale after later writes; letters can be missing from a glyph cache.
- Cause: a protect skipped a page another thread was unprotecting, leaving it writable with a generation already covered by the upload stamp. Taking the stamp after protecting, or setting the cached last-check generation after the upload, could also cover a fault whose bytes arrived after the read. Separately, an unprotect advanced the global clock before publishing the page generation: a checker could cache a clean result in between and never scan again once the page became writable. Both the page-fault path and batched host pre-write path had that publication window.
- Fix: take the stamp before protecting and reading, and start the last-check generation at that stamp. Finish any pending protection run before waiting for a BUSY page, then retry that page. Both unprotect paths use
stamp_pageto publish the page generation and advance the global clock once more afterward, invalidating checks that ran before publication. These are general tracker and cache rules, with no game-specific gate. - Found on: Tears of the Kingdom, reported in MacNativeGames commit
a166602and ported through PR #197. The missing-letter symptom has not been reproduced in nxnative; the tracker failures are reproduced locally by the regression tests. - Check:
t_dirty_racecovers the BUSY-page retry, stamp ordering, and a check between clock allocation and generation publication in both unprotect paths. The publication cases fail on main; the batched case also fails with the original PR applied.
- Symptom: a host system call writing into storage after its NVN pool was finalized fails with
EFAULT; a direct write can fault after the tracker stopped owning the page. - Cause:
nvn_dirty_protectfound a live range without holding the slot mutex. Pool retirement could restore its pages to writable and mark the range dead before protect changed the page state to BUSY. Protect then set the retired storage read-only, with no live range left to release it. In the same window a new pool could reuse the slot, and protect would then compute its pages from the new pool's base and state array. - Fix: hold the pool-slot mutex across protect's lookup, page-state transitions and final
mprotect. Pool creation and retirement already hold that mutex. The page-fault and host pre-write paths still take no slot mutex, so they can finish a BUSY transition while protect waits. - Found on: code review of the shared tracker, from a fork's compatibility commit
587d5206ported through PR #686. No game is known to hit it: a game normally finalizes a pool only after the GPU is done with it. - Check:
t_dirty_retire_raceretires the pool between protect's lookup and its first page-state exchange, then reads 16 bytes into the pool withread(). Without the fix, retirement completes first and the read fails withEFAULT; with it, retirement waits for protection, unregisters the pool and the read returns 16.t_dirty_raceandt_dirty_slotscover page-state publication and shared / reused pool slots.
- Symptom:
[RED] NVN unimplemented function called: nvnSamplerBuilderSetBorderColoruiduring renderer initialization. - Cause: only the floating-point setter was implemented.
- Fix: implement signed and unsigned integer setters with numeric conversion into the sampler's border-colour state. Metal's existing selection among transparent black, opaque black and opaque white then sees integer zero and one correctly; bit-casting integer one to float would select the wrong colour.
- Found on: FINAL FANTASY X/X-2 HD Remaster, launcher and both games.
- Check:
t_nvninvokes both setters through the real entry-point lookup and checks initialized sampler state.
- Symptom:
[RED] sampler ID 4096 not registered(or another ID past the sampler pool), once, from the first frames on. - Cause: NVN's texture, separate texture and sampler handles are 64-bit, but the driver writes each bound one into the c2 handle table as a 32-bit word (texture ID in bits 0-19, sampler ID in bits 20-31), so the GPU never sees the upper half. The host kept the whole 64 bits of the handles of
nvnCommandBufferBindTextures,BindSeparateTextures,BindSeparateSamplersandBindImages, and took the sampler ID as the handle shifted right by 20. EA SPORTS FC 27 binds separate textures with bit 32 set (0x1000001d1, texture 0x1d1): a shader that takes its sampler from that combined word got ID 4096, where the GPU reads sampler 0. The singlenvnCommandBufferBindTexturerecord already read its handle as 32 bits. - Fix:
bind_handles(hle_nvn.c) keeps the low 32 bits of each handle before it moves the IDs to the part of the host's table of the pool the command buffer bound (which may then pass 12 bits). - Found on: EA SPORTS FC 27, at the title and in the menus.
- Symptom: device / subsystem setup returns quietly and the next stage never starts.
- Cause: a version or capability check failed (NVN API version, a feature query).
- Check: compare what the game queries with what the host answers. Two cases follow: The device's API version and Newest GPU code version reported by the driver.
- Found on: every game (general).
- Symptom: a null-pointer crash early in
nnMain, right after NVN setup should have happened (Super Mario 3D World + Bowser's Fury,main+0x759e08: the device singletonmain+0x1a28ea0is still null). - Cause: the game checks
nvnDeviceGetInteger(API_MAJOR / API_MINOR)for major == 53 and minor ≥ 313 (main+0x711c88) and quietly skips creating its device otherwise. - Fix: the reported version follows the SDK (
hle_nvn.cnvn_api: 53.313 for SDK 10 and older, 55.15 for later ones, Mario's requirement); the profile keydevice_apioverrides it. NVN 53 also bringsnvnQueueAcquireTexture(the older window acquire: the queue waits instead of a sync object),nvnSamplerCompareand the debug group calls (no-ops). - Found on: Super Mario 3D World + Bowser's Fury (SDK 10); Super Mario Bros. Wonder (needs 55.15).
- Symptom: null read at
main+0xb8462cright after the first two textures, called frommain+0x670eb0(Bayonetta 3), or cleanup crashes atmain+0x9c0d1cafter shader initialization fails (ASTRAL CHAIN). - Cause: not the fault itself:
nnMain's start-up (main+0x6705c0) returns false when any of its 30 steps fails, the game then skips its main loop and goes straight to shutdown, and the shutdown walks a pool (main+0x4efce70) whose initialization (main+0xb840d0) came after the failed step.DM_PROBEon each step's entry (lldb was unavailable: developer mode off) showed the last one entered,main+0x69c698, the built-in shaders ofshader2.dat(hwfillcolor,hwshaderpf, …), and probes inside it the step that failed: creating a shader from its object (main+0xd0d998). The engine checks each shader object's GPU code version (byte 6 major, byte 7 minor of the object) against the driver's range (nvnDeviceGetInteger0x2E–0x31: newest major, oldest major, newest minor, oldest minor) and refuses newer code; the host reported 1.14, the table measured on Super Mario Odyssey's SDK 7 driver, andhwfillcolor.vsois 1.15 (the game's objects run from 1.9 to 1.16). - Fix: report the translator's supported GPU-code range, through 1.16, independently of the linked SDK. Precompiled shaders can come from a newer compiler: ASTRAL CHAIN links SDK 6.4 but ships 1.15 objects, defeating the earlier SDK-based floor. Keep the additional floor from a GLSLC the game carries (
hle_nvn.c; that GLSLC check: GLSLC GPU code version against the driver's range).t_nvn.cchecks the advertised range even without a loaded SDK. - Note: a wider range only lets more shaders pass such checks.
- Found on: Bayonetta 3 (SDK 13.3.2, PlatinumGames' engine); Catherine: Full Body (its GLSLC 8.2.1 set the earlier SDK 8 value); Super Mario Odyssey (the table's 1.14); ASTRAL CHAIN (SDK 6.4, precompiled GPU code 1.15).
- Symptom: none seen: a Switch 2 game asking
nvnDeviceGetIntegergot the Tegra X1's values (measured on a Switch 1), and items past 0x5F answered 0 with a red line. - Cause: NVN2 is compiled into the game's own
sdk, and its answers differ: the procedure table'sDeviceGetIntegercalls a switch over the items (Donkey Kong Bananza's sdk 20.5.19:sdk+0x5d9960, items 0 to 0xA9, the default 231) that falls back on a table lookup (sdk+0x5c0a20). The item numbers are NVN's (API 231.30 at 0 / 1, GPU code 201.0 to 201.17 at 0x2E to 0x31, 201 = 0xc9, the version of its shaders' control sections). Ampere's differ from the X1's in 32 vertex bindings and attributes, 1,024 compute threads, an unbounded dispatch width, 16 texture levels, 16 KB scratch granularity, and 2 KB shader code padding (both answer 32 separate sampler bindings), and items 0x69 to 0xA9 are new. Seventeen items come from the device's own GPU description at run time (scratch scale factors, L2 size, texture layers and sizes, linear stride alignment, pool page size and others), which the binary cannot give. - Fix:
g_devinfo2(hle_nvn.c), taken for an SDK with the Switch 2's spec (dm_sdk_ounce): NVN2's constants, read statically, except vertex bindings and attributes, which stay 16, all the host's tables hold. The run-time items take the X1 table's value where it has one and otherwise answer 0 with a red line. - Found on: Donkey Kong Bananza: plays as before with the new values (a 6,900-frame run at 60 fps, no red line).
- Note: the switch's own constants move with the SDK (API 231.30 and GPU code minor 17 in 20.5.19; 231.39 and 20 in 21.4.4), and games check them: Splatoon Raiders requires minor 39 or more and otherwise creates no graphics system (
main+0x31b86b0). So the host reads them from the game's ownsdk(dm_sdk_nvn2_item,loader.c), andg_devinfo2holds the newest seen for what it cannot read.
- Symptom: a SIGSEGV on the game's
Rendererthread a few seconds into loading, before the first frame, atmain+0x1aa90ac(Frostpunk 1.0.1): a virtual call through an object whose first words are floats (±1.0, a quad's vertices), with the slot argument 64. - Cause:
nvnDeviceGetIntegeranswered 4096 for item 0x5F,SEPARATE_SAMPLER_BINDINGS_PER_STAGE, a guess from the sampler pool's size: the table measured on a Switch (Super Mario Odyssey's SDK) ends at 0x5E, and the host answered 0 with a red line for 0x60 to 0x68 but 0x67. Frostpunk's renderer keeps a binding table of 32 slots per stage, takes its slot count as min(0x5E, 0x5F) (main+0x1aa68d4, then min(128, 4096) = 128) and clears that many slots each frame (main+0x762f50), so it wrote 96 slots past the table and released whatever object pointers lay there. - Fix:
g_devinfo(hle_nvn.c) takes 0x5F to 0x68 from the Switch's own driver. The NVN linked into a game'ssdkanswers the items it does not read from the device from a table of constants, items 2 to 0x68 and 0 past them (SDK 19.3: Metroid Prime 4'ssdk+0x4a3cb0, Pokémon Legends: Z-A'ssdk+0x4a3820, read statically; both alike, and they agree with the measured items). 0x5F is 32, as NVN2's; 0x60 to 0x68 are 65535, 1, 32 (0x62: the driver reads it from the device,+0xbc, whichnvnDeviceInitializecopies from the GPU description the system gives,+0x228(sdk+0x4b7f30,sdk+0x4b34e4); the table's 32 is the X1's, as on 0x2B, 0x2D and 0x38, where the table and the device both answer and the table holds the measured values), 256, 16, 65535, 65535, 256 and 16383.t_nvn.cchecks 0x5E, 0x5F and the new items. - Found on: Frostpunk (
0100B1C0249B4000, SDK 20.5.19, 11 bit studios' Liquid engine): the Mac.
- Symptom: a jump to pc=0 right after the NVN bootstrap loader (
lr=main+0x37fe930,[RED] NVN address lookup of an unknown name: nvnSetExternalDebuggerEnabledNVX). - Cause: the game fetches an NVX extension of the SDK 8 driver by name and calls it without checking the pointer. Unknown names return null, as the real driver does for a name it lacks; this one the driver has.
- Fix: it joins
dm/gfx/nvn/names.txt(thunks regenerated withgen_protos.py; no prototype innvn.h, soNVN_NOPROTO) as a no-op. - Found on: Super Smash Bros. Ultimate (13.0.5).
- Symptom: SIGBUS in
AGCDeserializedReply, address in the guest stack region. - Cause: the render thread's stack is small, and Metal's driver compiles internal programs (a fast-clear blit) on the calling thread while the host ends a render pass inside
nvnQueueSubmitCommands, which overflowed the guest stack. - Fix: all implemented NVN functions except the
nvnCommandBuffer*recording calls run on the per-thread 512 KB host stack (nvn_big_entryinnvn_entry.S). The general mechanism (dm_bigstack_enter, which everyHLE_FORCEtake-over goes through): Host calls on small guest stacks. - Found on: The Legend of Zelda: Breath of the Wild.
- Symptom: red
NVN unimplemented function calledlines fornvnColorStateGetBlendEnable,nvnBlendStateGetBlendFunc,nvnPolygonStateGetCullFace,nvnDepthStencilStateGetDepthTestEnable,nvnBufferBuilderGetSize,nvnTextureBuilderGetWidth, …. - Cause: Trinity builds its pipeline states from state objects it reads back; the red-reporting stub returned whatever was in x0, the object's address, so a blend enable or depth test read as on and a texture builder's width as an address.
- Fix: the getters read the packed state objects (
hle_nvn.c); the multisample setters the host lacked (alpha-to-coverage dither, raster samples, coverage modulation and coverage-to-colour) are recorded in fieldsms_stalready had. - Found on: Pokémon Legends: Z-A (19.3.11, Game Freak's Trinity engine; one boot).
- Fix:
nvnDeviceGetWindowOriginModeandnvnDeviceGetDepthModewere red stubs: the game sets neither mode and reads both.nvnDeviceGetTexelFetchHandlereturns the texture ID with sampler 0. - Found on: Pokémon Sword (SDK 7: the window origin and depth modes); Monster Hunter Rise (SDK 11:
nvnDeviceGetTexelFetchHandle).
- Symptom: an abort in device setup when
nvnTexturePoolGetSize/nvnSamplerPoolGetSizereturned 0. - Fix: implemented:
nvnTexturePoolGetSize/nvnSamplerPoolGetSizeand their memory pool / offset getters;nvnCommandBufferFenceSync, which Unreal's RHI records in its command buffers (Fence syncs recorded in command buffers);nvnTextureGetTextureAddress(pool address + offset, asnvnMemoryPoolGetBufferAddress);nvnCommandBufferGetCommandMemorySize(the bytes the game last added, as in NVN:GetCommandMemorySizeread while another thread records;GetCommandMemoryFreeis what is left in the current segment). - Found on: Octopath Traveler 0 (the pool getters and
nvnCommandBufferFenceSync, new with Unreal); DRAGON QUEST XI S (Unreal Engine 4.18:nvnTextureGetTextureAddress,nvnCommandBufferGetCommandMemorySize).
- Symptom:
[RED] NVN unimplemented function called: nvnTextureBuilderGetMemoryOffset, thennvnTextureBuilderGetRawStorageClass. - Cause: the renderer reads a texture builder's storage back before it initializes the texture; the host had the texture's getters (
nvnTextureGetMemoryPool/Offset/RawStorageClass) but not the builders'. - Fix: the texture, buffer and event builders return the pool and offset
SetStoragestored (the pool getters return its address, asnvnTextureGetMemoryPooldoes; the header the prototypes come from declares the builders' by value, so they areNVN_NOPROTO), and the texture builder's raw storage class is the texture's (1): it only names the page kind a virtual pool maps (NVNmappingRequest.storageClass), which the host's virtual pools do not distinguish. - Found on: EA SPORTS FC 27 (SDK 22, Frostbite's NVN renderer NRO).
- Symptom:
[nvn-mtl] FATAL buffer copy address is in no memory pool src=0x70d08a8b00 dst=0 n=0x29a00as a match loads: a recordedCopyBufferToBufferwhose destination is 0. - Cause: NVN maps a buffer or pool created with
NVN_MEMORY_POOL_FLAGS_CPU_NO_ACCESSto NULL, and the game depends on it: Frostbite's renderer (OpcodeCopySubresourceinEngine.Render.Core2.PlatformNvn.nro) takes a buffer's GPU address asnvnBufferGetAddress + (its data pointer − nvnBufferMap), with a null data pointer for a GPU-only buffer. The host's storage is CPU memory, and itsMapreturned the same pointerGetAddressdoes, so the sum came to 0 (traced to aBufferMapof a pool with flags0x21, CPU_NO_ACCESS | GPU_CACHED, right before the copy). - Fix:
nvnBufferMapandnvnMemoryPoolMapreturn NULL when the pool has no CPU access, as in NVN (hle_nvn.c). The host's own accesses to the storage go through the pool, not throughMap. - Found on: EA SPORTS FC 27 (SDK 22): the first match load.
- Symptom: red stub lines for NVN calls.
- Fix (
hle_nvn.c):nvnDeviceSetIntermediateShaderCache.nvnCommandBufferDiscardColor: a discarded target is undefined, so keeping its content is correct.nvnWindowGetCrop: read back every few frames; before anySetCropit is the whole first window texture.nvnSamplerBuilderGetLodClamp.
- Found on: The Great Ace Attorney Chronicles (SDK 10.4.1: the first two), Bayonetta 3 (SDK 13.3.2: the two getters).
- Fix:
nvnDeviceRegisterFastClearDepth, a hardware hint, returns success instead of reaching the red stub. - Found on: The Legend of Zelda: Tears of the Kingdom.
- Symptom:
nvnWindowGetPresentInterval,nvnCommandBufferSetCopyImageStridemissing. - Fix: implemented (the latter is the layer stride of texture ↔ buffer copies).
- Found on: The Legend of Zelda: Link's Awakening (SDK 8.2.0).
nvnTexturePoolRegisterImagewrites the same table asRegisterTexture(image handles are pool indices).- Found on: Hyrule Warriors: Age of Calamity.
- Symptom:
[RED] texture bound to an image slot lacks the shader-write usage, and compute image writes go to a dummy texture. - Cause: Metal usage came only from the texture builder's
IMAGEflag. A render texture may instead have onlyCOMPRESSIBLE, then its descriptor be used in a shader image slot. Snowdrop writes these descriptors withRegisterTexture, notRegisterImage. Its depth-conversion compute shadercf5ba107e29b51f4gathers from a depth texture and unconditionally writes four pixels per invocation to a 256×256 R16F texture whose flags are0x8. Its Metal texture had read / render-target usage (0x5), so the image binding was rejected. - Fix: before a draw or dispatch uses a writable image,
image_prepareresolves it with image intent and ensures its backing allows shader writes.RegisterImagealso keeps this intent in its host snapshot; neither path changes the guest texture. A texture already rendered into is upgraded throughtex_upgrade: end its render pass first, copy every mip / slice, replace all shared serials and invalidate cached sampled views. Load-only image slots accept read-only storage without upgrading, and ordinary render targets retain their original usage.t_image_usage.mexercises both registration entry points, a late registration while a render pass is open, the clear and other mip surviving the upgrade, and a GPU image write read through a sampled alias. - Found on: Mario + Rabbids Sparks of Hope (SDK 13.3.5, Snowdrop).
nvnCommandBufferBindTextures(the plural ofBindTexture),nvnSamplerPoolRegisterSamplerBuilder(a sampler registered from its builder),nvnTextureWriteTexelsStrided(WriteTexelswith row and image strides) and device item 0x67, which the game reads before every program it loads and uses as the alignment of its copies of the shader (256, the driver's own constant: Separate sampler bindings per stage).- Found on: FINAL FANTASY TACTICS - The Ivalice Chronicles.
nvnPrivate0, a new SDK 20 import, needs nothing: it is a flag insdk's bss that enables an inlined command-buffer fast path when the NVN driver sets it (it stays 0). Found on The Legend of Zelda: Breath of the Wild (1.9.0, SDK 20.5.22).
- Symptom:
[RED] NVN unimplemented function calledfor each. - Fix: the builder's offset is the texture's (
view_offset). The compare is true when the two views set the same fields to the same values: that true means equal is taken from the name, as no caller inmainwas found to confirm it. The blend flag is kept for its getter; the fixed-function factors the replay uses read a normalized target as it is. - Found on: Kirby Air Riders.
- Symptom: a pool walk makes gigabytes of textures from far fewer images: Ocarina of Time's 32,768 IDs name 3,607 storages, and one texture per ID came to 2 GB.
- Cause: a packaged texture belongs to its serial, and the engine registers the same storage under many texture objects.
- Fix: general (
nvn_mtl.mg_pkg_share,tex_alias_key,pkg_shared). Packaged textures are shared by storage, layout and format through a weak table, so a new serial of the same storage takes the existing one. A pool's removal evicts its keys (mtl_alias_drop_pool), and the walk's budget does not count a texture that is already shared. - Found on: The Legend of Zelda: Ocarina of Time.
- Symptom: after 15 to 45 minutes of play, a segment command buffer fails with
kIOGPUCommandBufferCallbackErrorPageFaultand every later submission is refused (SubmissionsIgnored): the picture freezes, or turns pink, while the audio goes on. WithDM_RES_CHECK=1,missing residencylines name 260x260 textures before the fault. - Cause: the serials of one storage share one packaged texture (previous entry). At the GPU's first write over that storage (
cpu_unc_drop), each of them, live or finalized in the ghost ring, gets the package's texture itself (tex_put), and no table owns it. The package's release (NvPkg'sdealloc) took it out of the residency set at once, and so did the release of any one holder (tex_put's ghost-ring eviction,serial_put,serial_del), while the other serials still drew with it. A command buffer reading it then faults. Retiring an object meant taking it out of the set, whoever else held it. - Fix: every game (
nvn_mtl.mdefer_drain;nvn_owner.mown_retire_locked,g_ghost_held). A retired resource that a serial entry still holds, ing_by_serialor the ghost ring (both counted, with each view's parents), keeps its residency and waits in the resource owner: the queue's fence gives completion, the owner's answer gives eligibility. The last count's release hands it back to the queue, behind the submission then in flight; for a view's parent, whose entry's own drop retires only the view, that hand-back is what retires it. The package's release goes through the same deferred release. A re-upload doesn't write into a spare that serials still hold. The sharing table's storage takeover no longer scans the ghost ring for that case, since the same rule covers it. The memory report counts the waiting textures (retired but held by a serial).t_cpu_tex --metal(handover_holders) hands a package over to two serials and drops the package, then each serial, the last through the ghost ring;retire_after_completionholds a texture through a view's entry and checks both conditions in order (resident before its fence, waiting after it, handed back with the entry's last submission, out after that one);t_serial_ownerchecks eligibility for live, ghost, pinned and shared holders and the hand-back alone.retire_across_threadsruns the drain and the fences on one thread while the other puts and removes entries (the texture, views of it, a ghost entry) 4,000 times with one view's entry holding it throughout: it never leaves the residency set, and once that entry goes it does (also under TSan; with every retirement taken as eligible it fails). - Found on: The Legend of Zelda: Ocarina of Time (GitHub issue #1326; diagnosis and A/B in PR #1644), v131072 on a Mac mini M4, 16 GB. The faults came at 44, 29, 24 and 15.5 minutes. A diagnostic that records where each object left the residency set named the missing textures before two faults: 260x260 terrain tiles handed over this way, released by the ghost-ring eviction. PR #1644 kept them in a table until their pool went: 107 minutes with no fault, against a fault at 37 minutes without it.
- Symptom:
CRASH sig=11inclass_conformsToProtocol(_class_conformsToProtocol_unrealized_nolock) on a Metal completion handler's thread,bt: class_conformsToProtocol -[NSObject conformsToProtocol:] defer_drain __mtl_sub_commit_block_invoke, with a garbage class pointer in x0. - Cause:
defer_drainruns on the guest submit thread and on completion handlers at once. When it could not takeg_smu(the previous entry's check), it put the retired resources back ong_deferredand only then went over its whole list again withconformsToProtocol:, to keep the rest. The other drain could take those entries back in the meantime, release their objects and cleard->object. A message sent to such an object's freed memory crashed in the runtime. - Fix: every game (
nvn_mtl.mdefer_drain). Each entry is sorted once, before anything goes back to the queue, and the busy branch drains only the entries it kept. - Found on: ROMANCE OF THE THREE KINGDOMS XIV, in its main menu about 80 s after boot, on its first run on the Mac (host from
bd979ced); the code came with the previous entry's fix (fa9cfb84).
- Symptom: thousands of first uploads a frame in a scene whose textures do not change: Ocarina of Time, about 15,000 a frame.
- Cause: the engine copies an
NVNtextureby value and finalizes the copy, while the original stays in use. The host keyed the texture by serial, so finalizing the copy deleted the host texture of the original, and its next use uploaded it again. - Fix: general (
nvn_mtl.mmtl_tex_release). The registry keeps the address of the object that was initialized, and a finalize is ignored when that owner still holds the texture's magic and serial (read withvm_read_overwrite, as the owner's memory may be gone).t_cpu_texchecks both orders. - Found on: The Legend of Zelda: Ocarina of Time.
- Symptom: the host stops with
NSInvalidArgumentException: -[NvPkg buffer]: unrecognized selector sent to instanceundertex_by_id(viapool_entry,pool_gpu,encode_args_,draw_,mesh_tasks_draw); not a GPU hang, nokIOGPUerrors. It came after minutes of play, right after a save or at a scene change. - Cause:
nvnMemoryPoolFinalizeon a game thread dropped the pool's textures from the CPU-written set (cpu_tex_drop_pool) before it marked the pool's texture IDs gone underg_rmu(treg_drop_pool_views). A pool walk on the submit thread holdsg_rmuand visits every registered ID; one of that pool's IDs reached in between was not gone, its pool's magic was still set, and its texture was no longer CPU-written, sotex_mtl_skipped the packaged path and returned the texture table's entry as it was: theNvPkgwrapper of the upload, not anMTLTexture.tex_by_id's first message to it threw. - Fix: general (
nvn_mtl.mmtl_alias_drop_pool). The IDs are marked gone first: a walk holdingg_rmueither finishes while the classification is still there or finds the ID gone and takestex_made, which unwraps theNvPkg.t_cpu_tex --metal(finalize_during_walk) finalizes a pool on another thread while it holdsg_rmu: with the old order the classification was gone andtex_mtlhanded out theNvPkg. - Found on: The Legend of Zelda: Ocarina of Time, from two players' reports on PR #1299 and PR #1420 (an M3 Max 412 s into Kokiri Forest after a save; an M4 Pro in the Deku Tree after about 40 minutes). PR #1420 found where the
NvPkgescaped.
- Symptom: after a GPU copy into one image of a texture uploaded from its storage, and a CPU write into another image of it in the same command buffer, the copied image comes out as the storage's old bytes through every texture object over that storage, while the storage itself holds the copy. The CPU-written image and the untouched ones are right.
- Cause: a texture object over the same storage and description as another takes the other's
NvPkg(pkg_shared), but the hold that keeps a stored texture from being uploaded again until the store's command buffer is committed (gsb_held) was keyed by the serial the store went through. A look through another serial after the CPU write found its pages written and replaced the shared host texture in an upload batch, which runs before the command buffer being encoded, so it read the storage without the store. The store then wrote the old host texture and the storage, and page tracking does not see a GPU write, so nothing uploaded it again. - Fix: general (
nvn_mtl.mgsb_hold,gsb_held). The hold is on the storage bytes: each store notes its texture's storage range for the command buffer being encoded, and a packaged texture whose storage overlaps one keeps its host texture until that command buffer is committed, through whichever object it is looked at. The CPU write is uploaded at its next use after the commit, from storage that holds the store.t_readback(copy_stream_alias) stores through one of two objects over a four-layer, three-level RGBA8 array, writes another layer on the CPU, and reads every image of both back on the GPU: before, both lost all 4,096 bytes of the copied level. Open: a texture object of another description over the same bytes (an sRGB twin) has a package of its own, which is now held too but never learns of the store: its next upload copies the stored image from its own host texture, which lacks it (the same test's sRGB run skips that image). A store would have to mark the other packages over its range for that image's upload. - Found on: no game; the synthetic test of PR #2182 (akratch), which found the serial key. No game artifact is attributed to it.