mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 17:09:57 -04:00
A draw that met a new pipeline waited 3 ms for its fast link, in every frame, which is just under what the link takes with the stage libraries in the driver's cache: in a first-visit session on a new install 66 of 87 such draws gave up at the end of the wait and their frame was held back. The wait is now 8 ms for each pipeline, counted from its first miss and shared by all its draws, and 16 ms for a frame, so a pipeline the driver has to compile costs one frame the wait and the frames after it nothing. The pipeline list tool also takes the compute variants a game folder dispatched, which no pipeline record names and which were translated in the middle of play, and reads a list made before as one of its inputs. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
327 lines
18 KiB
Python
327 lines
18 KiB
Python
"""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 <tsv>] [--no-run]
|
|
[--romfs DIR --exefs DIR] # instead of build\\extracted\\<version>
|
|
"""
|
|
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, "<vs variant>:<fs variant>:<the rest in hex>"; older revisions and contribution files
|
|
hold the whole key in hex, "<vs variant>\\0<fs variant>\\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('<III', control, 0x58 if nvn2(control) else 0x748)
|
|
local = [(v or 1) & 0xffff for v in local]
|
|
packed = bytes(tex + img + fmt + attr + fout) + struct.pack('<HHHIB', *local, 0, 0)
|
|
assert len(packed) == 147
|
|
return packed
|
|
|
|
|
|
def nvn2(control):
|
|
"""An NVN2 (Switch 2) control section as shader_census.nvn2_stages keeps it: 0xe0 bytes, zeros to 0x880."""
|
|
return control[:4] == bytes.fromhex('34127698') and not any(control[0xe0:0x880])
|
|
|
|
|
|
def bounds(control, code):
|
|
"""(c1 offset, length) as runtime/gfx_shader.c req_init takes them.
|
|
|
|
NVN2: play hands req_init the code size of runtime/vk_exec_draw.c ctl_stage_len, which reaches the end of the c1
|
|
constants only when there are some (+0x40 below +0x44). With an empty c1 range past the code the length is the code
|
|
size alone; cutting at the range's end (as shader_census does) seeded most of Ocarina's vertex programs under keys
|
|
play never asks for."""
|
|
if nvn2(control):
|
|
size, c1_start, c1_end = struct.unpack_from('<III', control, 0x38)[0], *struct.unpack_from('<II', control, 0x40)
|
|
code_size = c1_end if c1_start >= 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('<II', control, 0x700)
|
|
|
|
|
|
def code_hash(stage, control, code, length, c1):
|
|
"""gfx_shader.c build_code_hash."""
|
|
return hashlib.sha256(struct.pack('<I', stage) + control + struct.pack('<Q', length) + code[:length] +
|
|
struct.pack('<I', len(c1)) + c1).digest()
|
|
|
|
|
|
def driver_crashed(out):
|
|
"""nx2navi died while the graphics driver built variants: the journal it left names at least one."""
|
|
p = out / JOURNAL
|
|
data = p.read_bytes() if p.is_file() else b''
|
|
return any(any(data[i:i + 32]) for i in range(0, len(data), 64))
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument('build', type=Path, help='build directory holding nx2navi.exe (build\\v121)')
|
|
ap.add_argument('--version', default='v121', help='build/extracted/<version>')
|
|
ap.add_argument('--romfs', type=Path, help='RomFS directory (default build/extracted/<version>/romfs)')
|
|
ap.add_argument('--exefs', type=Path, help='ExeFS directory (default build/extracted/<version>/exefs)')
|
|
ap.add_argument('--list', help='a shader_list.tsv instead of tools/data/shader_list.tsv or refs/MacNativeGames')
|
|
ap.add_argument('--no-run', action='store_true', help='write the seed files only')
|
|
ap.add_argument('--no-list', action='store_true', help='default variants only, without a shared shader list')
|
|
ap.add_argument('--pipeline-list', help='a pipeline list instead of tools/data/pipeline_list.bin (tools/pipeline_list.py)')
|
|
ap.add_argument('--no-pipeline-list', action='store_true', help='no shared pipeline list, also when one is bundled')
|
|
ap.add_argument('--all-shaders', action='store_true',
|
|
help='also the default variants SLOW_UNUSED leaves out (an hour more of GPU driver builds)')
|
|
ap.add_argument('--exe', default='nx2navi.exe' if os.name == 'nt' else 'nx2navi',
|
|
help='the game executable in the build directory that runs --prepare-shaders')
|
|
a = ap.parse_args()
|
|
t0 = time.monotonic()
|
|
variants, prio = parse_list('' if a.no_list else read_list(a.list))
|
|
extracted = ROOT / 'build' / 'extracted' / a.version
|
|
listed = len(variants)
|
|
shaders, _ = inventory(a.romfs or extracted / 'romfs', a.exefs or extracted / 'exefs', everything=False)
|
|
# A missing list row says nothing about the walk: the list covers several versions, and 1.1.0 holds 17,587 of its
|
|
# 41,414 rows, so most rows are absent because the game does not have them, not because the walk missed them. A walk
|
|
# that found next to nothing does say something: the shader directories have moved.
|
|
if len(shaders) < max(64, listed // 20):
|
|
print(f'only {len(shaders)} shaders found of the {listed} listed variants: reading every RomFS file instead',
|
|
flush=True)
|
|
shaders, _ = inventory(a.romfs or extracted / 'romfs', a.exefs or extracted / 'exefs')
|
|
t1 = time.monotonic()
|
|
progs, recs, seen, skipped = {}, [], set(), {'not in this version': 0, 'stage': 0}
|
|
left_out = 0 if a.all_shaders else sum(1 for s in shaders.values() if slow_unused(s))
|
|
variants += [(key, DEFAULT) for key in sorted(shaders) if a.all_shaders or not slow_unused(shaders[key])]
|
|
for key, deps in variants:
|
|
s = shaders.get(key)
|
|
if not s:
|
|
skipped['not in this version'] += 1
|
|
continue
|
|
stage = STAGES.get(s['stage'])
|
|
if stage is None:
|
|
skipped['stage'] += 1
|
|
continue
|
|
control, code = s['control'][:CONTROL], s['code']
|
|
c1_off, length = bounds(control, code)
|
|
c1 = code[c1_off:length]
|
|
if key not in progs:
|
|
h = code_hash(stage, control, code, length, c1)
|
|
progs[key] = (h, struct.pack('<III', stage, length, len(c1)) + h + control + code[:length] + c1)
|
|
h = progs[key][0]
|
|
default = deps is DEFAULT
|
|
packed = answers({} if default and stage == STAGES['cs'] else deps, stage, control)
|
|
if (h, packed) in seen: # a list row of the default bind state
|
|
continue
|
|
seen.add((h, packed))
|
|
rec = struct.pack('<I', stage) + h + bytes(32) + packed + bytes([prio.get(key, 2) | (DEFAULT_FLAG if default else 0)])
|
|
assert len(rec) == ANS_REC
|
|
recs.append((prio.get(key, 2), rec))
|
|
out = a.build.resolve()
|
|
plist = None if a.no_pipeline_list else read_pipeline_list(a.pipeline_list)
|
|
(out / SEED_KEYS).unlink(missing_ok=True) # a list this build no longer has
|
|
if plist:
|
|
extra, absent = list_variants(plist[0], {h for h, _ in progs.values()}, seen)
|
|
recs += [(LIST_PRIORITY, r) for r in extra]
|
|
(out / SEED_KEYS).write_bytes(plist[1])
|
|
n_keys = len(pipeline_list.read_keys(plist[1])[3])
|
|
print(f'pipeline list: {n_keys} pipelines; of its {len(plist[0])} shader variants (the ones they name and the '
|
|
f'compute variants), {len(extra)} join the seed, {absent} are of shaders {a.version} does not have, the '
|
|
'others are in it already', flush=True)
|
|
recs.sort(key=lambda r: r[0]) # stable: list order within a priority
|
|
with open(out / 'seed-programs.bin', 'wb') as f:
|
|
f.write(b'NX2NSPRG' + struct.pack('<II', 1, 0))
|
|
for _, body in progs.values():
|
|
f.write(body)
|
|
with open(out / 'seed-answers.bin', 'wb') as f:
|
|
f.write(b'NX2NSANS' + struct.pack('<II', 1, ANS_REC))
|
|
for _, rec in recs:
|
|
f.write(rec)
|
|
by = [sum(1 for p, _ in recs if p == i) for i in range(3)]
|
|
print(f'{listed} listed variants and {len(shaders)} default ones: {len(recs)} of {len(progs)} shaders in {a.version} '
|
|
f'(priority t {by[0]}, warm {by[1]}, rest {by[2]}), skipped {skipped}, {left_out} slow unused default variants '
|
|
f'left out (SLOW_UNUSED); inventory {t1 - t0:.1f} s, '
|
|
f'seed written in {time.monotonic() - t0:.1f} s', flush=True)
|
|
if a.no_run:
|
|
return 0
|
|
exe = out / a.exe
|
|
r = subprocess.run([str(exe), '--prepare-shaders'], cwd=str(out))
|
|
for _ in range(RETRIES):
|
|
if not r.returncode or not driver_crashed(out):
|
|
break
|
|
print(f'prepare_shaders: the graphics driver crashed while building shaders (rc={r.returncode}); building them '
|
|
'again without the variants it was building (nx2navi.exe --warm-shaders)', flush=True)
|
|
r = subprocess.run([str(exe), '--warm-shaders'], cwd=str(out))
|
|
print(f'{a.exe} --prepare-shaders: rc={r.returncode}; total {time.monotonic() - t0:.1f} s')
|
|
return r.returncode
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|