mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Comments say what the code does and why, docs/maxwell.md keeps the Maxwell facts, and fetch_refs.sh clones only the checkout the build reads. Code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
591 lines
33 KiB
Python
591 lines
33 KiB
Python
"""tools/make_romfs_patches.py --prompts and tools/button_icons.py on a RomFS made here, with no game files: each prompt
|
|
names the label of the Xbox button that the pad layout presses, the shoulder buttons, triggers, Plus, Minus and the
|
|
d-pad are drawn as on an Xbox controller, the PC prompts draw every button as its key or mouse button (the defaults or
|
|
a nx2navi.ini's bind_ lines), the resource size table grows with the fonts, every other file and member stays byte for
|
|
byte, the same input gives the same output, and a RomFS the tool does not recognise fails before it writes anything. Both game versions: 1.2.1 (Common.Product.110, 72x72 button guide images) and 1.4.3
|
|
(Common.Product.140, 108x108).
|
|
|
|
Run: .venv-py314\\Scripts\\python tests\\romfs_prompts_test.py
|
|
"""
|
|
import compression.zstd as zstd
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import random
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
from fontTools.fontBuilder import FontBuilder
|
|
from fontTools.pens.recordingPen import RecordingPen
|
|
from fontTools.pens.t2CharStringPen import T2CharStringPen
|
|
from fontTools.ttLib import TTFont
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
TOOL = ROOT / 'tools' / 'make_romfs_patches.py'
|
|
spec = importlib.util.spec_from_file_location('button_icons', ROOT / 'tools' / 'button_icons.py')
|
|
icons = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(icons)
|
|
|
|
# The face button each Switch button is in a pad layout (docs/controls.md, runtime/input_map.c), the label an Xbox pad
|
|
# prints on it, and the layout each overlay is made for (runtime/input_map.c input_prompts_folder).
|
|
POSITION = {'nintendo': {'A': 'east', 'B': 'south', 'X': 'north', 'Y': 'west'},
|
|
'xbox': {'A': 'south', 'B': 'east', 'X': 'west', 'Y': 'north'}}
|
|
XBOX_LABEL = {'south': 'A', 'east': 'B', 'west': 'X', 'north': 'Y'}
|
|
STYLE_LAYOUT = {'xbox': 'nintendo', 'xbox-label': 'xbox'}
|
|
FACE = {'A': 0xE040, 'B': 0xE041, 'X': 0xE042, 'Y': 0xE043}
|
|
# The glyphs drawn as Xbox icons, with their advance: LB RB LT RT, Menu and View, the d-pad.
|
|
DRAWN = {0xE044: 820, 0xE045: 820, 0xE046: 760, 0xE047: 760, 0xE04B: 640, 0xE04C: 640, 0xE049: 640,
|
|
**{cp: 640 for cp in range(0xE060, 0xE066)}}
|
|
|
|
# restbl: the resource size table's name after its version, in both forms the tool takes.
|
|
VERSIONS = {'v121': dict(common='110', size=72, newest=121, restbl='Nin_NX_NVN.rsizetable.zs'),
|
|
'v143': dict(common='140', size=108, newest=143, restbl='rsizetable.zs')}
|
|
DEFAULT_FONT, REGIONAL_FONT = 'Font/Font.Nin_NX_NVN.bfarc.zs', 'Font/Font_KRko.Nin_NX_NVN.bfarc.zs'
|
|
PANES = {'T_A_00': 'A', 'T_B_00': 'B', 'T_X_00': 'X', 'T_Y_00': 'Y', 'T_Name_00': 'A'}
|
|
MESSAGES = {**PANES, 'Greeting': 'Hello'}
|
|
R8_UNORM, BC1 = 0x201, 0x1a01
|
|
IMAGE = 16384 # an R8 square up to 128 rows, two GOBs wide, block height 16: the size of the game's key images
|
|
|
|
|
|
# ---- the formats, written here from their layout so the tools' readers are checked against an independent writer
|
|
def sarc(members, align=0x80):
|
|
names, sfnt, name_at = list(members), b'', {}
|
|
for name in names:
|
|
name_at[name] = len(sfnt) // 4
|
|
sfnt += name.encode() + b'\0'
|
|
sfnt += bytes(-len(sfnt) % 4)
|
|
head = 0x14 + 12 + 16 * len(names) + 8 + len(sfnt)
|
|
start = head + -head % align
|
|
data, nodes = b'', b''
|
|
for name in names:
|
|
data += bytes(-len(data) % align)
|
|
nodes += struct.pack('<4I', zlib.crc32(name.encode()), 0x01000000 | name_at[name], len(data), len(data) + len(members[name]))
|
|
data += members[name]
|
|
out = (b'SARC' + struct.pack('<HHIIHH', 0x14, 0xFEFF, start + len(data), start, 0x100, 0)
|
|
+ b'SFAT' + struct.pack('<HHI', 12, len(names), 0x65) + nodes + b'SFNT' + struct.pack('<HH', 8, 0) + sfnt)
|
|
return out + bytes(start - len(out)) + data
|
|
|
|
|
|
def sarc_files(data):
|
|
header, start = struct.unpack_from('<H', data, 4)[0], struct.unpack_from('<I', data, 12)[0]
|
|
count, out = struct.unpack_from('<H', data, header + 6)[0], {}
|
|
names = header + 12 + count * 16 + 8
|
|
for i in range(count):
|
|
_, attribute, lo, hi = struct.unpack_from('<4I', data, header + 12 + i * 16)
|
|
at = names + (attribute & 0xffffff) * 4
|
|
out[data[at:data.index(0, at)].decode()] = data[start + lo:start + hi]
|
|
return out
|
|
|
|
|
|
def bflan(panes):
|
|
"""A FLAN whose pai1 section steps each pane's visibility from 0 at frame 0 to 1 at frame 1."""
|
|
entries = [name.encode().ljust(28, b'\0') + bytes([1, 0, 0, 0]) + struct.pack('<I', 36)
|
|
+ b'FLVI' + bytes([1, 0, 0, 0]) + struct.pack('<I', 12)
|
|
+ bytes([0, 0, 1, 0]) + struct.pack('<HHI', 2, 0, 12)
|
|
+ struct.pack('<fHxx', 0.0, 0) + struct.pack('<fHxx', 1.0, 1) for name in panes]
|
|
table, body = 20, b''
|
|
offsets = []
|
|
for e in entries:
|
|
offsets.append(table + 4 * len(entries) + len(body))
|
|
body += e
|
|
section = struct.pack('<HBxHHI', 0, 0, 0, len(entries), table) + struct.pack(f'<{len(entries)}I', *offsets) + body
|
|
section = b'pai1' + struct.pack('<I', 8 + len(section)) + section
|
|
return b'FLAN' + struct.pack('<HHIIHH', 0xFEFF, 0x14, 0x08000000, 0x14 + len(section), 1, 0) + section
|
|
|
|
|
|
def visibility(data):
|
|
"""pane -> [(frame, value)] of a FLAN made by bflan()."""
|
|
pos, out = 0x14, {}
|
|
count, table = struct.unpack_from('<H', data, pos + 14)[0], pos + struct.unpack_from('<I', data, pos + 16)[0]
|
|
for e in range(count):
|
|
eo = pos + struct.unpack_from('<I', data, table + 4 * e)[0]
|
|
keys = eo + 36 + 12 + 12
|
|
out[data[eo:eo + 28].split(b'\0')[0].decode()] = [struct.unpack_from('<fH', data, keys + 8 * k) for k in range(2)]
|
|
return out
|
|
|
|
|
|
def bntx(textures):
|
|
"""A BNTX of single-mip square textures: name -> (format, size, image)."""
|
|
out = bytearray(0x30)
|
|
out[0:4], out[0x20:0x24] = b'BNTX', b'NX '
|
|
table = len(out)
|
|
out += bytes(8 * len(textures))
|
|
struct.pack_into('<IQ', out, 0x24, len(textures), table)
|
|
for k, (name, (fmt, size, image)) in enumerate(textures.items()):
|
|
info = len(out)
|
|
out += bytes(0x80)
|
|
out[info:info + 4] = b'BRTI'
|
|
struct.pack_into('<Q', out, table + 8 * k, info)
|
|
struct.pack_into('<H', out, info + 0x16, 1)
|
|
struct.pack_into('<I', out, info + 0x1c, fmt)
|
|
struct.pack_into('<II', out, info + 0x24, size, size)
|
|
struct.pack_into('<I', out, info + 0x34, 4) # block height 1 << 4 GOBs
|
|
struct.pack_into('<I', out, info + 0x50, len(image))
|
|
struct.pack_into('<Q', out, info + 0x60, len(out))
|
|
out += struct.pack('<H', len(name)) + name.encode() + b'\0'
|
|
out += bytes(-len(out) % 8)
|
|
mips = len(out)
|
|
struct.pack_into('<Q', out, info + 0x70, mips)
|
|
out += bytes(8)
|
|
out += bytes(-len(out) % 0x200)
|
|
struct.pack_into('<Q', out, mips, len(out))
|
|
out += image
|
|
return bytes(out)
|
|
|
|
|
|
def bntx_images(data):
|
|
"""name without the ^ suffix -> (format, size, image) of a BNTX made by bntx()."""
|
|
count, table = struct.unpack_from('<IQ', data, 0x24)
|
|
out = {}
|
|
for k in range(count):
|
|
info = struct.unpack_from('<Q', data, table + 8 * k)[0]
|
|
at = struct.unpack_from('<Q', data, info + 0x60)[0]
|
|
name = data[at + 2:at + 2 + struct.unpack_from('<H', data, at)[0]].decode().split('^')[0]
|
|
start = struct.unpack_from('<Q', data, struct.unpack_from('<Q', data, info + 0x70)[0])[0]
|
|
out[name] = (struct.unpack_from('<I', data, info + 0x1c)[0], struct.unpack_from('<I', data, info + 0x24)[0],
|
|
data[start:start + struct.unpack_from('<I', data, info + 0x50)[0]])
|
|
return out
|
|
|
|
|
|
def bflyt(panes):
|
|
"""A FLYT with one txt1 section per pane (name -> text) after a pan1 section."""
|
|
sections = b'pan1' + struct.pack('<I', 0x4c) + bytes(0x44)
|
|
for name, text in panes.items():
|
|
s = bytearray(0x80)
|
|
s[0:4] = b'txt1'
|
|
struct.pack_into('<I', s, 4, len(s))
|
|
s[12:12 + len(name)] = name.encode()
|
|
struct.pack_into('<H', s, 0x56, 2 * len(text) + 2)
|
|
struct.pack_into('<I', s, 0x64, 0x70)
|
|
s[0x70:0x70 + 2 * len(text) + 2] = text.encode('utf-16-le') + b'\0\0'
|
|
sections += s
|
|
return b'FLYT' + struct.pack('<HHIIHH', 0xFEFF, 0x14, 0x08000000, 0x14 + len(sections), len(panes) + 1, 0) + sections
|
|
|
|
|
|
def pane_texts(data):
|
|
pos, out = struct.unpack_from('<H', data, 6)[0], {}
|
|
while pos + 8 <= len(data):
|
|
magic, size = data[pos:pos + 4], struct.unpack_from('<I', data, pos + 4)[0]
|
|
if magic == b'txt1':
|
|
length, at = struct.unpack_from('<H', data, pos + 0x56)[0], pos + struct.unpack_from('<I', data, pos + 0x64)[0]
|
|
out[data[pos + 12:pos + 36].split(b'\0')[0].decode()] = data[at:at + length - 2].decode('utf-16-le')
|
|
pos += size
|
|
return out
|
|
|
|
|
|
def msbt(messages):
|
|
"""A UTF-16 MSBT: one LBL1 group with every label, then TXT2 (label -> text)."""
|
|
labels = b''.join(bytes([len(name)]) + name.encode() + struct.pack('<I', i) for i, name in enumerate(messages))
|
|
lbl1 = struct.pack('<III', 1, len(messages), 12) + labels
|
|
texts = [text.encode('utf-16-le') + b'\0\0' for text in messages.values()]
|
|
offsets, body = [], b''
|
|
for text in texts:
|
|
offsets.append(4 + 4 * len(texts) + len(body))
|
|
body += text
|
|
txt2 = struct.pack(f'<I{len(texts)}I', len(texts), *offsets) + body
|
|
|
|
def section(magic, content):
|
|
s = magic + struct.pack('<I', len(content)) + bytes(8) + content
|
|
return s + bytes(-len(s) % 16)
|
|
sections = section(b'LBL1', lbl1) + section(b'TXT2', txt2)
|
|
return b'MsgStdBn' + struct.pack('<HHBBHHI', 0xFEFF, 0, 1, 3, 2, 0, 0x20 + len(sections)) + bytes(10) + sections
|
|
|
|
|
|
def message_texts(data):
|
|
sections, pos = {}, 0x20
|
|
for _ in range(struct.unpack_from('<H', data, 0x0e)[0]):
|
|
size = struct.unpack_from('<I', data, pos + 4)[0]
|
|
sections[data[pos:pos + 4]] = (pos + 16, size)
|
|
pos = (pos + 16 + size + 15) & ~15
|
|
(base, _), labels = sections[b'LBL1'], {}
|
|
for g in range(struct.unpack_from('<I', data, base)[0]):
|
|
count, at = struct.unpack_from('<II', data, base + 4 + 8 * g)
|
|
at += base
|
|
for _ in range(count):
|
|
n = data[at]
|
|
labels[struct.unpack_from('<I', data, at + 1 + n)[0]] = data[at + 1:at + 1 + n].decode()
|
|
at += 1 + n + 4
|
|
base, size = sections[b'TXT2']
|
|
count = struct.unpack_from('<I', data, base)[0]
|
|
offsets = list(struct.unpack_from(f'<{count}I', data, base + 4)) + [size]
|
|
return {labels[i]: data[base + offsets[i]:base + offsets[i + 1]].decode('utf-16-le').rstrip('\0') for i in range(count)}
|
|
|
|
|
|
def restbl(entries):
|
|
"""A RESTBL keyed by the CRC32 of each path without .zs (path -> size)."""
|
|
crcs = sorted((zlib.crc32(path.removesuffix('.zs').encode()), size) for path, size in entries.items())
|
|
return b'RESTBL' + struct.pack('<IIII', 1, 160, len(crcs), 0) + b''.join(struct.pack('<II', *c) for c in crcs)
|
|
|
|
|
|
def restbl_sizes(table):
|
|
count = struct.unpack_from('<I', table, 14)[0]
|
|
return dict(struct.unpack_from('<II', table, 22 + 8 * i) for i in range(count))
|
|
|
|
|
|
MAGIC = 0x18029a7f # a .bfotf's first word is its key XOR this
|
|
|
|
|
|
def bfotf(otf, key):
|
|
"""A .bfotf as tools/button_icons.py bfotf_encode writes it: 32-bit words XORed with the key."""
|
|
size = len(otf)
|
|
out = struct.pack('<II', MAGIC ^ key, struct.unpack('<I', size.to_bytes(4, 'big'))[0] ^ key)
|
|
out += b''.join(struct.pack('<I', struct.unpack_from('<I', otf, i)[0] ^ key) for i in range(0, size - size % 4, 4))
|
|
return out + otf[size - size % 4:]
|
|
|
|
|
|
def unbfotf(blob):
|
|
key = struct.unpack_from('<I', blob, 0)[0] ^ MAGIC
|
|
size = int.from_bytes(struct.pack('<I', struct.unpack_from('<I', blob, 4)[0] ^ key), 'big')
|
|
words = b''.join(struct.pack('<I', struct.unpack_from('<I', blob, 8 + i)[0] ^ key) for i in range(0, size - size % 4, 4))
|
|
return words + blob[8 + size - size % 4:8 + size]
|
|
|
|
|
|
def rect(pen, x0, y0, x1, y1, hole=False):
|
|
"""A rectangle, counter-clockwise (filled, the CFF way) or clockwise (a counter)."""
|
|
points = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
points = points[::-1] if hole else points
|
|
pen.moveTo(points[0])
|
|
for p in points[1:]:
|
|
pen.lineTo(p)
|
|
pen.closePath()
|
|
|
|
|
|
def polygon(*points):
|
|
def draw(pen):
|
|
pen.moveTo(points[0])
|
|
for p in points[1:]:
|
|
pen.lineTo(p)
|
|
pen.closePath()
|
|
return draw
|
|
|
|
|
|
def cff_font(name, glyphs):
|
|
"""A CFF OpenType font with a 1000-unit em: code point -> (advance, draw(pen))."""
|
|
names = {cp: f'uni{cp:04X}' for cp in glyphs}
|
|
fb = FontBuilder(1000, isTTF=False)
|
|
fb.setupGlyphOrder(['.notdef'] + list(names.values()))
|
|
fb.setupCharacterMap(names)
|
|
charstrings, metrics = {}, {}
|
|
for gname, (advance, draw) in [('.notdef', (500, lambda pen: rect(pen, 50, 0, 450, 700)))] + \
|
|
[(names[cp], glyphs[cp]) for cp in glyphs]:
|
|
pen = T2CharStringPen(advance, None)
|
|
draw(pen)
|
|
charstrings[gname], metrics[gname] = pen.getCharString(), (advance, 0)
|
|
fb.setupCFF(name, {'FullName': name}, charstrings, {})
|
|
fb.setupHorizontalMetrics(metrics)
|
|
fb.setupHorizontalHeader(ascent=800, descent=-200)
|
|
fb.setupNameTable({'familyName': name, 'styleName': 'Regular'})
|
|
fb.setupOS2(sTypoAscender=800, usWinAscent=800, usWinDescent=200)
|
|
fb.setupPost()
|
|
out = io.BytesIO()
|
|
fb.save(out)
|
|
return out.getvalue()
|
|
|
|
|
|
# A distinct rectangle and advance for every button glyph, so a glyph that moved is found by its outline.
|
|
BUTTONS = {cp: (600 + 4 * (cp - 0xE040), lambda pen, i=cp - 0xE040: rect(pen, 20 + 5 * i, 20, 300 + 5 * i, 400 + 3 * i))
|
|
for cp in range(0xE040, 0xE069)}
|
|
LETTERS_1 = {ord('L'): (560, polygon((60, 0), (480, 0), (480, 100), (160, 100), (160, 700), (60, 700))),
|
|
ord('B'): (560, lambda pen: [rect(pen, 60, 0, 460, 700), rect(pen, 160, 100, 360, 300, True),
|
|
rect(pen, 160, 400, 360, 600, True)]),
|
|
ord('R'): (560, lambda pen: [rect(pen, 60, 0, 460, 700), rect(pen, 160, 400, 360, 600, True)])}
|
|
LETTERS_2 = {ord('T'): (560, polygon((230, 0), (330, 0), (330, 600), (520, 600), (520, 700), (40, 700), (40, 600), (230, 600)))}
|
|
# The other characters the PC prompts' keys show (button_icons.PC_LABELS of the defaults): a block with a counter, each
|
|
# its own size.
|
|
LETTERS_2.update({ord(ch): (560, lambda pen, i=i: [rect(pen, 60, 0, 300 + 10 * i, 700), rect(pen, 120, 100, 240, 600, True)])
|
|
for i, ch in enumerate('ACDEFMQSW1234⇧G')})
|
|
PC_PANES = {'A': 'E', 'B': '↑', 'X': '▬', 'Y': 'L'} # the diamond's letters with the default keys
|
|
|
|
|
|
class Romfs:
|
|
"""A RomFS (romfs.bin, index.json) holding the files make_romfs_patches.py reads, the originals kept to compare."""
|
|
|
|
def __init__(self, folder, version, text_fonts=True, restbl_skips=(), l_format=R8_UNORM):
|
|
v, rnd = VERSIONS[version], random.Random(version)
|
|
words = [b'Layout', b'Pane', b'Font', b'Msg', b'Texture', b'Anim', b'Group', b'Text', b'Base', b'Key']
|
|
samples = [b' '.join(rnd.choice(words) + str(rnd.randrange(999)).encode() for _ in range(rnd.randrange(20, 80)))
|
|
for _ in range(400)]
|
|
self.dictionary = zstd.train_dict(samples, 4096)
|
|
self.common = f'UI/LayoutArchive/Common.Product.{v["common"]}.Nin_NX_NVN.blarc.zs'
|
|
self.mals = [f'Mals/EUen.Product.{v["newest"]}.sarc.zs', f'Mals/USen.Product.{v["newest"]}.sarc.zs']
|
|
self.old_mals = 'Mals/USen.Product.110.sarc.zs'
|
|
self.restbl = f'System/Resource/ResourceSizeTable.Product.{v["newest"]}.{v["restbl"]}'
|
|
self.old_restbl = f'System/Resource/ResourceSizeTable.Product.100.{v["restbl"]}' # an older version's: left alone
|
|
size = v['size']
|
|
images = {f'Nt_KeyTex{b}_00': (R8_UNORM, size, rnd.randbytes(IMAGE)) for b in 'ABXY'}
|
|
images.update({'Nt_KeyTexL_00^z': (l_format, size, rnd.randbytes(IMAGE)),
|
|
'Nt_KeyTexZL_00': (R8_UNORM, size, rnd.randbytes(IMAGE)),
|
|
'Nt_KeyTexLSt_00': (R8_UNORM, size, rnd.randbytes(IMAGE)),
|
|
'Nt_KeyTexLSt_01': (R8_UNORM, size, rnd.randbytes(IMAGE))})
|
|
self.text_fonts = [bfotf(cff_font('Text1', LETTERS_1), 0x01020304), bfotf(cff_font('Text2', LETTERS_2), 0x0a0b0c0d)]
|
|
default = {'scft/External.bfotf': bfotf(cff_font('External', BUTTONS), 0x5a17c3e9), 'fnt/rest.bin': rnd.randbytes(700)}
|
|
if text_fonts:
|
|
default.update(zip(icons.TEXT_FONTS, self.text_fonts))
|
|
mals = sarc({'LayoutMsg/DoCommand_00.msbt': msbt(MESSAGES), 'LayoutMsg/Other_00.msbt': msbt({'T_A_00': 'A'})})
|
|
self.contents = { # RomFS path -> contents before compression
|
|
self.common: sarc({'anim/ShortCutBG_00_In.bflan': bflan(('C_CaptureBG_00', 'C_CaptureClearBG_00',
|
|
'P_CaptureClearBG_00', 'N_Other_00')),
|
|
'anim/AutoBuilder_00_In.bflan': bflan(('C_CaptureBG_00',)),
|
|
'blyt/DoCommand_00.bflyt': bflyt(PANES),
|
|
'blyt/Title_00.bflyt': bflyt({'N_Title_00': 'Z'}),
|
|
'timg/__Combined.bntx': bntx(images)}),
|
|
DEFAULT_FONT: sarc(default),
|
|
# The same font under another key: drawn once, stored under each archive's own key.
|
|
REGIONAL_FONT: sarc({'scft/External.bfotf': bfotf(cff_font('External', BUTTONS), 0x77777777),
|
|
'fnt/rest.bin': rnd.randbytes(500)}),
|
|
**{path: mals for path in self.mals + [self.old_mals]},
|
|
}
|
|
sizes = {path: len(data) + 256 for path, data in self.contents.items() if path not in restbl_skips}
|
|
self.contents[self.restbl] = restbl({**sizes, **{f'Other/{i}.bin': 1000 + i for i in range(20)}})
|
|
self.contents[self.old_restbl] = restbl({DEFAULT_FONT: 1})
|
|
plain = (REGIONAL_FONT, self.restbl, self.old_restbl) # stored without a dictionary
|
|
self.files = {'Pack/ZsDic.pack.zs': zstd.compress(sarc({'zs.zsdic': self.dictionary.dict_content}))}
|
|
self.files.update({path: zstd.compress(data, zstd_dict=None if path in plain else self.dictionary)
|
|
for path, data in self.contents.items()})
|
|
self.files['Other/file.bin'] = rnd.randbytes(100)
|
|
image, entries = bytearray(), []
|
|
for path, data in self.files.items():
|
|
image += bytes(-len(image) % 16)
|
|
entries.append(dict(path=path, offset=len(image), size=len(data)))
|
|
image += data
|
|
folder.mkdir(parents=True)
|
|
(folder / 'romfs.bin').write_bytes(image)
|
|
(folder / 'index.json').write_text(json.dumps(dict(status='pass', files=entries)))
|
|
self.folder, self.version = folder, version
|
|
|
|
def run(self, out, *flags):
|
|
return subprocess.run([sys.executable, '-I', str(TOOL), '--version', self.version, '--romfs', str(self.folder),
|
|
'--out', str(out), *flags], capture_output=True, text=True, encoding='utf-8')
|
|
|
|
def read(self, out, path):
|
|
"""The overlay's file, decompressed."""
|
|
raw = (out / path).read_bytes()
|
|
return zstd.decompress(raw, zstd_dict=self.dictionary if zstd.get_frame_info(raw).dictionary_id else None)
|
|
|
|
|
|
def font(blob):
|
|
return TTFont(io.BytesIO(unbfotf(blob)))
|
|
|
|
|
|
def glyph(f, cp):
|
|
"""(outline, advance) of the glyph of code point cp."""
|
|
name = f.getBestCmap()[cp]
|
|
pen = RecordingPen()
|
|
f.getGlyphSet()[name].draw(pen)
|
|
return pen.value, f['hmtx'][name][0]
|
|
|
|
|
|
def manifest(out):
|
|
return {line.split('\t')[0]: line.split('\t')[1:] for line in (out / 'manifest.tsv').read_text().splitlines()
|
|
if not line.startswith('#')}
|
|
|
|
|
|
class PromptsTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.tmp = tempfile.TemporaryDirectory(prefix='nx2navi-prompts-')
|
|
cls.base = Path(cls.tmp.name)
|
|
cls.romfs = {v: Romfs(cls.base / v / 'romfs', v) for v in VERSIONS}
|
|
cls.outs = {}
|
|
for v, romfs in cls.romfs.items():
|
|
for style in ('plain', 'xbox', 'xbox-label', 'pc'):
|
|
out = cls.base / v / style
|
|
proc = romfs.run(out, *(() if style == 'plain' else ('--prompts', style)))
|
|
assert proc.returncode == 0, proc.stderr
|
|
cls.outs[v, style] = (out, json.loads(proc.stdout.strip().splitlines()[-1]))
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.tmp.cleanup()
|
|
|
|
def each(self, styles=('xbox', 'xbox-label')):
|
|
"""(RomFS, style, overlay folder, summary) of every run of these styles."""
|
|
return [(self.romfs[v], style, out, summary) for (v, style), (out, summary) in self.outs.items()
|
|
if style in styles]
|
|
|
|
def test_plain_overlay_has_only_the_frame_rate_patch(self):
|
|
for romfs, _, out, summary in self.each(('plain',)):
|
|
with self.subTest(version=romfs.version, style="plain"):
|
|
self.assertEqual(set(manifest(out)), {romfs.common})
|
|
self.assertIsNone(summary['prompts'])
|
|
keys = visibility(sarc_files(romfs.read(out, romfs.common))['anim/ShortCutBG_00_In.bflan'])
|
|
self.assertEqual(keys['C_CaptureBG_00'], [(0.0, 0), (0.0, 1)])
|
|
self.assertEqual(keys['N_Other_00'], [(0.0, 0), (1.0, 1)])
|
|
|
|
def test_face_buttons_name_the_xbox_label_of_the_button_pressed(self):
|
|
for romfs, style, out, _ in self.each():
|
|
with self.subTest(version=romfs.version, style=style):
|
|
layout = STYLE_LAYOUT[style]
|
|
label = {b: XBOX_LABEL[POSITION[layout][b]] for b in FACE} # Switch button -> the label of its button
|
|
before, after = sarc_files(romfs.contents[romfs.common]), sarc_files(romfs.read(out, romfs.common))
|
|
old, new = bntx_images(before['timg/__Combined.bntx']), bntx_images(after['timg/__Combined.bntx'])
|
|
panes, messages = pane_texts(after['blyt/DoCommand_00.bflyt']), {}
|
|
for path in romfs.mals if layout == 'nintendo' else []:
|
|
messages[path] = message_texts(sarc_files(romfs.read(out, path))['LayoutMsg/DoCommand_00.msbt'])
|
|
original = font(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
|
for path in (DEFAULT_FONT, REGIONAL_FONT):
|
|
drawn = font(sarc_files(romfs.read(out, path))['scft/External.bfotf'])
|
|
for b, cp in FACE.items():
|
|
self.assertEqual(glyph(drawn, cp), glyph(original, FACE[label[b]]), f'{path}: glyph of {b}')
|
|
for b in FACE:
|
|
self.assertEqual(new[f'Nt_KeyTex{b}_00'], old[f'Nt_KeyTex{label[b]}_00'], f'action prompt image of {b}')
|
|
self.assertEqual(panes[f'T_{b}_00'], label[b], f'diamond letter of {b}')
|
|
for path, texts in messages.items():
|
|
self.assertEqual(texts[f'T_{b}_00'], label[b], f'{path}: diamond message of {b}')
|
|
# Text that only looks like a letter pane, and every other message, stays.
|
|
self.assertEqual((panes['T_Name_00'], pane_texts(after['blyt/Title_00.bflyt'])), ('A', {'N_Title_00': 'Z'}))
|
|
for path, texts in messages.items():
|
|
self.assertEqual((texts['T_Name_00'], texts['Greeting']), ('A', 'Hello'))
|
|
self.assertEqual(sarc_files(romfs.read(out, path))['LayoutMsg/Other_00.msbt'], msbt({'T_A_00': 'A'}))
|
|
|
|
def test_other_buttons_are_drawn_as_xbox_icons(self):
|
|
for romfs, style, out, _ in self.each():
|
|
with self.subTest(version=romfs.version, style=style):
|
|
original = font(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
|
default = sarc_files(romfs.read(out, DEFAULT_FONT))
|
|
drawn = font(default['scft/External.bfotf'])
|
|
for cp in range(0xE040, 0xE069):
|
|
outline, advance = glyph(drawn, cp)
|
|
if cp in DRAWN:
|
|
self.assertNotEqual(outline, glyph(original, cp)[0], f'U+{cp:04X} drawn')
|
|
self.assertEqual(advance, DRAWN[cp], f'U+{cp:04X} advance')
|
|
elif cp not in FACE.values():
|
|
self.assertEqual((outline, advance), glyph(original, cp), f'U+{cp:04X} kept')
|
|
# The letters are holes in the key: LB is the key, L, and B with its two counters.
|
|
moves = sum(op == 'moveTo' for op, _ in glyph(drawn, 0xE044)[0])
|
|
self.assertEqual(moves, 5)
|
|
self.assertEqual(sum(op == 'moveTo' for op, _ in glyph(drawn, 0xE047)[0]), 1 + 1 + 1 + 1) # RT: key, R + counter, T
|
|
self.assertEqual(sum(op == 'moveTo' for op, _ in glyph(drawn, 0xE060)[0]), 2) # d-pad up: cross, arrow
|
|
# The text fonts and the archives' other members stay byte for byte; both archives hold the same font.
|
|
before = sarc_files(romfs.contents[DEFAULT_FONT])
|
|
self.assertEqual({k: v for k, v in default.items() if k != 'scft/External.bfotf'},
|
|
{k: v for k, v in before.items() if k != 'scft/External.bfotf'})
|
|
regional = sarc_files(romfs.read(out, REGIONAL_FONT))
|
|
self.assertEqual(regional['fnt/rest.bin'], sarc_files(romfs.contents[REGIONAL_FONT])['fnt/rest.bin'])
|
|
self.assertEqual(unbfotf(regional['scft/External.bfotf']), unbfotf(default['scft/External.bfotf']))
|
|
self.assertEqual(struct.unpack_from('<I', regional['scft/External.bfotf'])[0] ^ MAGIC, 0x77777777)
|
|
|
|
def test_button_guide_images_show_the_same_icons(self):
|
|
for romfs, style, out, _ in self.each():
|
|
with self.subTest(version=romfs.version, style=style):
|
|
size = VERSIONS[romfs.version]['size']
|
|
before = sarc_files(romfs.contents[romfs.common])
|
|
after = sarc_files(romfs.read(out, romfs.common))
|
|
self.assertEqual(len(after['timg/__Combined.bntx']), len(before['timg/__Combined.bntx']))
|
|
old, new = bntx_images(before['timg/__Combined.bntx']), bntx_images(after['timg/__Combined.bntx'])
|
|
glyphs = icons.xbox_icons(icons.text_glyphs(romfs.text_fonts))
|
|
for name, cp in icons.HUD_TEXTURES.items():
|
|
fmt, width, image = new[name]
|
|
pixels = bytes(image[icons.gob_offset(x, y, (size + 63) // 64, 16)] for y in range(size) for x in range(size))
|
|
self.assertEqual(pixels, icons.rasterize(glyphs[cp][1], size), name)
|
|
self.assertEqual({pixels[0], pixels[-1], max(pixels)}, {0, 255}, name) # a margin around a white key
|
|
self.assertEqual(new['Nt_KeyTexLSt_00'], old['Nt_KeyTexLSt_00'])
|
|
for member in before: # the 60 fps patch and the prompts touch only these
|
|
if member not in ('timg/__Combined.bntx', 'blyt/DoCommand_00.bflyt') and not member.startswith('anim/'):
|
|
self.assertEqual(after[member], before[member], member)
|
|
|
|
def test_resource_size_table_grows_with_the_fonts(self):
|
|
for romfs, style, out, summary in self.each(('xbox', 'xbox-label', 'pc')):
|
|
with self.subTest(version=romfs.version, style=style):
|
|
table = restbl_sizes(romfs.read(out, romfs.restbl))
|
|
original = restbl_sizes(romfs.contents[romfs.restbl])
|
|
grown = {}
|
|
for path in manifest(out):
|
|
if path != romfs.restbl:
|
|
grown[path] = len(romfs.read(out, path)) - len(romfs.contents[path])
|
|
self.assertTrue(grown[DEFAULT_FONT] > 0 and grown[REGIONAL_FONT] > 0, grown)
|
|
self.assertEqual(summary['prompts']['grown'], {p: g for p, g in grown.items() if g > 0})
|
|
for crc, entry in original.items():
|
|
paths = [p for p in grown if zlib.crc32(p.removesuffix('.zs').encode()) == crc and grown[p] > 0]
|
|
self.assertEqual(table[crc], entry + (grown[paths[0]] if paths else 0))
|
|
self.assertEqual(set(table), set(original))
|
|
|
|
def test_manifest_lists_what_changed_with_both_hashes(self):
|
|
for romfs, style, out, _ in self.each(('plain', 'xbox', 'xbox-label', 'pc')):
|
|
with self.subTest(version=romfs.version, style=style):
|
|
expect = {romfs.common}
|
|
if style != 'plain':
|
|
expect |= {DEFAULT_FONT, REGIONAL_FONT, romfs.restbl}
|
|
expect |= set(romfs.mals) if style in ('xbox', 'pc') else set()
|
|
lines = manifest(out)
|
|
self.assertEqual(set(lines), expect)
|
|
for path, (size, sha, original) in lines.items():
|
|
data = (out / path).read_bytes()
|
|
self.assertEqual((int(size), sha), (len(data), hashlib.sha256(data).hexdigest()), path)
|
|
self.assertEqual(original, hashlib.sha256(romfs.files[path]).hexdigest(), path)
|
|
|
|
def test_pc_prompts_draw_every_button_as_its_key(self):
|
|
for romfs, _, out, _ in self.each(('pc',)):
|
|
with self.subTest(version=romfs.version):
|
|
original = font(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
|
drawn = font(sarc_files(romfs.read(out, DEFAULT_FONT))['scft/External.bfotf'])
|
|
advances = icons.font_advances(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
|
pc = icons.pc_icons(icons.text_glyphs(romfs.text_fonts), advances)
|
|
self.assertIn(0xE05E, pc) # the left stick's W A S D
|
|
for cp in range(0xE040, 0xE069):
|
|
outline, advance = glyph(drawn, cp)
|
|
if cp in pc:
|
|
self.assertNotEqual(outline, glyph(original, cp)[0], f'U+{cp:04X} drawn')
|
|
else:
|
|
self.assertEqual(outline, glyph(original, cp)[0], f'U+{cp:04X} kept')
|
|
self.assertEqual(advance, glyph(original, cp)[1], f'U+{cp:04X} keeps its advance')
|
|
# The key's label is a hole: E is the key, the block and its counter.
|
|
self.assertEqual(sum(op == 'moveTo' for op, _ in glyph(drawn, 0xE040)[0]), 3)
|
|
after = sarc_files(romfs.read(out, romfs.common))
|
|
new = bntx_images(after['timg/__Combined.bntx'])
|
|
size = VERSIONS[romfs.version]['size']
|
|
for name, cp in icons.PC_HUD_TEXTURES.items():
|
|
image = new[name][2]
|
|
pixels = bytes(image[icons.gob_offset(x, y, (size + 63) // 64, 16)] for y in range(size) for x in range(size))
|
|
self.assertEqual(pixels, icons.rasterize(pc[cp][1], size), name)
|
|
panes = pane_texts(after['blyt/DoCommand_00.bflyt'])
|
|
for b, letter in PC_PANES.items():
|
|
self.assertEqual(panes[f'T_{b}_00'], letter, f'diamond letter of {b}')
|
|
for path in romfs.mals:
|
|
texts = message_texts(sarc_files(romfs.read(out, path))['LayoutMsg/DoCommand_00.msbt'])
|
|
self.assertEqual((texts[f'T_{b}_00'], texts['T_Name_00']), (letter, 'A'), path)
|
|
|
|
def test_pc_prompts_follow_the_bind_lines(self):
|
|
romfs = self.romfs['v143']
|
|
ini = self.base / 'binds' / 'nx2navi.ini'
|
|
ini.parent.mkdir(parents=True)
|
|
ini.write_text('[input]\n# bind_A = key:q\nbind_A = pad:east, key:F\nbind_B = pad:south\nbind_X = key:default\n',
|
|
encoding='utf-8')
|
|
out = self.base / 'binds' / 'out'
|
|
proc = romfs.run(out, '--prompts', 'pc', '--binds', str(ini))
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
panes = pane_texts(sarc_files(romfs.read(out, romfs.common))['blyt/DoCommand_00.bflyt'])
|
|
self.assertEqual({b: panes[f'T_{b}_00'] for b in PC_PANES}, {**PC_PANES, 'A': 'F'})
|
|
self.assertEqual(icons.pc_binds(ini.read_text(encoding='utf-8')), {**icons.PC_BINDS, 'A': 'f'})
|
|
|
|
def test_same_input_same_output_and_the_older_flag(self):
|
|
romfs = self.romfs['v121']
|
|
for flags in (('--prompts', 'xbox'), ('--xbox-prompts',)):
|
|
out = self.base / 'again' / '-'.join(flags)
|
|
proc = romfs.run(out, *flags)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertEqual((out / 'manifest.tsv').read_bytes(), (self.outs['v121', 'xbox'][0] / 'manifest.tsv').read_bytes())
|
|
|
|
def test_unrecognised_romfs_fails_before_writing(self):
|
|
cases = {'no text fonts': (dict(text_fonts=False), icons.TEXT_FONTS[0]),
|
|
'no size entry': (dict(restbl_skips=(REGIONAL_FONT,)), 'has no entry in the resource size table'),
|
|
'compressed L image': (dict(l_format=BC1), 'Nt_KeyTexL_00: format')}
|
|
for i, (case, (options, message)) in enumerate(cases.items()):
|
|
with self.subTest(case):
|
|
romfs = Romfs(self.base / 'bad' / str(i) / 'romfs', 'v121', **options)
|
|
out = self.base / 'bad' / str(i) / 'out'
|
|
proc = romfs.run(out, '--prompts', 'xbox-label')
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertIn(message, proc.stderr)
|
|
self.assertFalse(out.exists(), 'a failed run writes nothing')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|