Files
NX2navi/tools/shader_census.py
chrg6 fc8a9523a4 Prepare shaders under the code hashes play asks for, from every shader package, with better guessed bind states.
The install step seeded only 63% of the programs a play session met under their run-time code hash. A Switch 2 program with an empty c1 range past its code was hashed with that range, the Switch 2 agl and NDSR packages had no reader, and Layout2Shader/ was not walked. It now seeds 99.3% of the programs play asks for.

A default variant's answers are read again from its first translation (runtime/gfx_shader_guess.c): vertex inputs the shader reads as integers become uint, and a compute shader takes the texture and image types its translation declares. Task and mesh defaults carry the workgroup words play answers with. Against a play session's recorded variants, --prepare-shaders now matches 98.3%; the plain default guess matched 89.5%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:34:29 -05:00

463 lines
23 KiB
Python

"""Census of every Maxwell shader in the extracted NX2navi RomFS and ExeFS, and an offline translation pass.
The inventory, the bnsh, sharcb and sarc readers and the package walk: Copyright (c) 2026 MacNativeGames contributors
(MIT; see THIRD_PARTY_NOTICES.md). This pass reads no shared shader list, runs no spirv-cross and makes no bind-state
variants; the module scan reads the NSO files under exefs/ directly;
a control section must start with CONTROL_MAGIC (sharcb_shaders says why).
Reads build/extracted/<version>/ (RomFS image plus index.json as tools/romfs_index.py reads them, and exefs/). Writes under
build/<version>/shaders/ only:
code/<key>.code translator input (graphics: SPH + instructions, compute: instructions)
jobs.json key -> job, source path, origin (bnsh|sharcb|ndsr|main)
spv/<key>.spv with --translate
failures.txt with --translate, when something failed: groups by message
The key is sha256(control[:0x880] + code), the shared shader list's key. Needs Python 3.14 (compression.zstd), so run in .venv-py314:
.venv-py314\\Scripts\\python tools\\shader_census.py --version v121 --translate
"""
import argparse
import zstd_compat as zstd # compression.zstd on 3.14, the zstandard package before
import concurrent.futures
import hashlib
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
import lz4.block
ROOT = Path(__file__).resolve().parent.parent
XLATE = ROOT / 'build' / 'xlate' / ('nx2navi_xlate.exe' if os.name == 'nt' else 'nx2navi_xlate')
SPIRV_VAL = Path(r'C:\msys64\ucrt64\bin\spirv-val.exe') if os.name == 'nt' else Path(shutil.which('spirv-val') or '/usr/bin/spirv-val')
ZSTD = b'\x28\xb5\x2f\xfd'
# NVN program control section magic, u32 0x98761234
CONTROL_MAGIC = b'\x34\x12\x76\x98'
STAGE_IDS = {0: 'vs', 1: 'ps', 5: 'cs'}
MAX_WORKERS = 20
def digest(data):
return hashlib.sha256(data).hexdigest()
def checked(data, offset, size):
if offset < 0 or size < 0 or offset + size > len(data):
raise ValueError('shader container range outside file')
return data[offset:offset + size]
def sarc(data):
if data[:4] != b'SARC':
raise ValueError('expected SARC')
endian = '<' if data[6:8] == b'\xff\xfe' else '>'
header, size, start = struct.unpack_from(endian + 'HxxII', data, 4)
if size != len(data) or header < 20 or checked(data, header, 4) != b'SFAT':
raise ValueError('invalid SARC header')
count = struct.unpack_from(endian + 'H', data, header + 6)[0]
names = header + 12 + count * 16
if checked(data, names, 4) != b'SFNT':
raise ValueError('invalid SARC string table')
names += 8
result = {}
for i in range(count):
_, attribute, lo, hi = struct.unpack_from(endian + '4I', checked(data, header + 12 + i * 16, 16))
off = names + (attribute & 0xffffff) * 4
if attribute >> 24 != 1 or not names <= off < start or hi < lo or start + lo < start:
raise ValueError('invalid SARC member')
name = data[off:data.index(0, off, start)].decode('utf-8')
if name.startswith('/') or '\\' in name or any(p in ('', '.', '..') for p in name.split('/')) or name in result:
continue # names only key the members here, nothing is written under them: an odd or repeated one is skipped
result[name] = checked(data, start + lo, hi - lo)
return result
NVN2_STAGES = {0: 'vs', 1: 'ps', 5: 'cs', 6: 'ts', 7: 'ms'} # the stage number in bits 16+ of control +0x54
NVN2_CONTROL = 0xe0 # the part of an NVN2 control section the runtime keeps (runtime/gfx_shader.c, "NVN2")
NVN2_SLOTS = range(8, 96, 8) # program slots that may point at a stage record: fragment +40, task +64, mesh +72 seen
def nvn2_record(data, offset):
"""(control, code) of the stage record at offset when it is an NVN2 one: {u64, control offset, code offset,
u32 code size, u32 control size}, the control shorter than NVN1's 0x880 and starting with the magic. Else None."""
if not offset or offset + 32 > len(data):
return None
control_off, code_off, code_size, control_size = struct.unpack_from('<QQII', data, offset + 8)
if not NVN2_CONTROL <= control_size < 0x880 or control_off + control_size > len(data) or code_off + code_size > len(data):
return None
control = data[control_off:control_off + control_size]
return (control, data[code_off:code_off + code_size]) if control[:4] == CONTROL_MAGIC else None
def nvn2_program(data, program):
return any(nvn2_record(data, struct.unpack_from('<Q', data, program + r)[0]) for r in NVN2_SLOTS
if program + r + 8 <= len(data))
def nvn2_stages(data, program):
"""The stages of an NVN2 (Switch 2) program as the runtime keys them: the control section's first 0xe0 bytes in a
zeroed 0x880 one, and the code from its first byte up to the end of its c1 constants (+0x40 to +0x44, after the
code of +0x38 bytes), the c1 part included."""
for r in NVN2_SLOTS:
if program + r + 8 > len(data):
break
rec = nvn2_record(data, struct.unpack_from('<Q', data, program + r)[0])
if not rec:
continue
control, code = rec
stage = NVN2_STAGES.get(struct.unpack_from('<I', control, 0x54)[0] >> 16)
if not stage:
continue
control = control[:NVN2_CONTROL] + bytes(0x880 - NVN2_CONTROL)
size, c1_start, c1_end = struct.unpack_from('<I', control, 0x38)[0], *struct.unpack_from('<II', control, 0x40)
length = c1_end if c1_start >= size and c1_end >= c1_start and c1_end <= len(code) else size
if not 0 < length <= len(code):
continue
yield digest(control + code[:length]), stage, control, code[:length]
def bnsh_shaders(data):
"""BNSH absolute offsets are relative to each embedded BNSH, not BFSHA."""
if data[:4] != b'BNSH' or checked(data, 12, 2) != b'\xff\xfe' or checked(data, 96, 4) != b'grsc':
raise ValueError('unsupported BNSH header')
size = struct.unpack_from('<I', data, 28)[0]
data = checked(data, 0, size)
count, variations = struct.unpack_from('<IQ', data, 124)
checked(data, variations, count * 64)
for i in range(count):
program = struct.unpack_from('<Q', data, variations + i * 64 + 16)[0]
checked(data, program, 56)
if nvn2_program(data, program):
yield from nvn2_stages(data, program)
continue
for stage, relative in [('vs', 8), ('ps', 40), ('cs', 48)]:
offset = struct.unpack_from('<Q', data, program + relative)[0]
if not offset:
continue
control_off, code_off, code_size, control_size = struct.unpack_from('<QQII', checked(data, offset + 8, 24))
control = checked(data, control_off, control_size)
code = checked(data, code_off, code_size)
if len(control) < 0x880:
continue # the SDK utility BNSH uses an older, shorter control layout
control = control[:0x880] # some are 0x88c; NVN and the runtime's key read 0x880
if control[:4] != CONTROL_MAGIC:
raise ValueError('NVN control section without its magic')
c1, length = struct.unpack_from('<II', control, 0x700)
actual = struct.unpack_from('<I', control, 0x714)[0]
if actual != {'vs': 0, 'ps': 1, 'cs': 5}[stage] or not 0 <= c1 <= length <= len(code) or not length:
raise ValueError('invalid NVN shader bounds or stage')
yield digest(control + code[:length]), stage, control, code[:length]
def sharcb_shaders(data, rejected):
"""agl shader packages (.sharcb, magic BAHS): records <u64 code offset in the pool, u32 control size (0x880 or
0x88c), i32 offset of the control from the record>; a record may reuse an earlier record's control.
The scan finds records by their size word, so it also meets 16-byte groups that only look like one. Without the
magic check, 1.2.1 yields 87 such keys: each has control offset 0 (the "control" starts at the record itself, so its
first 8 bytes are the code offset) and no CONTROL_MAGIC. The game hands NVN real control sections and the runtime
key hashes those, so no draw can ever produce these keys. Their code is cut from the wrong place: they were all 67
translation failures of 1.2.1 (IndexOutOfRange in ShaderHeader.ctor and ArgumentOutOfRange in Decoder.FillBlock,
on 0 to 4,718 bytes of code). A scan without the magic check keeps them; the 7 that translate are
V rows in the shared shader list only because shader_list.py add_cache() folds the seed's own .variants files back in. A
candidate without CONTROL_MAGIC is skipped and counted in rejected[0]."""
if len(data) < 32 or data[:4] != b'BAHS':
raise ValueError('expected an agl shader package')
start, pool_size, pool = struct.unpack_from('<3I', data, 20)
if not 32 <= start <= pool or pool + pool_size > len(data):
raise ValueError('agl shader package layout outside the file')
seen = set()
for size in (b'\x80\x08\x00\x00', b'\x8c\x08\x00\x00', b'\xe0\x00\x00\x00'):
at = start
while (at := data.find(size, at, pool)) >= 0:
record, at = at - 8, at + 4
if record < start or record % 4:
continue
code_off = struct.unpack_from('<Q', data, record)[0]
control_at = record + struct.unpack_from('<i', data, record + 12)[0]
if size[0] == NVN2_CONTROL: # NVN2 (Switch 2) packages: the same records with a 0xe0-byte control section
found = nvn2_sharcb_record(data, start, pool, pool_size, code_off, control_at)
if found and found[0] not in seen:
seen.add(found[0])
yield found
continue
if not start <= control_at <= pool - 0x880 or code_off >= pool_size:
continue
control = data[control_at:control_at + 0x880]
if control[:4] != CONTROL_MAGIC:
rejected[0] += 1
continue
c1, length = struct.unpack_from('<II', control, 0x700)
stage = STAGE_IDS.get(struct.unpack_from('<I', control, 0x714)[0])
if not stage or not 0 < length <= pool_size - code_off or c1 > length:
continue
code = data[pool + code_off:pool + code_off + length]
key = digest(control + code)
if key not in seen:
seen.add(key)
yield key, stage, control, code
def ndsr_shaders(data):
"""NDSR packages (Lib/agl/agl_ndsr*.pck: compute programs). Items of 8 bytes, <u32 offset, u32 size << 4 | kind>:
an NVN2 control section is kind 6 with size 0xe0 and the item after it, kind 7, is its code."""
if data[:4] != b'NDSR':
raise ValueError('expected an NDSR package')
seen = set()
for at in range(4, len(data) - 16, 4):
off, word, code_off, code_word = struct.unpack_from('<4I', data, at)
if word != NVN2_CONTROL << 4 | 6 or code_word & 15 != 7 or off + NVN2_CONTROL > len(data):
continue
control = data[off:off + NVN2_CONTROL]
stage = NVN2_STAGES.get(struct.unpack_from('<I', control, 0x54)[0] >> 16)
room = code_word >> 4
if control[:4] != CONTROL_MAGIC or not stage or code_off + room > len(data):
continue
control += bytes(0x880 - NVN2_CONTROL)
size, c1_start, c1_end = struct.unpack_from('<I', control, 0x38)[0], *struct.unpack_from('<II', control, 0x40)
length = c1_end if c1_start >= size and c1_end >= c1_start and c1_end <= room else size
if not 0 < length <= room:
continue
code = data[code_off:code_off + length]
key = digest(control + code)
if key not in seen:
seen.add(key)
yield key, stage, control, code
def nvn2_sharcb_record(data, start, pool, pool_size, code_off, control_at):
"""(key, stage, control, code) of an NVN2 .sharcb record, as nvn2_stages cuts them, or None. A size word of 0xe0
is common, so a candidate counts only with the control magic and a known stage."""
if not start <= control_at <= pool - NVN2_CONTROL or code_off >= pool_size:
return None
control = data[control_at:control_at + NVN2_CONTROL]
stage = NVN2_STAGES.get(struct.unpack_from('<I', control, 0x54)[0] >> 16)
if control[:4] != CONTROL_MAGIC or not stage:
return None
control += bytes(0x880 - NVN2_CONTROL)
size, c1_start, c1_end = struct.unpack_from('<I', control, 0x38)[0], *struct.unpack_from('<II', control, 0x40)
room = pool_size - code_off
length = c1_end if c1_start >= size and c1_end >= c1_start and c1_end <= room else size
if not 0 < length <= room:
return None
code = data[pool + code_off:pool + code_off + length]
return digest(control + code), stage, control, code
def nso_image(path):
"""The decompressed text, rodata and data segments of an NSO, concatenated (enough to find embedded BNSH files)."""
raw = path.read_bytes()
if raw[:4] != b'NSO0':
raise ValueError(f'{path} is not an NSO')
flags = struct.unpack_from('<I', raw, 0xC)[0]
parts = []
for i in range(3):
file_off, _, dec_size = struct.unpack_from('<III', raw, 0x10 + i * 0x10)
comp_size = struct.unpack_from('<I', raw, 0x60 + i * 4)[0]
data = raw[file_off:file_off + comp_size]
parts.append(lz4.block.decompress(data, uncompressed_size=dec_size) if flags & (1 << i) else data)
return b''.join(parts)
def startup_package(name):
"""The RomFS files the shared shader list's startup shaders come from; effects and UI layouts carry more."""
return (name == 'Lib/NintendoSDK/TextureShader.bnsh' or name == 'Pack/Bootup.Nin_NX_NVN.pack.zs' or
name.startswith('Shader/') and name.endswith('.zs'))
# The RomFS directories that can hold shaders: the agl and gsys packages, the effects, terrain, the UI layout archives
# and the game's own shader packs. Every shader of 1.1.0 (27,576) comes from these six; they are 1,996 entries and
# 147 MiB, 0.9% of a RomFS whose every file the walk below used to read and decompress first (65 s against 2.8 s).
# Ocarina keeps its UI layout shaders (MergeShader) in Layout2Shader/.
SHADER_DIRS = ('Shader/', 'Effect/', 'Lib/agl/', 'Lib/Terrain/', 'Lib/gsys/', 'UI/LayoutArchive/', 'Layout2Shader/')
def inventory(romfs, exefs, everything=True):
"""Every unique shader: key -> dict(stage, control, code, source, origin, startup). As in the shared shader list, a shader is a
startup shader when it comes from an agl package, a game module, or a startup_package() RomFS file; the others come
from effect and UI layout resources, which the list covers only for bind states players have met. Also returns the number
of .sharcb record candidates rejected for a missing control magic (see sharcb_shaders)."""
index = json.loads((romfs / 'index.json').read_text(encoding='utf-8'))
if index.get('status') != 'pass':
raise ValueError('RomFS has not passed integrity verification')
entries = {r['path']: r for r in index['files']}
shaders = {}
rejected = [0]
def add(origin, name, found, startup):
for key, stage, control, code in found:
s = shaders.setdefault(key, dict(stage=stage, control=control, code=code, source=name, origin=origin,
startup=startup))
if startup and not s['startup']:
s.update(source=name, origin=origin, startup=True)
with (romfs / 'romfs.bin').open('rb') as stream:
def read(name):
row = entries[name]
stream.seek(row['offset'])
raw = stream.read(row['size'])
if len(raw) != row['size']:
raise ValueError('short RomFS read')
return raw
dictionaries = {}
for raw in sarc(zstd.decompress(read('Pack/ZsDic.pack.zs'))).values():
d = zstd.ZstdDict(raw)
dictionaries[d.dict_id] = d
def decode(raw):
info = zstd.get_frame_info(raw)
if info.decompressed_size is None or info.decompressed_size > 512 << 20:
raise ValueError('unbounded shader package')
return zstd.decompress(raw, zstd_dict=dictionaries[info.dictionary_id] if info.dictionary_id else None)
def visit(origin, name, data, startup, depth=0):
if data[:4] == b'BAHS':
add('sharcb', name, sharcb_shaders(data, rejected), True)
return
if data[:4] == b'NDSR':
add('ndsr', name, ndsr_shaders(data), True)
return
if data[:4] == b'SARC':
if depth > 4:
return
for member, raw in sarc(data).items():
visit(origin, name + '/' + member, decode(raw) if raw[:4] == ZSTD else raw, startup, depth + 1)
return
pos = 0 # BFSHA and BNSH files, effect and layout resources: complete, independently addressed BNSH files
while (pos := data.find(b'BNSH\0\0\0\0', pos)) >= 0:
size = struct.unpack_from('<I', checked(data, pos + 28, 4))[0]
add(origin, name, bnsh_shaders(checked(data, pos, size)), startup)
pos += max(size, 8)
for name in sorted(entries):
if not everything and not name.startswith(SHADER_DIRS):
continue
raw = read(name)
if raw[:4] not in (ZSTD, b'SARC', b'FSHA', b'BNSH', b'BAHS', b'NDSR') and b'BNSH\0\0\0\0' not in raw:
continue
visit('bnsh', name, decode(raw) if raw[:4] == ZSTD else raw, startup_package(name))
for module in sorted(exefs.iterdir()):
if module.name in ('main', 'sdk', 'subsdk0', 'rtld'):
visit('main', 'module:' + module.name, nso_image(module), True)
return shaders, rejected[0]
def write_shaders(shaders, out, everything):
"""Write code files and jobs.json; returns {key: dict(job, source, origin)}."""
(out / 'code').mkdir(parents=True, exist_ok=True)
jobs = {}
for key, s in sorted(shaders.items()):
if not s['startup'] and not everything:
continue
control, code, stage = s['control'], s['code'], s['stage']
c1, length = struct.unpack_from('<II', control, 0x700)
body = code[0 if stage == 'cs' else 0x30:length]
path = out / 'code' / f'{key}.code'
if not path.is_file() or path.read_bytes() != body:
path.write_bytes(body)
job = dict(key=key, stage=stage, code=str(path), c1=code[c1:length].hex(), debug=False)
if stage == 'cs':
job['local'] = list(struct.unpack_from('<III', control, 0x748))
jobs[key] = dict(job=job, source=s['source'], origin=s['origin'], startup=s['startup'])
(out / 'jobs.json').write_text(json.dumps(jobs, indent=0), encoding='utf-8')
return jobs
def worker(keys, jobs, spv_dir):
"""One --serve process for a slice of keys. Returns (translated, failures {key: message}, errors {key: message})."""
failures, errors, ok = {}, {}, 0
process = subprocess.Popen([str(XLATE), '--serve'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
try:
for key in keys:
process.stdin.write(json.dumps({'job': jobs[key]['job'], 'out': str(spv_dir)}) + '\n')
process.stdin.flush()
reply = process.stdout.readline().rstrip('\n')
if not reply:
failures[key] = 'translator service exited'
break
if not reply.startswith('ok '):
failures[key] = reply.split(' ', 2)[2] if reply.count(' ') >= 2 else reply
continue
v = subprocess.run([str(SPIRV_VAL), '--target-env', 'vulkan1.3', str(spv_dir / f'{key}.spv')],
capture_output=True, text=True)
if v.returncode:
text = (v.stderr or v.stdout).strip()
errors[key] = text.splitlines()[0] if text else 'spirv-val failed'
else:
ok += 1
finally:
process.stdin.close()
process.wait()
return ok, failures, errors
def normalize(message):
return re.sub(r'\b\d+\b|0x[0-9a-fA-F]+', 'N', message)[:300]
def translate(jobs, out):
spv_dir = out / 'spv'
spv_dir.mkdir(exist_ok=True)
keys = sorted(jobs)
count = max(1, min(MAX_WORKERS, len(keys)))
slices = [keys[i::count] for i in range(count)]
ok, failures, errors = 0, {}, {}
with concurrent.futures.ThreadPoolExecutor(count) as pool:
for n, f, e in pool.map(lambda s: worker(s, jobs, spv_dir), slices):
ok += n
failures.update(f)
errors.update(e)
if failures or errors:
lines = []
for title, table in (('translate failures', failures), ('spirv-val errors', errors)):
groups = defaultdict(list)
for key, message in table.items():
groups[normalize(message)].append((key, message))
lines.append(f'== {title}: {len(table)} in {len(groups)} groups')
for _, members in sorted(groups.items(), key=lambda g: -len(g[1])):
lines.append(f'{len(members)}\t{members[0][1][:300]}')
lines.extend(f'\texample {key}' for key, _ in sorted(members)[:2])
(out / 'failures.txt').write_text('\n'.join(lines) + '\n', encoding='utf-8')
else:
(out / 'failures.txt').unlink(missing_ok=True)
return ok, len(failures), len(errors)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--version', required=True, help='v121 (build/extracted/<version>)')
ap.add_argument('--all', action='store_true', help='also the layout and effect shaders MNG leaves to bind-state variants')
ap.add_argument('--translate', action='store_true', help='translate every shader and run spirv-val')
args = ap.parse_args()
t0 = time.monotonic()
extracted = ROOT / 'build' / 'extracted' / args.version
out = ROOT / 'build' / args.version / 'shaders'
out.mkdir(parents=True, exist_ok=True)
shaders, rejected = inventory(extracted / 'romfs', extracted / 'exefs')
jobs = write_shaders(shaders, out, args.all)
a, b, n, c = (sum(1 for j in jobs.values() if j['origin'] == o) for o in ('bnsh', 'sharcb', 'ndsr', 'main'))
print(f'{len(shaders)} unique shaders found in all, {len(jobs)} selected; {rejected} .sharcb record candidates '
f'without a control section rejected', flush=True)
summary = f'shaders {len(jobs)} (bnsh {a}, sharcb {b}, ndsr {n}, main {c})'
print(f'extracted {summary} in {time.monotonic() - t0:.1f} s', flush=True)
if not args.translate:
print(f'{summary}, total {time.monotonic() - t0:.1f} s')
return 0
if not XLATE.is_file() or not SPIRV_VAL.is_file():
sys.exit(f'missing {XLATE} or {SPIRV_VAL}')
ok, failed, bad = translate(jobs, out)
print(f'{summary}, translated {ok}, failed {failed}, spirv-val errors {bad}, total {time.monotonic() - t0:.1f} s')
return 0
if __name__ == '__main__':
sys.exit(main())