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.
- 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] 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:
[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:
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):nvn_dirty_hold(nvn_dirty.c) makes the protected pages of its range writable and counts them as written, so their textures are read and protected again at their next use, and holds off protection until the buffer is made. Protection and pool retirement wait meanwhile; the page-fault path takes no lock. Storage the GPU wrote would then count as the CPU's and its textures be taken from guest memory the GPU never wrote, so a span the CPU had not written since its stamp gets its stamp and protection back once the buffer is made. Open: a CPU write into such a span while the buffer is being made is not seen. An upload's own source buffer (up_big_src) is only read by the GPU and stays as it was. - 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. Not run here with this fix. - 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 page made writable counts as written and is tracked again once protected; and a chunk made over GPU-written storage leaves it the GPU's, and storage the CPU wrote since the CPU's.
- 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: in
nvn_mtl.m, for every game. A change to one serial's object bumpsg_ser_genthroughser_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), more than 64 notes, or any sparse mapping (nvn_sparse_gen) starts a new epoch, which re-checks every ID as before. 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 (
ser_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, not the texture, so that texture can go while the twin is live:tex_mtl_then finds the view stale and drops it (serial_del), a note naming the twin's serial. 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, a pool that grew, or a sparse mapping, the table is built whole as before. 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.
- 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: 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 512-entry table (
tex_put,g_ghost; the memory line'sfinalized textures still referenced). - Found on: The Legend of Zelda: Breath of the Wild (the HUD captures, the 640×360 buffers; found while fixing GitHub issue #9).
- 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. The host writes its records only to command memory and uses no byte of the game's control memory, so in NVN's terms (size = used + free) the whole block stays free. - Fix:
cmd_trecords the size of the lastAddControlMemoryblock and returns it from bothGetControlMemoryFreeandGetControlMemorySize;GetControlMemoryUsedstays 0.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:
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 the game re-grants memory overlapping the guest bytes of the recording that jumped into it (hostseg_releaseinAddCommandMemory): 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).
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 (plus 16 bytes for the jump). 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: the host now keeps 16 bytes (one JUMP), lets the END take a segment's last bytes, and links only recordings that have records, so a JUMP only stands where a record of 16 bytes or more was to go and never writes past what
GetCommandMemoryUsedreported (OP_RESERVEinhle_nvn.c;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 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: 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: 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) fences the sync at replay with the fence value of the submission segment being encoded, so it signals once everything before it has completed (the replay fences the sync with the submission that reaches it). - 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:
nvnQueueSubmitCommandsnow holds a lock across the replay (g_sub_muinhle_nvn.c). It 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 submissions finish 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: 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: 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 the host's-O1, 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_block_hstores byte 0 + 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. - 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 the GPU does not render or store into it (linear_cpu_only:LINEARwithoutLINEAR_RENDER_TARGETorIMAGE). 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.
- Limits: a linear render target or image that no view can take stays a private texture. Its first fill now 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). - 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 stays a GPU texture, filled at the stride. Each part is needed: - without the storage read, the reads come back empty;
- without
linear_cpu_only, the image is made CPU-written; - 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: 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.
- 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. 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: 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: 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: a copy 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 (nvn_tex_mirror_t2t/nvn_tex_mirror_b2tinhle_nvn.c, fromcopy_t2t/copy_b2tinnvn_mtl.m), when its source is a buffer or a texture whose storage holds its content; the write marks the pages, so the next upload carries the levels. When the source is the GPU's (a texture whose host texture, or the one its packaged record holds, can be rendered into or written by shaders,tex_gpu_touched; a buffer a copy, fill or update queued in the submission, a shader or a copy of a submission still running wrote,pend_hits/sbw_hits/gw_live), when a readback into the source or the destination is still pending (mrb_pending: the storage lacks those bytes until it lands, and the later copy must land after it), or when the CPU mirror cannot do the copy (a format change, a scaled or flipped copy drawn bycopy_t2t_draw), the destination region is read back after the copy on the GPU into a shared buffer and written into the storage in the submission's completion handler, before its fence is published (mirror_readback/mirror_rb_land, a kind 4 post-op). Depth and stencil formats, multisample textures and 3D destinations are left out and reported red. - Check:
t_nvn6c3 checks region and whole-level copies between textures of different block heights (RGBA8, BC1) and from a strided buffer, byte for byte against a level round trip;t_readbackcopies a cleared render target into a packaged texture and reads the clear colour back from its storage. In the log,texture→texture copy into ser=… written into the storage as wellor… read back into the storage when the GPU completes. - 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.
- 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). A colour attachment whose texture the host uploads from its storage (tex_storage_dst) is read back into the storage when its pass ends (rt_mirror_add/rt_mirror_flush, throughmirror_readback, as for GPU copies): its whole level, and in a layered or slice-merged pass every layer the pass draws (rt_mirror_layers, fromrenderTargetArrayLength); the copy encoder the readbacks 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). That write is not a change to upload: the completion handler stamps and protects the storage again and keeps the texture's bytes in its edge pages (pkg_absorb_note); at the next resolution the texture keeps its host texture when that is still the one read back, no page wholly inside it was written since and its edge bytes are unchanged (pkg_absorb_take), and while a readback is pending it is not uploaded at all (pkg_absorb_pending). Without that, the four CPU-written targets Tears of the Kingdom draws into every frame were uploaded again after every readback (31,249 uploads in 200 s instead of 19). 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. - Open: not read back: 3D textures (as for GPU copies;
nvn_tex_mirror_b2twrites 2D layers only), block-compressed targets drawn through a shadow (bcrt_attach; the shadow is copied back into the host texture only) and depth targets. A CPU write into the texture made after its pass is replayed and before the readback lands gets a page generation below the readback's stamp, sopkg_absorb_takekeeps the host texture without it (the storage holds it; the next CPU write to that page uploads it). Telling those writes from the readback's own needs a dirty-tracker query for a generation range; no game is known to write a texture while the GPU draws into it. - Check:
t_readbackclears a packaged texture through a render pass, reads the clear colour back 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 readback both reads fail. In the log,render→texture copy into ser=… read back into the storage when the GPU completes. - 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: 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:
mrbuf_get/mrbuf_putinnvn_mtl.m, for every game: a readback takes a kept buffer of its power-of-two size (4 KB and up) and gives it back once it has landed (mirror_rb_land, after the GPU is done with it); at most 16 MB of them is kept, and a buffer past that leaves the residency set and is released. - Check:
make -C dm/gfx/tests GAME=<id> check_t_readback: a landed buffer serves the next readback of its size, a larger one takes the next size, no more than 16 MB is kept, and a landed buffer is held only by the kept set (Completion readbacks survive a guest waiter's pool). - 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: 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). - 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.
- 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, 2 KB shader code padding and 32 separate sampler bindings, and items 0x60 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 keep the X1 table's value below 0x60 and answer 0 with a red line above it. - 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 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, andHLE_FORCE_BIGfor the file system hooks): 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(used + free, as in NVN;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 (answered 256, not measured).- 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,g_retire_held,g_ghost_held). A retired resource that a serial entry still holds, ing_by_serialor the ghost ring (both counted), keeps its residency and waits ing_retire_held. The last entry's release retires it again. 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. - 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: 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.