Files
NX2navi/tools/ui_textures.py
chrg6 8daedbd806 Redraw the in-game menu with the game's own interface textures, and bring in the launcher's save import and faster builds.
In-game menu: the settings window, its tabs, rows, buttons and cursor, the performance overlay and the Preparing
shaders box are drawn with the game's interface textures where the RomFS holds them (runtime/ui/menu_skin.cpp,
perf_panel.cpp; plain shapes until they are decoded). Restart joins Continue and Quit, each behind a confirmation;
VSync is an on/off switch; DLSS is listed only on NVIDIA GPUs; the menu sounds are quieter; Picture shape is gone and
the setting descriptions are shorter; edge cases of input, layout and fonts are fixed. tools/menu_preview.py draws the
menu without the game.

Launcher: Import Saves takes a save from the game folder's saves/ folder or one the player picks; downloads fall back
to Windows' own certificate check when Python's refuses a server; updates are faster; the title bar is drawn in the
window and Navi is every icon. tools/make_game.py builds with CLANG64's clang where the launcher installed it, packs
the RomFS beside the lift and the compile, takes a prebuilt runtime, and compiles the game's resources without cmd.exe,
so a user folder with a space in its name builds.

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

246 lines
10 KiB
Python

"""List and dump the game's interface textures, for choosing the art the settings menu draws (runtime/ui/menu_art.cpp).
python tools/ui_textures.py ROMFS [--out build/ui-textures] [--archive Common] [--match REGEX] [--sheet]
ROMFS is an extracted RomFS folder. Without --match it only lists; with it the matching textures become PNGs under
--out, and --sheet also writes contact sheets of them (numbered; sheet.txt says which number is which). Reads files the
way runtime/menu_assets.c does, and decodes ASTC with the runtime's own decoder (runtime/tex_astc.c, built into --out).
Everything written is game data and stays under build/. Needs Python 3.14 (compression.zstd) and numpy.
"""
import argparse
import ctypes
import re
import shutil
import struct
import subprocess
import sys
import zlib
from compression import zstd
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
ARCHIVES = 'UI/Layout2Archive'
# the second byte of a BNTX format: name, block size, bytes per block
KINDS = {0x0b: ('RGBA8', 1, 4), 0x1a: ('BC1', 4, 8), 0x1b: ('BC2', 4, 16), 0x1c: ('BC3', 4, 16), 0x1d: ('BC4', 4, 8),
0x1e: ('BC5', 4, 16), 0x20: ('BC7', 4, 16), 0x2d: ('ASTC4x4', 4, 16)}
def u16(b, at): return struct.unpack_from('<H', b, at)[0]
def u32(b, at): return struct.unpack_from('<I', b, at)[0]
def u64(b, at): return struct.unpack_from('<Q', b, at)[0]
def sarc_members(p):
"""The members of a SARC archive as a name -> bytes dictionary."""
hl, data = u16(p, 4), u32(p, 0xc)
count, ents = u16(p, hl + 6), hl + 0xc
names = ents + 16 * count + 8
out = {}
for i in range(count):
e = ents + 16 * i
at = names + (u32(p, e + 4) & 0xffff) * 4
name = p[at:p.index(b'\0', at)].decode()
out[name] = p[data + u32(p, e + 8):data + u32(p, e + 12)]
return out
class Romfs:
def __init__(self, root):
self.root = Path(root)
self.dicts = None
def load_dicts(self):
self.dicts = {}
raw = zstd.decompress((self.root / 'Pack/ZsDic.pack.zs').read_bytes())
for name, data in sarc_members(raw).items():
d = zstd.ZstdDict(data)
self.dicts[d.dict_id] = d
def read(self, rel):
b = (self.root / rel).read_bytes()
if not rel.endswith('.zs'):
return b
id_ = zstd.get_frame_info(b).dictionary_id
if not id_:
return zstd.decompress(b)
if self.dicts is None:
self.load_dicts()
return zstd.decompress(b, zstd_dict=self.dicts[id_])
def bntx_textures(b):
"""Each texture of a BNTX file: name, format, size, block height, data offset and size."""
out = []
count, arr = u32(b, 0x24), u64(b, 0x28)
for i in range(count):
p = u64(b, arr + 8 * i)
if b[p:p + 4] != b'BRTI':
continue
no = u64(b, p + 0x60)
name = b[no + 2:no + 2 + u16(b, no)].decode()
out.append({'name': name, 'fmt': u32(b, p + 0x1c), 'w': u32(b, p + 0x24), 'h': u32(b, p + 0x28),
'th': u32(b, p + 0x34), 'size': u32(b, p + 0x50), 'data': u64(b, u64(b, p + 0x70))})
return out
def deswizzle(t, b, row, rows):
"""The first level as tightly packed rows, in the Switch 2 GOB layout (runtime/tex_deswizzle.c tex_bl_copy)."""
th = t['th']
gobs, unit, block = (row + 63) // 64, 8 << th, 512 << th
need = gobs * ((rows + unit - 1) // unit) * block
bl = np.zeros(max(need, t['size']) // 16 + 1, dtype='V16')
src = bytearray(bl.tobytes())
src[:t['size']] = b[t['data']:t['data'] + t['size']]
bl = np.frombuffer(bytes(src), dtype='V16')
cols = (row + 15) // 16
y = np.arange(rows)[:, None]
x = (np.arange(cols) * 16)[None, :]
at = (y // unit) * gobs * block + (x // 64) * block + ((y % unit) // 8) * 512 + \
(x % 64 // 32) * 256 + (y % 8 // 4) * 128 + (x % 32 // 16) * 64 + (y % 4) * 16
flat = np.frombuffer(bl[at // 16].tobytes(), dtype=np.uint8).reshape(rows, cols * 16)
return flat[:, :row]
def bc4(lin, wb, hb, w, h):
"""White with the mask as alpha (runtime/menu_assets.c bc4_decode)."""
blocks = lin.reshape(hb, wb, 8).astype(np.uint32)
a, b = blocks[..., 0], blocks[..., 1]
pal = np.zeros((hb, wb, 8), dtype=np.uint32)
pal[..., 0], pal[..., 1] = a, b
big = a > b
for i in range(6):
pal[..., 2 + i] = np.where(big, ((6 - i) * a + (i + 1) * b) // 7, 0)
for i in range(4):
pal[..., 2 + i] = np.where(big, pal[..., 2 + i], ((4 - i) * a + (i + 1) * b) // 5)
pal[..., 6] = np.where(big, pal[..., 6], 0)
pal[..., 7] = np.where(big, pal[..., 7], 255)
bits = np.zeros((hb, wb), dtype=np.uint64)
for i in range(6):
bits |= blocks[..., 2 + i].astype(np.uint64) << np.uint64(8 * i)
alpha = np.zeros((hb * 4, wb * 4), dtype=np.uint8)
for i in range(16):
sel = ((bits >> np.uint64(3 * i)) & np.uint64(7)).astype(np.intp)
alpha[i // 4::4, i % 4::4] = np.take_along_axis(pal, sel[..., None], 2)[..., 0]
rgba = np.full((h, w, 4), 255, dtype=np.uint8)
rgba[..., 3] = alpha[:h, :w]
return rgba
def astc_library(out):
"""The runtime's ASTC decoder as a library, built once into out."""
dll = out / 'astc.dll'
src = ROOT / 'runtime' / 'tex_astc.c'
if not dll.is_file() or dll.stat().st_mtime < src.stat().st_mtime:
cc = shutil.which('clang') or r'C:\msys64\ucrt64\bin\clang.exe'
vma = Path(cc).resolve().parents[1] / 'include' / 'vma' # tex.h reaches the Vulkan allocator's header
if subprocess.run([cc, '-shared', '-O2', '-static', '-w', '-I', str(ROOT / 'runtime'), '-I', str(vma), str(src), '-o',
str(dll)]).returncode:
sys.exit('tex_astc.c did not compile')
lib = ctypes.CDLL(str(dll))
lib.astc_decode_ldr.restype = None
lib.astc_decode_ldr.argtypes = [ctypes.c_char_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.c_bool, ctypes.c_void_p]
return lib
def decode(t, b, lib):
kind = t['fmt'] >> 8
if kind not in (0x0b, 0x1d, 0x2d):
return None
_, bs, bpb = KINDS[kind]
w, h = t['w'], t['h']
wb, hb = (w + bs - 1) // bs, (h + bs - 1) // bs
lin = deswizzle(t, b, wb * bpb, hb)
if kind == 0x1d:
return bc4(lin, wb, hb, w, h)
if kind == 0x0b:
return lin.reshape(h, w, 4).copy()
rgba = np.zeros((h, w, 4), dtype=np.uint8)
lib.astc_decode_ldr(lin.tobytes(), w, h, 4, 4, (t['fmt'] & 0xff) == 6, rgba.ctypes.data)
return rgba
def png(path, a):
h, w = a.shape[:2]
rows = b''.join(b'\0' + a[y].tobytes() for y in range(h))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
Path(path).write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0)) +
chunk(b'IDAT', zlib.compress(rows, 6)) + chunk(b'IEND', b''))
DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001',
'111100111001111', '111100111101111', '111001010010010', '111101111101111', '111101111001111']
def label(sheet, x, y, n):
for c in str(n):
for j in range(5):
for i in range(3):
if DIGITS[int(c)][j * 3 + i] == '1':
sheet[y + j * 2:y + j * 2 + 2, x + i * 2:x + i * 2 + 2] = (255, 255, 0, 255)
x += 8
def sheets(out, items, cell=220, cols=7, per=35):
"""Contact sheets over a mid brown, so white masks and dark art both show."""
lines = []
for s in range(0, len(items), per):
page = items[s:s + per]
rows = (len(page) + cols - 1) // cols
sheet = np.full((rows * cell, cols * cell, 4), (84, 70, 58, 255), dtype=np.uint8)
for k, (name, a) in enumerate(page):
n = s + k
lines.append(f'{n} {name} {a.shape[1]}x{a.shape[0]}')
step = max(1, max(a.shape[0], a.shape[1]) // (cell - 8) + (max(a.shape[:2]) % (cell - 8) > 0))
t = a[::step, ::step]
cy, cx = (k // cols) * cell + 4, (k % cols) * cell + 4
al = t[..., 3:4].astype(np.float32) / 255
bg = sheet[cy:cy + t.shape[0], cx:cx + t.shape[1], :3].astype(np.float32)
sheet[cy:cy + t.shape[0], cx:cx + t.shape[1], :3] = (t[..., :3] * al + bg * (1 - al)).astype(np.uint8)
label(sheet, cx, cy, n)
png(out / f'sheet{s // per:02}.png', sheet)
(out / 'sheet.txt').write_text('\n'.join(lines) + '\n')
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('romfs', type=Path)
ap.add_argument('--out', type=Path, default=ROOT / 'build' / 'ui-textures')
ap.add_argument('--archive', default='', help='only archives whose name contains this')
ap.add_argument('--match', help='a regular expression: the textures to decode')
ap.add_argument('--sheet', action='store_true', help='write contact sheets of what was decoded')
a = ap.parse_args()
fs = Romfs(a.romfs)
a.out.mkdir(parents=True, exist_ok=True)
lib = astc_library(a.out) if a.match else None
decoded = []
for f in sorted((a.romfs / ARCHIVES).glob('*.bntx.zs')):
if a.archive not in f.name:
continue
b = fs.read(f'{ARCHIVES}/{f.name}')
ts = bntx_textures(b)
print(f'# {f.name}: {len(ts)} textures')
for t in sorted(ts, key=lambda t: t['name']):
kind = KINDS.get(t['fmt'] >> 8, (f"{t['fmt']:#x}",))[0]
if not a.match:
print(f"{t['name']} {kind} {t['w']}x{t['h']}")
elif re.search(a.match, t['name']):
rgba = decode(t, b, lib)
if rgba is None:
print(f"{t['name']}: format {kind} is not read by the runtime", file=sys.stderr)
continue
png(a.out / f"{t['name']}.png", rgba)
decoded.append((t['name'], rgba))
if a.match:
print(f'{len(decoded)} textures written to {a.out}')
if a.sheet and decoded:
sheets(a.out, decoded)
if __name__ == '__main__':
main()