On this page
Finding, keying and translating shaders (dm/gfx/tools/shader_split.py, dmsh_build.py, dmsh_xlate, Program.cs) and their pipelines. Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.
Containers by engine
Sumo Ninty_NX shaders and MuxFDMR streams
- Symptom: the split finds no shaders in
mainor RomFS, while reporting control sections inmainwith no code area. The game has.sp4resources and loose.nvnbinfiles rather than BNSH archives or whole GLSLC outputs. - Cause: Sumo's
Ninty_NXdescriptors store control/code offsets relative to their container, whose start is recovered from the descriptor's own relative offset. The code is not necessarily adjacent to its control section.MuxFDMRversion 2 resources wrap these containers in one or two zlib streams; their header holds both the stored and decoded lengths. LooseNVNshversion 1 records instead name one stage and its control/code ranges directly. - Fix:
shader_split.pyreads the bounded descriptor ranges and loose records, checks each stage through the existing control/code validation, and scans decoded Mux members recursively. It validates container versions, non-overlapping ranges, complete zlib frames and exact decoded lengths before accepting their shaders. As with Needle, a magic whose container does not fit the data is a literal in executable code and is passed over; the split readsmainwhole, where a stop would end it. Detection follows the format bytes, not a filename or title ID. Original control/code bytes and shader keys are preserved; single-stage vertex programs use the existing pixel-input cover.test_sumo.pycovers embedded containers, loose records, compressed members, malformed input and file-scanner integration. - Found on: Team Sonic Racing 1.0.3: 337
.sp4resources contain 28,258 descriptors,mainholds 586, and two.nvnbinfiles hold four stages. The split finds 4,516 unique shaders (2,116 vertex, 2,399 pixel, one compute), against zero before.
Phyre NVN effects in Virtuos VBF archives
- Symptom: the shader split finds no game programs despite loose
.fx.phyreeffects and multi-GB.vbfarchives. - Cause: Phyre stores GPU code immediately before its control section, often at unaligned offsets inside
RYHPeffect containers. VirtuosSRYKarchives keep the effects in 64 KB zlib or raw blocks. These are neither BNSH nor whole GLSLC outputs. - Fix:
vbf.pyreads the Switch archive's two hash tables, file records, names and block lengths through the RomFS adapter. The split reads compiled effects and shader members without extracting textures or audio.phyre_shadersverifies the adjacent code header, the control's code length and stage, and preserves the original bytes and keys. Single-stage vertex programs use a pixel-input cover for padding. - Found on: FINAL FANTASY X/X-2 HD Remaster; base and both supplied DLC packs yield 5,752 unique shaders.
- Check:
test_vbf.pycovers raw and zlib blocks, shared/missing members, truncation, unaligned effects, loose shader keys and inconsistent lengths. All 5,752 programs translate on the Mac.
A file no reader reads
- Symptom: the shader split stops on one archive with a Python traceback (an
IndexErrororstruct.errornaming no file), or a reader's[RED]line about a format it does not know, and nothing is written; the NSP script loses the build at the split. - Cause: a worker's exception (and
red(), aSystemExit) ended the whole split through the process pool, so one archive of a format revision a reader does not know stopped every other file's shaders too.nlg_dictread its index without checking the layout, so an index of another revision gave anIndexErrorinstead of a line naming the archive. - Fix:
scan_fileleaves out a file whose reader fails, with[RED] <file>: <why> (its shaders are left out), and the split goes on; at the end it prints[RED] <n> file(s) could not be read, their shaders are left out (first: …)and writes its tables. The NSP script shows that line and packs a provisional profile's IPA without those shaders; for a registered profile it stops there, since the profile's tables were split from every file.nlg_dictchecks that the records and chunks fit the index and every chunk index is in range before reading it. The format itself still needs a reader: the line names the archive and, for NLG, its header bytes. - Found on: Mario Strikers: Battle League (no profile; seedds/nxnative#282): one of its 879 Next Level Games archives, a revision
nlg_dictdoes not read.
A Yaz0 member that expands is not overlap
- Symptom: the split leaves out archives whose members do not overlap:
[RED] <file>/<member>.szs/#7: the archive members add up to more than 16 times the file (members that overlap), then[RED] <n> file(s) could not be read, and a registered profile's build stops there. - Cause:
scan_blobbounds archives in archives (SARC, PlatinumGames' DAT) so that a damaged or crafted file cannot make its work grow with each level: they nest at mostNEST(16) deep, and the members a file's archives open add up to at most 16 times the file. It counted a Yaz0 member by what it expands to, then counted that member's members again, so the same decoded bytes were paid twice. A 204 KB SARC holding one layout archive that expands 9 times added up to 18 times the file. - Fix: two budgets, each 16 times the file. The first counts each member by what it takes up in its archive, so it still stops members that overlap. The second counts what Yaz0 members expand to, charged from the Yaz0 header before the member is decoded. Each bound stays what it was, so the work is still within a small multiple of 16 times the file. Charging only the stored size, with no second budget, would have let the work grow by the Yaz0 ratio (about 90 at most) as well.
test_split_nesting.pycovers the layout archive's shape, the fan-out corpus input, a compressed fan-out (stopped after 19 scans; 8^6 without the bounds) and a Yaz0 member that expands 87 times, stopped before it is decoded. - Found on: Mario Kart 8 Deluxe (GitHub issue #640): its race and result layout archives (
UI/<lang>/race.sarc,tv.sarc,UI/cmn/tv_result.sarc), 14 files in 4.0.0 and 28 with 4.0.1.
BNSH in NintendoWare archives and in main
- BNSH (
BNSH\0\0\0\0+grscblock at +0x60) is found insidebfsha, particle effects (esetb, VFXB), UI layouts (blarc), andmainitself. - Variation table at +0x80 (count at +0x7c); binary program at variation +0x10; six stage pointers from program +0x08 (vs, hs, ds, gs, ps, cs).
- Stage code descriptor: control ptr +0x08, code ptr +0x10, code size +0x18, control size +0x1c. All pointers are relative to the BNSH start.
Nintendo EPD (sead / agl / gsys): BNSH, agl sharcb and resource packs
- Super Mario 3D World + Bowser's Fury: bfsha (BNSH) and agl sharcb (BAHS version 9, next entry) in
ShaderData/*.szs, particle effects (BNSH, including 25 compute shaders) inEffectData, six layouts andSystemData/AglResource.szs(BAHS), and two BNSH inmain: 11,080 shaders. All bind with Mario's texture handle layout (0x20 + 8 × slot); the one failure is a geometry shader (fur fins,RenderFinFur), which Metal cannot express (Geometry shaders with no Metal stage). - The Legend of Zelda: Breath of the Wild: 19,592 shaders:
Shader/*.sbfsha(BNSH) and the gsys / ksys / terrain resource packs insidePack/Bootup_Graphics.packandPack/TitleBG.pack, the particle effects inEffect/*.sesetlist(11,373 shaders, 101 compute), layouts (.sblarc) and 37 inmain. All bind with Mario's texture handle layout; the full build had no failure and no unresolved binding.
BAHS in agl sharcb
- How it is stored: agl
sharcb, the engine utility shaders: code region at header +0x1c (size +0x18). Binary entries in the table area are 16 bytes{code offset, 0, control size (0x880/0x88c; 0x848 in BAHS version 9, Super Mario 3D World), signed offset from this entry to the control section}. - Pairing: control sections are shared between programs, so pair by entry, never by searching for code near a control.
- Found on: Super Mario 3D World + Bowser's Fury (BAHS version 9).
Zstd files recognised by their magic
- Symptom:
shader_split.pyfinds 0 shaders in a RomFS of zstd files named.zst. - Cause: the split decompressed only
.zsfiles. - Fix: it now goes by the zstd magic (
28 b5 2f fd) whatever the name. - Found on: Paper Mario: The Thousand-Year Door (8,726 of the RomFS's 9,959 files, the shader archives among them). Of the other 21 games' RomFS on hand only Super Mario Galaxy's
GDDR3.zst(a Vulkan game, no NVN split) has such a file.
HAL's .cmp containers: a size-prefixed frame
- Symptom: the game asks for shaders the map does not have (
[RED] shader ... is not in the shader map, hundreds of lines), and the split finds far fewer shaders than the game uses (121 against 27,288 on Kirby and the Forgotten Land). - Cause: HAL keeps its assets in
.cmpcontainers: a 4-byte little-endian uncompressed size, then a frame (zstd, or LZ4 in theubin/map data). The split decompressed a zstd file by its magic at offset 0, so a frame that starts at offset 4 was read raw, and the shaders inside the BFRES archives of those containers were never seen. - Fix: the split opens a size-prefixed frame as well (
unsized: zstd, with a dictionary as for a plain zstd file, or LZ4) when the file's bytes 4 to 8 are a frame magic and bytes 0 to 4 are not the zstd magic. A frame whose header records a size other than the prefix is not such a container, and the file is read as it is: Tears of the Kingdom's 1,202Cave/…/C.crbin.<hash>/*.quadfiles are a 4-byte hash (the one in their folder name) and a zstd frame, the only files with a frame at +4 in the RomFS of 39 other games on hand. A frame that records no size must decompress to exactly the prefix's size; otherwise (it does not decompress, it holds more or less, or it names a dictionaryPack/ZsDic.pack.zsdoes not have) the split prints a[RED]line and reads the file as it is. - Found on: Kirby and the Forgotten Land: 7,201
.cmpfiles, 5,121 of them zstd frames with 19,364 BNSH inside (11.2 GB decompressed) and 2,080 LZ4 frames, all of the latter inubin/(0.3 GB, and no BNSH); the same layout is in 430.cmpbinfiles (76 zstd inlyt/, 354 LZ4 insnd/). Every zstd frame records its size, the same as its prefix; no LZ4 frame does, and each decompresses to its prefix's size. The split now finds 27,288 shaders in the base game (18,476 ingfx/, 8,313 inptcl/, 378 inlyt/, 67 in the looseshaderfiles, 54 inmain), against 121 before, and 27,302 with update 1.1.0 (v65536: 14 more inlyt/).
BNSH in Bezel Engine archives
- How it is stored: the shaders are BNSH in the
.bfshaentries of the.beaarchives (zstd entries, 01), most of them the engine's own inSystem.nx.bea(_BezelSystemResources/<system>/shader/*.bfsha: PBR shadow maps 846, post effects 770, …), the rest in the layouts'__ArchiveShader.bnshand the per-archive shader packs (.bnbshpk), plus one BNSH inmain. - Reader:
shader_split.pyscans each archive entry as a file of its own (bea_members). - Found on: Big Brain Academy: Brain vs. Brain (NintendoWare Bezel Engine 1.56): 2,125 shaders in about 90 s.
Bezel Engine 2.9 archives keep their entries inline
- Symptom: the split finds almost no shaders and reports
[RED] /Archive/<name>.bea: entry 0 @0x4854535341 is not an ASST entry (its shaders are left out)for every archive (… file(s) could not be read); the "offset" is the bytesASSTH. - Cause: the 1.56 form (version 0x10100 at +8) has at +0x28 the offset of an array of entry offsets; the 2.9 form (version 0x60000) has the entries themselves there, back to back, each 0x48 bytes (its size the u32 at
ASST+4), with an 8-byte type tag (_G3DSHA,_TEXDATA, …) at +0x1c and a 16-byte hash before the data and name offsets, which are the last two u64 of an entry in both forms. - Fix:
bea_membersreads the entries in place when the array starts withASST. - Found on: Super Mario Party Jamboree (Bezel Engine 2.9, 949 archives, 127,203 entries): 25,693 shaders, against 246 (those in
main) before, most of them in the 13,839.fshb(_G3DSHA) entries; the split takes about 10 minutes.
BNSH entries of GFPAK archives
- Symptom: a provisional split finds only a fraction of the shaders (3,126 of Pokémon Sword's 41,516).
- Fix:
shader_split.pygfpak_membersreads the BNSH entries of GFPAK archives (LZ4 entries, 01). - Found on: Pokémon Sword: 41,516 shaders, nearly all BNSH entries of the 7,699 GFPAK archives under
bin/archive(the models' and effects' shader archives), beside loose BNSH files (bin/battle/waza/model/**/*.bnsh_vsh/_fsh,bin/graphics,system_resource/shader) and two inmain. The YEBIS shaders it also draws are inmain(Shaders in a zlib stream in main).
Oodle entries of GFPAK archives
- Symptom: the split counts hundreds of files as unreadable (678 on Pokémon Legends: Arceus), each with
[RED] <file>.gfpak: entry 0 has compression 3 (Oodle), which this reader does not decode, and leaves their shaders out. - Cause: every entry of Arceus's GFPAKs is compression 3, Oodle Kraken, where every entry of Sword's is LZ4 (BNSH entries of GFPAK archives).
- Fix:
shader_split.pygfpak_membersdecodes compression 3 with the game's own decoder, as it decodes Trinity packs (oodle,DM_OODLE_HOST/DM_OODLE_MODULES).profile_guess.pybuild_sourcescounts the GFPAKs that hold an Oodle entry, so the NSP script sets that decoder up for the split (oodle_packs).dm/gfx/tests/test_gfpak.pycovers each compression. The Mac path was checked without Arceus's files: a GFPAK of 300 Kraken streams from Pokémon Scarlet's packs (28 MB raw), read bygfpak_membersthrough the Mac host, decodes the same with Scarlet's and Z-A'sOodleLZ_Decompress, andbuild_sourcessetsoodle_packsfor it but not for an LZ4-only GFPAK. - Found on: Pokémon Legends: Arceus 1.0.2 (
01001F5010DFA000, no profile yet;mainhasOodleLZ_Decompress): all 678 GFPAKs underbin/archive, Oodle in every entry. The provisional split finds 12,851 shaders with them, 69 more than without. 73 shader map rows first appear in a GFPAK:field/resident_release.gfpak40,appli/battle_resident.gfpak26,appli/system_resident.gfpak5,appli/poke_dex.gfpak2.
BNSH in Trinity packs
- How it is stored: every shader is a BNSH inside the Oodle-compressed files of
arc/data.trpfs(01). - Reader: for any game with Trinity packs (
ONEPACKfiles),trpfs_jobs/trinity_shadersinshader_split.pyname the packs fromdata.trpfd, decode every file with the game's own Oodle in the Mac host (DM_OODLE_HOST/DM_OODLE_MODULES, set by the NSP script) and scan it like a loose file. - Copies that differ: the layout archives' control sections are 104 KB long, and their stage descriptors run 456 bytes past the control's own length (control +0x20), with bytes that differ between copies of one shader;
same_shadercompares copies only up to the control's own length. - Found on: Pokémon Legends: Z-A (Game Freak's engine): 28,603 shaders (11,633 vertex, 16,845 pixel, 125 compute) and 26,051 variation pairs: the material shaders in
system_resource/gfx2/shader/NX64/env_ikkaku(material/standard.bnsh,material/transparent.bnsh,system/light.bnsh, and thespecial/imgui.trshapack with the engine's system shaders), particle effects (.ptcl/.trtmlinik_effectdata, 19,842 of the shaders), UI layouts (__ArchiveShader.bnshin the.truivpacks ofuidata) and the 40 BNSH inmain.
GLSLC outputs in main and in loose files
- How it is stored: 13 Sentinels (Vanillaware's engine) has no shader file in RomFS, whose six CRI CPK archives hold textures, scripts, CRI audio and USM movies: 838 GLSLC compiler outputs sit in
main's read-only segment, each referenced from a{pointer, size, 0}table in.data. Other engines keep whole outputs in loose RomFS files of their own formats (below). - Layout: an output starts with the magic
0x19866891, its total size is at +0x44, and it holds exactly one control section with the code area right after it (control +0x20 = control length); a debug section with the NV assembly and the GLSL source follows. The stage comes from SPH word 0, as for BAHS. Outputs that hold several stages, and SDK 4's outputs, are read through their section headers (GLSLC outputs holding several stages, GLSLC outputs read through their section headers). - Reader:
shader_split.pyreads them in every game'smain, accepting only outputs that pass the control / code / length check. Loose RomFS files are searched the same way: it reads GLSLC outputs in any RomFS file no other reader takes. When the reader was written, of the other profiles' files only Metroid Dread's MSHD files held any, whichmshd_shadersreads (MSHD in MercurySteam's bshdat files). - Found on:
- 13 Sentinels: Aegis Rim: 838 in
main. - Fire Emblem Engage: 11 in
main, Unity's own. - FINAL FANTASY TACTICS - The Ivalice Chronicles: 257 whole GLSLC outputs in the engine's technique files (
*.tec:resources_nx/release/master/shader/*.tec, magicTEC\0, each output whole after a table of parameter names) beside 49 inmain. - Absolum: 163 GLSLC outputs in
shaders.nx.bin(rom:/shaders.nx.bin: a u32 count, then per entry a u32 hash, a u32 size and one whole output): 162 unique shaders, 83 pixel and 79 vertex, every vertex shader paired with the single-stage cover (Single-stage programs paired with a cover). Its.xnbeffects are XNA's D3D9 effects, MOJOSHADER linked in, which the GPU does not run. - Catherine: Full Body: 3,050 loose files in
data/shaders_binary(*.vs/*.ps, one stage each, one output per file), read like FINAL FANTASY TACTICS' technique files; its vertex shaders are in two parts (VertexA and VertexB parts merged into one vertex shader).
- 13 Sentinels: Aegis Rim: 838 in
GLSLC outputs holding several stages
- Symptom: the split found no shader in the game (0 shaders).
- Cause: an output may hold several stages compiled together as one program (Baba Is You: the 120 outputs in
Assets.dat, each a pixel and a vertex shader, then a reflection section), which the single-stage reader does not take. - Fix: such an output is read by its section headers (+0x4c section count, section i's header at +0x90 + 0x90 i, type 0 = GPU code;
glslc_program; the same headers SDK 4's outputs are read through, GLSLC outputs read through their section headers), and its stages are paired with each other, not as single-stage programs. The NSP script now stops at the split when it finds none. - Found on: Baba Is You.
GLSLC outputs in a zip archive, and those NROs compile at run time
- Symptom: an NVN game's split finds none of its shipped shaders, and the ones it compiles at run time were not captured: the game's renderer is in NROs that call
glslcCompile, and its whole RomFS is one zip. - Cause: Portal keeps all its files in
game.zip(41,416 members, Deflate or stored). The Source engine's D3D9 layer (materialsystem.nro) links each vertex and pixel shader combination into one program; the programs of the game's precache list (portal/glbaseshaders.cfg, 219 entries) ship compiled asnxcontent/nvsb/<hash>.nvsb(NVSB, version 0x65, the file's length at +8, a 0x60-byte header, then one GLSLC output holding a vertex and a pixel shader). A deflated member hides the GLSLC magic from the loose-file scan. The game compiles the rest withglslcCompile(its movie shaders and its own blits: 10 outputs within 30 s of boot), and the capture rule looked only formainholding GLSL sources and importingglslcCompile. - Fix:
shader_split.pyreads zip archives member by member in parallel ranges (zip_jobs/zip_shaders;zipfile), each member scanned like a loose file, whole GLSLC outputs included.profile_guess.pyshader_sourcesasks for the run-time GLSLC capture when an NRO the game loads importsglslcCompileand a subsdk defines it (glslc_capture). Portal's map: 242 keys from the.nvsbfiles and 13 from the capture, 229 vertex and pixel pairs. - Found on: Portal. No other profile's RomFS has a zip archive holding shaders.
GLSLC outputs in Criterion's Bundle 2 resources
- Symptom: an NVN game's split finds a few dozen shaders (
main's and a loose file's) and the screen stays black but for the loading indicator, with[RED] shader … is not in the shader mapandprogram has no vertex shader; the RomFS holds.BNDLfiles that start withbnd2. - Cause: Criterion's Bundle 2 (Burnout Paradise's format; version 3 here) keeps each resource zlib-compressed: the header gives the entry count and table offset (+0x10, +0x14), four block data offsets (+0x18) and the flags (+0x28, bit 0: compressed); a 0x50-byte entry gives the resource ID, four sizes on disk (+0x20, alignment in the top nibble), four offsets (+0x30) and the type (+0x44). Need for Speed: Hot Pursuit Remastered's
SHADERS.BNDLholds 2,112 whole GLSLC outputs in block 1 of its type 0x8 resources, none of which a raw scan of the file can see. - Fix:
shader_split.pyreads a file that starts withbnd2resource by resource (bnd2_resources) and scans each inflated block for GLSLC outputs (bnd2_shaders), by the outputs' magic rather than the resource type, whose numbering is per game. Hot Pursuit Remastered: 89 → 2,154 shaders, the 10 its first frames asked for among them. - Found on: Need for Speed: Hot Pursuit Remastered.
G2S containers in KTGL RDB archives
- How it is stored: each material's shaders are a G2S container (
_S2G3000, type KTID0x7bcd279f; 23,108 entries inasset/system.rdb.bin*, 2.3 GB inflated) of GLSLC outputs, as in 13 Sentinels'main, inside zlib-chunked IDRK entries (01). One_DOK0000container (type0x20a6a0bb, the engine's system resources) holds 151 more bare GLSLC outputs, 21 of them drawn in the first battle. - Reader:
shader_split.pymaps the RDB files and inflates only entries that start with those two magics (idrk_shaders), and the 117 outputs inmainas in every game. Many outputs carry 0x850-byte control sections and the compute ones have no code header, soglslc_shadersaccepts controls from 0x760 bytes and takes the stage of headerless code from the thread group size at control +0x748. Pairs come from the single-stage cover (separable_pairs). - Found on: Hyrule Warriors: Age of Calamity (Koei Tecmo's KTGL): 40,726 shaders (37,043 pixel, 3,440 vertex, 241 compute, 2 geometry), Mario's texture handle layout (
0x20 + 8 × slot). ROMANCE OF THE THREE KINGDOMS 8 REMAKE's newer KTGL keeps them (_S2G5000/6000/7000, and_DOK0000) in.fdatafiles whose IDRK entries are LZ4 or zlib in chunks of another form (01): 59,214 outputs there and 149 inmain, all of them built. - Note: the older KTGL of Three Houses packs the same G2S containers differently (SPKG packages in KTGL LINKDATA).
SPKG packages in KTGL LINKDATA
- How it is stored: shader packages (
SPKG:patch*/nx/shader/*.spkgand 132 entries ofDATA1.bin) of G2S containers as in the RDB entry, one_SPK4100package without G2S (the post effects,DATA1.binentry 549), and map and model archives carrying packages of their own. - Reader:
kt_shadersinflates every LINKDATA entry and loose*.gz/*.spkgfile (01), takes every GLSLC output that passes the triple check, and scans its SARCs for BNSH;DATA1.binis split into 64 scan jobs by entry range (linkdata_jobs). - Symptom: 24 shaders before the control-length fix, 1,355 after.
- Cause: its GLSLC (output version 0x1d.2) writes the 0x848 / 0x850-byte controls and headerless compute code described in the RDB entry, which
glslc_shadersused to drop. A few outputs of version 0x1d.0 keep something else at control +0x20 (odd values such as 0x136b), so a control length must be a multiple of 4; the section-header reading now takes 8 of them (GLSLC outputs read through their section headers). - Found on: Fire Emblem: Three Houses (the older KTGL).
- Note: nine more shaders exist only at run time (Built-in shaders compiled with the game's own GLSLC).
Gust's chunked zlib .gz files
- Symptom: every frame black, the title screen included, while the game runs at full rate and draws (about 19,000 NVN calls a frame), with
[RED] shader … is not in the shader map(26 keys in the first minute) andprogram has no vertex shader; the dumped shaders (DM_NVN_SHDUMP) match no file byte for byte, and some differ from a split shader only in the control section's compile information (+0x7d0), which is a sign of another GLSLC compile of the same source, not of a shared section. - Cause: Gust's
*.elixir.gzfiles come in two forms: most areCRAEarchives stored as they are, which the split reads like any loose file, and the rest are chunked zlib, each chunk a u32 stored length and one zlib stream (16 KB inflated, the last shorter), ended by a zero length. The post effects' archiveData/NX/PostEffect/pe_pack.elixir.gzis one of them, with 162 GLSLC outputs (128 keys found nowhere else, among them the frame composite and the copy passes), which a raw scan cannot see. - Fix:
shader_split.pyinflates a.gzfile in that form (gust_gz: every chunk must be one whole zlib stream and the zero length must end the file, so another format is left alone) and scans it as a loose file. Atelier Ryza DX: 921 → 1,049 shaders, every key of the opening, title, story and field runs. - Found on: Atelier Ryza: Ever Darkness & the Secret Hideout DX (Gust's engine on KTGL): 156 of its 4,112
.gzfiles are chunked zlib.
GLSLC outputs in FromSoftware's BND3 and dvdbnd archives
- How it is stored: RomFS is two archives,
dvdbnd0.bdtanddvdbnd1.bdt(3.6 GB), each indexed by the.bhd5beside it:BHD5, 0xff (little-endian), u32 1, u32 file size, u32 bucket count, u32 0, u64 bucket table offset; each 16-byte bucket{u32 count, u32, u64 offset}points at 16-byte entries{u32 path hash, u32 size, u64 offset}(paths only hashed). 5,184 entries are DCX containers (DCX\0, big-endian header,DFLT= a zlib stream at +0x4c), most of them BND3 archives (28-byte entries{u8 flags, i32 size, u64 offset, i32 ID, u32 name offset, i32 unpacked size}in formats 0x5c / 0x7c). Six BND3 archives indvdbnd0.bdthold the shaders, one GLSLC output per entry (.vpovertex,.fpopixel, namedShader/FRPG_Sfx/NVNShaderBinary/ps/…or by base name in the two FLVER material archives, 2,602 + 486 entries). - Reader:
bhd5_entries,undcx,bnd3_members,bdt_shadersinshader_split.py, in 64 jobs by entry range, about 10 s. Only archives that contain a GLSLC magic are parsed (the others use format 0x78). - Note: SDK 4's GLSLC needs its section headers read (GLSLC outputs read through their section headers).
- Found on: DARK SOULS: REMASTERED.
MSHD in MercurySteam's bshdat files
- How it is stored:
system/shd/*.bshdat, the only place the game's shaders live (nothing inmainor the.pkgpacks). Each stage is a control section, whose length is at control +0x20, sometimes followed by a small compiler-option table (fastmath true), then the code area at the next code header. Every file holds vertex / pixel stages in alternating order (1, 2 or 4 programs) and is paired two by two. - The control section carries NV assembly text after its first 0x880 bytes, and the SASS disassembly follows each code area, so the binding layout can be read straight from the files (
TEX/TEXShandle immediates). - Reader:
mshd_shaders. - Found on: Metroid Dread (MercurySteam's engine): 478 files, 1,342 stages, 1,010 unique keys (359 vertex, 651 pixel), 659 pairs.
SDF definitions in RE Engine PAKs
- How it is stored: shader definitions (
SDF\0, 2,685 files in the PAK view, 01): u16 version 1, u16 entry count, u64, then 0x108-byte entries from +0x10. Entry +0x10 holds six u64 offsets (vertex, hull, domain, geometry, pixel, compute; 0 = none), each to{u32 control length, u32 code length}followed by the control section (0x880 bytes) and the code area; compute code has no header. An entry's stages are one program, so its vertex / pixel stages are a pair. Empty definitions are 16 bytes of version 0. - Reader:
kpka_jobsmaps the PAKs, takes the newest copy of each path, peeks at 4 decompressed bytes and inflates only SDF entries (sdf_shaders). - Found on: Monster Hunter Rise: 63,111 shaders (30,233 vertex, 21,475 pixel, 11,271 compute, 53 geometry, 33 hull, 46 domain) and 102,345 pairs, in 5 s with 12 jobs.
Shader records in Snowdrop SDF data slices
- Symptom: the split finds no shaders in a RomFS made of
.sdfdata,.sdftocand.sdfverfiles. - Cause: Snowdrop stores the shaders in individually compressed records inside its
BERGdata slices. A cache record can itself be wrapped in an SDF zstd chunk. These SDF archives are a different format from RE Engine's shader definitions above. A shader record has a 20-byte header{u32 codec, flags, 0, raw length, stored length}:0xabcd00c5precedes zlib data,0xabcd00c6a zstd frame; reflection data precedes the NVN sections inside it. Both supplied games use SDF version 22, so that version does not select the codec. - Fix:
shader_split.pyrecognizesBERGby magic and reads the length-delimited compressed records (snowdrop_shaders). Every control section is followed immediately by its code, with lengths from control +0x20 and +0x704; they are byte-packed, so a control need not be aligned. A single record can hold more than one shader. Each section must pass the control / code / stage checks. Programs hold one stage, and the vertex shaders take the single-stage cover. The reader also peeks at each zstd frame's decoded header and finishes only frames beginning with a cache record. This finds nested records without treating a coincidental frame magic or a bare frame containing control-like bytes as a shader asset. - DLC: additional shader records ship in the DLC's data slices. The NSP script includes each extracted DLC RomFS as an extra shader root, without an ExeFS module, and fingerprints its index and extraction timestamps for split reuse. Vulkan and OpenGL builds include the DLC roots too. When installing or removing optional DLC changes an NVN shader union, the script uses an external profile with new shader tables only after every non-DLC shader row and fact matches the registered tables; package indices may change. The registered module builds, settings and build-gated cuts are preserved. A cached proof of this comparison permits repeat builds to reuse the split.
- Found on: Mario + Rabbids Sparks of Hope (zstd); Mario + Rabbids Kingdom Battle (zlib records, some wrapped in zstd chunks; shorter control sections and headerless compute code).
CompiledBytecode resources in Frostbite superbundles
- Symptom: the survey finds no shader container in a RomFS of
.tocand.casfiles (Data/NX,Patch/NX,Data/layout.toc,Data/initfs_NX); the split reads every file loosely and finds nothing. - Cause: Frostbite keeps its data in bundles that superbundle tables (
.toc, magic00 D1 CE 01) list, the bundles' manifests and items in the install chunks' cas files, each item in blocks of at most 256 KB compressed with Oodle Leviathan (8C 0C). A toc entry gives each item's cas file by an 8-byte cas id (Data or Patch, the install chunk'spersistentIndexfromlayout.toc, the cas number), its offset and stored size; items of one bundle need not lie together. The NVN shaders are the bundles'CompiledBytecoderesources (shaders/bytecode/<GUID>, res type0x0e2b02f7, the type name's hash): a 0x20-byte header, then aVNVNrecord (fb::NvnCustomShaderDataof the renderer NRO) with one control section and code area at offsets its trailer gives.ShaderBlockDepotresources hold parameter blocks only. - Fix:
dm/gfx/tools/frostbite.pyreadslayout.toc's install chunks (a DbObject), every toc's bundle entries, the manifests and the blocks;shader_split.py(frostbite_jobs,frostbite_shaders) decodes eachCompiledBytecoderesource with the game's own Oodle in the Mac host's tool mode, as for Trinity packs, and leaves the.tocand.casfiles out of the loose scan.profile_guess.pyturns the Oodle decoder on for a RomFS withData/layout.toc. Data and Patch are read alike: keys are content hashes, so a base copy the update replaced only adds an unused shader. The resources name no vertex / pixel pairs (Frostbite pairs them in its pipeline data), so the tables have none. - Found on: EA SPORTS FC 27 (Frostbite 2025.1.2): 83,325 bundles, 10,401 resources, 10,342 shaders (3,738 vertex, 3,306 pixel, 3,298 compute), 35 MB stored; the split takes about 6 s.
SPK shader package in an MT Framework ARC
- How it is stored: the engine's one shader package,
sc\NX_64\root(SPK\0, type hash0x02358e1a) innativeNXx64/sa/NX_64/root.arc(01). After the package's tables every shader is a code area followed directly by its control section, back to back to the end of the file: 230 vertex shaders, then 230 pixel shaders. - Reader: each control section names its code length (+0x704), so the code is the bytes right before it, and every block must start where the previous one ended (
mtspk_shaders). The tables that say which vertex and pixel shaders the engine combines are not read: the vertex shaders are paired with the single-stage cover (one pixel shader reads every input any of them reads). - Note:
CustomShaderPackage.mfx(MFX\0, the effect descriptions) holds no shader code, and neithermainnor any other ARC entry does. - Found on: The Great Ace Attorney Chronicles (Capcom's MT Framework): all 460 build, with Mario's binding layout; every binding decodes.
PKZ version 1 archives
- Symptom:
[RED] ... PKZ version 0x10000: only 0x20000 is knownstops the shader split. - Cause: the PKZ reader accepted only the version first found on Bayonetta 3. Version
0x10000uses the same 0x20-byte header, 0x20-byte entries and relative name offsets; the last header word is zero rather than a v2 alignment. The named compression method still determines how each entry is decoded. - Fix:
shader_split.pyaccepts both0x10000and0x20000, retaining rejection of unknown versions. The existing Zstandard decoder and DAT/shader scanners handle the contents. Synthetic directory fixtures cover both versions indm/gfx/tests/test_pkz.py. - Found on: ASTRAL CHAIN.
PlatinumGames' shader objects
- PlatinumGames' shader objects:
VSO\0/PSO\0/CSO\0/HSO\0/DSO\0(the stage), u32 compiler version, u32 control length, u32 control offset (0x18), u32 code length, u32 code offset, and nothing else. The engine makes a program of each and binds them per stage at draw time, so they are single-stage programs, paired with a cover pixel shader (shader_split.pypg_shader; the archives: 01). It finds its built-in ones by name (hwfillcolor.vso, …) and checks each object's GPU code version against the driver's (the NVN layer reports the translator's supported GPU-code range: Newest GPU code version reported by the driver, SDK 13.3.2). - Found on: Bayonetta 3: 8,024 objects in
root_000.pkz(shader.dat,shader2.dat,shadereff*.dat,autoshadereff.dat); 6,424 distinct keys (4,288 pixel, 2,047 vertex, 78 compute, 6 domain, 5 hull).
Size-delimited PlatinumGames shader objects
- Symptom: the split finds zero shaders and refuses the shader build, although the CPK's DAT archives contain the objects passed to NVN at boot.
- Cause: another PlatinumGames wrapper stores a binding table before the code and control sections, rather than the stage-tagged object above or a BNSH file: u32 prefix length (including that word), binding bytes, u32 code length, code, u32 control length, control. The observed prefix is
0x88bytes and the control section0x880bytes. - Fix:
shader_split.pypg_sized_shaderrecognizes the self-described section boundaries and NVN magics (0x12345678for graphics code,0x12345679for compute,0x98761234for control). The prefix must be aligned and in bounds, both sections must fit, the record must end with its control section, and the control's code length at+0x704must agree. Graphics stages come from SPH; the compute magic identifies compute. The reader emits#PGSOrecords so the existing single-stage cover pairing applies. Recognition needs no title or filename rule, and the shader translation chain is unchanged. - Found on: NieR:Automata The End of YoRHa Edition: the objects in five DAT shader archives inside
data000.cpk; the game note records the census and the runtime-key checks.
BNSH files in PlatinumGames DAT archives inside CRI CPK archives
- PlatinumGames' engine in its Wii U form keeps its shaders as BNSH files of one stage each, the
.vso/.psomembers of PlatinumGames DAT archives (shader.dat,shader2.dat,shadereff.dat) inside a CRI CPK archive, all CRILAYLA-compressed (01 has the CPK layout).dm/gfx/tools/cpk.pyreads the @UTF tables (and the masked ones of Unicorn Overlord's CPKs, the same tables as before once unmasked), the TOC and CRILAYLA (a decoder over a bit string, about 29 MB/s);shader_split.pycpk_jobssplits the CPKs into jobs and scans each entry (a CPK it cannot read is scanned as one file, with a[RED]line). - Each file is one variation with one stage, so the split pairs nothing; as in Hades II, the vertex shaders write every output their pixel shaders read and no pipeline failed to link.
- Found on: Bayonetta 2: 1,856 distinct keys (1,547 pixel, 309 vertex) in
shader.dat,shader2.datandshadereff.datofdata000.cpk(the split about 2.5 minutes); Bayonetta: 458 distinct keys (236 pixel, 222 vertex), the members ofshader.datindata00.cpk(about 1.7 minutes). All build for both. - Not checked: 13 Sentinels and Catherine: Full Body's CPKs (no files on the drives).
Monolith ARH1, XBC1 and HCPS/SLCT shader containers
- Symptom: archive or loose asset shaders are absent from the map; only boot shaders in
mainare found, sometimes reported as raw controls with no identified code area. - Cause: Monolith uses ARH1/ARD archives and XBC1 streams. HCPS shader containers live inside models, map streams and standalone shader files; a standalone SLCT in
mainomits the HCPS header. - Fix:
shader_split.pyreconstructs paths from the ARH1 XOR trie, reads bounded archive entries in parallel, and decodes stored/zlib/zstd XBC1 streams recursively. The ARH stored size excludes an XBC1 wrapper's 48 bytes. Decoded members are searched for both HCPS and complete GLSLC outputs (effect asset packs); compressed literals are visited only after decoding. HCPS's SLCT/NVSD descriptors locate each variant's control and code ranges; a vertex bundle can include geometry, so stages are split by each control's own lengths. Text-bearing controls advance at eight-byte alignment. Standalone SLCT sections use offsets relative to their own start. Source-only HCPS containers with empty code/control ranges hold no NVN binaries. Pairs stay within their program variant. Synthetic fixtures cover wrapper bounds, embedded geometry, variants, standalone offsets and compressed streams containing literal container magics. - Nested effect compression: Firehawk WIPAC resources can wrap their
BlShaderArchiveGLSLC programs incmp\0zlib: an eight-byte signature, little-endian stored/raw u32 lengths, then zlib. This layer lives inside the decoded XBC1 entry. The recursive reader checks both lengths and the end of the stream; it excludes these compressed ranges from raw container scans too. The four initially missing geometry/pixel keys and two additional vertex/pixel keys from Xenoblade Chronicles 3's opening match/effect/fld/ma01a.wipacafter this decoding. Synthetic tests cover nested literal streams and incorrect sizes. - Loose containers: HCPS programs, XBC1 streams, DRSM model streams and compressed effect resources also occur outside ARH/ARD archives. Both the file scanner and nested-container scanner dispatch their headers to the same recursive decoder, before the generic shader-magic filter. Otherwise even a complete loose HCPS program is discarded. Synthetic file scans cover direct programs, compressed programs and several embedded streams, independent of filename suffix.
- Found on: Xenoblade Chronicles 3, whose note records the census and runtime key checks; Xenoblade Chronicles 2, with the same archive and standalone SLCT layouts on SDK 3.5.0; Xenoblade Chronicles: Definitive Edition, with loose containers.
- Format references: ARH1 layout, xc3_lib shader structures. The reader's lengths and keys were checked against the supplied game files.
Needle shader containers in main and GFD archives
- Symptom: the split finds no shaders, although
maincontains NVN control sections; it reports that no data record or code-address pair identifies their code areas. - Cause: Needle keeps independent shader programs in
HNSHV002containers, embedded inmainand in GFD's GSC files inside CRI CPK archives. These are neither GLSLC outputs nor BNSH files. A container can hold many variants separated by reflection metadata. - Fix:
shader_split.pyneedle_shadersuses the container's big-endian whole length at +8, then the little-endian NVN section descriptors: 0x90 bytes immediately before each control section, with control offset, code offset, code length and control length at +0x30. Both regions must fit within that container, and the control/code headers and lengths must agree. Graphics stages come from SPH; headerless compute stages from the control's thread-group dimensions. Each program is paired with the single-stage cover, since its draw-time partner is not recorded by this wrapper. Synthetic fixtures check embedded variants, compute, length mismatches and container boundaries. - Found on: Persona 5 Royal, with 0x850-byte control sections. The game note records counts and runtime coverage.
Shaders in a zlib stream in main (YEBIS .tfxo)
- Symptom: shaders the game draws are missing from the map (
DM_NVN_SHDUMP), in no RomFS file and no module, stored or LZ4, and not compiled at run time. - Cause: Silicon Studio's YEBIS post-effect library (bloom, glare, depth of field, vignette;
C:/workspace/YEBIS_draft/src/GPU/…inmain) keeps its shaders in a zlib stream inmain: YEBIS's.tfxo file, a text header (the shader count,hlsl,TexUtil_NVN_NX, the technique list) and the shaders, each a control section (its own length 0x850 on SDK 7) directly followed by its code area. - Fix:
shader_split.pyinflates it and reads the shaders as single-stage programs (tfxo_streams,tfxo_shaders). - Found on: Pokémon Sword: after the GFPAK reader, 38 shaders the opening drew were still missing; none was a patched copy of a split shader (the nearest ones of the same length differ in hundreds of bytes).
mainimportsglslcCompilefromsubsdk1but never calls it and holds no GLSL source. The only zlib stream inmainthat inflates (main+0x1c98ed9, 39.5 MB) holds 3,155 shaders (3,015 distinct, 3,112 pixel and 43 vertex); they covered all 38. - Check: a temporary trace in
nvnProgramSetShaders(the key's code and control addresses, a backtrace and the heap around them) showed control sections packed back to back with technique names such asps_BrightPassT1_THRESHOLD_LUMINANCE_S1beside them; the names are inmain, and so is the data.
Loose control sections and code areas in main (Bink 2)
- Symptom: two
[RED] shader … is not in the shader maplines andprogram has no vertex shaderas the opening movie starts; every movie drew white. - Cause: the movie player (Bink 2's Unity plugin) builds its programs from 32 control sections and code areas in
main's read-only segment that are in no GLSLC output and no BNSH file. 26 are 0x20-byte records in two tables in.data({code area pointer, control section pointer, u32 code length, u32 control length}, both pointers relative relocations); the code takes the other 6 by address (adrp+addto the control section and, a few instructions away, to the code area it copies withmemcpy), the code area right after the control section's own length (+0x20, 16-byte aligned) or right before it, and two control sections share one code area. The 8 compute shaders' code areas have no header (SDK 10). - Fix: the reader (
raw_shaders) takes them. Both neighbours can pass the header and length checks, so it takes only a code area the code builds the address of, the nearest one to the control section's. - Found on: Advance Wars 1+2: Re-Boot Camp (Unity 2020.2 with Bink 2's Unity plugin). Of 25
mains on hand no other has such control sections.
SNVN program records in Retro Studios' RFRM forms
- Symptom: the provisional split finds only
main's 294 GLSLC outputs; the 3D scene and the title logo are magenta, with[RED] shader … is not in the shader maplines. - How it is stored: Retro's files are RFRM forms (
RFRM, big-endian u64 data length, u64, then the form type and two u32 versions; chunks inside are a fourcc, u64 length, two u32 versions, then the data).MaterialArchive.arcis oneMTRLform holding anSDXchunk (the same materials' D3D bytecode, 3,468 DXBC programs) and anSNVNchunk (the NVN ones). The.pakfiles arePACKforms whoseADIRchunk lists the resources (u32 count, then 36-byte entries: fourcc type, 16-byte GUID, big-endian u64 offset and length); eachMTRLresource is an RFRM form whose data is one zlib stream holding anotherMTRLform with anSNVNchunk. An SNVN program record is a big-endian u32 code length, u32 control length, a byte, u32 stream length, then a zlib stream that inflates to the program's code areas back to back followed by their control sections in the same order (0x840 bytes each on SDK 4). The chunk's tables differ between files, so the reader finds the records by their own lengths: a stream whose header ends where it says and that inflates to exactly code + control bytes. - Startup archive in
main: the first programs the game builds (theCGraphicsUtilCachequads that draw the title logo) come from a startup material archive compressed intomain(NMaterialStartup::get_startup_archive_compressed_buffer, 5,870 bytes;NMaterialArchive::ImportStartupArchiveinflates it at boot). ItsCBufferedCompressionheader is a type byte (1: LZSS on 8-bit units,CLZSSDecompressor::DecompressBuffer8) and three zero bytes; the data inflates to anMTRLform likeMaterialArchive.arc's. Its programs share code areas withMaterialArchive.arc's but differ in control +0x7d0 (a per-program value), so their keys are their own. - Little-endian forms (Metroid Prime Remastered): the same forms with every length, count and record header little-endian (
rfrm_ordertells the order from the u64 length: the reading that is a length is the small one). A pack's first chunk is aTOCCform holding theADIRchunk, whose entries are 52 bytes: fourcc type, GUID, two u32 versions, u64 offset, u64 length and u64 stored length (shorter for a compressed resource; none of the 778MTRLresources is). AnMTRLresource's zlib stream inflates to anEXPTchunk and then theMTRLform. Control sections are SDK 12's 0x880 bytes, and four vertex stages ofMaterialArchive.arccarry 0xc0 bytes more than their own length (+0x20) says: GLSLC's extra data at +0x7e8 (offset 0x880) / +0x7ec (length) / +0x7f0 (16 entries of 12 bytes), which the reader steps over to reach the next stage's section. - Reader:
rfrm_shaders,snvn_records,retro_main_formsandretro_lzss8inshader_split.py; each record is one program, so its stages pair with each other. - Found on: Donkey Kong Country: Tropical Freeze (SDK 4.4.0): 24,098 shaders (11,644 vertex, 11,734 pixel, 720 geometry, which run as compute kernels): 3,460 first seen in
MaterialArchive.arc(its 3,468 stages), the rest in the 795MTRLresources of the packs, 8 in the startup archive and 294 GLSLC outputs inmain; about 8 s. - Also found on: Metroid Prime Remastered (SDK 12.3.1, little-endian): 40,483 shaders (20,233 vertex, 20,233 pixel, 12 geometry, 5 compute): 1,141 stages in
MaterialArchive.arc, the rest in the 778MTRLresources of the world, preload and UI packs, and 8 in the startup archive (main+0x1d0e7a4, the sameCBufferedCompressiontype 1);mainholds no GLSLC output. Tropical Freeze's split gives the same 80,212 stages from its 76 RFRM files with the byte-order-aware reader.
MGFX effects in MonoGame XNB files
- How it is stored: the 13 XNB files in
Content/Effects(XNB platformS, uncompressed, read byEffectReader) each hold one MGFX effect, andmainholds SpriteBatch's built-in one; no other file and no GLSLC output. A compressed XNB (MonoGame's LZX flag 0x80 or LZ4 flag 0x40, as in Stardew Valley) is decompressed first (xnb_decompress). - Layout: MGFX version 8 is MonoGame's own layout:
MGFX, u8 version, u8 profile, u32 effect key, the constant buffers, then each shader as u8 vertex (1) / pixel (0), u32 length, the bytecode (u32 control length, the control section, u32 code length, the code area: GLSLC's output without its header), the samplers, the constant buffer indices and an attribute list (as MonoGame's OpenGL backend writes it), then the parameters and the techniques with their passes (mgfx_effect). - Reader: the reader parses every effect to its end, which checks the layout; in
main, four bytesMGFXcount only when version 8 and a control section follow. - Pairing: a pass names its vertex and pixel shader by index, 255 for none, and 10 of the 13 effects have no vertex shader: SpriteBatch binds its own and the effect's pass only replaces the pixel shader. Vertex and pixel shaders therefore combine across effects, and they are paired as single-stage programs.
- Found on: Celeste (MonoGame for the Switch, its C# compiled to C++ by Sickhead's BRUTE): 26 shaders (20 pixel, 6 vertex), all with Mario's binding layout; every binding decodes. Stardew Valley (compressed XNB).
MGFX version 5 effects (MonoGame 3.0)
- Symptom: the NSP script's split stops with
[RED] 24 file(s) could not be read, eachan XNB effect without an MGFX version 8 effect of NVN shaders. - Cause: MonoGame 3.0's EffectReader (
MonoGame.Framework, Version=3.0.0.0in the XNB) writes MGFX version 5:MGFX, u8 version, u8 profile and no effect key; constant buffers without names (i16 size, u8 count, {u8 parameter, u16 offset}); each shader's bytecode is a whole GLSLC output of one stage (magic 0x19866891, its +0x44 length equal to the shader's), not a control section and a code area; and the shaders are in MonoGame's DirectX layout: samplers without names and no attribute list. The parameters and techniques are version 8's. The length in front ofMGFXin the XNB runs about 0xff0 bytes past the effect, all zeros. - Fix:
mgfx_effectreads version 5 too, taking each shader throughglslc_shaders; an XNB effect may end in zeros. The 7.dx11.mgfxofiles inResources(MonoGame's stock effects) are version 5 as well: 5 hold GLSLC outputs and are read the same way,EnvironmentMapEffectandSkinnedEffecthold only DirectX bytecode and give no shader. - Found on: Bastion: 72 shaders, 24 pixel shaders in
Content/Effects(no vertex shader: SpriteBatch's own pairs with them) and 48 inResources.
Unity 2020.3 Shader objects in UnityFS bundles
- How it is stored: Unity 2020.3 keeps each shader's compiled programs in its
Shaderobject (class 48), in UnityFS bundles (StreamingAssets/aa) and in the player's own serialized files (data.unity3d,Resources/unity default resources). - Reader:
dm/gfx/tools/unityfs.pyreads them: UnityFS blocks (LZ4 / LZMA), the serialized file, the object through its type tree, thencompressedBlobper platform (LZ4 segments; each segment starts with a u32 count and 12-byte{offset, length, segment}entries). - Type trees: the player's files have no type trees; they borrow the bundles'
Shadertree with the same type hash (same Unity build, same layout). Type-tree strings with the high bit set index Unity's common string table, whichunityfs.common_stringsreads from the game's ownmain(AABB\0AnimationClip\0…). - Program code: on Switch every subprogram is one whole NVN program. Its code is a 0x60-byte header of four
i32 ×6arrays indexed by NVN stage (0 vertex, 1 fragment, 2 geometry, 3 tess control, 4 tess evaluation, 5 compute): an unused / scratch size, the block offset from +0x60 (−1 = no stage), the control size and the code size. Each block is the control section followed by the code area. The vertex and fragment stages of one program form a pair. - Found on: Fire Emblem Engage: 209
Shaderobjects in 188 bundles plus the engine files, 9,269 stage shaders (4,634 vertex / pixel pairs and one geometry shader), no compute shaders.
Legacy Unity stages lose their pass partners
- Symptom:
failed to create render pipelinewith a fragmentuser(locn1)input missing from the vertex output, while the split found all shaders but almost no vertex / pixel pairs. - Cause: before Unity 2019.3 each blob entry contains one stage. The vertex and pixel variants of a parsed pass have different blob indices, so pairing by the source's
#var<platform>.<index>connects none of them. Without its pixel partner, output padding does not know which unwritten vertex attributes the fragment shader reads. Return of the Obra Dinn'sOneBit/Ropehas vertex blob 0 with pixel blob 8 and vertex blob 28 with pixel blob 35; both fragment stages read generic locations 0–4. - Fix:
unity_pass_pairsinshader_split.pyreads the stages listed by each parsed pass, separately per platform, and pairs its vertex stages with a deterministic pixel-input cover in the supplementalpso_pairs.tsv. This covers every input used by the pass's pixel variants without depending on equal stage keyword lists or generating every combination. Existing evidence-pair tables, combined-stage Unity programs and source labels stay unchanged; profiles without a stored supplemental table take the generated one beside the shader package. - Found on: Return of the Obra Dinn (Unity 2017.4.28f1, 1.0.1): the two
OneBit/Ropepipeline failures reported in PR #427.
Unity 2022 Shader objects with parameter blobs
- How it is stored: the program code keeps the 0x60-byte stage header (Unity 2020.3 Shader objects in UnityFS bundles), but two things changed. From Unity 2022 the blob also holds each program's parameter tables as entries of their own (the parsed form's
m_ParameterBlobIndices; their bytes startPGlobals), and the player's programs are listed inm_PlayerSubPrograms[tier][].m_BlobIndex(m_SubProgramsis empty). - Reader:
unity_code_blobskeeps only those player programs. UnityPy's type trees for 2020.3 (Engage) and 2021.3 have neither field, so those read every entry as before. Subprogram version 202012090 (from Unity 2021.2) also has no local keyword list, sounityfs.subprogramreads no second keyword array (with one it read the code length as a keyword count). - Type trees: the player's serialized files have no type trees and the Addressables bundles hold no
Shader, except theunitybuiltinshadersbundle, whose tree has the same hash and is borrowed in the second pass. - Found on: Ace Attorney Investigations Collection (Unity 2022.3.17f1): 4,951 stage shaders: 2,475 vertex / pixel pairs (4,294 in the player's files, 517 built-ins, 140 in bundles) and one geometry shader, which Metal cannot express (its build fails).
- Note: armyja's issue #17 proposed UnityPy and a reader of its own; neither was needed.
Unity Shader layouts written out for players without bundles
- Symptom: the split stopped with
no bundle has a Shader type tree of the same hash to borrow. - Cause: a Unity player with no bundle to borrow a tree from: its serialized files have no type trees, and no bundle carries a
Shadertree of the same hash. - Fix:
unityfs.pycarries the layouts written out (KNOWN_TREES;tree_from_layoutbuilds the tree a file with type trees would have), and the split's second pass gives one to the stripped files when no bundle lent a tree of that hash. Only there: taken in the first pass, Ace Attorney Investigations Collection's player files came before itsunitybuiltinshadersbundle and the built-ins' source column and package indices moved, while with the second pass its tables come out byte-identical. The structs both the 2020.3 and 2022.3 layouts share are written once (_SHADER_COMMON), and the 2022.3 layout comes out byte-identical. - Found on:
- Brain Memory Training Academy (Unity 2022.3.5f1: only
Data/*.assets,level*and the two engine files inData/Resources, none with type trees). TheShadertype hash there (ea424a5f…) is the one in Ace Attorney Investigations Collection's bundles (2022.3.17f1): the same layout, about 30 structs. Checked against Ace Attorney's own tree: the same 2,324 nodes with the same alignment flags, and its 40 bundleShaderobjects decode identically with either. 496 stage shaders: 439 engine built-ins, 46 of the game's, 11 of the player's inmain, and the built-in VR blit's geometry shader, which does not build. - Patrick's Parabox (Unity 2020.3.30f1, built-in shaders only, no bundles) stopped there with hash
a30e3dcf…, which one 18 KB shader bundle of Fire Emblem Engage (2020.3.18f1) carries; its tree is now in the table too (SHADER_2020_3: per subprogram global and local keyword indices, every subprogram inm_SubPrograms, no parameter blobs, keyword names or stage counts), equal to Engage's node for node (2,179 nodes, the alignment flags included), and each of the game's 48Shaderobjects reads to its exact end. - Raging Loop (Unity 2018.3.10f1, no bundles: 48
Shaderobjects inData/*.assetsandData/Resources) stopped there with hash8db76007…, and no game of that Unity version with a bundle was at hand, soSHADER_2018_3is written from AssetRipper's TypeTreeDumps (StructsDump/release/2018.3.10f1.dump), checked node for node against the dump (1,325 nodes, the alignment flags included). It is a layout of its own, older than the common structs: no conservative raster state,BufferBindingwithoutm_ArraySize,ConstantBufferwithoutm_IsPartialCB, the parameters in each subprogram and nom_CommonParameters, one segment per platform (the pre-2019.3 form Cuphead's trees gaveshader_programs). Each of the 48 objects reads to its exact end: 450 subprograms of version 201802150 (older than the local keyword list, which starts at 201806140). - Return of the Obra Dinn (Unity 2017.4.28f1): hash
266d5311….SHADER_2017_4derives from the 2018.3 layout by removingm_HasProceduralInstancingVariantandm_NonModifiableTextures, with the instancing flag retaining the alignment. It matches all 1,313 nodes and alignment flags in AssetRipper's release type tree; the game note records the object census and exact-end checks. - Donkey Kong Country Returns HD (Unity 6000.0.28f1, no bundles): hash
2ec7223d…matches BALL x PIT's Unity 6000.0.62f1 bundles.SHADER_6000_0derives from the 2022.3 layout by removing each pass'sm_EditorDataHashandm_Platformsarrays and appending the shader'sm_AssetGUID. The generated tree equals the bundle tree in types, field order and alignment flags; the game note records the object census and build results. - LEGO Builder's Journey (Unity 2021.2.13f1, provisional profile: every serialized file is stripped and no bundle carries a
Shadertree): hashd8236c9b….SHADER_2021_2is written from AssetRipper's 2021.2.13f1 release tree and matches it node for node (2,174 nodes, the alignment flags included): its subprograms drop the local keyword list (as Unity 2022 does), keep their parameters and use signed 64-bit requirements; each pass appendsm_SerializedKeywordStateMask, and the parsed form adds keyword names and flags. All 195Shaderobjects read to their exact ends, and the split finds 9,410 stage shaders (3,567 vertex, 3,571 pixel, 2,271 compute, one geometry) in the player's files andmain. - Dodgeball Academia (Unity 2019.4.21f1, provisional profile: every serialized file is stripped and no bundle carries a
Shadertree): hashed2a06ce….SHADER_2019_4derives from the 2018.3 layout, checked node for node against AssetRipper's 2019.4.21f1 release tree (1,481 nodes, the alignment flags included) and equal to the tree Subnautica's Unity 2019.4.36f1 bundles carry under the same hash: global and local keyword indices in each subprogram, a ray tracing program in each pass, and 2019.3's segments (oneoffsets/compressedLengths/decompressedLengthslist per platform); the parameters stay in each subprogram, withoutm_CommonParameters. All 62Shaderobjects read to their exact ends, and the split finds 7,996 stage shaders (4,162 vertex, 3,778 pixel, 55 compute, one geometry) where 11 came frommainalone.
- Brain Memory Training Academy (Unity 2022.3.5f1: only
- Note: a Unity version whose
Shaderhash is not in the table still stops with the same message; read its tree from any bundle of that Unity version, or from AssetRipper's TypeTreeDumps for that exact version (compared node for node, the alignment flags included), and add it with its hash toKNOWN_TREESand todm/gfx/tests/test_unity_known_trees.py. To take one file out of an NSP without extracting the RomFS, filternx_extract'sCopyby path.
Unity 6 Shader objects
- Read with no change: Hollow Knight: Silksong's 2,068 Addressables bundles carry their type trees (they report the engine version
0.0.0, Unity's stripped version string), so no layout had to be written out. - BALL x PIT keeps most of its shaders in the player's loose serialized files (
resources.assets,sharedassets1.assets, the built-in resources), which have no type trees; they borrow theShadertree its 10 shader bundles carry (shader_split.pydefers a file that raisesNoTreeto a second pass), so it needed no layout either. - Found on: Hollow Knight: Silksong (Unity 6000.0.50f1): 2,450 stage shaders: 1,189 vertex, 1,193 pixel, 67 compute and one geometry shader, which does not build; 11 of them are the player's own in
main. BALL x PIT (6000.0.62f1): 38,099 shaders, most of them in the loose files. Silver Pines (6000.3.15f1): 56,386 stage shaders in its Addressables bundles, read with no change.
Unity ComputeShader objects
- Symptom: the 3D battle scene was black behind a correct UI, with
[RED] shader 854a8afc9d07f88d is not in the shader mapandthe dispatched program has no compute shader. - Cause:
DM_NVN_SHDUMPsaved the program; its code bytes were inData/sharedassets2.assets, uncompressed, inside aComputeShader(class 72): the post-processing stack's colour grading LUT baker (Lut3DBaker#KGenLut3D_AcesTonemap), so the LUT the scene is graded through stayed black. The split read onlyShaderobjects. AComputeShaderholds per platform variant a list of kernels, each with its unique variants; a variant'scodeis the program code of aShadersubprogram (the 0x60-byte stage header, only the compute block present) and itsthreadGroupSizematches control +0x748. - Fix:
unity_computeinshader_split.pyreads them beside theShaderobjects of every serialized file. The game has no bundle with a class 72 tree, sounityfs.pycarries Unity 2022.3.54f1's layout (COMPUTE_2022_3, written from AssetRipper's TypeTreeDumps for that version; all 12 objects decode,resourcesResolvedtrue at each end). 85 compute shaders came in (86 with the player's own). - Found on: Monster Train 2 (Unity 2022.3.54f1).
- Older layouts: Unity 2019.4 stores each kernel's code directly, without
uniqueVariantsorvariantMap.unity_computeaccepts that form too, andCOMPUTE_2019_4supplies the stripped-file layout. Found on Pokémon Brilliant Diamond (2019.4.27f1); its missing runtime shader is the built-in GPU skinning kernel. The layout matches the release dump's 116 nodes and alignment flags, and both built-in compute objects decode to their exact ends. - Unity 2021.3:
COMPUTE_2021_3covers stripped class 72 hashabd9135b8ce83d043fef4e9ec7f53366, matching all 144 nodes and alignment flags in AssetRipper's 2021.3.18f1 release tree. Kernel parents usevariantMap, global and local keywords; each kernel holds byte-vector code, astaticvectorthread group size and signed 64-bit requirements. Unlike 2022.3 there are nouniqueVariants,variantIndicesor dynamic keywords; both versions store code as bytes.resourcesResolvedaligns after every platform variant, including when another platform follows. The synthetic tests cover two platforms, multiple keyword variants, byte-code padding, exact object consumption and shader provenance. Found on Shin chan: Shiro and the Coal Town (Unity 2021.3.18f1): PR #440 reports recovered GPU skinning kernels and correctly rendered characters on the iPhone; that gameplay result has not been independently repeated here. - Unity 2017.4: Return of the Obra Dinn needs hash
20291e02…for strippedinternalskinningobjects.COMPUTE_2017_4restores the nested buffer-counter fields; all 120 nodes and alignment flags match the release dump. The game note records the object census and exact-end checks. - Registered tables after a reader update: a newly supported layout can add kernels to an existing game's shader map. For registered module builds, the NSP script permits a shader-only derived profile only when every original shader's package, stage and source, every original fact and every evidence pair remain unchanged (package indices may move), and every added shader is compute with matching key/source evidence from
unity_compute. Each added kernel must also have its fact row. Registered settings, build rows and cuts are retained; the repository tables are not edited. A cached comparison pins the registered tables, split inputs and comparison code. Deleted or changed original rows, graphics additions and additions without reader evidence still stop the build. The incremental-build tests cover acceptance, rejection and cache invalidation. - Note: a file whose
ComputeShaderhas no tree still goes to the second pass; if it has none there either, the split prints a[RED]line and leaves its kernels out instead of stopping. The original bring-up omitted stripped objects in Cuphead (2), Fire Emblem Engage (4) and Raging Loop (2 built-ins; no compute dispatched). Their type hashes were not recorded, so their compatibility with the newer layouts remains unchecked. Unity 2017.4.9f1, 2017.4.17f1 and 2017.4.28f1's published class 72 release dumps are identical, but that does not establish the hashes in Cuphead or AI: THE SOMNIUM FILES.
GLSLC outputs in a Unity TextAsset
- Symptom: black, jagged holes in the ground of a map (whole tiles of the terrain mesh missing while others draw), with about 25 distinct
[RED] shader … is not in the shader mapkeys andprogram has no vertex shader, on a Unity game whoseShaderobjects all split cleanly. - Cause: the game draws its maps and effects with programs of its own, not Unity's:
DM_NVN_SHDUMPsaved 24 of them, and none was in anyShadersubprogram of the 1,606Shaderobjects in the RomFS (all of them decoded); their code areas were inData/resources.assets, uncompressed, inside theTextAsset(class 49)shader.switch.vfxsb(11 MB, first word 0x20160817): 1,160 whole GLSLC outputs of a vertex and a pixel stage each. Their control sections carry GLSLC's full header (control +0x20 = 0xa10 and more), unlike the 0x850-byte sections Unity 2018 stores in its subprograms. The split read onlyShaderandComputeShaderobjects, and the fallback for loose GLSLC outputs skips Unity files. - Fix:
unity_text_glslcinshader_split.pyreads theTextAssets of a serialized file that holds the GLSLC magic (unityfs.text_assets: name and bytes, the two leading fields every Unity version shares, so no type tree is needed) throughglslc_shaders, which keeps only outputs that pass its checks. A file without the magic is not decoded, so other games' tables do not change. This one TextAsset added 1,207 distinct shaders (the game has 31,635 in all), with 1,156 vertex / pixel pairs; they covered all 57 keys the runs had reported missing. - Found on: FINAL FANTASY CRYSTAL CHRONICLES Remastered Edition (Unity 2018.4.2f1): the intro's overworld shot.
Unreal Engine 5 shader code archives (Oodle)
- How it is stored:
ShaderArchive-Global-SF_GLSL_SWITCH_MOBILE-SP_GLSL_SWITCH_MOBILE.ushaderbytecode(1,219 shaders) andShaderArchive-Octopath_Traveler0_Chunk0-…(9,377), both stored in the pak (01). Version 2 archives of shader map hashes, shader hashes, shader map entries, shader entries{u64 offset, u32 size, u32 raw size, u8 frequency}(17 bytes packed; frequency 0 vertex, 3 pixel, 5 compute), preload entries and shader indices, then the code. - Compression: every shader is Oodle-compressed (Mermaid, block header
8C 0A). There is no Oodle decoder in this repository, so the game's ownOodleLZ_Decompress(statically linked intomain; the host finds it as the one function that takes the address of the library's own name string "OodleLZ_Decompress", its profiler scope: Octopath Traveler 0main+0x4f77e10, Pokemon Legends: Z-Amain+0x1a9c9e0) decodes them in the Mac host:DM_OODLE_JOB(dm/host/hle_oodle.c), the NSP script'sdecode_ue_shadersfor any game with Unreal paks. - Record: a decoded shader is an
LSLGrecord:LSLG, a stage tag (SV,SP,SC), a header with the source name (/Engine/Private/…usf:MainPS), thenu32code length, the code area,u32control length (0x880), the control section and a 24- or 29-byte tail, all at unaligned offsets (uepak.lslg). - Check: the host keys came out exactly: 37 of 37 shaders a first run reported missing were in the two archives.
- Found on: Octopath Traveler 0: 10,324 unique shaders (1,457 vertex, 6,346 pixel, 2,521 compute); vertex and pixel shaders combine per draw, so every vertex shader is paired with the single-stage cover. Bindings: separate textures and samplers, as Engage's (
0x168/0x568); 61 vertex shaders pick a texture slot at run time (profile keytex_dyn_slots, Texture slots picked at run time), 2,084 shaders do atomics on buffer images (Image atomics). - Note: 16 compute shaders do atomics on a 3D
r32uiimage, all Unreal's paged global distance field (GlobalDistanceFieldCompositeObjects.usf:CompositeObjectsIntoPagesCS), which the mobile renderer does not dispatch (none in any run). They build since image atomics are native (Image atomics).
Unreal Engine 4.27 shader code archives (LZ4)
- How it is stored: the desktop renderer's
ShaderArchive-Global-SF_GLSL_SWITCH.ushaderbytecode(1,764 shaders) andShaderArchive-DOLL_Chunk0-SF_GLSL_SWITCH.ushaderbytecode(63,185), stored in the pak: the same version 2 archives and the sameLSLGrecords as Unreal Engine 5's, but every shader is an LZ4 block (the first bytes are LZ4 tokens followed byLSLG; LZ4 is Unreal's default shader compression since 4.26). - Reader:
uepak.pydecodes them itself (lz4_shaders): every compressed shader must decode to its raw size as anLSLGrecord, else the archive goes to the Oodle job as before. - Found on: DRAGON QUEST VII Reimagined (Unreal Engine 4.27): 62,901 unique shaders (10,114 vertex, 50,756 pixel, 1,968 compute, 63 geometry).
- Note: its fragment shaders carry no output types either: one global shader writes integers bit-cast into a float output to an
R8Uinttarget, listed in the profile'sshader_facts_manual.tsv(Integer fragment outputs declared by hand).
Unreal Engine 5.6 IoStore shader libraries
- How it is stored: the desktop renderer (
SF_GLSL_SWITCH) with the sameLSLGrecords, but no archive in the pak: the two libraries are IoStore shader libraries inFFRS-Switch.ucas, whose shader groups are Oodle-compressed as a whole (01). Each shader entry packs its frequency (bits 0–3), group (4–33) and offset in the group's decoded bytes (from bit 34), and a shader runs to the next one's offset. - Reader:
uepak.pywrites them out as version 2 archives, so the split reads them as any other. - Found on: FINAL FANTASY RESONANCE (Unreal Engine 5.6.1): 73,496 unique shaders (9,253 vertex, 61,305 pixel, 2,934 compute, 4 geometry).
IoStore patch containers keep their shader groups in the base container
- Symptom:
ValueError: …/Glow-Switch_0_P.utoc: shader group 0 has no chunkfromuepak.py io_archivewhile the NSP script decodes the shaders (decode_ue_shaders, after[4/8] UE shader decode). - Cause: an update's IoStore container is a patch: its shader libraries have the base game's chunk IDs (so they replace their tables), and an unchanged shader group is listed with the base container's chunk ID.
io_archivelooked every group up in the container the library chunk came from. - Fix:
uepak.pyreads every.utocunder the RomFS (io_tocs, in mount order: the base game's containers, then_Ppatches and numbered_0_P,_1_P, … patches,mount_order, case-insensitive_Pmarker) and resolves a group's chunk ID in any of them; a library chunk ID two containers share is taken from the later (patch) one, whose tables are the current set. TOC metadata is read without a key;Toc.readrefuses encrypted data without a key only when the selected library or group needs it, so an encrypted group-only container is refused and a fully superseded encrypted library is never read. - Found on: LEGO Horizon Adventures (update v262144, Unreal Engine 5; provisional profile): the update's
Glow/Content/Paks/Glow-Switch_0_P.utocover the base'sGlow-Switch.utoc; 20,889 shaders in its two libraries, 20,880 unique after the split.
An Unreal pak key split into two 16-byte literals
- Symptom:
[RED] …/Nicola-Switch_0_P.pak: the index is encrypted and no window of main decrypts itfrom the NSP script before[5/8] UE shader decode(the message now readsno key in main (a 32-byte window or two 16-byte literals its code stores together) decrypts it). - Cause:
uepak.py find_keytried every 32-byte window ofmain's read-only and data segments. The engine gets the key from a callback that copies a 32-byte array into its argument (UE_REGISTER_ENCRYPTION_KEY), and clang compiled the copy as two 16-byte constants loaded from separate places:adrp,adrp,ldr q0,ldr q1,stp q0, q1, [x0],ret, so the key's halves are not side by side and no window holds it. - Fix:
find_keyfirst tries the keys the code builds that way (code_keys: eachstp qA, qB, [x0]with both registers loaded byldr qfrom anadrppage within the 8 instructions before it, a register written by anything else in between dropped), then the windows as before. The right key is the one that decrypts the index start to a short mount point, as for a window. Covered bydm/tools/tests/test_uepak_key.py(a syntheticmainand pak, no game files). - Found on: DRAGON QUEST III HD-2D Remake (Unreal Engine 4.27): 8 candidate pairs in
main, the key found in about 2 s.
Unreal Engine 4.18 shaders inline in cooked packages
- How it is stored: legacy version 4 paks with LZ5-compressed blocks (01) and no shader code archive. Each material's shader map is serialized in its
.uexp(38 to 87 shaders in the ones looked at) and the engine's global shaders inEngine/GlobalShaderCache-GLSL_SWITCH.bin(945: 597 pixel, 104 vertex, 241 compute, 3 geometry). Each shader's code is a byte array (i32length, then the bytes) holding one zlib stream that inflates to the sameLSLGrecord as Unreal Engine 5's. - Reader:
shader_split.py(ue_legacy_jobs,ue_inline_shaders) decodes every file of the newest copy of each path with the game's own LZ5 and takes each zlib stream that inflates whole from exactly the length in front of it and starts withLSLG(every such stream in the files looked at). Records stored uncompressed are taken too, but none were seen. - Found on: DRAGON QUEST XI S: 38,892 unique shaders (4,654 vertex, 33,981 pixel, 234 compute, 23 geometry) from 1,740 files; vertex and pixel shaders are paired as single-stage programs, as for Octopath Traveler 0.
Unreal Engine 4.27 shaders inline in cooked packages (LZ4)
- Symptom:
the RomFS has Unreal paks, but none … holds a shader code archive, thenthe shader split found no shaders in main or the RomFS. - How it is stored: a version 11 pak (Zlib blocks, index not encrypted) with no
.ushaderbytecode: the project turnedbShareMaterialShaderCodeoff, so each material keeps its shaders in its own.uexpand the engine's global shaders are inEngine/GlobalShaderCache-SF_GLSL_SWITCH.bin, as in Unreal Engine 4.18. Unreal 4.27 serializes each one as an entry ofFShaderMapResourceCode: the code (i32length, then an LZ4 block, the engine's shader compression since 4.26), the raw size (i32) and the frequency (u8). - Reader:
uepak.pyinline_shaderstells such a pak from one with archives by its directory index (cooked packages and no.ushaderbytecode; an encrypted index is taken for one with archives), andshader_split.pyue_legacy_jobsreads it file by file, Zlib and LZ4 blocks decoded in Python (Pak.read), so no host runs.ue_inline_shadersfinds each LZ4 block by its first literals, the record'sLSLG, with the token and literal length bytes in front of it, and takes it when it decodes to exactly the raw size after the block. - Found on: OCTOPATH TRAVELER II: 13,980 unique shaders (1,910 vertex, 11,346 pixel, 694 compute, 30 geometry) from the 3.3 GB pak in 13 s.
Unreal Engine 3 shader caches (Oodle packages)
- Symptom:
the shader split found no shaders in main or the RomFS; the survey lists the RomFS as cooked packages (.xxx) and.usmmovies only. - How it is stored: the engine's global shaders are in
GlobalShaderCache-Switch.bin(magicBMSG, not compressed), the materials' inCombinedShaderCache-Switch.xxx, an Unreal Engine 3 package (tag0x9E2A83C1, a positive file version after it: 864; Unreal Engine 4's is a negative legacy version). After the package summary come compressed chunks, each {tag, block size, compressed and raw size of the chunk, then each block's two sizes} and the blocks: 0x20000 bytes of Oodle Mermaid each (8C 0A), a block stored whole having its two sizes equal. Each shader is a record {u32length,u32code area length,u32control length (0x880), in the global cache one moreu32}, then its code area and right after it its control section. - Reader:
shader_split.pyue3_package/ue3_bodydecode a package's chunks (Zlib in Python, Oodle with the game's own decoder through the Mac host's tool mode,DM_OODLE_SERVE), andue3_shadersfinds each record by its control section's magic, its code area length (+0x704) back to the code area's magic, and the two lengths in front.profile_guess.pycounts the packages whose first block is Oodle, so the NSP script gives the split the host. All of the game's 4,058 packages are decoded (the whole split took about a minute); none of 68 packages sampled besides the combined cache holds a shader. - Found on: Batman: Arkham Knight: 1,847 records in the global cache and 22,874 in the combined one, 21,923 shaders with the profile's
shader_key_skip. Vertex and pixel shaders are paired as single-stage programs.
Control sections and code
Code area and control section layout
- Code area: graphics = 0x30-byte header (magic
0x12345678) + 0x50 SPH + instructions (at +0x80); compute = 0x30-byte header (magic0x12345679) + zero padding, instructions at +0x100 (all 71 of Mario's). - Control section: magic
0x98761234; +0x700 = c1 offset in the code area, +0x704 = code size, +0x748 = compute thread-group size (u32 ×3), +0x754 = compute shared memory bytes. - Found on: Super Mario Bros. Wonder.
Control sections shorter than 0x880 bytes
- Cause: the control section stores its own length at +0x20. Mario's, Engage's, Dread's and 13 Sentinels' are all at least 0x880 bytes; 3D World has 2,888 of 0x850 bytes (its ShaderData bfsha and six layouts) and 0x848 in BAHS version 9, each followed directly by other data.
- Fix: the key hashes the first
min(0x880, control +0x20)bytes (ctl_leninshader_split.py,keys_hashinhle_nvn.c); the other games' tables came out byte-identical. - Found on: Super Mario 3D World + Bowser's Fury (SDK 10).
Program keys computed inside nvnProgramSetShaders
- Symptom: shaders reported missing at random, and their dumped control sections are garbage (Unity).
- Cause: NVN lets the caller free a program's control section once
nvnProgramSetShadersreturns; Unity does, and the host used to hash it at the first draw, so keys computed later hashed freed memory. - Fix: the keys are now computed inside
SetShaders(hle_nvn.cprog_key). Mario callsSetShadersfor some programs with a code address that is not mapped yet, so a stage whose code is not in a registered memory pool at that moment is hashed at its first draw, as before. - Found on: Fire Emblem Engage and Unity games in general (the source names no other); Super Mario Bros. Wonder (the unmapped code addresses).
Control sections shared among shaders that differ only in their compile information
- Symptom:
[RED] shader … is not in the shader mapfor shaders whose control section and code area are both in the game files, but in different records: dumped (DM_NVN_SHDUMP), the control section is one record's and the code area another's. - Cause: the engine shares one control section among shaders whose sections differ only in GLSLC's compile information (+0x778..+0x791 and +0x7d0..+0x7e7):
DM_NVN_PROGTRACEshowed the same control pointer set first with its own code area and later with another shader's. The GPU does not read those bytes, so on hardware it makes no difference; the host's key hashes them. Masked, Batman: Arkham Knight's 24,721 control sections are 5,615; the tools read nothing there (dmsh_build.pyreads up to +0x75f). - Fix: the profile key
shader_key_skip <offset>+<length>(0x770+0x78): the key hashes that range as zeros, inshader_split.py(key_ctl, andsame_shaderignores it) and in the host (hle_nvn.ckeys_hash). Without the key, every game's keys are as before. It is a profile input of the shader chain, so the libraries follow it. - Found on: Batman: Arkham Knight: 40 shaders missing in the first 45 s before the key. With
0x7d0+0x18alone the opening cutscene still met one whose section differed at +0x778, as pull request #340 had found, so the range starts at +0x770. - Also found on: EA SPORTS FC 27 (
0x7d0+0x18): Frostbite's NVN renderer keeps one copy of each control section in a cache keyed by a hash that leaves out the compile information (fb::ShaderPlatformDataNvn,s_controlBlockCacheMutexinEngine.Render.Core2.PlatformNvn.nro); 10 programs were missing in the first menus, and the 10,342 keys are 7,873 shaders with the key. Its sections differed only at +0x7d0..+0x7e7 (+0x778 is zero in all of them).
Compute code with and without a header
- Cause: compute code areas have a header in Mario (
0x12345679), not in D2R. 3D World's particle compute shaders (SDK 10) start their instructions at code +0 (like D2R), not after a0x12345679header. - Fix:
shader_split.pyaccepts both;dmsh_build.pystrips only a header that is there. - Found on: Super Mario Bros. Wonder (header); Super Mario 3D World + Bowser's Fury (no header, SDK 10).
Exact program start for the decoder
- Symptom: 12 compute shaders (particles, post effects) translated into an endless loop that hung the GPU at the first gameplay scene; SPIRV-Cross failed on 7 shaders instead of 2 (SPIRV-Cross parse failures).
- Cause: Ryujinx's decoder treats every 32-byte boundary relative to the start as a scheduling word. Mario's compute code was first fed from +0x30: the groups were misaligned by 0x10, and instructions were dropped or misread.
- Fix: the program start must be exact (compute instructions at +0x100).
dmsh_build.pynow refuses compute code whose padding is not zero. - Check: for a new title, the first non-zero word after the header must be a control word, and the trailing
EXIT(e30000000007000f) /BRA-to-self (e2400fffff87000f) must sit in instruction slots of 32-byte groups counted from your chosen start. - Found on: Super Mario Bros. Wonder.
GLSLC outputs read through their section headers
- Symptom: GLSLC outputs fail the reader's check and are dropped (44 of DARK SOULS: REMASTERED's
main's 72 outputs and most of its archives' pixel shaders). - Cause: SDK 4's GLSLC outputs (version 0x20.4): pixel shaders' control sections store at +0x20 a value 12 bytes short of their length, so the code area is not right after it.
- Fix: an output's own section headers say where everything is: the output's +0x4c is the section count, section i's header is at +0x90 + 0x90 i (
{size, data offset from the output, type}, type 0 = GPU code), and a GPU code header adds +0x30 control offset, +0x34 code offset (both from the section's data), +0x38 code length, +0x3c control length.glslc_sectionreads them for outputs the old rule rejects. - Found on: DARK SOULS: REMASTERED (SDK 4.5.0).
- Check: on every output the old rule keeps (1,073 in 13 Sentinels, Three Houses, FINAL FANTASY TACTICS, Engage and Age of Calamity) both give the same key; the new reading also takes 8 outputs of Fire Emblem: Three Houses and 3 of Hyrule Warriors: Age of Calamity (GLSLC version 0x1d.0, whose control +0x20 holds odd values) that used to be dropped, so those two profiles' tables gained them.
VertexA and VertexB parts merged into one vertex shader
- Symptom:
shader_split.pyfinds no stage for vertex shaders and stops at the first captured output (no GLSLC output). Translated, every post pass and the window draw one texel's colour (a black screen with sound); every varying at (0, 0, 0, 1). - Cause: shader type 0 in every vertex shader's SPH (word 0 =
0x0061; every other game's vertex shaders have0x0461). Shader type 0 is Maxwell's first vertex slot (VERTEX_CULL_BEFORE_FETCH), and this GLSLC (GPU code version 1.15) writes every vertex shader as two programs in one code area: part A at code +0 (the type-0 SPH) loads the position attribute and writes only the position; part B, at control +0x700 with a 0x30-byte header of zeros and an SPH of type 1, reads the other attributes and writes the other outputs. Control +0x748 lists both as {SPH offset, length from the SPH, ?}. The hardware runs A, then B. Translated alone, A left every varying at (0, 0, 0, 1), so the full-screen passes sampled their sources at (0, 0) and the 2D draws their atlases' first texel. - Fix: Ryujinx's
ShaderHeaderreads a graphics header's type 0 as a vertex shader;sph_stagedoes too.dmsh_build.py(vertex_ab) merges the two parts as Ryujinx'sTranslatorContext.Translate(VertexA)does: A's instructions with each unconditionalEXITturned into aBRAto B's first instruction, then B's, under B's SPH with A's flags and input / output maps ORed in and the larger local memory sizes. c1 is what follows part B. A type-0 shader with no part B listed is translated as it is. - Found on: Catherine: Full Body: in all 1,244 vertex shaders A writes no generic output, A ends in an
EXITpredicated on CC FCSM_TR (which Ryujinx never takes; left as it is), an unconditionalEXITand aBRAto itself, and neither part has an absolute or table jump, so moving B keeps every branch; c1 (what follows part B) is zero padding in all of them. A census of the other games' packages and of everymainon hand found no other type-0 vertex shader.
Translation
Tested tools and compiler-specific shader caches
- Symptom: a shader build accepts tools that differ from
dmsh_chain.lock; after an override build, returning to the tested Metal compiler can still restore the override's libraries. - Cause:
own_compilers.pypreviously accepted any present Metal compiler or SPIRV-Cross. The shader and fetch-variant caches hashed the recorded Metal version and flags, without the actual compiler or SDK. Prefill checked only a sample's compiled bytes, so a mixed folder's other libraries could be attributed to the wrong compiler. The NSP stage key recorded Xcode's version, which can stay unchanged when its separately downloaded Metal toolchain changes, and did not followSPIRV_CROSSoverrides. - Fix: ship a universal SPIRV-Cross built from a pinned commit and require its fingerprint for NVN builds. Require both tested Metal compilers before an NVN game's shader steps;
--allow-other-metal(DM_ALLOW_OTHER_METAL=1forown_compilers.py) explicitly permits another present Metal compiler. Shader and fetch-variant keys include the actual Metal compiler and SDK identities. Earlier-library prefill requires that identity on every library's reflection (chain_compiler); matching a few sample compiles cannot establish the unsampled libraries' compiler. Unmarked legacy libraries are not imported, and the Metal cache format is bumped to discard entries prefill might have misclassified. NSP and regression keys hash the selected converter's bytes, including overrides; NSP keys also include both SDKs and Metal tools. - Found on: Breath of the Wild, issue #242. The report used an untested compiler, but whether that caused the corrupted picture remains open. The cache failures were reproduced with stand-in libraries; this does not establish the rendering cause.
- Check:
test_own_compilers.pychecks enforcement and the explicit Metal override.test_shader_toolchain_cache.pyfirst restored an untested library after switching compiler identities, for both main shaders and fetch variants, and now rejects it. It also checks stage invalidation on actual Metal, SDK and selected converter changes.test_spirv_cross_pin.pychecks the shipped binary, pin, lock, architectures and licences.
Metal compiler of a newer Xcode
- Symptom: after an Xcode update the build stops with
[RED] this Mac's Metal compiler is not the one the shaders were tested with (dm/gfx/tools/dmsh_chain.lock): metal_macosx Apple metal version 32023.922 …;xcodebuild -downloadComponent MetalToolchain, as the message said, downloads the same untested compiler again. - Cause: since Xcode 26 the Metal toolchain is a system asset that each Xcode downloads in its own build and
xcruntakes for the selected Xcode (xcodebuild -showComponent MetalToolchainnames it), whatever the macOS version: macOS 27.2 with Xcode 27.1 selected gives the tested 32023.921, with Xcode 27.2 selected 32023.922. The message named the Xcode to install but not that it must be the selected one, and README linked the App Store, which offers only the newest Xcode. - Fix: the stop message names the selected Xcode and its folder, and says to install the tested Xcode from Apple's download page (beside a newer one as
Xcode-<version>.app), select it (xcode-select -sorDEVELOPER_DIR), then download its Metal toolchain; README says the same. The lock stays at the one tested compiler: another one needs a regression run before it is recorded, and the lock is part of the chain version. Whether two installed toolchain assets each follow their own Xcode is inferred from-showComponent, not yet tried with both installed (open). - Found on: no game: GitHub issue #717, on macOS 27.2 with Xcode 27.2 (Metal Toolchain 27B47).
- Check:
test_own_compilers.py(the selection step before the download),test_ci_toolchain.py(the message's and README's Xcode are CI's).
SPIRV-Cross parse failures
- Symptom: SPIRV-Cross can't parse the SPIR-V (
nullptrexception). - Cause: 2 agl post-effect compute shaders still produce such SPIR-V. Before the compute start-offset fix (Exact program start for the decoder) it was 7: misdecoded code produced the bad SPIR-V.
- Found on: Super Mario Bros. Wonder.
Pixel shader inputs the vertex shader does not write
- Symptom:
Fragment input(s) user(locnN) mismatching vertex shader output type(s) or not written by vertex shaderand the draw is skipped; the host reports[RED] failed to create render pipeline (vertex … fragment …). Offline packing fails withpso_offline: metal-tt failedfor the same reason. - Cause: every pixel shader input must be a vertex shader output. Maxwell attributes are untyped slots: a pixel shader that reads a slot the vertex shader never wrote gets a default, but Metal refuses to build the pipeline. Translating one shader at a time cannot know what the partner reads. The offline archive exposed a second omission: prebuild reads both
shader_pairs.tsvand BAHS program variations inpso_pairs.tsv, but padding read only the first table. - Fix:
dmsh_build.pyreads both pair tables and gives each vertex shader the outputs its partners read but it doesn't write, asfloat4(0, 0, 0, 1) (vs_pad). A profile withoutpso_pairs.tsvtakes the split's copy beside the package, as the archive builder does. The union controls pixel-before-vertex build order and shader cache dependencies (Shader cache key covering the pairs); the profile input stamp includes itspso_pairs.tsv, and the NSP script's shader reuse key includes the generated fallback. Padding precedes the depth, shadow-input, pulling and geometry-capture variants. Rebuild shaders and the host for the new chain. The host caches failed pipelines and reports each once with its vertex / fragment keys; before that, every draw retried the failing compile. - Found on: Super Mario Bros. Wonder: 331 of 9,043 pairs failed; the opening course used none of them, later levels did (about 11,500 failed builds in one 11-minute session). The missing BAHS table was reproduced on Tears of the Kingdom (#283) and Super Mario Odyssey (#276); their notes keep the pair counts and validation.
- Check:
dm/gfx/tests/test_dmsh_pairs.pycovers both table locations, union/deduplication, defaults and cache dependencies; Metal compiles the generated sources for Mac and iOS and builds an archive with regular and pulling vertex entries in both depth modes.test_nsp_mac.pychecks fallback invalidation and progress ordering;test_nsp_incremental.pyregenerates a missing fallback. Check all pairs offline after a game build (recipe in 05).
Single-stage programs paired with a cover
- Cause: a game that builds one NVN program per GLSLC output and combines vertex and pixel programs per draw with
BindProgramstage bits has its real pairs only at draw time, so the offline build cannot know which pixel shaders a vertex shader must feed. - Fix: the composite-program path (Programs bound per stage) does all the pairing at replay. When every pixel shader input is a plain
float4at fixed locations,shader_split.pypairs every vertex shader with a cover: the fewest pixel shaders whose inputs together are every input any pixel shader reads.vs_padthen gives each vertex shader all of them, and any vertex / pixel combination links. - Found on: 13 Sentinels: Aegis Rim: 838
ProgramSetShadersat startup; every pixel shader input is a plainfloat4at locations 1–9 (the SPH input map, words 6–13, matches the translated MSL inputs for all 375 pixel shaders), so the cover is one shader.
Unwritten components of a written output start as (0, 0, 0, 1)
- Symptom: outdoors dim and nothing casts a sun shadow (every shadow-map lookup fails).
- Cause: a vertex shader may write only part of an output (Link's Awakening writes a world position's x, y and z) while the pixel shader reads the rest (it divides by w). On the hardware the unwritten components read as (0, 0, 0, 1); Ryujinx gets the same by initializing the components the next stage reads when it links stages. The translator's MSL starts every vertex shader with
main0_out out = {};, all zeros, so w was 0, and a fragment shader dividing by it got infinite compare values. - Fix: general:
dmsh_build.py(vs_init) sets everyfloat4output to (0, 0, 0, 1) right after it, and the translated body overwrites what it writes. - Found on: The Legend of Zelda: Link's Awakening (Mac and iPhone): 12,281 of 18,410 vertex outputs never write w. The sun's shadow map (2048×2048, depth 32-bit float) was correct; the surface shaders compute the lookup from a world position the vertex shader passes in
locn24, divided by its w. Found withDM_NVN_TEXRAWon the shadow map, the light matrices fromDM_NVN_CBDUMP, and a hand-edited copy of one surface shader that wrote the compare value and the stored depth as its colour. Compared with forcing the compare to pass (compareFunctionalways): the same brightness, plus shadows from the fences, the house and the trees.
Early fragment tests from the control section
- Symptom: rows of dark dots along contour lines of alpha-tested surfaces drawn after a depth pre-pass (Tears of the Kingdom: the stone lattice in front of the Great Sky Island start, as reported by MacNativeGames ae8f4f4; not reproduced here).
- Cause: byte +0x718 of a pixel shader's NVN control section is its early fragment tests flag: the NVN driver's getter is
ldrb w0, [x8, #0x718]in thesdkof all 21 games on hand, and Ryujinx receives it as the 3D engine's EarlyZForce. The translator always passedearlyZForcefalse. Without the attribute, Metal tests depth after a fragment shader that writes memory. In the lattice's G-buffer pass (EQUAL, no depth write, after a LESS pre-pass) that late depth differed by an ulp from the pre-pass along contour lines, the test failed there and the G-buffer kept its clear value. - Fix: general:
dmsh_build.py(early_fragment_tests) setsearly_fragment_testsin the translation job of every pixel shader whose control byte +0x718 is set.dmsh_xlatepasses it to Ryujinx asearlyZForce, and the MSL entry point is declared[[ early_fragment_tests ]]. A shader whose SPH writes depth (word 19 bit 1) is left as it is, since a test made early would not use the depth it computes. - Found on: Tears of the Kingdom: 1,677 of its pixel shaders set the flag, and none of them writes depth. Rebuilding the chain changed exactly those 1,677 shaders' MSL, by the attribute alone. Elsewhere the attribute alone changed 699 of Wonder's 16,268 shaders and 51 of Scarlet's 30,142. Breath of the Wild and ANIMAL WELL set the flag in no shader.
- Check:
dm/gfx/tests/test_early_fragment_tests.py. - Open: with the attribute, a fragment shader that writes memory gives a picture that varies from run to run on Apple's GPU: Tears of the Kingdom's
8f470f5ce5864df7, a per-fragment visibility flag (2026-10-30; the frame's replays: one digest without the attribute, 3 to 7 in 8 with it).
Dead-code removal dropped the inputs of atomics
- Cause: Ryujinx's
Optimizer.RemoveNoderemoves a dead node, then follows each source whose last use went away into the node that produced it. It did so even when that producer has side effects, such as an atomic or a call whose result is unused. Such a producer stays in the block, but its sources lost their uses, so the next dead-code pass deleted the chain that feeds it, and the atomic read undefined values. - Fix: general:
RemoveNodefollows only into producers without side effects (HasSideEffects). This follows MacNativeGames' translator patch 0002. - Found on: no game on hand so far. Rebuilding the chain changed no shader for this reason in Tears of the Kingdom, Breath of the Wild, Wonder, Scarlet or ANIMAL WELL (94,717 shaders); every MSL change was the early fragment tests attribute.
Position invariance across shaders
- Symptom: the ground kept stale 32×32 blocks where the G-buffer draw failed the depth test: blocks of a stale image where a depth pre-pass and a later pass with other vertex shaders meet (Odyssey: 32×32 blocks on every fourth frame).
- Cause: a game may draw the same geometry with two programs and rely on identical depth, for example a depth pre-pass and then a less-or-equal G-buffer pass. Maxwell evaluates identical position math identically; Metal only does for outputs marked
[[invariant]](the translation marksgl_Position) when compiled with-fpreserve-invariance. Without it, Metal compiled two vertex shaders' identical position math to slightly different depths, so fragments of the second pass failed a less-or-equal depth test against the pre-pass and those pixels kept the previous content. - Fix:
dmsh_build.py(Mac) andios_metal.py(iOS) pass-fpreserve-invarianceto every compile. - Found on: Super Mario Odyssey (Mac): the Cap Kingdom ground. Presents 4k were brighter and blocky, 4k+1 and 4k+2 clean, 4k+3 partly blocked, repeating to the decimal across runs. The blocks were already in the HDR target when the G-buffer pass ended, and only with the ground's material (
b6384a85afd3ef94) drawn. In some frames the game first draws a depth pre-pass (alRenderDepthOnly) for objects its occlusion queries report visible (the queries rotate through three slots, hence the cycle), then draws the G-buffer with other vertex shaders and depth test less-or-equal, without clearing the colour targets. - Note: ruled out on the way: the TBDR optimizations, same-pass feedback, the present path. Evaluating the game's conditional rendering on the CPU from earlier frames' results made it worse (the condition belongs to the same frame on the GPU), so the host still ignores the condition there and draws.
iOS math mode (relaxed)
- Cause: the translated shaders reproduce Maxwell's NaN behaviour with
isnanselects (8,337 of 12,809 use them), and the default fast mode allows the GPU compiler to assume there is no NaN. - Fix: the iOS libraries are compiled with
-fmetal-math-mode=relaxed(dm/ios/tools/ios_metal.py; the Vulkan and GL libraries invk_shaders.pyandgl_shaders.pytoo). Relaxed keeps fast math's reassociation but not that assumption. - Found on: listed among the mechanisms first met in Super Mario Bros. Wonder; the source gives the 8,337 of 12,809 count without naming the game.
Front end at -O1: Apple's control-flow lowering
- Symptom: making a pipeline archive prints
Max Unstruct ID 758 in function agc.main in shader 13941 exceeds supported limit, and one pipeline takes many minutes, inmetal-ttoffline or in the host's prebuild on the device (Preparing pipelinesholding one count for minutes). The line is a note, not an error:metal-ttgoes on, exits 0 and writes that pipeline into the archive. - Cause: at the front end's default
-O2,CorrelatedValuePropagationand theSimplifyCFGafter it fold a translated shader's repeated tests of one value into switches whose cases jump past their own join, so nestedifs become an unstructured graph. Apple's GPU backend lowers unstructured control flow with per-function IDs; past its budget (255 onapplegpu_g13s, the check inlibapplegpu-nt.dylibof AIR-TT 32023.921 picks 255 or 65,535 by a capability not yet characterized), it prints the note and lowers the function another way that is far slower and larger. At-O1those two passes do not run; the backend still optimizes the AIR itself, so ordinary shaders come out the same. - Fix:
dmsh_build.py(Mac) andios_metal.py(iOS) compile with-O1. - Found on: Super Mario Odyssey, issue #276: the float body of
alRenderMaterialUber(fragment007d7e411ef14ebd, 16,227ifs in one function) took 24.6 minutes and 17.3 MB ofg13scode at-O2, 31 s and 1.3 MB at-O1. Its-O1graph still goes past the budget (642), at that small cost. - Check: random samples of archive bases from seven games (Odyssey, Tears of the Kingdom, Breath of the Wild, Kirby, Luigi's Mansion 3, Wonder, Pikmin 4; 22 to 400 each) compile at
-O1to vertex and fragment code within a few percent of-O2(Odyssey 400 bases: −0.9% / +0.01%; TotK: +0.1% / −1.0%; Kirby: −3.1% / +0.07%), with noMax Unstruct IDnote at either level, and the front end is faster. Odyssey's fullg13sarchive of 32,864 bases builds in 15.6 minutes (exit 0); its Cap Kingdom frames 4,200 to 6,600 match-O2except for scattered differences of at most 3 of 255 in three of nine frames, and their GPU time per frame (DM_NVN_GPUPROF=every:150, three runs each on an M-seriesg18g) has medians of 4.76 to 5.05 ms at-O1and 4.75 to 4.83 ms at-O2, within run-to-run noise.
min, max and clamp with a NaN operand
- Symptom: the half of Mario facing the camera's left was white (GitHub issue 72, on an iPhone).
- Cause: spirv-cross writes
FMin/FMax/FClampasfast::min/max/clamp, which leave a NaN operand's result to the GPU. Maxwell'sFMNMXreturns the other operand and its saturation gives 0, as Metal'sprecise::functions (IEEEfmin/fmax) do. Plainmax/min/clampcompile to thefast_intrinsics in every Metal math mode, relaxed included (iOS math mode (relaxed)). Galaxy's GX lighting normalizes light directions the game leaves at (0, 0, 0) (the key and fill lights of its characters) and takesmax(0, dot(ldir, NaN))as the spot term: 0 on the M4 Pro with either function, so the attenuation is 1. The fill light sits at the camera's left in view space, so its terminator runs down the middle of a character facing the camera. - Fix:
dmsh_build.py(precise),vk_shaders.pyandgl_shaders.pywrite theprecise::functions instead (every game). - Found on: Super Mario Galaxy 2 (GitHub issue 72). The NVN chain had about 124,000
fast::max, 98,000fast::clampand 41,000fast::minin Paper Mario: The Thousand-Year Door, none guarded by itsisnanselects.
Integer fragment outputs declared by hand
- Symptom: a pipeline red line for a float output meeting an integer attachment (the red line prints the row to add); a BC encoder's pipeline fails.
- Cause: with no PC version the translator has no output types and declares every output float, and Metal refuses a pipeline whose float output meets an integer attachment. A scan for outputs built from integer operations was not trusted: the translated code keeps float bits in integer variables (register reuse), so such a scan cannot tell an integer output from raw float bits stored into a float target.
- Fix: the profile's
shader_facts_manual.tsvdeclares such outputsuint. When another one fails, the pipeline red line prints the row to add. Shaders that write an output from integer bits now also get integer entry points (Integer entry points for integer colour outputs). - Found on: The Legend of Zelda: Breath of the Wild (agl's BC4 encoder
8e2cc2671cae6020and its twin for a 2D source93aa5e22a8202409, whose failure showed up in an iPhone run); The Legend of Zelda: Tears of the Kingdom (the BC4 encoder0544282c162305dband the Ultrahand maske3fb0bb58a691e10, R8Uint, found by PR #11; the Mac route does not reach it); Pokémon Legends: Z-A (two system pixel shaders write an ID into anR8_UItarget on the character creation screen); DRAGON QUEST VII Reimagined (one global shader writing anR8Uinttarget, Unreal Engine 4.27 shader code archives). - Note: the compressed render target side is in Compressed textures as render targets through an uncompressed shadow.
Integer entry points for integer colour outputs
- Symptom:
failed to create render pipeline … output of type float4 is not compatible with a MTLPixelFormatR32Uint color attachment(orMTLPixelFormatRGBA16Uint); every draw into the target skipped. - Cause: with no PC version the translator types every fragment output float, and the shaders write bit-packed integers through
as_type<float>. A hand-written row per shader (shader_facts_manual.tsv, Breath of the Wild's way, Integer fragment outputs declared by hand) does not scale. - Fix:
dmsh_build.pyfs_uintgives a pixel shader a second entry point,main0_u, with every colour output the unsigned integer vector of the same bits, when it writes an output from integer bits or nothing but constants (a clear drawn into an R32Uint target); the hardware writes the output's bits to an integer target whatever the shader computed, so the copy is exact. The host takes it for a pipeline whose written colour attachments are all unsigned integer formats (pso_all_uint). A shader whose outputs are of both kinds also gets a third entry point,main0_m, that returns only the outputs written from integer bits as unsigned integers, and records their colour indices (uint_mask_min its reflection); the host takes it for a pipeline whose integer attachments are exactly those (pso_mixed_uint). Otherwise a pipeline with a float target as well keepsmain0and the red line. - Found on: Luigi's Mansion 3:
main0_u; about 1,400 pixel shaders writing its G-buffer of two integer targets (R32Uint, RGBA8Uint), so every scene draw was skipped. Pokémon Sword:main0_m; 10 pipelines as the opening's stadium loads; the scene shaders write colour 0 and 2 as floats and colour 1 from integer bits into an RGBA16Uint target (colour 1 in all 336 of Sword's). Silver Pines:main0_m; URP's rendering-layers mask, an R8Uint target beside float outputs (colour 1 forward, 4 and 5 deferred), in 9,720 pixel shaders; without it only the fog drew.
Integer textures and images through float declarations keep their bits
- Integer textures and images read and written through float declarations (an
RGBA8_UINTtexture read bytexture3d<float>and bit-cast,R16_UINT/R32_UINTbuffers throughtexture_buffer<float>, integer stores into a float-declaredtexture3dimage) were checked on an Apple GPU: all keep the integer bits, so they need no facts. - Found on: DRAGON QUEST VII Reimagined (the volumetric lightmap's indirection texture, the light grid buffers).
Integer-typed vertex inputs fed normalized formats
- Symptom:
failed to create render pipeline … Cannot convert attribute …; draws missing (a pipeline that fails to build). - Cause: the translation declares a vertex input
uint4when the shader only moves its bits (x bit-cast to an integer, y copied as a float; Integer vertex inputs), and a game that feeds it a 16-bit normalized format gets a pipeline Metal will not build (no integer input pairs with a normalized format). - Fix: the profile's
shader_facts_manual.tsvdeclares such inputs float (the bits Maxwell would see), which changes only that profile's chain version. The guess is not wrong in general, so overrides are added only for shaders seen failing with float formats. The general replay-side answers are the CPU conversion and the shadow vertex inputs (CPU conversion of vertex attributes the input type cannot take, Shadow vertex inputs). - Found on: The Legend of Zelda: Link's Awakening: missing palm tree and flowers (the pipeline
147c829e79fb322f+0ebba5ff6a44aa52,shadowmap_pbr.bfshavariant 641, vertex inputs 1–5): the foliage's shadow-map and depth pre-pass draws; the main pass shades with depth test EQUAL, so without the pre-pass the plants never appeared. Four outdoor shaders with the same loop (03c43da3e3f46da8,079700f3577e311f,15c02683a10f8e9d,2344874045b1e758) failed the same way on the iPhone in Mabe Village (Half2, RG8 / RG16 normalized) and are listed too. 1,437 of the game's vertex shaders have integer-typed inputs, many of them bone indices the shader multiplies as integers.
Depth mode near = 0 needs the per-mode entry points
- Symptom: outdoors the whole screen blurred (depth of field out of focus everywhere).
- Cause: a game that sets depth mode near = 0 had every vertex shader translated for GL, so all depths sat in 0.5..1.
- Fix: the per-mode entry points of 04, section 5.
- Found on: The Legend of Zelda: Link's Awakening (Mac and iPhone): the depth-of-field composite (
52fcbb4ca2a78907,postprocess.bfsha) blends the sharp frame with two blurred levels by a blur amount computed from the half-size depth copy:pow(clamp((c5[2].y / (depth + c5[2].x) − c5[3].x) · c5[3].y), c5[3].z)above a screen line, by screen position below it. Found with a save file from the iPhone (Mabe Village outside the house),DM_NVN_PASSDUMP/DM_NVN_TEXDUMPof that frame, and LASRecomp, which rewrites z only for the GL mode.
Open: the window Y-direction register SR_Y_DIRECTION
- The register is −1 under a lower-left origin. The Ryujinx recompiler answers a constant +1 (a TODO in
InstEmitMove.cs; LASRecomp, a static recompilation of Link's Awakening, answers −1). Answering it per profile means a translator change, i.e. every game's chain. - Found on: The Legend of Zelda: Link's Awakening: 2,487 of its pixel shaders read it; in the room shader the value only scales a depth slope under
abs(); other shaders may use its sign (dFdy-based effects).
Subgroup operations rewritten for a one-lane subgroup
- Symptom:
SPIRV-Cross threw an exception: Subgroup builtins are not available in this type of function. - Cause: Metal has no SIMD-group functions in vertex functions, and SPIRV-Cross's own subgroup emulation covers only
Elect. RE Engine scalarizes uniform loads: ballot the active lanes, shuffle the first lane's value to all and take a fast path when all agree. - Fix: with a one-lane subgroup that is exact, so
dmsh_build.pyrewrites the vertex shaders' SPIR-V that way before SPIRV-Cross (spv_single_lane: the ballot is the lane's own bit, shuffles and broadcasts return its own value, all / any are the predicate, all-equal and elect are true, lane ID 0, size 1). A mask component read throughOpAccessChain/OpInBoundsAccessChainbecomes an extraction from the constant mask, preserving the index. - Found on: Monster Hunter Rise (about 4 % of its vertex shaders); Xenoblade Chronicles 3 (vertex
4b98320a0f512efe, a prefix ballot that readsSubgroupLtMaskthrough a component pointer). - Note: the rewrite covers vertex shaders only: two of Bayonetta 3's domain shaders still fail this way (Tessellation stages are not drawn).
Geometry shaders that only pick the layer
- Symptom: the tone mapper read an empty LUT and the screen came out a flat blue.
- Cause: Unreal Engine 4.18 builds its 32×32×32 colour-grading LUT with
WriteToSliceMainVS+FWriteToSliceGS, one slice per instance. Metal has no geometry stage. - Fix:
dmsh_build.pyrecognises such a shader in its SPIR-V (gs_slice: triangles in, at most 3 vertices out, positions and other inputs copied,gl_Layertaking the bits of one input component). It builds no library for it and records the component in its reflection. Each vertex shader that writes integer bits from the instance index into an output component gets two more entry points (vs_layer_entry:main0_l/main0_lzwrite that component as[[render_target_array_index]]). At bind timeprog_fold_gs(nvn_mtl.m) swaps the vertex shader for its layer variant and drops the geometry stage when the components match. The pass and pipeline side of layer-writing vertex shaders: Layered passes for vertex shaders that write the layer, Layer-writing vertex shaders get the triangle class. - Note: Unreal binds each stage from a program of its own, so a vertex shader is often paired for a moment with the previous draw's geometry shader. A stub that does not fit stays in the program, and only a draw that uses it prints a red line (
draw_gs_check). The other 22 geometry shaders in DQ11 (one-pass cube shadows that emit transformed triangles) still do not build (Geometry shaders with no Metal stage). - Found on: DRAGON QUEST XI S.
Geometry shaders with no Metal stage
- Symptom: a geometry shader fails to compile with
unknown main0_out: SPIRV-Cross writesunknown main0_out main0()for the geometry stage, and the shader does not build. - Cause: Metal has no geometry stage, so a geometry shader that does more than pick the layer has nothing to translate to. A geometry shader that only picks the layer is folded into the vertex shader instead (Geometry shaders that only pick the layer:
gs_slice,prog_fold_gs; DRAGON QUEST XI S). - Found on: Fire Emblem Engage (one geometry shader, in
unity_builtin_extra; nothing in the opening scenes uses it); Super Mario 3D World + Bowser's Fury (fur fins,RenderFinFur, its one failing shader); Captain Toad: Treasure Tracker (the fin fur geometry shader, as in Super Mario 3D World, is the one shader of its 7,258 that does not build); Monster Hunter Rise (53 geometry shaders); FINAL FANTASY TACTICS - The Ivalice Chronicles (one,bahamuteffect.tec); Big Brain Academy: Brain vs. Brain (Bezel's line renderer, below); Luigi's Mansion 3 (10 geometry shaders, one expands a box from two corners; no draw used one from the title through the opening cutscene to the lobby). The Unity games' built-in geometry shaders do not build either (the container entries above). - Note: a program whose geometry shader has no library used to draw silently without that stage, with the vertex shader's positions taken as clip coordinates;
draw with geometry shader … which has no librarynow names it (Luigi's Mansion 3). - Note: Big Brain Academy's 12 geometry shaders (
_BezelSystemResources/line/shader/line.bfsha) do not build (unknown main0_out) and their draws are skipped ([RED] primitive type 11 not implemented). They take a line strip with adjacency (primitive type 11) and emit up to 12 vertices per segment as triangle strips: thick lines with joins and caps (28 emit sites). From the main menu on, every frame draws one such line (width 10 in its uniform block) into the top-left 512x512 of the frame and copies that region into a texture, so the menu's chart loses its outlines. Not done. - Note: geometry shaders that do more than pick the layer are now built as compute kernels from the vendored translator's geometry-to-compute transforms (geometry shaders on Metal, Passthrough geometry shaders).
Passthrough geometry shaders
- Symptom: the models a passthrough geometry shader draws are missing: its own draw (a depth prepass) shows nothing, and so do the draws that test against that depth for equality. Before the shader loaded at all, the same models showed as black silhouettes.
- Cause: bit 24 of the shader header's first word marks a Maxwell passthrough geometry shader (NV_geometry_shader_passthrough): the input primitive goes on to the rasterizer unchanged, and the shader only writes per-primitive outputs and memory. A viewport mask (
a[0x3a0]) of 0 sends the primitive to no viewport, which drops it. Ryujinx emits the primitive itself only when the host reports no passthrough support. The translator's accessor kept the default answer, "supported", so the compute kernel emitted no vertex and nothing was rasterized. - Fix:
Program.csanswersQueryHostSupportsGeometryShaderPassthroughwith false for the compute geometry jobs;- the vendored translator then emits the input primitive at each return of the shader (
EmitterContext.PrepareForReturn) with every attribute the shader does not write: it is translated without the next stage, against the fixed 144-word input map. It skips the primitive when the shader wrote a viewport mask of 0, and the mask starts at all viewports (Translator.EmitOutputsInitialization); geometry_finishindmsh_build.pytakes the output vertex count and primitive class of such a shader from the input topology (the header declares one vertex of a triangle strip).
- Found on: NieR:Automata The End of YoRHa Edition:
IndexBuildIndependent_{b,x}{cw,ccw}.gsoare the depth prepass, one draw per mesh, and each also writes the mesh's culled index list and its count for the shading pass's indexed indirect draws.
Geometry passthrough and viewport masks in compute
- Symptom: geometry reader compilation fails with undeclared
gl_BuiltIn_5253and a scalar.elementsaccess. Compiling only the compute kernel also leaves native passthrough geometry without emitted vertices. - Cause: the accessor advertises NV geometry passthrough, but Metal needs explicit compute emission. The reader tries to expose the unsupported NV viewport-mask array, and the raw SPH output bound can be one even though passthrough emits a complete input primitive. Geometry storage buffers also need their own driver-table base, and
BufferUsageFlags.Writeis bit 0. - Fix: compute jobs report native geometry passthrough unsupported, activating the translator's explicit passthrough emission.
geometry_verticesrecords the translated bound per input topology.GeometryToComputeapplies viewport-zero mask visibility to emitted indices before strip compaction; the vertex reader skips the mask output. This covers the current replay's viewport-zero path, not replication across multiple viewports. Geometry storage-buffer slots decode fromc0 + 0x410in reflection and replay, and writable reflection follows the translator's actual flag definition. - Found on: NieR:Automata US 1.0.3 (four index-building passthrough shaders; all 1,225 compile; visible/rejected primitive GPU fixtures pass).
Geometry capture indices deduplicated with a bounded bitset
- Symptom: sorting repeated mesh indices consumes much of the render thread's CPU time.
- Cause: every indexed geometry draw copied and comparison-sorted its full input index list to construct sorted unique vertex capture IDs, even for static meshes rendered every frame.
- Fix: mark indices relative to the validated minimum in a bitset, then enumerate set bits in ascending order. The existing vertex-capture bound limits the bitset to about 57 KiB; primitive assembly retains the original index order and restart boundaries. Non-indexed captures need no unique index list. No cross-frame cache or new hazard assumptions are involved.
- Found on: NieR:Automata: a Mac CPU sample attributes 316 of 687 replay samples to
qsort. The geometry tests cover repeated indices, a sparse range spanning multiple bitset words, restart, base vertex and instancing (121 GPU checks, also passing with forced conservative waits).
Geometry runs retain external buffer dependencies
- Symptom: later geometry uses stale shader-written data or appears in the earlier draw's position when draws are combined into a geometry run.
- Cause: a run captures all vertex inputs, dispatches all geometry shaders, then rasterizes all draws. That reorders a later capture before an earlier geometry write. Shader writes can also communicate with other geometry or fragment work in the run. Separate scratch outputs do not prove independence of the game's own buffers.
- Fix: record external vertex-buffer, uniform and storage-buffer access ranges for each job, including write access and read-only storage metadata. A read/write or write/write overlap stops the run before the next capture; read-only sharing and disjoint writes can still batch. Unknown sizes, range overflow, indirect storage pointers, image accesses and typed buffers conservatively stop batching. Existing encoder barriers then order the separate jobs.
t_geometry_hazardsexercises an actual geometry write followed by vertex capture through a storage binding and through a uniform alias, checking both the counter and literal covered pixels. All 14 checks pass with early submission enabled or disabled. Removing the dependency guard causes six failures: both counters are stale and both dependent triangles disappear from their expected positions. - Found on: investigation of NieR:Automata US shadow jitter. This is a reproduced general replay bug; its contribution to that game's reported shadow motion is not yet established.
Geometry dependency checks skip read-only pairs
- Symptom: the CPU spends substantial replay time checking dependencies among geometry jobs that share many read-only buffers.
- Cause: the original dependency predicate compares every pair of access ranges, including read/read pairs that cannot communicate. Repeating that quadratic scan across a run can dominate the check even when each job has only one write.
- Fix: maintain a 64-bit write mask as ranges are added or promoted from read to write. Compare each write in the first job with all ranges in the second, then each write in the second with only reads in the first. Read-only jobs return immediately. Unknown access, overflow, half-open bounds and the 64-range limit retain their conservative behavior; the run's ordering policy is unchanged.
- Check:
t_geometry_hazardscompares 4,096 generated pairs with the exhaustive scalar oracle and covers bit 63, duplicate promotion, adjacent bounds and overflow. All 22 checks, including the existing real-GPU storage/uniform dependency cases, pass with direct encoding, worker encoding and early submission disabled. The 914-check geometry suite passes with Metal validation. A balanced-order 60,000-call Mac benchmark of shared reads and disjoint writes reduces median predicate time from about 24.5 to 1.7 ms; this is an isolated CPU result, not game FPS. - Found on: NieR:Automata iPhone CPU capture.
Parallel geometry index compaction
- Symptom: geometry-heavy rendering spends substantial GPU time compacting the geometry shader's emitted indices.
- Cause: the original
geo_pack_indiceskernel walks every invocation group in a single GPU thread. - Fix: use upstream commit
16f4889's ordered count, offset-scan and write dispatches. The count kernel computes exclusive offsets within each 64-group block using SIMD scans and a threadgroup barrier; the middle kernel scans block totals, and the write kernel adds local and block offsets. Scratch holds one word per group plus one per block. The middle scan now uses 128 GPU lanes, each walking a contiguous chunk before scanning lane totals across SIMD groups. Draws with at most 1,024 invocation groups instead compact in one 128-thread workgroup, without global scratch or two extra dispatches. Both paths preserve exact invocation order, strip winding, restart boundaries and whole-primitive clipping at the output capacity.DM_NVN_GEOMETRY_PACK=legacyrestores the earlier three-dispatch policy with its single-thread block scan. - Found on: upstream OCTOPATH TRAVELER II; the blocked scan was measured on NieR:Automata. On an M4 Pro, a 65,536-group compaction benchmark with alternating warmed runs measured 3,918 us median for the upstream scan and 81 us for the blocked scan, with identical output. Production geometry checks pass 157 checks, including 257-group partial blocks, empty groups, restart boundaries, output capacity and pending index writes. Both normal and forced-wait policies pass. Full-game timing remains scene-dependent.
- Further check: the newer fused/parallel paths pass 914 geometry checks in normal, legacy and split-compute modes, including 0–1,024-group boundaries, points/lines/triangles, partial chunks and a 16,387-block prefix stress case. An alternating warmed 256-draw query workload improves 2.998 to 2.832 ms GPU median with identical query results; the large block-scan stress benchmark improves 1,221 to 45.8 us with byte-identical prefixes and indirect counts. The latter is an isolated stress workload, not a measured game frame. Game FPS with these additional changes remains to be measured.
Geometry reader viewport masks
- Symptom: the geometry kernel compiles, but its
_bgv.metalvertex reader fails withuse of undeclared identifier 'gl_BuiltIn_5253'andmember reference base type 'int' is not a structure or union. - Cause:
ResourceReservationspacksViewportMaskinto one output word;GenerateVertexPassthroughForComputestores that word as a scalar although SPIR-VIoMapdeclares builtin 5253 (ViewportMaskNV) asint[1]. SPIRV-Cross emits an invalid array-copy expression for it, and Metal has no viewport-mask output. The mask still matters: zero discards a primitive, while bit zero selects viewport zero (NV_viewport_array2). - Fix:
dmsh_geometry.readerrecognizes that generated scalar-array store, retains the mask's integer bits and moves vertices beyond the X clip plane when bit zero is clear. This runs beforevs_depthmakes both depth entries. Using the X plane preserves culling with depth clamp too; moving vertices beyond the far depth plane allowed masked points to render when depth clamp was enabled. Unknown mask expressions fail the build instead of silently losing culling. No pinned translator change is required. - Found on: NieR:Automata The End of YoRHa Edition: its four geometry shaders' readers.
- Check:
dm/gfx/tests/test_dmsh_geometry.pyuses synthetic generated-reader MSL. Both Mac and iOS compiler checks reproduce the original error before the fix. Mac GPU readback covers six masks, points and triangles, depth clip and clamp, and both production depth entries (48 cases); zero-mask points under depth clamp fail with the earlier depth-plane cull. All four original readers also pass both platform syntax checks. - Limits: replay binds viewport zero only. Multi-viewport fanout is not implemented, and per-vertex-disagreeing masks are not validated; the tested primitives use one mask for every vertex. These tests establish compilation and viewport-zero culling, not the appearance of those effects in gameplay.
Passthrough geometry output bounds follow input topology
- Symptom: a passthrough geometry kernel writes beyond its vertex and raw-index scratch regions, and compaction reads the wrong groups; corruption can reach the following primitive-map buffers without a red diagnostic.
- Cause: the builder used the SPH's raw maximum output count for every topology. Ryujinx's
ShaderDefinitionsoverrides that count forGpPassthrough(SPH word 0 bit 24) with the input primitive's vertex count excluding adjacency. A header can declare one vertex while the triangle variant writes three vertices and six raw index words per group. Allocating two index words per group then overlaps later scratch regions. - Fix:
dmsh_geometry.output_layoutmirrors the translator's override for each input variant: points, lines, lines with adjacency, triangles, triangles with adjacency use vertex bounds1, 2, 2, 3, 3and index strides2, 4, 4, 6, 6. Passthrough also inherits the input primitive: native GLSL omits the SPH's output primitive declaration, soShaderDefinitionsand metadata use point, line strip or triangle strip accordingly.geometry_finishwrites those bounds and topology into each variant's metadata, which replay uses for allocation and compaction. Ordinary geometry shaders retain their declared bound and topology. Rebuild shaders and the host for the new chain. - Found on: NieR:Automata The End of YoRHa Edition: its passthrough index-building geometry shaders; the triangle kernel's six-word stores disagreed with its two-word metadata while rendering flight. The game note tracks the visual result after correction.
- Check:
test_dmsh_geometry.pycovers all five variants, ordinary bounds and malformed headers. WithDM_TEST_GEOMETRYpointing att_geometry, it passes production-generated layouts to a native emitter with independent topology bounds. GPU canaries protect the nominal vertex and raw-index allocations, and compaction must preserve the emitted count and order. The old rule produced 34 failures; the corrected metadata passes all 170 native checks with Metal validation.
Passthrough geometry kernels lose raster vertices
- Symptom: a geometry kernel compiles and runs its storage-buffer side effects, but contains no
geometry_vb_outputbinding and fills its index stream with restart markers; its following raster pass draws no geometry. - Cause: the offline accessor inherited native geometry-passthrough support. Native NV passthrough forwards input vertices without an explicit emit, but Metal's compute stage must emit them. Disabling native support alone still loses generic attributes: independently compiled stages have no linked next-stage usage to populate
PassthroughAttributes. - Fix: fixed-input compute jobs report no native geometry-passthrough support.
EmitterContextexplicitly emits input positions and every captured generic attribute the GS did not replace, removing adjacency vertices as the input topology requires. This uses the complete Maxwell capture map; ordinary linked translation retains its existing usage mask. Copying all 128 generic components also exposes a Metal backend compiler failure at normal optimization (Internal compiler errorwhen creating the pipeline, despite successful offline compilation).dmsh_geometry.capture_inputkeeps capture reads volatile, preserving their values and normal kernel optimization while preventing that failure. No game IDs or shader keys participate. - Found on: NieR:Automata The End of YoRHa Edition: its passthrough index-building shaders run before depth-writing raster passes during flight. The game note tracks rendering after rebuilding.
- Check:
PassthroughCompilertranslates a synthetic EXIT-only Maxwell passthrough shader for all five input topologies, deliberately declaring triangle output in every raw header. Both Mac and iOS compile checks run. WithDM_TEST_GEOMETRY, production reflection and the argument encoder dispatch the actual kernels; readback checks positions, all 32 generic attributes, adjacency selection, two instances, raw indices, restart padding, primitive compaction and allocation canaries (9,066 native checks). The original pinned translator fails all five variants because raster output is absent. Removing volatile capture reads makes the 128-component synthetic pipeline fail to compile on the M4 Pro; reducing to 96 components or compiling at-O0independently removes that failure.
Geometry capture waits for every indexed draw
- Symptom: geometry-heavy frames have a large submission wall/CPU gap despite short GPU execution; stack samples spend most of the main thread in
geo_draw→mdi_wait→ shared-event wait, with index sorting another substantial cost. - Cause: every indexed geometry draw unconditionally committed and drained the GPU before assembling its primitives on the CPU. It also waited for vertex streams consumed only by native GPU capture. The capture list sorted every index reference to remove duplicates, even though the scratch bound already limits the vertex span.
- Fix:
nvn_geometry.incchecks each actual CPU-read range against pending copies/updates/fills, current shader writes and previous live submission writes. Unknown or overflowing write sets retain the wait. Indirect records are checked before decoding; their selected index ranges are checked afterwards. CPU-converted vertex bindings retain the same hazard checks, while native capture follows GPU encoder ordering. A successful drain covers the remaining reads of that draw. Capture uniqueness uses a bounded vertex-span bitset and ascending set-bit enumeration; allocation failure or a wider span retains an in-place sort fallback. Non-indexed captures need no unique index list. The primitive map, restart handling, base vertex and instance semantics are unchanged. - Found on: NieR:Automata The End of YoRHa Edition: its flight geometry path. The game note records matched frame-time and visual validation.
- Check:
t_geometryfirst reproduced five unnecessary drains for clean static and indirect inputs. Its 226 native checks cover clean versus pending/current/prior GPU-written ranges, unknown write sets, GPU-only and converted streams, indirect arguments selecting a different dirty index range, 8/16/32-bit indices, restart, base vertex and instances. Actual copy/compute writes and raster readback validate synchronization; converted data must see the new GPU-written value. Sparse duplicates, ascending output, short destinations, fallback and allocation canaries cover the capture list. These checks establish behavior; throughput is measured separately in gameplay.
Geometry compaction serial prefix scan
- Symptom: geometry-heavy frames remain GPU-bound after CPU readback waits are removed; encoder timestamps attribute most compaction time to the offset scan rather than the geometry shader or index writes.
- Cause: count and write dispatches ran in parallel, but one GPU thread walked all group counts to compute ordered offsets. Geometry replay also created a separate compute encoder for each of the geometry, count, scan and write dispatches, with full queue barriers at every boundary.
- Fix:
geo_pack_scandivides the groups into contiguous chunks across at most 256 threads. Each thread sums its chunk; a threadgroup prefix scan gives its starting offset; a second chunk pass writes the ordered offsets. The group count does not need to fit in the threadgroup. Totals use 64 bits; unrepresentable 32-bit offsets saturate, output writes use subtraction-based capacity checks, and indirect counts retain complete-primitive truncation.geo_pack_encodekeeps the geometry and three compaction dispatches in one encoder, with explicit dispatch-to-dispatch device-memory barriers. Full barriers remain at its boundaries. The primitive order and large-draw algorithm are preserved; no shader-library chain change is required. - Found on: NieR:Automata The End of YoRHa Edition: its flight geometry passes. The game note records the baseline encoder timings and matched result.
- Check:
t_geometrypasses 1,066 checks with Metal validation. A GPU producer and the production compaction stages share one encoder; readback compares every offset and ordered index with an independent CPU result, including 129,032 groups. Cases cover points, lines and triangle strips, empty groups, zero/odd/thread-boundary group counts, 1/32/192/256-thread scans, short capacities, integer overflow and allocation canaries. The actual translated passthrough integration also passes all five input topologies through the same encoder path. Performance is checked separately with matched gameplay.
Geometry draws above the capture scratch bound
- Symptom:
[RED] geometry draw needs 261 MiB of scratch, above the 256 MiB bound(orexceeds the capture or invocation bound); the draw is skipped. - Cause: the geometry path captures the vertex shader's outputs for every vertex between the draw's lowest and highest index, for every instance, and the geometry kernel's outputs take room per primitive (
nvn_geometry.incgeo_draw). Unreal's one-pass point light shadow draws a whole level mesh through its geometry shader. - Fix: such a draw is drawn in runs in the hardware's order (
geo_split): one instance at a time, then runs of at most 4,096 primitives of a list topology whose index range fits 64 MB of capture. The index list is read once and the runs skip the GPU wait the outer draw took (a first version halved the draw recursively, each part reading the indices again and waiting: that frame took 805 ms). The index compaction after the geometry kernel was one thread walking every group; it is a count, an offset scan and a write dispatch now (nvn_geometry.h,t_geometry). Scratch arenas above 32 MB are kept while such draws go on and released after 600 idle submissions (nvn_mtl.marena_begin), andarena_newtakes the smallest free arena that fits. - Found on: OCTOPATH TRAVELER II: 129,032 triangles of one mesh (vertex
9214254d203a4e45, geometrycb17a9453d4789d0, both from the default material) at the start of the first battle and in the scene after it; with the one-thread compaction each such frame took about 300 ms, now only the first is long (395 ms on the Mac, with two new pipelines and the scratch allocation). - Note: strips, fans and primitive restart are not split. Guest primitive numbering retains the original draw's offset across supported splits: Split geometry primitive IDs.
Split geometry primitive IDs
- Symptom: a geometry shader that reads
PrimitiveIDrepeats its visibility decisions or storage addresses after a large draw is split into compute dispatches. - Cause: fixed-input
GeometryToComputelowered the guest ID to dispatch-localGlobalId.x. Each child dispatch starts at zero. That local index is correct for capture, topology remapping and scratch output addresses, but loses the guest draw's primitive offset. - Fix: replay passes an explicit primitive base through recursive splits, keeping it unchanged when splitting instances and adding each primitive run's start when splitting the range. The counter resets between instances, as specified for geometry primitive IDs.
vb_info.geometry_counts.ycarries it to the shader. Only the guestPrimitiveIDload adds that base; helper addressing remains local, and ordinary draws start at zero. The rebuilt translator and shared chain stamp prevent older libraries from using the new ABI. - Found on: review of OCTOPATH TRAVELER II's large-draw splitter following the NieR integration. The primitive-ID failure is reproduced with a synthetic Maxwell shader; no new Octopath visual failure is attributed to it.
- Check:
test_geometry_split.pychecks 160 nested range/ID cases over all five supported list topologies, all index widths, non-indexed draws, two instance counts and nonzero ancestor bases.ComputeSplitPrimitiveIdintest_dmsh_geometry.pycompiles a real MaxwellALD PrimitiveIDshader for both Metal targets. Its nativet_geometry_primitive_idreplay checks every emitted ID and captured vertex/instance attribute in 40 GPU cases, with 1/8,199 primitives, five topologies, 1/2 instances and 1/3 shader invocations. The old translator repeats ID zero at primitive 4,096; the corrected translator preserves it. A GPU-independent ABI assertion catches stale translator binaries too. Feedback counts and an output canary also check that the offset never leaks into local scratch addresses.
Recursive geometry splits retain their original draw range
- Symptom: a large geometry draw crashes in
geo_split/memcpywhile reading indices, sometimes preceded bygeometry draw exceeds the capture or invocation boundfor an unexpectedly small child draw. - Cause:
geo_preppasses a draw descriptor stored in the reusableg_geo_runjob to the splitter. A child draw prepares its own job in the same slot, overwriting the parent's borrowed descriptor. Later chunks add their offsets to the previous child's index address or first vertex instead of the original draw's range. The job-based geometry integration exposed this lifetime error; the earlier splitter received a local descriptor. - Fix:
geo_splitsnapshots the input descriptors before issuing any child draw. Every chunk and recursive instance split derives its range from that immutable copy. This repairs ownership for indexed and non-indexed draws without changing split limits or skipping geometry. - Found on: OCTOPATH TRAVELER II, the 129,032-triangle shadow draw after Hikari's first attack, during the NieR geometry integration's cross-game review.
- Check:
test_geometry_split.pycompiles the production splitter with a callback that reuses the caller's descriptor exactly asgeo_prepdoes. The expanded 160-case test checks each chunk's range, instance and primitive base for 8/16/32-bit indices and non-indexed draws, including 129,032 primitives and nested splits. Removing the snapshot reproduces the range drift.
Geometry jobs preserve transform-feedback replay
- Symptom: geometry draws with transform feedback report
geometry draw cannot capture this pipelineand write no feedback records after the geometry-job integration. - Cause: the new preparation path rejected active feedback and omitted main's gather of the geometry shader's emitted vertices. Replaying a geometry reader through the ordinary vertex-feedback branch would also capture its synthetic draw a second time.
- Fix: full geometry jobs gather their packed output into feedback before rasterization or raster discard. Active feedback remains in draw order, and feedback state/buffer commands end lookahead. The subsequent synthetic raster draw does not capture vertex feedback again. Lean and mesh eligibility still exclude active feedback.
- Found on: final review of the NieR geometry integration, against main's existing geometry-feedback implementation; no game-specific condition is involved.
- Check:
t_geometry_feedbackreplays actual capture, geometry, compaction, feedback pause/resume and rasterization with GPU readback. Its oracle checks every emitted vertex and saved count, excludes the draw made while paused, and runs with raster discard both enabled and disabled.
Passthrough geometry shaders drawn by the vertex shader
- Symptom: geometry-heavy frames are GPU-bound on the emulation itself. A
DM_NVN_GPUPROFframe shows, for every geometry draw, a capture pass (0x0 0c), a compute encoder and a one-draw reopening of the game's pass, with most of the frame's GPU time in the first two. - Cause: a passthrough geometry shader (SPH word 0 bit 24, NV_geometry_shader_passthrough) cannot change the vertices it passes on; it only decides per primitive (the viewport mask) and may write storage. The emulation still captured all 144 words of every vertex, ran a kernel that copied 134 words per output vertex (volatile loads of the whole capture map, Passthrough geometry kernels lose raster vertices), and drew the copies through the reader.
- Fix: for point, line and triangle input (no adjacency),
dmsh_build.pyadds a lean kernel<key>_bl<topology>(dmsh_geometry.lean): every output store but the viewport mask's word goes, the mask is written one word per output vertex, what only fed the removed stores is dropped (a worklist over the generated straight-line code), and the capture reads that remain (literal words of the record) are renumbered into a record of just those words (capture_reads). The capture writes each word once through a slot table and a record stride the host passes (dm_geometry_capture; the full kernel gets every word in order). At replay (nvn_geometry.inc)geo_lean_cullturns the vertices of masked primitives into restarts before the usual compaction,geo_lean_indexmaps the packed output vertices to the draw's own vertex indices (output vertexg × n + kis input vertexkof primitiveg, the primitive map's word) and sets the base instance, and the program's own vertex and fragment stages draw them indexed and indirect. Instanced or multi-invocation draws, vertex shaders with storage writes (they would run twice), transform feedback and adjacency input keep the full path; so doesDM_NVN_GEO_LEAN=0. The CPU side reads indices with typed loops and copies list topologies without restart directly into the primitive map; amemcpyper index was most of the translation time of such frames. - Found on: NieR:Automata The End of YoRHa Edition: its four
IndexBuildIndependent_*shaders, about 80 draws a frame in the opening flight, most of which keep no primitive (they build culled index lists for later passes). The lean kernels read 4 of the 144 capture words and are about 970 lines instead of 10,500. On an M1 Mac at 720p: GPU work per frame 33–43 ms → about 10 ms, translation 75 M → 42 M instructions per frame, frame time 50 → 27 ms where the Mac was otherwise idle. - Check:
test_dmsh_geometry.pycovers capture slots, lean rewriting, both Metal targets and mesh composition. The native geometry suite covers capture, compaction and dependency ordering. All M1 measurements in this entry were reported for the original implementation in PR #416; they are not measurements of the reconciled host. - Fix: the shared geometry run (
geo_run_walk,nvn_geometry.inc) batches independent captures, geometry kernels and rasters, retaining #336's buffer dependency proof, exact-byte index cache, query positions and parallel compaction. A storage write that could affect another capture or raster ends the run. Lean triangle output uses fixed triangle slots and skips compaction; point and line output retains ordered compaction, then maps output vertices back to the draw's own indices.DM_NVN_GEO_HOIST=0disables batching for comparison. No game identity participates in these decisions. - Found on: the same scene, M1 at 720p: render passes a frame 205 → 39, encoders 290 → 45, attachment traffic 860 → 190 MB, GPU busy 7.6 ms, translation 4.6 ms, frame time p50 / p95 16.66 / 17.26 ms: 60 fps held through four minutes of the flight, factory and ocean combat (frame time 16.6–16.8 ms in every 300-frame window), where it was 50 ms at first.
- Fix: the compute path still costs the CPU a capture, a kernel and a compaction per draw, and the GPU the vertex shader twice. A pair listed in the profile's
geo_mesh.tsvgets a mesh library<vertex key>_<geometry key>_m3at the end ofdmsh_build.py(dmsh_mesh.py): the vertex shader's pulling entry (vertex pulling) and the lean triangle kernel, each in its own namespace, under one mesh entry per depth convention (dm_mesh,dm_mesh_z). A threadgroup takes 32 triangles of a list: 96 threads run the vertex shader once per index, write the vertex, and keep the words the kernel reads in threadgroup memory; the first 32 run the kernel body (capture reads from that memory, the topology map as the identity, the mask store into a variable, index stores dropped) and cull their triangle when viewport 0's bit is clear ([[primitive_culled]]). The kernel's storage writes and the subgroup operations that order them run as before, one triangle per thread, 32 consecutive triangles per SIMD group. The host (geo_mesh_draw,DM_NVN_GEO_MESH=0off) takes it for a non-instanced, non-indirect triangle list without restart whose vertex shader has a pulling entry and no storage writes: the draw stays in the open pass, with the pulling layout, the vertex and fragment arguments as for any draw, the kernel's arguments and the draw's index source in two free slots of the vertex table (which the mesh stage shares), anddrawMeshThreadgroups. The mesh pipeline (the draw's colour state, aspso_descgives it) is built in the background; until it is ready, the draw takes the compute path. The vertex shader runs per index instead of per vertex. - Found on: the same game: its 24 shadow-map vertex shaders (
zmap_vs_*.vso) with the four kernels, 96 pairs. In the Resistance Camp on an M1 at 720p, every geometry draw became a mesh draw: translation 184 M → 122 M instructions (16.3 → 12.0 ms) a frame, GPU 12.3 → 11.3 ms, attachment traffic 463 → 415 MB a frame, frame time 21.2 → 16.6–17.4 ms.
Mesh geometry primitive IDs restart per group
- Symptom: a passthrough geometry shader that uses
PrimitiveIDmakes different visibility decisions or storage writes after the first 32 triangles when composed into a mesh shader. - Cause:
GeometryToComputeusesGlobalId.xfor both the guest primitive ID and capture-buffer addressing. The mesh composer supplied a group-local lane as that ID to address local capture records, repeating guest IDs in every mesh group. - Fix:
dmsh_mesh.composesupplies the draw-wide primitive ID and rebases only capture loads to the mesh group's first record. Guest calculations and storage writes retain the original ID. A translated Maxwell fixture readsPrimitiveID, shifts it by five and combines it with a captured attribute to control viewport zero. Its GPU test covers 1–97 primitives, both depth conventions and non-indexed, 16-bit and 32-bit indexed draws with a negative base vertex. The original mapping fails at 33 triangles; the corrected mapping passes all 48 cases. - Found on: review of NieR:Automata and PR #416. Its four actual
IndexBuildIndependent_*shaders load generic attributes only, with noPrimitiveIDor physical attribute loads. This is a general mesh-composition defect exposed by the synthetic shader, not an observed NieR rendering failure.
Tessellation stages are not drawn
- Symptom:
primitive type 14 not implemented; patch draws skipped. - Cause: SPIRV-Cross already writes the hull shaders as kernels and the domain shaders as post-tessellation vertex functions, but Metal 4's render encoder and pipelines have no tessellation (no
drawPatches, no factor buffer). The hull kernels also write quad factors (MTLQuadTessellationFactorsHalf) for triangle patches. - Fix: not done. The draws would need a tessellator in a compute kernel and domain shaders rewritten as plain vertex functions.
- Found on: Luigi's Mansion 3: 15 hull and 18 domain shaders. Two tessellated programs (vertex
28a39709d0adfcfa/ hull51d2d156bb12c260/ domain0b95742e863bec50, andd4d89931b5a9a514/8aa6feeb7d33e9f6/f254b4dce2d6c012) draw twice a frame for about 30 frames, in the title scene behind the file select as it slides out before a new game. Bayonetta 3: two domain shaders do not build (DS_Vanish05_*.dso:SPIRV-Cross threw an exception: Subgroup builtins are not available in this type of function); the one-lane rewrite (spv_single_lane, Subgroup operations rewritten for a one-lane subgroup) covers vertex shaders only, and tessellated draws are skipped anyway. - Note: the vertex shaders that feed a hull or geometry shader can be found from the shader headers (SPH): the generic attribute map at +0x18 of the consumer's header (inputs) equals the one at +0x36 of the vertex shader's (outputs). In Luigi's Mansion 3, 40 vertex shaders match a hull shader and 49 a geometry shader.
Bindings and handles
- Note: the layout of NVN's driver segments
c0andc2(the handle tables, the storage and uniform buffer tables, the words the host fills) and the binding kinds of the reflection JSON have one definition,dm/gfx/nvn/dmsh_abi.h. The host includes it (nvn_mtl.m,nvn_refl.m,nvn_obj.h);dmsh_build.pyreads it throughdm/gfx/tools/dmsh_abi.py, which stops on any line it cannot read. Both files are part of the chain version.dm/tools/tests/test_dmsh_abi.pychecks that the tables fit together, that the host converts every kind the build may write, that the translator's own copy of thec2layout (Program.cs) agrees, and that the values do not come back as literals in the host or the build. The entries below cite the offsets.
Driver-segment reads outside fragment shaders
- Symptom: a vertex or compute shader reads zeros from NVN's driver segment
c0. - Cause:
dmsh_build.py'sdrv_readsscan matches only fragment-shader names (fp_c0/fp_c2), so vertex and compute shaders that readc0were never marked, and the host passed them an all-zeroc0; these reads are not listed in the JSON. - Fix: the host treats a declared driver-segment binding as read (
nvn_refl.m). The words the host fills are in the entries below. - Found on: The Legend of Zelda: Link's Awakening, Super Mario Odyssey, Pokémon Legends: Z-A.
Compute c0: dispatch size and uniform buffer table
- Symptom: flickering hard-edged white tiles (tiled lighting with every tile row getting row 0's or other rows' lights).
- Cause: a compute shader that computes its tile index as workgroup y ×
c0[0x200]+ workgroup x reads the dispatch's workgroup count from the driver segment, which the host left zero. - Fix: for compute,
c0carries the dispatch size at 0x200 (x, y, z) and the uniform buffer table at 0x210 + 16 r (address, size) in front of the storage buffer table at 0x310 (encode_args_). - Found on: The Legend of Zelda: Link's Awakening (Mac and iPhone): the step of Marin and Tarin's doorway. The room shaders use tiled forward lighting: the fragment position picks a 64×64-pixel tile (
trunc(y / 64) × ceil(width / 64) + trunc(x / 64)), whose light count and light indices come from two storage buffers that a compute shader (light_culling_compute.bfsha, one thread per workgroup, one workgroup per tile) fills every frame. Every row's workgroups wrote row 0's entries, the last writer won (a different one each frame), and the bottom tile row, where the step is, got the lights of other rows; a dump of the tile lists showed only the first of 17 rows filled. After the fix every tile row has its lights and the doorway shows the game's soft light from outside. - Check: Breath of the Wild (101 compute and 4 vertex shaders read
c0) and Super Mario 3D World (11 and 1) render the same as before on the Mac.
Geometry-stage storage buffer table
- Symptom:
[RED] shader ... reflection conversion failed: B binding gp_s0 has no resolved slot number; a geometry shader reading driver segmentc0reports that its storage-buffer table base was never measured. - Cause: geometry shaders access their storage buffers through
c0 + 0x410 + 0x10 r, but the builder'sSSBOmap and replay'sc0_ssbo_basecovered only vertex, pixel and compute. Running the lowered geometry shader as a Metal kernel does not change its logical NVN stage or driver table. - Fix: geometry uses base
0x410in both reflection and driver-buffer population.encode_argskeeps NVN stage 2 for these kernels, so storage slots and driver-table rows come from the geometry bindings. Hull and domain bases remain unmeasured and unresolved. - Found on: NieR:Automata The End of YoRHa Edition: the original shader's constant-buffer operands and the pinned translator's metadata agree on addresses in rows 0 and 2.
- Check:
dm/gfx/tests/test_dmsh_reflect.pyresolves synthetic geometry descriptors to slots 0 and 2 and rejects unknown or unaligned table addresses.t_geometrypasses synthetic metadata through native reflection and the production argument encoder; GPU readback checks both rows' guest addresses and sizes with different compute-stage bindings, plus a write through the geometry storage pointer.
Storage-buffer write flag in translation metadata
- Symptom: a storage buffer the shader stores to or uses atomically has
rw: falsein its generated reflection; replay does not record its GPU writes for dependent buffer reads. - Cause: Ryujinx's
BufferUsageFlags.Writeis bit 0 (1), andProgram.csserializes that enum unchanged. The builder tested bit 1 (2), incorrectly marking every such descriptor read-only. Native reflection honors the explicitrwfield, so its older fallback to the MSL pointer qualifier could not correct it. - Fix:
reflecttests bit 0 for both driver-table storage buffers and addresses carried in guest uniform buffers. Replay then uses its existing write-access and pending-write tracking paths. Rebuild shaders to replace the old metadata. - Found on: NieR:Automata The End of YoRHa Edition: its geometry counter atomic and output stream both emitted translator flag
1but reached reflection as read-only. - Check:
test_dmsh_reflect.pyexercises writable and read-only translator descriptors, including an address in a guest uniform.t_geometryverifies that native reflection preserves explicit true and false flags and that the writable geometry output enters the GPU-write tracker before its dispatch.
Graphics c0 + 0x610: the largest point size
- Symptom: point sprites of size 0 (Odyssey's Cap Kingdom fog never parted around Mario).
- Cause: a vertex shader that writes
gl_PointSize = min(size, c0[0x610])read 0, because the host had nothing at that offset of the driver segment. - Fix:
c0 + 0x610holds the largest point size, and the host writes 2047 there for the graphics stages. Writing theSetPointSizevalue instead (always 1 in Odyssey) left one-pixel splats whose disc weight came out 0 (the fields held-0.0). - Found on: Super Mario Odyssey (reported on an iPhone; fix checked on the Mac): on the Switch the ground fog opens around Mario as he walks through it. The fog's shading reads velocity fields that a small fluid simulation updates every frame: two 256×256 and two 128×128 RG16F targets, with a two-pass blur and a blended composite. Its only input is a point-sprite splat per mover, drawn with
alPointSpriteShader: one vertex per mover, its size in the vertex data (Mario's main splat asks for about 18 px, others 3–5 px) and a soft disc in the fragment shader (clamp(1 - 4r², 0, 1)× velocity). The simulation fields stayed exactly zero (raw dumps withDM_NVN_TEXDUMP+DM_NVN_SNAPRAW; the 8-bit dump shows velocities as black either way). After the fix the fields hold velocities up to ±44, and the fog clears around Mario.
Graphics c0 + 0x644: the draw's base instance
- Symptom: occlusion queries that always return 0 samples (Z-A: characters' legs folded back from the hips once they walked); every instanced draw's transform taken for instance 0.
- Cause: a shader that picks its transform with (instance index − base instance) +
c0[0x644]: Maxwell's instance ID leaves the base instance out, and the NVN driver keeps it in its constant segment, where GLSLC reads it forgl_InstanceID + gl_BaseInstance. The host left0x644at 0. - Fix:
encode_argswrites the draw's base instance there for graphics stages and does not reuse a stage's argument block across draws with different base instances. The word before it,0x640, is added to the vertex ID by 58 of Z-A's shaders and stays 0 until what the driver puts there is known. - Found on: Pokémon Legends: Z-A: from the first step on, the player's legs stuck straight out behind her at hip height, and the rival's forward in a later cutscene; standing still beforehand looked right. The bone palette the game computes on the CPU was already bent. The cause was six occlusion queries a frame, one 36-vertex box each (
a793f436dcabd350, base instance 61, 62, …), drawn against mip level 2 of the depth pyramid: every box was box 0, every query came back with 0 samples, and the game took its characters for hidden and posed their legs that way. Forcing every result to visible fixed the legs, and depth testing with ALWAYS did not. 793 of the game's vertex shaders read the word. - Note: how it was found: the raycasts were logged with scratch observation gates (all correct), then
DM_HLECOUNTshowed nothing walking-specific, then the counter reports.
The base instance of indirect draws comes from Metal
- Symptom: flat grey or background-coloured triangles with straight edges across part of the scene, changing shape from frame to frame while the camera stands still; dropping the colour pass's fragment shaders in groups (
DM_NVN_SKIP_FS) did not remove them, as the instances are misplaced in the depth pre-pass too, whose depth the colour pass tests for equality. - Cause: for an indirect draw (
DrawElementsIndirect/DrawArraysIndirect) the base instance is in the argument record, which the game's GPU culling writes earlier in the same submission;encode_argscould only write the command's base instance intoc0 + 0x644, 0 for an indirect draw. A vertex shader whose instance is (instance index − base instance) +c0[0x644]then read its transforms from the first records for every indirect draw. - Fix: the translation reads the word from Metal's
[[base_instance]], which holds every draw's base instance, direct, indirect or masked (dmsh_build.pyvs_base_instance: in vertex functionsc0[100].ybecomes the base instance's bits, andmain0gets the parameter when it lacks it).encode_argsstill writes the word for the other stages. - Found on: Super Mario Party Jamboree: every board. Its culling (
2b5485a2a731a231, 62 points with rasterization off) writes 80-byte transforms per visible instance into a 30 KB buffer and counts them,276504dc5c987ecfwrites nine indirect records from the count, and the instanced draws (5a805bbb08421b86in the depth pre-pass,4b05556737e9535fandeaa3a44ba28f0744in the colour pass) draws them, both reading record = instance +c0[0x644]. 5,249 of its vertex shaders read the word.
Pixel c0 + 0x620: the sample index and GLSLC's sample position table
- Symptom:
[RED] shader … reflection conversion failed: reads driver-bank locations at run time that the replay layer cannot fill: c0 dynamic index c0+0x630, and that shader's draws are missing; the build notesShader instruction Pixld is not implementedfor pixel shaders;[RED] NVN unimplemented function called: nvnProgramSetSampleShading. - Cause: GLSLC reads the sample index with
PIXLD.MYINDEX(RZ, offset 0; nouveau readsgl_SampleIDthe same way), which Ryujinx's translator left unwritten, so the register read as 0. Forgl_SamplePosition(or interpolation at a sample) it then indexes a byte table the NVN driver keeps atc0 + 0x620: index = ((((y & w) << z) + (x & z)) << log2 samples) + (sample & mask), with {mask, log2 samples, z, w} atc0 + 0x630and x, y the pixel (gl_FragCoordover the render scale); each byte holds the sample's x (low nibble) and y in 1/16 pixel. The replay filled neither row, and the build refused any computed index intoc0. - Fix: the translator writes the sample index for
PIXLD.MYINDEX(IoVariable.SampleId, SPIR-VSampleIdwithSampleRateShading; MSL[[sample_id]], so the fragment function runs per sample on a multisampled target, and SPIRV-Cross movesgl_FragCoordto the sample); the other modes still log.encode_argsfills both rows for the pixel stage from the pass's sample count (c0_sample_rows: Metal's default positions, the same for every pixel, so the grid masks are 0; one sample is 0x88, the pixel centre), anddmsh_build.pycounts them as filled for pixel shaders (SAMPLE_ROWS), with a computedc0index accepted when the shader reads the 0x630 row.nvnProgramSetSampleShadingreturns true: Metal has no switch to force per-sample shading, which a shader gets by reading the sample index. - Found on: Metroid Prime Remastered: 438 of the 562 pixel shaders of
MaterialArchive.arcread the sample index; in 30 the translated shader keeps it, and there it reaches the table, whose byte the G-buffer shaders pack into an integer target (04023899305e93e1). Its targets are single-sample (1600×900), so sample 0 was right and only the table mattered: 0 there put every sample in the pixel's corner. No other game here reads either row (58 games' libraries checked). The nibble order for several samples is the hardware's, not seen in a game.
Texture and sampler pools with overlapping IDs
- Symptom: a menu's labels drawn as plain bars, other menus empty except for the cursor (one pool's textures overwritten by another's); a black screen when IDs past a fixed table read as empty slots.
- Cause: a game that binds two texture pools and two sampler pools whose IDs overlap; the replay had one ID table.
- Fix: each pool has a base in the table, in creation order;
RegisterTexture/RegisterSampleradd it, and binds recorded on a command buffer add the base of the pool that command buffer last set (bind_handlesinhle_nvn.c, the base kept incmd_t, next entry; log linepool #2 (…): numbered from N). Handles read from uniform buffers take the base of the pool bound at replay (DARK SOULS: REMASTERED,ubo_tex_id/ubo_samp_id, Handles from uniform buffers take the bound pools' bases). Deferred binds are not remapped (red line; Deferred texture binds read at execution). The table's capacity is the sum of the pools. - Found on: Fire Emblem: Three Houses: the scene binds a 12,544-entry texture pool and a 3,856-entry sampler pool, the 2D UI's command buffer binds 4,096-entry ones, and both fill IDs from 256. The scene's default 8×8 white texture overwrote the UI's textures (the title menu's labels drew as plain bars). The scene registers each draw's textures into fresh IDs of a ring (8,729 and up a few seconds in), which used to read as empty slots and left the screen black.
Bases of bound handles kept in the command buffer
- Symptom: red lines from the list of command buffers that bound a non-first pool (6,734 in one DARK SOULS: REMASTERED run).
- Cause: that list had 64 entries and was never cleared, and the game binds a non-first pool in more than 64.
- Fix: the bases of bound handles live in the command buffer (
cmd_ttex_base/samp_base). - Found on: DARK SOULS: REMASTERED.
Handles from uniform buffers take the bound pools' bases
- Symptom: distant walls and floors smeared sideways (DARK SOULS: REMASTERED): tiling textures took a clamp-to-edge sampler and repeated their edge column.
- Cause: the map materials read their combined texture / sampler handles from a uniform buffer (
fp_t_cb13_*, resolved at replay byubo_handle), and the game keeps several sampler pools, each with its own samplers under the same IDs, one per command buffer that records the scene. The replay resolved uniform-buffer handles in the first pool. - Fix: the replay keeps the texture and sampler pools bound (
OP_SET_TEX_POOL/OP_SET_SAMP_POOL, which it used to skip; a command buffer that binds none keeps the queue's, as on the GPU) and adds their bases to handles read from uniform buffers (ubo_tex_id/ubo_samp_idinnvn_mtl.m). - Found on: DARK SOULS: REMASTERED: the map materials
FRPG_Phn_*Lit*; 12 sampler pools of 2,304 entries (ID 261 is clamp-to-edge with LOD 0..0 in one pool, repeat with mips in others); the walls' tiling base textures (u ≈ −3.45). - Check: found by highlighting the six most common fragment shaders of the scene pass (
DM_NVN_HILITE), then the next six, and reading the wall draw's binding lines (DM_NVN_PASSDUMP_ALL).
Deferred texture binds read at execution
- Symptom: the 3D scene black, only particles and the HUD drawn; pass dump
Blines alltid=0 dummy. - Cause:
nvnCommandBufferBindTexturesDeferredpasses a pointer to the handle, and "deferred" means the GPU reads the handle from that address when the command executes. A game that records its command blocks once, submits them every frame and writes the handles afterwards got the handles the host copied at recording, mostly zeros, so draws read the dummy texture 0. - Fix: the bind record keeps the source address (
p_bind_handles.defer / src) and every replay-time reader refreshes the handles first (bh_freshinnvn_mtl.m). - Found on: The Legend of Zelda: Breath of the Wild: the engine binds almost every texture this way (
main+0xec2e64, one handle, the pointer being the handle field inside the texture-sampler object); the deferred-lighting composite (one full-screen triangle per material class, blending into the HDR target) read texture 0 for all of its inputs. - Note: how it was found: a frame data-flow table built from
DM_NVN_PASSDUMP_ALL(per pass: attachments, shader keys, sampled serials) showed the half-resolution lighting outputs never being read, and the composite pass'sBlines were alltid=0 dummy. Without_ALLthe dump lists only the first draw of each pass, which hid the composite. Deferred binds are not remapped by pool base (Texture and sampler pools with overlapping IDs).
Handles in uniform buffers written earlier in the same submission
- Symptom: bindless handles read from a uniform buffer name the wrong texture when the game writes them earlier in the same submission.
- Cause: the replay reads handles on the CPU, and a write queued earlier in the submission has not landed yet.
- Fix: inline uniform updates keep where their data sits, so the handle is read from the newest one covering it (
pend_read). The old limit of 256 writes per submission, after which every address counted as written, is gone: pending writes are now indexed by 4 KB page (8,192 per submission). - Found on: Monster Hunter Rise (it queues more than 256 writes per submission).
Storage buffer addresses in the game's own uniform buffer
- Symptom: a storage buffer reported with
cb_slot3, which had no NVN slot. - Cause: the shader reads a storage buffer's address from the game's own uniform buffer (c3 + 0x38), which Ryujinx reports with
cb_slot3. - Fix: the build marks it
sbuf_ubo(uniform slotcb_slot - 3, byte offset), and the replay reads the 64-bit address there at bind time, as it reads bindless handles. - Found on: Monster Hunter Rise (about 30 compute shaders).
Bindless handles through a loop phi
- Symptom:
no resolved slot number, the shader does not load; the translation has a 32-entry texture-pool array (fp_t2d_cb0_40000000, handle type Direct). - Cause: the handle is
c2[0x1A0] | 0x10500000(separate texture slot 7, constant sampler 0x105), kept in a loop variable. Ryujinx's bindless elimination compared only the phi's direct inputs, gave up, and turned the access into an index into a 32-entry texture-pool array, which the replay cannot bind. - Fix: the vendored translator now follows the phis that feed a phi, and treats two
constant buffer | constanthandles as the same value (third_party/ryujinx/README.md). Those accesses become ordinary type-3 handles. - Found on: Monster Hunter Rise (218 pixel shaders).
Bindless reads whose handle phi also carries other values
- Symptom: none in the log: texture reads come back 0. Particle meshes collapse to a point (their scale reads 0), so effects are missing or show as dark patches. The translator's own message (
Failed to find handle source for bindless access of type "textureBuffer") used to be dropped, becausedmsh_xlate's accessor had noLog; its messages now go into the shader's notes, markedtranslator:, and the build summary counts them apart from unanswered queries. - Cause: the handle reaches the read through a phi. Some inputs are c2 handle words, the others are not handles: undefined, or a value the compiler kept in the same register on a path where the predicated read does not run (float arithmetic, a texture result, the absolute value of a uniform word). Some phis merge two or more different c2 handles too (a float and a half-precision buffer).
TryConvertBindlesscannot trace one handle, andGenerateBindlessAccessrefuses a phi with such an input, so bindless elimination deleted the read and wrote 0 to its results. - Fix: only for a phi
GenerateBindlessAccessrefuses (TryTableHandlein the vendoredBindlessElimination.cs, markednxnative:), the read takes the c2 handles among the phi's inputs (a word, or a texture word ORed with a sampler word or a constant sampler). With one handle, it takes the phi's place. With several, one read is made per handle and the result is the read whose handle equals the phi's value at run time: the host fills c2 with the bound handles, or with slot indices fortex_dyn_slots. A candidate that other reads of the shader use as a texture of another shape is left out, so those reads are converted in a second sweep once every other read has registered its texture (ResourceManager.GetTextureTypes). An image store or atomic with several candidates is left alone, since one per candidate would write each of them. A phi made only of handle words keepsGenerateBindlessAccess(the dynamic arrays of Texture slots picked at run time), so no other shader changes: in FINAL FANTASY RESONANCE, the SPIR-V of the 69,449 other shaders is byte for byte what it was. A chain change. - Found on: FINAL FANTASY RESONANCE: 58,122 reads removed (57,861 of them texture-buffer reads, mostly in Unreal's Niagara particle vertex factories), of which 58,096 in 3,197 shaders are now resolved: 21,380 with one handle, the rest choosing among 2 to 5 at run time. 26 reads in 14 shaders, whose handle comes from no c2 word, are still removed. Reported in GitHub issue #168.
Texture slots picked at run time
- Symptom: the shaders' bindless arrays have no slot and used not to load; the translator falls back to a 32-entry array such as
array<texture_buffer<float>, 32> vp_tb_cb0_40000000. - Cause: the shader reads one of several c2 texture-table words, chosen by branches. With separate textures (Octopath Traveler 0) it ORs the word with a sampler-table word; with combined ones (Paper Mario) the handle is read from the combined texture table (c2
0x20 + 8 × slot, constant offsets only). The translator cannot trace that to one slot and falls back to Ryujinx's bindless arrays,array<texture_buffer<float>, 32> vp_tb_cb0_40000000andarray<sampler, 32> vp_s_cb0_40000000, indexed by the handle's texture ID (low 20 bits) and sampler ID (high 12), capped at 31; the build leaves them without a slot. - Fix: for buffer textures (and images). A sampled texture of another kind indexes the whole texture pool instead, whatever the handle's source (Texture handles from game memory index the pools), which took over Paper Mario's cube and 2D arrays. With the profile key
tex_dyn_slotsthe reflection reads them asNVN_RB_TEXD/NVN_RB_SAMPDarrays (nvn_refl.m), the replay writes slot indices into that shader's c2 tables (texture slot r → r, sampler slot r → r << 20; in the combined tabler | r << 20in slot r) and binds slot k at element k, so the shader's own arithmetic lands on the binding: the handle names element r, which holds the texture bound in slot r. The array's other elements hold whatever the other slots do, mostly 2D textures where the array is of cubes; an array element bound to a 2D texture gets the zero cube without a type mismatch report (tex_fit_elem), since the shader does not pick it there. The host logs each such shader's first encode with the slots that held textures. Host only: the chain version does not change. - Check: check first that the handles come only from c2's tables: in Octopath Traveler 0 every integer read of c2 in those shaders is a table slot (texture slots 0-10, samplers 0-4).
- Found on: Octopath Traveler 0: 61 Unreal vertex shaders, particles reading their data from buffer textures (separate textures and samplers). FINAL FANTASY RESONANCE: 2,077 particle vertex shaders. Paper Mario: The Thousand-Year Door: 2,386 pixel shaders (5,476 cube samples, 487 2D), slots 0, 7, 8, 13 and 28 (combined table), until the pools took them over.
A texture chosen at run time between two uniform-buffer handles
- Symptom: Ryujinx's fallback arrays (
fp_t2d_cb0_40000000) in pixel shaders whose handle comes from the game's own uniform buffers; the shaders do not load and their draws are skipped. - Cause: the handle is one of two words of the game's own uniform buffers, chosen by a flag, packed as texture ID | sampler ID << 20.
tex_dyn_slotscovers only handles from the driver's c2 tables (Texture slots picked at run time). - Fix: the pools: whichever word the flag picks, its texture ID indexes the texture pool (Texture handles from game memory index the pools). Not checked on Big Brain Academy, whose files are not on the Mac the change was made on.
- Found on: Big Brain Academy: Brain vs. Brain: ten pixel shaders (six in
03GamePerception.bnbshpk, two PBR, two inmain); ine10f24835bbaa5f6the handle is c3 +0x130 or c12 +0x1d0, chosen by a flag in c12. None of the activities checked uses them; a long run of activities met one once.
Texture handles from game memory index the pools
- Symptom:
[RED] shader … reflection conversion failed: a texture handle the translator could not trace to one slot (profile key tex_dyn_slots off), thenfailed to load, and the draws are skipped: in Mario Party Superstars every character and NPC in a lit scene is a flat grey silhouette (the depth pre-pass draws, the colour pass does not), and the opening scene's pipe is grey. - Cause: a lit pixel shader loops over the lights, reading each light's shadow map, cube map and sampler handles from the game's storage buffer (
fp_s1) at a computed offset. Ryujinx cannot trace such a handle to a slot and falls back to arrays indexed by the handle's IDs (array<texturecube<float>, n> fp_tcube_cb0_40000000,array<sampler, n> fp_s_cb0_40000000, texture ID in bits 0–19, sampler ID from bit 20), clamped ton − 1. The translator's accessor gaven= 32 and the replay had no pool to bind there: onlytex_dyn_slotsarrays, whose handles must come from c2. - Fix: a chain change. The accessor sizes these arrays to the whole ID ranges (
Program.csQueryTextureArrayLengthFromPool2²⁰,QuerySamplerArrayLengthFromPool2¹²) for sampled textures other than buffer textures; buffer textures and images keep 32-entry slot arrays (vendoredBindlessElimination.cs,NvnSlotArrayLength).dmsh_build.pypool_arraysturns each such array into a pointer tostruct spvPool_<name> { uint n; array<element, 1> e; }, read as(&e[0])[min(index, n)], and renumbers the argument IDs (a pointer takes one). The struct declares one element because Metal's argument encoder walks every element of a declared array (AGX::IndirectArgumentLayoutGen2::parseArray): with 2²⁰ textures each pool shader's encoder took 57 ms and 66 MB, and the prewarm of 733 pipelines left the Mac host at 12.5 GB resident (9.7 GB of large mallocs); the elements are 8-byte resource IDs, so the pointer reaches the others (checked on the GPU with a test kernel); the reflection names themtex_pool(with the element type) andsamp_pool. The host keeps one buffer per element type and pool base (nvn_mtl.mpool_gpu): the bound n, then fromDMSH_POOL_HEADthe resource ID of each texture (sampler) ID of the pool bound at the draw, the dummy where an ID holds nothing or a texture of another type and at n. It is rebuilt when a registration or a texture's storage changes (g_reg_gen,g_ser_gen), the old buffer released after its submissions, and an argument block holding a pool address is not reused past a rebuild. Handles read from game memory are the bound pool's IDs, so the pool takes that pool's base (asubo_tex_id), and a pool shader's c2 holds the bound handles without the basebind_handlesadded, as the game wrote them. Every texture is already in the residency set. A walk over every ID also reaches IDs no draw binds, which a game leaves registered after it is done with them: the first walk once crashed innvn_sparse_protectmaking such a texture (a garbage page table pointer; the ID was not caught). So a texture no draw has made is made only while its storage exists (tex_storage_live: the memory pool object still holds its magic, and an ordinary pool's storage is in a pool the host maps), and an ID whose memory pool the game finalized is skipped until it is registered again (treg_t.gone, set intreg_drop_pool_views). - Found on: Mario Party Superstars: 6,370 of its 82,790 shaders, every character and NPC material (
_chara/pc/*.bnbshpk,_chara/npc/*.bnbshpk) and some board variants (hsmn_bg.bnbshpk); a 2,304-entry texture pool (1,286–1,314 2D textures, 65 cubes) and a 512-entry sampler pool, about 0.1 ms per rebuild once the textures exist.
An array of handles in a uniform buffer
- Symptom: the host stops at the shader's first load:
unexpected argument buffer layout: encodedLength=640 maxAid=48. - Cause: a loop reads one handle per light from the game's uniform buffer at a computed offset; Ryujinx's texture array from a buffer indexes it by the handle's byte offset ÷ 8 (an NVN handle is 64-bit, the shader computes
(word >> 2) << 1 | (word & 3) >> 1). The build reads the name as one bindless texture (tex_ubo, offset 0), so the argument buffer was 32 entries longer than the reflection said. - Fix: the host's reflection reader takes the array length from the MSL type, and the replay fills element k from the handle at byte 8k of that uniform slot (
NVN_RB_TEXU/SAMPUwithn); elements whose bytes are not a registered texture or sampler ID read the dummy, silently. No chain change: the build already wrote the array type. - Found on: Paper Mario: The Thousand-Year Door: the reflection shaders (
reflection.bfsha:array<texture2d<float>, 32> fp_t2d_cb14_0and its samplers), c14 (uniform slot 11). The first run showed elements 0–3, 6 and 8–12 holding textures, among them the shader's fixed handles at +8, +16 and +24 and the lights' from +64.
An array of handles from the driver table c2
- Symptom:
shaders with a binding whose slot was not decoded 588; shadow cascades read througharray<texture2d_array<float>, 32> fp_t2das_cb2_0. - Cause: Ryujinx's texture array from a buffer, as Paper Mario's uniform-buffer arrays (An array of handles in a uniform buffer), but over the driver table c2, indexed by (0x80 + a cascade offset) ÷ 8.
- Fix:
dmsh_build.pyc2_arraynames ittex_c2a/samp_c2awith the c2 byte offset of element 0; the replay fills element k from the slot whose c2 word is at that offset + 8k (combined, else separate), and the dummy elsewhere (NVN_RB_TEXA/NVN_RB_SAMPA,c2_handle). - Found on: Luigi's Mansion 3 (586 pixel shaders, the shadow cascades).
Arrays of c2 handles cover the whole table, with a separate sampler word
- Symptom:
[RED] shader … reflection conversion failed: B binding fp_t2d_cb196610_10570000 has no resolved slot number, then, with that resolved,… reads driver-bank locations at run time that the replay layer cannot fill: c2 dynamic index; the shader does not load and its draws are skipped. - Cause: two gaps in An array of handles from the driver table c2. The translator sized every texture array from a buffer at 32 elements (
Program.csQueryTextureArrayLengthFromBuffer) and clamps the index to the last one, but over c2 the index is any handle word's byte offset ÷ 8: EA SPORTS FC 27's pixel shaderdc446d7f52c14ea6loops over separate texture slots i = 1, 2, …, reads each handle word at c2 byte 0x168 + 8i and samples element 45 + i, so every element it picked was clamped to 31 (a combined slot). And the handle is type 1:BindlessToArrayORs one fixed separate sampler word (c2 byte 0x570, separate sampler slot 1) into every element's handle, whichc2_arraydid not decode (it took only_cb2_with type 0), so the texture array had no slot and its sampler array took that one slot. - Fix: a chain change.
Program.csgives an array over c2 one element per handle word up to the end of the separate sampler table (C2HandleWords, 301; other buffers keep 32),c2_arraydecodes type 1 with the sampler word in c2 too (soffin the reflection), and the replay ORs that word into each element's handle for the samplers (NVN_RB_SAMPA;c2_handlenow also answers the separate sampler table).nvn_rbind.nis 16 bits andNVN_REFL_MAXARG768 (such a pair takes 602 argument entries). The same loop reads each handle word itself at a computed c2 index (fp_c2._m0[int(_237)], to test the light's handle), whichunfillablerefused for any bank; the replay fills c2's handle tables whole (DRV2inencode_args), so a computed index into c2 is accepted, and one into c0, filled only in its tables, still refused. Luigi's Mansion 3's cascade arrays become 301 long as well; the host fills the elements past the handles with the dummy. - Found on: EA SPORTS FC 27 (its lights in a match).
- Check:
test_dmsh_reflect.py(DriverTableArrays,DriverReads) andtest_dmsh_abi.py(the translator's table length agrees with the header).
A texture slot that shares its c2 word with an image slot
- Symptom: characters and props glowing white, about 10⁴ too bright, in DRAGON QUEST VII Reimagined's opening cutscene only, while walls and gameplay look right.
- Cause: a size query (
GetDimensionson aRWTexture3D) is a TXQ on the image's handle, c20x120(image slot 0): the build numbers it combined texture slot 32, the slot that shares that word, and with no type in the facts table the translator read it as 2D. The host bound the empty texture slot 32, and the 1×1 dummy's size passed only thread (0, 0, 0). - Fix: two general fixes: a texture binding of slot 32–39 reads the image bound in slot 0–7 when there is one, as c2 holds it (
nvn_mtl.mtex_slot_h; Hades II's texture slot 34 without an image still reads the texture), and a query on an image handle the facts do not type is translated again with the type of that image from the first translation (dmsh_xlateImageTypesOf). - Found on: DRAGON QUEST VII Reimagined: the per-pixel volumetric lightmap read brick (0, 0, 0) of the brick atlas for almost every cell. Unreal merges each streamed level's lightmap into one atlas on the GPU: brick copies (
0a7ff4171bd1a82a), then a patch of the shared 16³RGBA8_UINTindirection texture (7b958a7b21e72801) whose bounds come fromGetDimensionson the veryRWTexture3Dit writes. Only cell (0, 0, 0) was written, every other cell kept size 0, and the shader fell back to the atlas origin: the first daytime level's bricks, baked about 2¹⁵ brighter (the same bricks in gameplay hold 0.5–1.4). Monster Hunter Rise has four such size queries (cp_t_tcb_48/_4A). - Check: how it was found: the raw scene colour (
texraw) put the characters at 1,000–2,000 and the walls at 0.05; a debug build of one character shader that wrote its terms in alternating 8-pixel columns isolated the volumetric lightmap ambient term; dumps of the atlas (texlist/texraw), of the uploaded level bricks (the 3D upload dump) and of the indirection texture, with the cell computed from the primitive's origin and ViewVolumetricLightmapWorldToUVScale / Add(View 220 / 221), gave the rest.
More than 64 bindings in one shader
- Symptom:
[RED] shader … reflection conversion failed: 66 bindings, over the limit 64, thenfailed to load, and the draws with it are dropped (a black map). - Cause: the reflection is converted into fixed arrays of
NVN_REFL_MAXBbindings (nvn_refl.h), which was 64. NVN gives a stage 32 textures and 32 samplers beside its uniform and storage buffers and images, so a shader can have more. - Fix:
NVN_REFL_MAXBis 128; the argument buffer entries keep their own limit (NVN_REFL_MAXARG, 768, see Arrays of c2 handles cover the whole table). It was raised from 96 when every sampled sampler got a twin binding (Border colours Metal has no sampler state for): that shader then has 97 bindings. - Found on: OCTOPATH TRAVELER II: the 3D world map of the traveler selection (
UiMM_WorldMap3D, pixel shaderf7c8373b7323687d: 30 textures, 30 samplers, 3 uniform buffers and the c1 / support / driver segments).
Argument buffer members with attributes after their id
- Symptom: the host stops at a shader's first load:
FATAL shader … has an unexpected argument buffer layout: encodedLength=88 maxAid=8(crash inc1_store/pull_load), with a storage buffer or image missing from its reflection json. - Cause: a pixel shader that writes a storage buffer and images inside a fragment interlock (the translator's FSI,
PixelInterlockOrderedEXT) gets those members as[[id(n), raster_order_group(0)]]from SPIRV-Cross.dmsh_build.pyreflectmatched only[[id(n)]], so it left them out, and the host's argument buffer came out longer than the reflection. - Fix:
reflectreads attributes after the id, and a member it still cannot read fails that shader's build (reflection failed) instead of loading with a short reflection. A chain change. - Found on: Mario Party Superstars:
light_primitive_point_cluster.bnsh(56071a5cf62dccc3, the engine's clustered point lights), first drawn on the way into a party.
Image atomics
- Symptom: the replay put a texture resource ID where the shader wanted an address; with the old 64 KB dummy buffer, pointers in the game's objects and in the host's program objects got their upper 4 bytes replaced and the game or the replay crashed, or
kIOGPUCommandBufferCallbackErrorPageFault, after which macOS ignores the process's command buffers (GitHub issue #101). Later,[RED] image atomics on a block-linear texture … not followed, the atomics go to a scratch buffer, and compute shaders with atomics on a 3D image did not build (array subscript is not an integer). - Cause: below MSL 3.1, SPIRV-Cross emulates an image atomic with a raw device pointer beside the texture (
texture_buffer<uint> cp_i_cb2_4C_r32uianddevice atomic_uint* cp_i_cb2_4C_r32ui_atomic), indexed by the bound image's width (spvImage2DAtomicCoord), so the host had to bind memory in that packed layout. A buffer image's pool memory has it. A block-linear image's Metal texture is a deswizzled copy whose pool memory has another layout, so its atomics went to a scratch buffer that nothing read, and a pointer shorter than the image (or an invalid coordinate) wrote over other GPU memory. For a 3D image SPIRV-Cross indexes the pointer with theint3coordinate itself. - Fix: image atomics are Metal's native texture atomics (MSL 3.1:
texture3d<uint, access::read_write>::atomic_fetch_max).dmsh_atomic.pyhas SPIRV-Cross write shaders with image atomics (anOpImageTexelPointer) at MSL 3.1, and every other shader at MSL 2.3 as before, so their MSL is unchanged and their libraries come from the compile cache; a shader whose MSL still has the pointer form fails the build. The atomics act on the texture every other access of the image uses (a buffer image's view of its pool, a linear image's pool memory, a block-linear image's Metal texture), for buffer, 2D, 2D-array and 3D images. Metal runs texture atomics only on an R32Uint / R32Sint texture made withMTLTextureUsageShaderAtomic: NVN images of those formats take that usage when they are made (nvn_mtl.mimg_usage; a view keeps working through its base), the build marks the slots a shader uses atomically ("atomic"in the reflection,nvn_rbind.atom), and the host binds an atomic-capable write sink, logged red, in place of any other texture there (dummy_img_atomic). The translator's coordinate guard stays (Bounds of image atomics). - Found on: Octopath Traveler 0: 2,084 shaders with atomics on buffer images (Unreal's GPU particles; three ran in the first 100 seconds) and 16 on a 3D image. Pokémon Legends: Z-A: an atomic max into a 480x270 r32ui block-linear 2D array (fragment shader
2bd5037003980716, the scene after the story opening's first cutscene), which ran about 1.4 MB past the 64 KB dummy buffer into zero-copy pools over the game's heap and into unmapped addresses (GitHub issue #101), then went to a scratch buffer. FINAL FANTASY RESONANCE (8 shaders) and Ghost Trick (5, one of them a compare-and-swap): atomics on 3D images. - Check:
dm/tools/tests/test_image_atomic_bounds.pywith the rebuilt translator,DMSH_TEST_METAL=1 DMSH_TEST_GPU=1: synthetic Maxwell atomics on buffer, 2D, 2D-array and 3D images, compare-and-swaps among them, run on native atomic textures of a Mac GPU under the Metal validation layer (t_atomic_return.m).t_refl.mchecks the atomic flag of a reflected image.
Buffer image reflection keeps its texture type
- Symptom:
image atomics: unsupported or overflowing IMG extentat the first dispatch, despite a valid four-byte counter buffer. - Cause:
nvn_refl.mclassifiedtexture_buffer<uint>as the default 2D texture. The atomic admission check of the pointer emulation then rejected the actual Metal buffer texture against that 2D descriptor. - Fix: the shared reflection type decoder recognizes
texture_bufferasMTLTextureTypeTextureBuffer, so a buffer image keeps its buffer-image shape.t_refl.mchecks the reflection converter with a buffer image. - Found on: SHIN MEGAMI TENSEI V: Vengeance, compute shader
2d2093c1f1d3a7e0, image slot 2 (one R32Uint texel).
Bounds of image atomics
- Symptom: Metal shader validation reports invalid image-atomic stores even with an image-sized scratch buffer; without validation, GPU page faults can freeze rendering.
- Cause: the translator lost the surface instruction's
IGNbounds mode before lowering an image atomic, so a coordinate outside the image reached memory. Neither a pointer nor a native texture atomic outside its image is defined on Metal. - Fix: the translator (
TranslationFlags.MetalImageAtomics, Metal only; other backends keep their behaviour) keeps the bounds mode and whether the result is used, queries the bound image's size and tests every original coordinate, the layer included, before the atomic. Negative coordinates fail an unsigned comparison; coordinates are not clamped or flattened. Invalid coordinates leave memory unchanged and return zero (ARB_shader_image_load_store, issue 30): a returningIGNatomic joins the old texel from the valid branch and zero from the other with an SPIR-V phi. A compare-and-swap (SUATOM.CAS: compare with Rb, swap in Rb+1) is Metal's texture compare-exchange behind the same guard; SPIRV-Cross loopsatomic_compare_exchange_weakuntil it succeeds or the texel differs. Predicate-returning,TRAPand unknown modes fail translation, as do 64-bit, floating-point and half2 sources (checked before the descriptor format can erase their size), descriptors that are not R32 integers, bindless handles that do not resolve statically, and indexed or multisample images. Source 1D and 1D-array surfaces keep the translator's one-row normalization to 2D. - Found on: Pokémon Legends: Z-A (PR #178); SHIN MEGAMI TENSEI V: Vengeance, whose two buffer-image counter shaders dispatched at boot consume their returned values.
- Check:
dm/tools/tests/test_image_atomic_bounds.py. Portable tests check the MSL version choice and the rejection of the pointer form. WithDMSH_TEST_XLATEset to a rebuilt translator (andspirv-valonPATH; spirv-cross is the in-repo build) the synthetic shaders are translated, the guard's dataflow is checked in the SPIR-V (the size query and comparison dominate the atomic, the phi carries zero on the invalid edge), and the MSL is produced;DMSH_TEST_METAL=1compiles and links it (DMSH_TEST_METAL_SDKS=macosx,iphoneosfor both SDKs) andDMSH_TEST_GPU=1runs the returning fixtures (above). Changing this path changes the chain version: rebuild the translator and update the lock before building shaders.
Shaders compiled at run time
A GLSL language preamble is not a complete shader
- Symptom: the NSP script stops with
captured only 0 run-time shadersalthough the game reaches its frame loop with precompiled shaders. - Cause: the detection counted every
#versionoccurrence. Snowdrop carries a GLSL header containing extensions and type macros, without an entry point; importingglslcCompilealongside it does not establish a boot shader compilation. - Fix:
profile_guess.pycounts embedded source strings containing both#versionandvoid main(...)before their terminating NUL. The survey, build and split share that detector. Three Houses still has its nine complete boot sources; NRO imports retain their separate capture rule. - Found on: Mario + Rabbids Kingdom Battle, whose only
#versionstring is the 646-byte language preamble also carried by Sparks of Hope.
Built-in shaders compiled with the game's own GLSLC
- Symptom: shaders missing from the map that no game file holds.
- Cause: a game whose shaders come from its own GLSLC at boot has no file holding their output. KTGL compiles 9 built-in GLSL shaders at boot with the game's own GLSLC (
glslcCompilefrommain+0x9c89f8; the sources are strings inmain). - Fix: they are captured once on the Mac (
DM_GLSLC_CAPTURE):DM_GLSLC_CAPTURE=<dir>saves every compile's output (hle_glslc.c; the compile object's first word points at {status, output}), andshader_split.pyreads<version dir>/glslc_capture(the NSP script makes it for any game whosemainholds GLSL sources and importsglslcCompile). The outputs are identical on every run.shader_split.pyasks for captured GLSLC outputs only when a module of the profile's definesglslcCompile, asprofile_guess.pydoes. - Found on:
- Fire Emblem: Three Houses (SDK 6.5.0): the 2D primitive, text and loading-feather shaders.
- Catherine: Full Body (SDK 8.2.1): its two built-in programs are captured on the Mac the same way; they compile only once the host reports the GLSLC's GPU code version (next entry).
- The Legend of Zelda: Link's Awakening:
subsdk0is NVN's runtime GLSL compiler andmainimportsglslcCompile*, but up to the house, the beach and the menus every shader the game loaded was in the offline tables. - Captain Toad: Treasure Tracker (the rule that a module must define
glslcCompile):mainimportsglslcCompileand holds agl's GLSL sources, but no module defines it, so nothing is compiled at run time.
GLSLC GPU code version against the driver's range
- Symptom: a null read at
main+0x3316dcinnnMain, right after the window setup (the program object's interface pointers at +0xd0 / +0xd8 both null). - Cause: the game compiles two built-in programs at boot with its own GLSLC (
subsdk0, the same build as Link's Awakening's), and first checks that GLSLC's GPU code version againstnvnDeviceGetIntegeritems 0x2E–0x31 (the driver's supported range,main+0x330dcc). The host reported Super Mario Odyssey's SDK 7 driver range, 1.5–1.14; this GLSLC writes 1.15, so nothing was compiled and the first uniform lookup read the empty program. - Fix: every SDK's driver runs the code of the GLSLC in the same SDK, so the host now reports at least the version the game's own
glslcGetVersionreturns (hle_glslc.cdm_glslc_gpu_code,hle_nvn.c). The host also advertises the translator's supported range for precompiled shaders (Newest GPU code version reported by the driver). - Found on: Catherine: Full Body (SDK 8.2.1, Atlus's GFD engine, CRI middleware; one boot).
GLSL ES 1.00 sources without a #version line
- Symptom:
gl_shaders.pyfailed on the captured sources:ES shaders for SPIR-V require version 310 or higher. - Cause: GameMaker's runner writes each shader variant as GLSL ES 1.00 with the version statement left out (
attribute,varying,texture2D,gl_FragColor,precision mediump float;), and usessampleas a variable name, a reserved word since ES 3.00. - Fix:
gl_shaders.py'ses100()pass, run when the source has no#versionline or says#version 100(that line is dropped): dropprecision <p> <t>;lines (the pattern has to accept\r, the captured files mix CRLF and LF), striplowp/mediump/highp(GameMaker has#define LOWPREC lowp, so a macro would otherwise expand to nothing useful), renameattribute→in,varying→out(vertex) /in(fragment), declareout vec4 gm_FragColor;and renamegl_FragColor, renamesample→gm_sample, then prepend#version 310 eswithprecision highp float; precision highp int;. Texture functions stay with the existingfixups()so the rename happens afterglslangValidator -Eand a macro cannot hide one. - Found on: Well Dweller (93 run-time shaders; see its
gl_capture.tsv).
Struct uniforms become member rows
- Symptom:
uniform LightEnv { … };failed withuniform u_LightEnv: type LightEnv (structs are not implemented), so every program that used one was missing from the library. - Cause: GameMaker declares several uniform blocks as struct uniforms, and the layer's
glGetUniformLocationcompares the name the game asks for (minus a[n]suffix) against the table's rows, whose name field is 48 bytes — the game asks foru_LightEnv.m_dirLights_dirand similar dotted member names. - Fix:
gl_shaders.pygaineduni_members(), which walks a struct type's members and emits one row per member with the dotted name (u:u_LightEnv.m_dirLights_dir[0]), adding each member's offset to its parent's (the reflection gives a nested member's offset relative to its parent); arrays of structs return an error. - Found on: Well Dweller, whose lighting programs were the missing ones.
Pipelines and caches
A compile that does not return stalls every flush and draw
- Symptom: the pipeline prebuild stops at the same line on every start (
pipeline prebuild: 7000 of 20740 pairs, 1987 built, 5013 already archived, 0 left to the draws, 0 failed), behind Preparing pipelines or, withDM_PSO_PREPARE=0, as a black screen while the sound plays on; the NSP script's[7/7] Pipeline prebuilddoes not end (dmhostat 0% CPU,MTLCompilerServiceat 100%), andMTLCompilerServicecrash reports (EXC_CRASH,llvm::report_bad_alloc_error) come every 6 to 10 minutes. The bases built since the last file are never written, so the next start builds them again and stops at the same pair. - Cause: one base Apple's GPU compiler cannot build, and a host lock that let its compile hold everything else. The compiler grows
MTLCompilerServicepast several GB until it aborts inllvm::report_bad_alloc_error, and Metal hands the request to the relaunched service; only after four aborts (33.7 minutes on an M6 with 16 GB, macOS 27.0) doesnewRenderPipelineStateWithDescriptorreturn, withCompilation failed due to an interrupted connection: XPC_ERROR_CONNECTION_INTERRUPTED. This error occurred after multiple retries.Metal's serializer cannot be written while it captures: the write (-[_MTLBinaryArchive airntSerializeToURL:options:error:]) runs on the archive's_serializeQueueand then empties the archive (its entries, AIR objects, library cache and file mapping) on its_queue, while each function a compile captures adds itself from the compile's completion (__122-[MTLCompiler compileFunctionRequestInternal:…]_block_invoke: a newMTLBinaryEntryfrom the request's AIR script and the function's bitcode, mapped bynewArchiverIdWithBinaryKeybefore it takes_queue, then added under it, before the compile reports back). A compile under way across a write came back with both nil and crashed indispatch_data_create_map(seen in lldb at the crash, the write's thread waiting for_serializeQueue). So every compile the archive captured held a read lock (g_arch_rw) and a flush its write lock. The flush at the next 1,000 bases waited behind the compile, and every other compile, draw and the present path waited behind the flush. The prepare step also waited for the last compile to return. - Fix: flush periods (
ArchEpochinnvn_mtl.m). Each period has its own compiler and serializer; a compile the archive captures joins the current period (arch_capture) and leaves it when it returns. A flush (arch_flush) gives the compiles after it a new period and returns at once; the old period's serializer is written (arch_write, on the archive queue) once every compile in it has returned. No compile waits for a flush, and a compile that does not return holds back only its own period's file. The prebuild's flushes every 1,000 bases no longer wait; its last one (arch_flush_wait) waits for every period beforeprebuilt.tsvis written. Specializations go through a compiler without a serializer (g_comp4). The prepare step waits until every pair is handed to a worker; compiles still under way then (at most one per worker) finish in the background, and the log sayspipeline prepare: every shader pair handed out after … s; N compiles still under way finish in the background. The prebuild tool still waits for every compile, since it writes them all. The base itself was made buildable by Front end at -O1 (11.4 s at run time on the same M6). A base that fails no longer keepsprebuilt.tsvfrom being written (every start compiled it again in the background, half an hour at-O2): the pair goes intopso_archive/prebuild_failed.tsvwith the system build and the stamp it failed under (pre_fail_load), and a later start of that system build with that stamp leaves it to the draws without compiling it; another system build (a new GPU compiler) or stamp tries it again. A failure Metal reports has already been retried by Metal itself. The log sayspipeline prebuild: N pairs whose base failed are left to the draws (…prebuild_failed.tsv; M of them failed on an earlier start of this system build, not built again). Open: a compile still under way when the game exits leaves no result, so a base that takes longer than a session to fail is compiled again in the background on the next start. - Found on: Super Mario Odyssey, update 1.4.1, GitHub issue #294: the base of pair 5641,
3c3435a62e05975b+007d7e411ef14ebd(alRenderMaterialUber.bfshavariation 0, 444,013 lines of MSL), found by a reporter's bisect of the pair list withDM_PSO_PREBUILD=tool, with that chain's-O2front end. On an M6 with 16 GB (macOS 27.0), the prebuild tool on the whole list with the-O2library and 8 workers: the old host stopped at 7,000 of 20,740 at 65 s and wrote nothing more in 5 minutes; with flush periods 20,000 pairs were done at 262 s, and 17 files held 18,739 of the other 20,739 bases, only the 2,000 in that compile's period waiting for it (three compiler aborts in 40 minutes). With the-O1library (all 20,740 built): 253 s cold and 227 s with the old host, 213 and 225 s with flush periods; the files written gave all 20,740 back, none compiled. - Check:
t_pso_stall: a compiler that holds one prebuild compile; while it is held, the prebuild hands out every pair and builds the others, a draw's compile returns and a later period's flush writes its file, and a base of a written period still specializes; once released, its period is written and the prebuild stamps. A pair whose compile fails: stamped all the same and listed; not compiled again on a later start of the same system build, also without the stamp; tried again with a list from another system build, and the list removed once it builds. On Odyssey's pair 5641 alone with the-O2library (M6, macOS 27.0): Metal's error after 36.6 minutes, the pair listed and the stamp written; the next start had nothing to do (1 s), and one withoutprebuilt.tsvleft the pair to the draws without compiling it. The Metal behaviour, by a probe outside the tree on an M6 (macOS 27.0), eight threads compiling 1,500 pipelines: one serializer written every 150 ms while they compile crashed 3 runs of 3 innewArchiverIdWithBinaryKey; a write that first waits for the compiles under way but lets new ones start, 6 of 6; one that lets no compile start until it is done, 1 run of 111, during the write itself, with issue #342's signature (dispatch_retain(NULL)inmaterializeAirNTFromOnlineForSlice); a compiler and serializer per period, none of 118, every pipeline found in the files, also with 121 periods in one run (no memory growth), and making a period took about 30 µs.
Shader-pair tables in a Mac app
- Symptom: a self-contained Mac app (
--platform mac-app) logspipeline prebuild: no .../Resources/dm/shader_pairs.tsv, nothing to buildalthough every shader library is there; it draws no hybrid stand-ins, and each pipeline seen for the first time compiles on the submit thread (frames of 100–550 ms). - Cause:
dm/mac/pack_app.pycopied the shader map, the prewarm manifest,c1_full.tsvandcb_extent.tsvbesideDM_NVN_SHMAP, but notshader_pairs.tsvandpso_pairs.tsv, whichbuild_ipa.shcopies into the IPA and the host's pipeline prebuild reads there (pso_prebuild). Without them the app makes no pulling bases. For a profile withoutpso_pairs.tsva Mac run (mac_run) passes the shader split's copy asDM_PSO_PAIRS; the app had neither. A full shader-library build does not build the pipeline bases by itself. - Fix: the packer copies both tables when the profile has them, and the split's
pso_pairs.tsv(--pso-pairs, which the NSP script passes) when the profile has none. They stay optional. An app packed before the fix needs repacking. An NVN game's app also carries its pipeline archive made offline for this Mac's GPU, so the first start builds no base: A Mac app. - Found on: The Legend of Zelda: Echoes of Wisdom, update 1.1.0: a 308.4 ms frame, 283.5 ms of it pipeline acquisition and 5.9 ms GPU work; with the tables the offline prebuild built all 7,495 bases. The Legend of Zelda: Breath of the Wild: frames of 100–546 ms before, none of its bases compiled on the first start after.
- Check:
test_nsp_mac_app:App.test_pipeline_pair_tables,App.test_split_pso_pairsandApp.test_layoutfail without the tables in the packer;Script.test_pack_commandchecks the split's copy is passed only when the profile has nopso_pairs.tsv.
Offline pipeline archive reuse and fallback
- Symptom: a truncated
.mtl4areachesMTL4ArchiveImpl::loadFromURL; the bundle reports coverage with0 of 0 probed bases found; rebuilt shader libraries reuse an archive of their old binaries; or packing fails withpso_offline: no base to buildwhen every eligible pair has function constants, or when the profile'sshader_pairs.tsvholds only its header (--platform mac-app, GitHub issue #338). - Cause: the archive guard checked only four magic bytes; coverage accepted
found == probedeven when every sampled pair was ineligible; the offline cache key included library paths and profile stamps but omitted library bytes; and a zero-base plan aborted before writing its device remainder list, also when there was no pair at all, somac_pso()never reached its no-archive branch. - Fix:
arch_file_okchecks the GPU Mach-O load commands and payload ranges, universal slice bounds and the serializer's bundled Metal library ranges before opening the archive. It does not validate the compiler's private payload.pso_bundle_coversrequires at least one actual lookup and no misses.pso_offline.pyhashes every consumed library's bytes into the cache key; when all eligible pairs need device specialization, it removes old archives and writesrest_pairs.tsvwithout invokingmetal-tt. A pair list with no pair takes the same path (old archives removed, stamp written, exit 0), and the app is packed without an archive; pairs that the host would leave out entirely (wrong or missing libraries) still fail. - Found on: The Legend of Zelda: Breath of the Wild, during review of its offline archive path with synthetic libraries and pair lists; no new gameplay or iPhone run. The empty pair list on ANIMAL WELL.
- Check:
dm/tools/tests/test_pso_offline.pychanges vertex and fragment library bytes without changing entries or stamps, and exercises the all-constant plan, the header-only pair list and pairs all left out.t_pso_bundlerejects bare headers, truncated archives, malformed load commands and out-of-range universal slices, accepts real offline and serialized archives, and rejects zero-probe coverage.
Offline pipeline archive and profile depth disagree
- Symptom: the host logs
pipeline archive bundled with the app: made for ... this run needs ..., naming different depths, and pipeline bases compile during play despite a successful archive build. There need not be a[RED]line. - Cause: offline builds use the profile's
depth_mode(glwhen omitted), while runtime vertex entries follownvnDeviceSetDepthMode. An archive built withmain0cannot supply a near-zero base usingmain0_z. The runtime stamp comparison correctly rejects the mismatch; the missing input is in the profile. - Fix: declare the observed mode in the profile and rebuild the archive. Both vertex entries already exist, so this does not require a shader-chain change.
scripts/regress.pyrecords the runtime depth and compares it with the tested revision's profile on every NVN smoke run, including quick runs without an archive. It also records bundled archive stamp rejection for any chain, depth or pair-list mismatch. New mismatches fail the check; unchanged baseline mismatches are reported as known and prevent--recordfrom marking the game validated. An absent depth log is unknown, not evidence for GL. This detects mismatches on exercised routes; it does not infer unobserved games' modes or edit profiles automatically. - Found on: The Legend of Zelda: Echoes of Wisdom, update 1.1.0, GitHub issue #758: its note already recorded near-zero depth, but its profile omitted the key. The game note records archive validation and its limits; castle-entry performance remains open.
- Also confirmed: missing depth keys on SteamWorld Heist II, NieR:Automata, Portal, DRAGON QUEST III HD-2D Remake, Raging Loop, Subnautica and Subnautica: Below Zero. Recorded routes and short isolated boots select near-zero depth on their registered module builds; their notes record the evidence. The profile corrections have not been measured as rebuilt-archive performance fixes.
- Check:
dm/tools/tests/test_pso_offline.pyruns the archive builder with the affected games' real profiles and synthetic libraries, checking the stamp and the vertex entry.dm/tools/tests/test_regress.pyreplays runtime depth and rejection logs through a smoke run, checking failure classification and reporting.
Offline pipeline archive made for another major version
- Symptom:
pipeline archive bundled with the app: this system cannot open …/applegpu_<gpu>.mtl4a (deployment target for architecture … found in archive … is not compatible with current OS …): the bases are built on the device. On macOS 26.6 with Xcode 27.1,t_pso_bundlefailed 10 checks, frombundle: openedon, and a Mac app packed there compiled every base on its first start, as without the archive. - Cause: a Metal 4 pipeline archive opens only on the major system version it was made for. Measured on macOS 27.2 (M4 Pro,
newArchiveWithURL:): archives thatmetal-ttmade with-macos-version-min26.0 or 26.6 were refused, from libraries built for 26.0 or 26.6 alike; those made for 27.0 and 27.2 opened.pso_offline.pytook the SDK's major version for every platform. That suits the iPhone, whose system is the SDK's, but the Mac app is built for the Mac that builds it, and Xcode 27.1's SDK is 27.0 on macOS 26.6. Also, the Mac host had no deployment target, so clang took the SDK's 27.0, andpack_app.pycopied it into the app'sLSMinimumSystemVersion.metal-ttalso refuses libraries built for a later system than the archive's (image AIR version (2.9) is bigger than the one of the target (2.8), macOS 27.0 libraries for a 26.6 archive); the chain's Mac libraries are built for the building Mac's system, so that Mac's major version is never below them. - Fix: for macOS,
pso_offline.py's default--os-minis this Mac's major version (sw_vers); iOS keeps the SDK's. The Mac host's minimum isMACOS_MINindm/mac/min_os.mk(26.6, as the README asks), on the host's compile lines (-mmacosx-version-min, libvpx's too), which the NSP script andpack_app.pyfollow. The host builds with the same warnings at 26.6 as at 27.0 (no unguarded newer API). An archive is still for one major version: an iPhone on iOS 26 cannot open the IPA's 27.0 archive and builds its bases itself (open: one archive per major version would serve both). - Found on: no game: GitHub issue #673,
t_pso_bundleon an M4 Pro with macOS 26.6.2 and Xcode 27.1. - Check:
dm/tools/tests/test_pso_offline.py(test_macos_takes_this_macs_major_version).t_pso_bundlebuilds its archive with the default and opens it on macOS 27.2: 52 checks pass with its libraries built for 27.0 and for 26.6 (MACOSX_DEPLOYMENT_TARGET=26.6), the mirror of the reporter's Mac; the same libraries in an archive made for 26.6 failed the 10 checks. Open: no run on macOS 26.x here.
Metal's build folders left by pipeline archive flushes
- Symptom: the user cache folder (
getconf DARWIN_USER_CACHE_DIR, thencom.apple.gpuarchiver/) fills withgpuarchiver-*folders of 0.3–5 MB each, one per pipeline archive flush (456 folders, 2.5 GB after two days). Later folders are larger, up to about 127 MB (an M4 Pro, 2026-10-29: 19 GB in 423 folders after a day of Mac runs and regression checks); why they grew is open. A later compile can fail withcannot open '…/gpuarchiver-…/PersistentState': No such file or directory. - Cause: the Metal 4 serializer (
MTL4PipelineDataSetSerializer) creates private working folders. The former cleanup inferred ownership from the sole new directory between two snapshots. The mutex protected only this process: if its serializer already had a folder and another compiler created one during the capture or flush, the foreign folder was claimed and deleted. Serialization success concerns the requested output archive, not the lifetime of Metal's private files. The public serializer/compiler API exposes neither a scratch-directory selector nor an ownership token; an empty open-file list does not prove a folder is unused. - Fix:
nvn_mtl.mleaves Metal's working folders alone during capture, flush and exit. Only caller-owned archive files are removed by compaction. The iOS cold start can remove folders from previous runs in its app-private cache, before this run captures anything. Mac cache reclamation belongs to Metal or an explicit cleanup outside the host:scripts/clean_metal_cache.pyremoves a folder only when nothing in it changed for an hour (--idle-min) and its last change is older than the start of every runningdmhost, so a running host keeps its own;scripts/regress.pyruns it at its start and when the disk falls under its limit. A folder of another program's live compiler that stayed idle for an hour would still go; none is known. Folders kept this way may still grow between cleanups; disk growth is preferable to deleting another live compiler's state. - Found on: Pokémon Scarlet: the ownership defect is reproduced with an isolated foreign-folder fixture. Its longer house run had 20 missing-
PersistentStatefailures, but the deleting process was not observed, so that run's attribution remains open. Earlier cache-growth report: MacNativeGames (1eff989,110e6c6, Metal 3'sMTLBinaryArchive, 25 GB overnight on Tears of the Kingdom). - Check: the ownership fixture reproduces the former deletion of a foreign
PersistentState.t_pso_archiveexercises base reuse, archive lookup and compaction with real Metal pipelines and preserves a foreign compiler fixture created during a compiler call through the subsequent flush.dm/tools/tests/test_clean_metal_cache.pykeeps folders changed within the idle time or since a running host started, and removes the rest.
A compaction flush that crashes, then crashes every start
- Symptom: a previously working game exits with 139 on every start after pipeline preparation;
dispatch_retain(NULL)is called by-[_MTLBinaryArchive materializeAirNTFromOnlineForSlice:entryList:]underairntSerializeToURLandarch_flush(issue #342). - Cause: more than eight run archives enables compaction. The prewarm used
MTL4CompilerTaskOptions.lookupArchivesto recapture their bases into the serializer that also captures newly compiled bases. Metal can crash while materializing that dataset for serialization. It crashes beforearch_compact_finishremoves the old files, so every restart retries it. Framework analysis recorded in issue #342 identifies a nilMTLBinaryEntry.bitcode, read from the captured function's library bybitcodeDataInternalwhen the bytes at its bitcode offset are not an AIR wrapper. Which function lacks AIR and why remain open. A lead, not a cause: a probe that reused one serializer after each write, with no compile during the write, crashed once in 111 runs with this signature, and a compile under way across a write comes back with its entry's AIR script and bitcode nil (A compile that does not return stalls every flush and draw); the host now never uses a serializer again after its write, and a serializer per period crashed in none of 118 runs; simple synthetic recaptures and a replay with no new bases did not reproduce it. A mixed Tears of the Kingdom replay did, with the reported null call and backtrace, on an M4 Pro with macOS 27.0.1 (26A434). - Fix: work around the framework defect by capturing each compacted base from its original shader functions, with no lookup archives. Metal's ordinary compiler cache still applies. The archive threshold, ordinary archive lookups, deferred flush during prewarm and removal of old files only after a successful write remain in place. A compaction start can do more compiler work; normal starts keep their archive hits.
arch_capturepasses no task options, so captured bases cannot accidentally import archive entries. The recovery guard from the earlier workaround remains:pso_archive/compactingrecords the system build and the capture method before a compaction flush, and is removed when the flush returns. A marker for this build and method suppresses another attempt after a crash. Legacy OS-only markers from archive imports are cleared so the safer capture method can run; a later OS or capture-method change also allows another attempt. - Found on: The Legend of Zelda: Tears of the Kingdom, reported on an M5 Max and reproduced with the local shader libraries on an M4 Pro. This is shared NVN archive code, not a game profile rule.
- Check: the same 19-file snapshot, 1,490 archived bases and 44 new bases failed in two of three runs with lookup imports and passed all three when only that option was removed. The implemented fix passed three further compactions and their following restarts: each compacted file supplied all 1,534 bases with zero new base compilations on restart. These are library-only manifest replays using the local
e7245308050dlibraries and a matching diagnostic chain header, not a new gameplay run or an iOS test.t_pso_archiverejects lookup imports at the real compiler seam;test_pso_archive.pyexercises disk-built libraries, concurrent recaptures mixed with a fresh function, function constants, compaction and restart in separate processes (make -C dm/gfx/tests GAME=<id> check_t_pso_archive). The marker tests retain the recovery guard and check migration from the old OS-only marker, including a fresh-process restart with that marker present.
Pipeline keys omit unused colour state
- Symptom: identical render pipelines compile repeatedly under different keys, lengthening startup prewarm and adding pipeline misses during play.
- Cause: NVN blend and channel-mask state persists between draws, including state for colour targets absent from the current pass.
pso_descskips targets without a format and reads blend factors only when blending is enabled, but the old keys retained unused state. Deduplicating manifest lines alone also misses old rows whose unused state differs. - Fix:
pso_key_norminnvn_pso_skey.hclears blend factors when blending is disabled and, for normal passes, the blend-enable bit and write-mask nibble of every target without a format. Merged passes retain their logical blend-enable and mask state becauselmapmaps physical attachments to logical targets; a missing physical format does not imply the same logical target is unused.pso_key_fillnormalizes live keys,pso_skey_parsenormalizes historical prewarm rows, andwarm_workerdeduplicates the parsed keys across all input lists before compiling. Depth and stencil formats remain in the key because theMTLDevicepath needs them, even though Metal 4 bases omit them. - Found on: The Legend of Zelda: Breath of the Wild (disabled blend factors), The Legend of Zelda: Tears of the Kingdom (absent-target state, reported by MacNativeGames).
- Check:
t_pso_skeycovers normalized round trips, historical rows differing only in unused state, and preservation of merged-pass logical masks and blend bits;t_pso_archivechecks disabled blend factors with all targets present.make -C dm/gfx/tests GAME=<id> check_t_pso_warmloads two synthetic manifests throughwarm_worker, checks compilation counts and draw-time cache hits, and compares GPU pixels with the original descriptors through both MTLDevice and Metal 4. It includes a merged pass whose physical attachment 0 uses logical target 7's blend and mask state.
Shader cache key covering the pairs
- Symptom: a vertex shader whose pairs changed was restored unpadded from
cas/. - Cause:
dmsh_build.py's shader cache was keyed without the pairs. - Fix: the per-shader key covers the pairs, and a paired vertex shader's key also covers its pixel shaders' keys (
shader_key,pair_keyed), so a change to a profile's pairs rebuilds what it affects (Pixel shader inputs the vertex shader does not write). - Found on: 13 Sentinels: Aegis Rim.
Target selection keeps the shader analysis files in the cache
- Symptom: a repeated iOS-only shader build crashes with
FileNotFoundErrorfor<key>_b.jsonin the Mac output directory; requesting only Mac libraries withIOSOUTset still compiles iOS side libraries. - Cause: target selection excluded the Mac directory from the per-shader cache entirely, but cache-hit processing, paired vertex padding and the profile tables still read its MSL and reflection. Side-library paths tested
IOSOUTrather than the selected targets. Prefilling the Metal cache also compiled validation samples for unrequested targets. - Fix: cache and restore the Mac MSL and reflection for every target; require and copy metallibs only for selected targets. A cache entry without its analysis source is a miss. Clear
IOSOUTwhen iOS is not selected and restrict prefill validation to selected targets. The NSP script writes only the requested target's reuse key. - Found on: Celeste, while reviewing PR #275 (resolved).
- Check: real Metal output and cache comparisons are recorded in Celeste's shader note above.
test_shader_toolchain_cache.pycovers geometry analysis files, incomplete entries and prefill selection;test_nsp_mac.pychecks target-specific reuse keys.
An iOS build after a Mac-only build kept no Mac libraries
- Symptom: after
scripts/build_ipa_from_nsp.py <NSP folder> --platform mac, a plain (iOS) run of the script on the same folder succeeds, but the Mac run command the first build printed then logs hundreds of[RED] failed to load shader <key>_b: cannot open …/shader/cache/b-<chain>/<key>_b.metalliblines, until--platform macis run again. - Cause: both builds write the Mac sources and reflection into
<id>_build/shader/cache/b-<chain>/, which also holds the Mac libraries. A fulldmsh_build.pyrun first removes every<key>_b*output there, so that a key that left the map or a shader that no longer builds keeps no old library, and then restores or compiles only the targets inDMSH_TARGETS. Since target selection (Target selection keeps the shader analysis files in the cache) the NSP script asked an iOS build foriosalone, so the Mac.metallibfiles were removed and only their.metaland.jsoncame back. - Fix: the NSP script (
build_shaders) asks an iOS build formac,ioswhen the folder holds the current chain's Mac libraries, and then writes both reuse keys, so a later--platform macrun is reused. The Mac libraries are rebuilt, not kept as they were, so none is stale: the Metal compile cache gives back every one whose MSL was compiled before, and only a changed MSL is compiled. The first such build of a folder may translate again, because the per-shader cache entries an iOS-only build left hold no Mac library. A folder without Mac libraries (only ever packed as an IPA) still compiles only the iPhone's.dmsh_build.py's rule, that a full build leaves only what it built, is unchanged; it is part of the chain version, so the fix stays in its caller. - Found on: Team Sonic Racing (resolved).
- Check:
test_nsp_mac.py(test_ios_after_mac_keeps_mac_libraries,test_ios_after_mac_of_another_chain).
Old shader library folders grew a game's build folder with each chain change
- Symptom: the build folder of a game (
<id>_build/shader/) gains about 2 GB for Breath of the Wild with every upstream update that changes the shader chain: after two updatesshader/cacheheldb-157e1b96c8da(2.1 GB) andb-a78f966a7b38(2.3 GB) besides the current one, and the next build added a third. - Cause: the libraries are written to
b-<chain>/(shader/cachefor the Mac,shader/iosfor iOS, with the reuse keysshader/ios/b-<chain>.keyandshader/mac-b-<chain>.key) and a chain change starts a new folder. Nothing removed the old ones. The host loads only the current chain's folder,dm/mac/pack_app.pyandbuild_ipa.shpack only it, andregress.pyreads the folder of its own run, so an old folder is dead weight.dmsh_build.pytook libraries of older folders into the Metal compile cache (dmsh_mtlcache.py) before it translated, andshader_regressions.pycompared the new build with the newest older folder's_b_build.json. - Fix: the NSP script (
prune_stale_chains, called last inbuild_shaders) removes the folders namedb-<12 hex digits>of other chain versions inshader/cacheandshader/ios, and their keys, once the libraries, their tables and the key of the current chain are in place (a reused build prunes too). It logs one line,removed old shader libraries b-… (N GB). A build that stops or fails earlier keeps the old libraries. The regression check has run by then, and the next chain change compares with the folder this build keeps. The compile cache already holds what the old libraries compiled, sincedmsh_build.pytook them in at the first build of the new chain. The only loss is for a chain that comes back (a checkout of an older commit): its shaders compile again where the cache does not hold their MSL. The compile cache itself (shader/cache/cas/metallib) is keyed by the MSL and the compiler, not by the chain, so entries are shared across chains and it is not pruned. It grows only with MSL that is new, and no code removes an entry. - Found on: Breath of the Wild (resolved).
- Check:
test_nsp_mac.py(test_prune_stale_chains_after_success,test_failed_build_keeps_stale_chains,test_prune_ignores_other_names).
Function constants specialized at load
- Symptom:
fragmentFunction main0 cannot be used to build a pipeline state(a Metal validation assertion). - Cause: SPIRV-Cross declares an optional function constant,
spvLinearTextureAlignmentOverride, in shaders that read a texel buffer, and Metal refuses any function with function constants until it is specialized. - Fix:
shader_loadspecializes such functions with an empty set of values. On the Metal 4 path the pipeline's descriptor specializes them, not the library: Functions with function constants specialized by the pipeline, not the library. - Found on: Pokémon Legends: Z-A (one imgui pixel shader).
Functions with function constants specialized by the pipeline, not the library
- Symptom: with Metal 4 pipelines on (the Mac; on iOS
pso_mtl4 onorDM_PSO_MTL4=1), Apple's performance HUD saysCompiled 2 shaders. Total compilation time is 102.70ms. Consider adopting MTL4Compiler for more fine grained compilationsduring play, when a shader with function constants loads. - Cause: a function that declares function constants must be specialized before it goes into a pipeline (Function constants specialized at load). The host did that through
MTLLibrary'snewFunctionWithName:constantValues:error:when it loaded the shader (shader_load) and the libraries made from it (the pulling and shadow-input vertex entries,pull_loadandvin_fn; the alpha test,at_fn). That call compiles through the MTLDevice, synchronously, on the thread that loads the shader. A Metal 4 pipeline does not need it:fn4_notealready gives MTL4Compiler anMTL4SpecializedFunctionDescriptorwith the same values. - Fix:
fn_specinnvn_mtl.m. With the Metal 4 compiler (pso4_compiler) theMTLFunctionstays unspecialized and is noted with its constant values, so MTL4Compiler specializes it with the pipeline. Its reflection and argument encoders are the same as the specialized function's. A pipeline the MTLDevice builds after all (pso_build's fallback, when a function has no Metal 4 descriptor, such as theDM_NVN_HILITEoverride) takes the functionfn_devspecializes through the library, once per function. A stable record per function serializes overlapping first uses, so they receive the same specialized function; unrelated functions can specialize concurrently, and registering the entry again preserves its cached result. Unchanged:- Compute shaders are still specialized at load: the MTLDevice builds their pipelines.
- Without the Metal 4 compiler, functions are specialized as before.
- The variants that differ only in their constant values still specialize through the library: the logic operation entries (
lo_fn) and the readable-tile versions (bf_fn,DM_NVN_TBDR). Their pipelines would need one function object per set of values, andnewFunctionWithName:may hand back the same object.
- Found on: Animal Crossing: New Horizons on an iPhone 17 Pro Max with Metal 4 pipelines turned on: the HUD warning when a shader loaded during play. On the Mac, with a probe that logs each MTLDevice compile, the specializations came from
shader_loadduring the prepare step and again when a shader loaded during play. After the fix none were left, and the picture was the same. - Check:
t_pso_archive. A function with a constant stays unspecialized, its descriptor specializes it, and a pipeline is made through the Metal 4 compiler. With a fragment function that has no descriptor, the pipeline goes through the MTLDevice with the vertex function specialized once. A gated library holds the first specialization while a second caller requests it: both receive one shared result, an unrelated function progresses, and registering the same entry again does not specialize it again.
Layer-writing vertex shaders get the triangle class
- Symptom: a pipeline for a vertex shader that writes
render_target_array_indexcannot be built. - Cause: Metal builds such a pipeline only with an input primitive class, and the translator writes a point size in every vertex shader, which needs the point class: no class fits both.
- Fix:
vs_layerdrops the point size from vertex shaders that write the layer, and the host gives their pipelines the triangle class once Metal names the reason (layer_outinpso_get_and in the background prewarm). Their passes are layered (Layered passes for vertex shaders that write the layer). - Found on: Pokémon Legends: Z-A: two full-screen vertex shaders (
57246f9a04af8529,b29f08619f379a83: one triangle per instance, layer = instance + offset, 20 pixel shaders read the layer).
The precompile look-ahead restores the scissor
- Symptom: the picture bright green and white from some scene on, in runs with pipelines compiled on demand (prewarm lists off or short) and in none with
DM_NVN_PREDICT=0. - Cause: after a pipeline miss the replay looks ahead through the rest of the submission, replaying state only, to compile the next pipelines in the background (
pso_predict_rest). It restoredS, butstate_opalso sets the scissor, which lives inR: the replay went on with the last scissor of the submission until its nextOP_SCISSOR, so the draws in between were clipped to another pass's rectangle. - Fix: the scissor is saved and restored with
S; the fusion probe (fz_probe_cov2) already restored all ofR. Every game. - Found on: OCTOPATH TRAVELER II: from Castti's opening flashback and the battlefield of Hikari's opening on.
- Check: found by bisecting with four runs started at once on copies of one save:
DM_NVN_PREDICT=0fixed it, a look-ahead that compiled nothing did not, and of its parts only the state ops brought it back; four runs green before the fix, four right after it.
GPU frame captures need pipelines built through the device
- Symptom: a GPU frame capture (
DM_GPU_CAPTURE, or Xcode withMTL_CAPTURE_ENABLED=1) fails withCapturing Render pipeline specialization is not supported, or the capture layer crashes on the pipeline archive; a capture of theMTLDeviceis refused. - Cause: Metal cannot capture pipelines made from Metal 4 bases (
DM_PSO_MTL4, the default on the Mac), and its Metal 4 to 3 conversion for the capture layer crashed on the pipeline archive. A device the replay drives through Metal 4 cannot be the capture object. - Fix: a run that may capture (
DM_GPU_CAPTUREorMTL_CAPTURE_ENABLEDset) builds its render pipelines through theMTLDevice, without bases or archive (pso4_on), andgpu_capture_framecaptures the Metal 4 queue (g_q4): work on the old queue (present, some uploads) is not in the trace. Such a run builds pipelines more slowly than a normal one, so its frame times are not the game's. Every game. - Found on: The Legend of Zelda: Breath of the Wild (Mac and iPhone).
A cold Metal cache looks like a long load
- Symptom: the first boot sat on the loading clock for about a minute, the CPU idle, with every file read done after 12 s.
- Cause: that run compiled its Metal libraries and pipelines on first use (cold cache). With a warm cache the title is up after about 15 s; what looked like a 40-second wait in the read log (
DM_FSLOG) was the title screen itself, which reads nothing. - Check: screenshots every few seconds (
ctl.sh snapfrom a script) told the two apart. - Found on: Bayonetta 3 (SDK 13.3.2).
Geometry runs across occlusion query boundaries
- Symptom: hundreds of geometry raster passes in combat, despite support for batching consecutive geometry draws.
- Cause: the lookahead stopped at each samples-passed query report/reset. NieR wraps most geometry draws in independent occlusion queries, so each one still captured vertices, ran geometry/compaction and reopened the depth buffer.
- Fix: queries remain in their original raster replay positions while lookahead prepares later geometry draws. The shared raster pass uses a separate visibility-result slot after each report/reset; helper capture and compute stages do not count raster samples. Query-result addresses remain marked as report writes for hazard checks.
DM_NVN_GEOMETRY_QUERY_RUN=0retains the earlier split path for comparison. - Found on: NieR:Automata US 1.0.3: the captured factory combat frame has 697 geometry raster draws with 670 intervening query/event/reset groups. A GPU fixture verifies that the first visible draw reports 216 samples and the second, hidden draw reports zero, with the completion event and later color/depth coverage preserved.
Prepared geometry indices reused after byte comparison
- Symptom: geometry preparation dominates rendering CPU even after geometry passes have been batched; primitive assembly and capture-index deduplication repeat for unchanged meshes.
- Cause: each draw decodes all source indices, assembles its primitive map and rebuilds sorted unique capture IDs, even when its source bytes and topology are identical to the previous frame.
- Fix: a 32 MB / 4,096-entry LRU cache keeps only the CPU primitive map, unique capture IDs and original index bytes. The key includes address, count, index width, topology and restart settings. Every reuse compares all original bytes, after the existing readability and GPU-write wait checks; there is no content-hash collision assumption or dependence on write-protection. CPU edits, GPU copies and reused storage with different contents rebuild preparation. Base vertex, instances, vertex capture, transforms and GPU outputs remain per draw.
DM_NVN_GEOMETRY_INDEX_CACHE=0disables reuse for comparison. - Found on: NieR:Automata US 1.0.3: the outdoor flight-unit encounter has approximately 2,300 draws including 855 geometry draws, 24.9 ms rendering CPU and 12.2 ms GPU time. A controlled CPU-only preparation benchmark (64 draws, 32 meshes, 6,144 indices each), alternating warmed paths, drops median CPU preparation from 2.783 to 0.149 ms; it does not execute the GPU geometry stages or establish a gameplay FPS. Production GPU tests preserve literal pixel coverage on unchanged repeats, CPU index edits and GPU index copies, including forced-wait and disabled-cache paths. Cache budget and topology/restart separation are covered too.
Geometry capture initializes only missing words
- Symptom: geometry vertex-capture passes consume substantial GPU time before geometry compute or depth rasterization.
- Cause: the capture entry first zeroed all 144 words of every output record, then wrote the default W components and finally overwrote the actual shader outputs. Much of the initialization was redundant GPU storage traffic.
- Fix:
dmsh_geometry.captureinitializes only words absent from the captured outputs. The capture slot map stores each selected word once, including defaults for absent outputs. Position W, point size, generic W, clip distances, layer and viewport retain their existing bit values. Full capture selects all 144 words; lean capture selects its consumed words. Shaders and host must be rebuilt together. - Check: the synthetic throughput comparison alternates control/candidate order and verifies all 9,437,184 captured words against both the original buffer and literal expected values. Mac runs vary in timing, so their percentage gain does not establish iPhone gameplay FPS. The optional real-translator capture fixture in
t_geometryalso checks all uncaptured defaults. All 1,225 NieR shaders build for Mac and iPhone; standard geometry replay and dependency checks pass. - Found on: NieR:Automata US 1.0.3, iPhone City Ruins: 14 geometry groups in the captured frame spend 7.485 ms in vertex capture, 6.512 ms in compute and 1.019 ms in depth raster. The accompanying pass dump affects total frame time; its large uncovered GPU interval must not be treated as a clean gameplay hitch measurement.
Republished from the nxnative repository under its MIT license (license and attribution). Original: docs/reference/shaders.md on GitHub.