"""Prepare the default variant of every game shader and the variants of the shared shader list for a build, once, before play. The list (tools/shader_list.py) names shader variants players met: a shader key (sha256 of the NVN control section and code, as tools/shader_census.py names shaders) and the translator answers it was translated for. This tool reads the shaders from the extracted RomFS and ExeFS (tools/shader_census.py inventory), keeps the list's variants whose shader is in this game version, adds each shader's default variant (every texture slot a 2D texture, float vertex inputs and colour outputs: the default bind state), and writes them next to the build's nx2navi.exe in the record layouts of runtime/gfx_shader.c ("answers met in play"): seed-programs.bin "NX2NSPRG": stage, length, c1 length, runtime code hash, control section, code, c1 seed-answers.bin "NX2NSANS": stage, code hash, input id (zero: the runtime computes it), answers, priority Priority (the pad byte): 0 shaders the list's warm rows flag as title/menu (t), 1 other warm rows (S, C, R), 2 the rest; a default variant takes its shader's priority and sets DEFAULT_FLAG (bit 7). The runtime reads a flagged record's first translation and translates it again with better answers where it reads other ones (runtime/gfx_shader_guess.c): integer vertex inputs, and the texture and image types a compute shader's translation declares, for which a compute default variant goes without texture answers. Then it runs `nx2navi.exe --prepare-shaders`, which translates every record into the build's shader cache, writes shader-prepared.bin (one record per variant, in priority order) and builds every variant's stage library or compute pipeline once, so the GPU driver's own disk cache holds them (vk_pipeline.c "stage library prebuild"). The seed files can be deleted afterwards. The shared pipeline list (tools/pipeline_list.py: graphics pipelines players met, with the shader variants they name) is read beside the shader list when there is one: tools/data/pipeline_list.bin, data/pipeline_list.bin in a release folder, or --pipeline-list. Its variants of shaders this game version has join the seed, and its key records are written as seed-pipeline-keys.bin, which --prepare-shaders builds into the pipeline caches once the seed is translated (vk_pipeline.c "the shared pipeline list"; mesh pipelines by default). That file stays in the build directory: `nx2navi.exe --warm-shaders` and `--prepare-pipelines` build from it again after an update or a new driver. A driver whose shader compiler crashes on a variant crashes on it in every run (NVIDIA 615.78.08 on an RTX 5080, issue #30). The translations and shader-prepared.bin are written by then, and the journal the run leaves names the variants the driver was building (vk_pipeline.c skip_init), so only the driver step runs again (`nx2navi.exe --warm-shaders`), up to RETRIES times, and skips them. Game-derived output stays in the build directory (ignored). The list is read at preparation time from tools/data/shader_list.tsv when that file exists (the installer archive bundles it; it stays out of the repository), else from refs/ through git with the rows its history dropped (tools/shader_list.py); nothing of it is copied into the repository. Needs Python 3.14 (compression.zstd), so run it in .venv-py314 or the embeddable 3.14: .venv-py314\\Scripts\\python tools\\prepare_shaders.py build\\v121 [--version v121] [--list ] [--no-run] [--romfs DIR --exefs DIR] # instead of build\\extracted\\ """ import argparse import hashlib import json import os import struct import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] while not (ROOT / 'tools' / 'shader_census.py').is_file() and ROOT != ROOT.parent: ROOT = ROOT.parent sys.path.insert(0, str(ROOT / 'tools')) import pipeline_list # noqa: E402 import shader_list # noqa: E402 from shader_census import inventory # noqa: E402 STAGES = {'vs': 0, 'ps': 4, 'cs': 5, 'ts': 6, 'ms': 7} # runtime/gfx.h GFX_STAGE_* TEX = [None, '1d', '1darray', 'buffer', '2d', '2darray', '2dms', '2dmsarray', '3d', 'cube', 'cubearray'] FMT = [None, 'R8Unorm', 'R8Snorm', 'R8Uint', 'R8Sint', 'R16Float', 'R16Unorm', 'R16Snorm', 'R16Uint', 'R16Sint', 'R32Float', 'R32Uint', 'R32Sint', 'R8G8Unorm', 'R8G8Snorm', 'R8G8Uint', 'R8G8Sint', 'R16G16Float', 'R16G16Unorm', 'R16G16Snorm', 'R16G16Uint', 'R16G16Sint', 'R32G32Float', 'R32G32Uint', 'R32G32Sint', 'R8G8B8A8Unorm', 'R8G8B8A8Snorm', 'R8G8B8A8Uint', 'R8G8B8A8Sint', 'R16G16B16A16Float', 'R16G16B16A16Unorm', 'R16G16B16A16Snorm', 'R16G16B16A16Uint', 'R16G16B16A16Sint', 'R32G32B32A32Float', 'R32G32B32A32Uint', 'R32G32B32A32Sint', 'R10G10B10A2Unorm', 'R10G10B10A2Uint', 'R11G11B10Float'] # gfx_shader.c fmt_names TYPES = {'float': 0, 'sint': 1, 'uint': 2} DEFAULT = {'tex': {str(slot): '2d' for slot in range(32)}} # answers outside a shader's use mask are dropped CONTROL = 0x880 ANS_REC = 4 + 32 + 32 + 147 + 1 DEFAULT_FLAG = 0x80 # pad byte: a default variant, its answers refined from its first translation (gfx_shader_guess.c) # Default variants left out unless --all-shaders: the larger compute programs of two of Ocarina's three NDSR upscaler # packages. The translator turns their 20-47 KB of code into 3-12.6 MB of SPIR-V, which the GPU driver took 34 s to # 44 min each to build (RTX 5090, driver 621.01): 35 programs, the 24 that translate took 19,400 of the 21,750 s # every compute variant together took. Play sessions met none of them that was slow to build (one 20.8 KB program built in 0.2 s), while all five # agl_ndsr_vr_light.pck programs play met took 77-153 s each, so that package stays whole. A program play does meet # here is translated and built at its first use, as before the NDSR packages were read at all. SLOW_UNUSED = (('/agl_ndsr.pck', 16 << 10), ('/agl_ndsr_vr.pck', 16 << 10)) # (source suffix, code bytes above which) def slow_unused(s): return any(s['source'].endswith(suffix) and len(s['code']) > limit for suffix, limit in SLOW_UNUSED) JOURNAL = 'shader-prepare-journal.bin' # vk_pipeline.c skip_init: a 64-byte slot per worker, input id first RETRIES = 3 BUNDLED_LIST = ROOT / 'tools' / 'data' / 'shader_list.tsv' # the installer archive carries a copy (no git, no refs/) if not BUNDLED_LIST.is_file(): BUNDLED_LIST = ROOT / 'data' / 'shader_list.tsv' # release folder layout (tools/make_release.py) BUNDLED_PIPELINES = ROOT / 'tools' / 'data' / 'pipeline_list.bin' # as BUNDLED_LIST: out of the repository if not BUNDLED_PIPELINES.is_file(): BUNDLED_PIPELINES = ROOT / 'data' / 'pipeline_list.bin' SEED_KEYS = 'seed-pipeline-keys.bin' LIST_PRIORITY = 1 # a pipeline list variant's seed priority: with the warm-up rows def read_pipeline_list(path): """(answers records, pipeline-keys.bin image) of the pipeline list at path, or of the bundled one; None without a list. A file that is not a list stops the run: a release that names one must not go on without it.""" p = Path(path) if path else BUNDLED_PIPELINES if not p.is_file(): if path: sys.exit(f'prepare_shaders: no pipeline list at {p}') return None got = pipeline_list.unpack(p.read_bytes()) if got is None: sys.exit(f'prepare_shaders: {p} is not a pipeline list (tools/pipeline_list.py merge writes one)') return got def list_variants(answers_recs, have, seen): """Seed records for the pipeline list's shader variants: those whose shader (runtime code hash) is in `have` and whose (hash, answers) the seed does not hold yet. The input id is left zero, as for every seed record: the runtime finds it, and finds the id the list's key records name, since it depends on the code and the answers alone.""" out, absent = [], 0 for a in answers_recs: h, packed = a[4:36], a[68:68 + 147] if h not in have: absent += 1 continue if (h, packed) in seen: continue seen.add((h, packed)) out.append(a[:36] + bytes(32) + packed + bytes([LIST_PRIORITY])) return out, absent def read_list(path): if path: return Path(path).read_text(encoding='utf-8') if BUNDLED_LIST.is_file(): return BUNDLED_LIST.read_text(encoding='utf-8') return shader_list.history(ROOT / 'refs' / 'MacNativeGames') def pipeline_variants(key): """The vertex and fragment variant names of an R/B row's pipeline key. Since its revision of 2026-10-28 the list keeps keys packed, "::"; older revisions and contribution files hold the whole key in hex, "\\0\\0" + a Metal descriptor.""" if ':' in key: return key.split(':', 2)[:2] return [n.decode('ascii', 'replace') for n in bytes.fromhex(key).split(b'\0')[:2]] def parse_list(text): """V rows as (key, deps); priority per shader key from the warm rows (variant names start with the key).""" variants, prio = [], {} def warm(key, flags): p = 0 if 't' in flags else 1 prio[key] = min(prio.get(key, 2), p) for line in text.splitlines(): f = line.split('\t') if f[0] == 'V' and len(f) == 3: variants.append((f[1], json.loads(f[2]))) elif f[0] == 'S' and len(f) == 4: warm(f[1][:64], f[3]) elif f[0] == 'C' and len(f) == 3: warm(f[1][:64], f[2]) elif f[0] in ('R', 'B') and len(f) >= 3: for n in pipeline_variants(f[1]): if len(n) >= 64: warm(n[:64], f[-1]) return variants, prio def answers(deps, stage, control): """GfxShaderAnswers in gfx_shader.c pack_answers layout (147 bytes) from the list's deps JSON.""" tex, img, fmt, attr, fout = bytearray(32), bytearray(32), bytearray(32), bytearray(32), bytearray(8) for slot, t in deps.get('tex', {}).items(): t = t.removesuffix('_depth') tex[int(slot)] = TEX.index('2d' if t == '*' else t) # "*": any kind, translated as the plain 2D it stands for for slot, o in deps.get('img', {}).items(): img[int(slot)] = TEX.index(o['type']) fmt[int(slot)] = FMT.index(o['fmt']) if o.get('fmt') in FMT else 0 for loc, t in deps.get('attr', {}).items(): attr[int(loc)] = TYPES[t] for idx, t in deps.get('fout', {}).items(): fout[int(idx)] = TYPES[t] local = [0, 0, 0] # vk_exec_draw.c answers_for: zero for vertex and fragment programs (they are in the input id) if stage not in (STAGES['vs'], STAGES['ps']): # compute, task and mesh: the three words at the control section's local size (NVN2 at +0x58), 0 read as 1, # each cut to 16 bits as answers_for stores them (task and mesh programs keep other fields there) local = struct.unpack_from('= size and c1_end > c1_start and c1_end - size < 1 << 20 else size if c1_start >= size and c1_end >= c1_start and c1_end <= min(code_size, len(code)): return c1_start, c1_end return min(size, len(code)), min(size, len(code)) return struct.unpack_from('