mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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>
463 lines
23 KiB
Python
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())
|