Files
totk-pc-recomp/tools/make_romfs_patches.py
T
Carlos DonatoandClaude Opus 5.5 fb34177cfd Make the Xbox prompts for every game resolution the setup lays the UI out for (#378)
The setup wrote romfs-patches-xbox and romfs-patches-xbox-label for 16:9 only, so prompts = xbox on a 21:9, 32:9,
16:10 or 3:2 screen (the Steam Deck's 1280x800 among them) kept the game resolution at 1920x1080 (cfg.c) or, with
output_res set to the screen's shape, fell back to the Switch prompts. #979 fixed this for prompts = pc; this extends
the same mechanism to both Xbox folders, the shared-files option of #378 by way of #979's --reuse.

- The setup builds romfs-patches-xbox-<w>x<h> and romfs-patches-xbox-label-<w>x<h> for each of ASPECT_SIZES, from
  their 16:9 folder with --reuse, as it does for romfs-patches-pc-<w>x<h>.
- romfs.c overlay_init: at a game resolution of another shape any prompts' folder loads from <folder>-<w>x<h> in place
  of romfs-patches-<w>x<h>, not only the PC one; without it the Switch prompts stay, and the log names the missing
  folder and the command that makes it. romfs_shaped_pc_ready becomes romfs_shaped_prompts_ready(folder, w, h).
- cfg.c: the display-shaped output_res default applies with prompts = xbox when the folder of the pad layout
  (romfs-patches-xbox with the Nintendo layout, romfs-patches-xbox-label with any other) is there for that size.
- The menu's note for a missing shaped folder reads "off at this game resolution (no layout made for it)" in every
  menu language, since 16:9 is no longer the only shape with prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:53:49 +01:00

496 lines
29 KiB
Python

# Adapted from MacNativeGames games/totk/scripts/make_romfs_patches.py @ ebe621d (MIT, Copyright (c) 2026
# MacNativeGames contributors).
"""Build the 60 fps RomFS overlay for TotK 1.1.0, 1.2.1 or 1.4.3 (runtime/romfs.c, TOTK_ROMFS_PATCHES).
UI/LayoutArchive/Common.Product.<110 or 140>.Nin_NX_NVN.blarc.zs: the weapon / shield / material quick menus capture the game
picture for their background when the panes C_CaptureBG_00, C_CaptureClearBG_00 and P_CaptureClearBG_00 become
visible at animation frame 1.0 (anim/ShortCutBG_00_In.bflan), AutoBuilder the same with C_CaptureBG_00
(anim/AutoBuilder_00_In.bflan). UI animations advance 0.5 frames per game frame at 60 fps, so the capture lands after
the world stopped drawing and the background can be black. The visibility keys move to frame 0.0: the live world is
captured at once (upstream checked weapon, shield, material and ability wheel at 60 fps, and 30 fps).
The game-over flow fix of the same upstream script is 1.0.0 only (1.1.0, 1.2.1 and 1.4.3 run DmF_SY_GameOver.110) and is
left out.
--aspect <w>x<h> (a game resolution of another shape than 16:9, such as 3440x1440 or 1280x800) also lays the UI out
for that shape (tools/ultrawide_layout.py): menus keep their 16:9 proportions in the middle, full-screen backgrounds
cover the screen and the HUD moves to the screen's corners (wider) or its top and bottom edges (narrower). totk.exe
loads that overlay from romfs-patches-<w>x<h> when the game resolution is that size (runtime/romfs.c overlay_init);
the camera's side of it is the patch set aspect (recomp/patches-v121.txt, patches-v143.txt).
--prompts xbox | xbox-label: the same overlay with Xbox button icons in the game's prompts, for prompts = xbox
(runtime/romfs.c overlay_init loads romfs-patches-xbox or romfs-patches-xbox-label in place of romfs-patches). Drawn
from the game's own files on the player's machine, nothing added (tools/button_icons.py, which needs fontTools):
- both: L, R, ZL, ZR, Plus, Minus and the d-pad become LB, RB, LT, RT, Menu, View and a cross in the button font
(scft/External.bfotf, the same font in each Font/Font*.Nin_NX_NVN.bfarc.zs), and L and ZL in the button guide's images
(Nt_KeyTexL_00 and Nt_KeyTexZL_00 of timg/__Combined.bntx in the Common archive). The fonts grow, so the overlay also
carries the resource size table (System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs) with their entries
raised by as much.
- xbox, for pad_layout = nintendo. That layout plays an Xbox pad by position: the button that does Switch A is
labelled B, so the face buttons' prompts are swapped to name the labels, A with B and X with Y:
- the action prompt (blyt/DoCommand_00.bflyt) draws the letter images Nt_KeyTexA/B/X/Y_00 of timg/__Combined.bntx in
the Common archive: their image data is swapped (same format, size and tiling). Around it, a diamond names the
other face buttons with one-letter text panes (T_B_00, T_X_00, ...). The game fills them from the messages of the
same name in LayoutMsg/DoCommand_00.msbt of each Mals/<language> archive, over the layout's own text: both have
their letters swapped the same way. The diamond's shape (Nt_KeyBtnSubBase*) shows the button's position, which
stays right.
- every other prompt: the glyphs U+E040 A, U+E041 B, U+E042 X, U+E043 Y of the button font trade places.
- xbox-label, for pad_layout = xbox and auto. Those layouts put Switch A on the button labelled A (auto picks xbox for
every pad not labelled the Nintendo way), so the face buttons keep their letters. The action prompt's diamond is
laid out as an Xbox pad's (bflyt_xbox_diamond): A at the bottom, B on the right, X on the left, Y at the top, the
button the prompt names lit in its place with its bar.
--xbox-prompts is the older name of --prompts xbox. With --aspect, romfs-patches-xbox-<w>x<h> or
romfs-patches-xbox-label-<w>x<h>: the same prompts with that shape's UI layout.
--prompts pc: keyboard and mouse prompts, for prompts = pc (romfs-patches-pc, or romfs-patches-pc-<w>x<h> with
--aspect): every button of the button font is drawn as the key or mouse button the keyboard defaults put on it
(button_icons.pc_icons; --binds <totk.ini> takes the first key of its bind_ lines instead), and so are the button
guide's images (A, B, X, Y, L, ZL and the stick's two). The one-letter panes around the action prompt name the keys
with one character of the Normal font (E, an arrow for Shift, a bar for Space, L and R for the mouse buttons).
--reuse <folder>: an overlay made before, such as romfs-patches-pc for romfs-patches-pc-<w>x<h>. Each file of it whose
contents match the new one is hard-linked (copied where links fail) instead of compressed again; the summary's reused
lists them. The output is byte for byte the same as without it.
Input: build/extracted/<version>/romfs (romfs.bin and index.json, verified by tools/extract.py). Output:
build/<version>/romfs-patches/<path> by default (romfs-patches-<style> with --prompts), and <out>/manifest.tsv (path, size, SHA-256 of the overlay, SHA-256 of the original). Run with .venv-py314.
"""
import argparse
import compression.zstd as zstd
import hashlib
import importlib.util
import json
import os
import re
import struct
from pathlib import Path
root = Path(__file__).resolve().parents[1]
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--version', choices=('v110', 'v121', 'v143'), default='v121')
ap.add_argument('--romfs', type=Path, help='default build/extracted/<version>/romfs')
ap.add_argument('--out', type=Path,
help='default build/<version>/romfs-patches (romfs-patches-<style> with --prompts)')
ap.add_argument('--aspect', help='<w>x<h> of a game resolution of another shape than 16:9: also lay the UI out for it')
ap.add_argument('--prompts', choices=('xbox', 'xbox-label', 'pc'),
help='Xbox button icons in the prompts: xbox for pad_layout = nintendo (A shown as B, X as Y), '
'xbox-label for pad_layout = xbox or auto (the face buttons keep their letters), '
'pc for the keyboard and mouse')
ap.add_argument('--binds', type=Path, help='--prompts pc: a totk.ini whose bind_ lines the key icons follow')
ap.add_argument('--xbox-prompts', dest='prompts', action='store_const', const='xbox', help='the same as --prompts xbox')
ap.add_argument('--reuse', type=Path,
help='an overlay folder made before: its files with the same contents are linked, not compressed again')
args = ap.parse_args()
args.romfs = args.romfs or root / 'build/extracted' / args.version / 'romfs'
args.out = args.out or root / 'build' / args.version / (f'romfs-patches-{args.prompts}' if args.prompts else 'romfs-patches')
index = json.loads((args.romfs / 'index.json').read_text())
assert index['status'] == 'pass'
files = {f['path']: f for f in index['files']}
def sarc_nodes(data):
"""(name, node offset, absolute start, end) of every SARC member, in SFAT order."""
assert data[:4] == b'SARC' and data[6:8] == b'\xff\xfe'
header, size, start = struct.unpack_from('<HxxII', data, 4)
assert size == len(data) and data[header:header + 4] == b'SFAT'
count = struct.unpack_from('<H', data, header + 6)[0]
names = header + 12 + count * 16
assert data[names:names + 4] == b'SFNT'
names += 8
out = []
for i in range(count):
node = header + 12 + i * 16
_, attribute, lo, hi = struct.unpack_from('<4I', data, node)
offset = names + (attribute & 0xffffff) * 4
out.append((data[offset:data.index(0, offset, start)].decode(), node, start + lo, start + hi))
return out
def sarc_members(data):
"""name -> (absolute start, end) of every SARC member."""
return {name: (lo, hi) for name, _, lo, hi in sarc_nodes(data)}
def flvi_keys(data, base):
"""(pane name, absolute key offset, keys) of every FLVI (visibility) step track in the BFLAN at data[base:]."""
assert data[base:base + 4] == b'FLAN' and data[base + 4:base + 6] == b'\xff\xfe'
pos, out = base + 0x14, []
for _ in range(struct.unpack_from('<H', data, base + 0x10)[0]):
magic, size = data[pos:pos + 4], struct.unpack_from('<I', data, pos + 4)[0]
if magic == b'pai1':
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]
name = data[eo:eo + 28].split(b'\0')[0].decode()
for t in range(data[eo + 28]):
to = eo + struct.unpack_from('<I', data, eo + 32 + 4 * t)[0]
if data[to:to + 4] != b'FLVI':
continue
for g in range(data[to + 4]):
go = to + struct.unpack_from('<I', data, to + 8 + 4 * g)[0]
assert data[go + 2] == 1, 'visibility tracks are step curves'
ko = go + struct.unpack_from('<I', data, go + 8)[0]
keys = [struct.unpack_from('<fH', data, ko + 8 * k)
for k in range(struct.unpack_from('<H', data, go + 4)[0])]
out.append((name, ko, keys))
pos += size
return out
def bntx_swap(data, base, pairs):
"""Swap the image data of each (name, name) pair of textures in the BNTX at data[base:] (names without the ^
suffix). Both of a pair must have the same header from flags to layout (format, size, tiling, mips) and image
size, so the bytes of one are a valid image of the other."""
assert data[base:base + 4] == b'BNTX' and data[base + 0x20:base + 0x24] == b'NX '
count, table = struct.unpack_from('<IQ', data, base + 0x24)
found = {}
for k in range(count):
info = base + struct.unpack_from('<Q', data, base + table + 8 * k)[0]
assert data[info:info + 4] == b'BRTI'
name = base + struct.unpack_from('<Q', data, info + 0x60)[0]
name = data[name + 2:name + 2 + struct.unpack_from('<H', data, name)[0]].decode().split('^')[0]
mips = base + struct.unpack_from('<Q', data, info + 0x70)[0]
shape = bytes(data[info + 0x10:info + 0x3c]) + bytes(data[info + 0x50:info + 0x54])
start, size = base + struct.unpack_from('<Q', data, mips)[0], struct.unpack_from('<I', data, info + 0x50)[0]
found[name] = (shape, start, size)
for a, b in pairs:
assert a in found and b in found, f'{a} or {b} missing from the texture list'
(shape_a, at_a, size), (shape_b, at_b, _) = found[a], found[b]
assert shape_a == shape_b, f'{a} and {b} differ in format, size or tiling'
data[at_a:at_a + size], data[at_b:at_b + size] = data[at_b:at_b + size], data[at_a:at_a + size]
def bflyt_letters(data, base, end, swap):
"""Swap the text of the one-letter text panes T_<letter>_<n> (A, B, X or Y) in the BFLYT at data[base:end] by
swap (letter -> letter); the names of the panes changed. Same length (one UTF-16 letter and its terminator)."""
assert data[base:base + 4] == b'FLYT'
pos, changed = base + struct.unpack_from('<H', data, base + 6)[0], []
while pos + 8 <= end:
magic, size = data[pos:pos + 4], struct.unpack_from('<I', data, pos + 4)[0]
if magic == b'txt1':
name = data[pos + 12:pos + 36].split(b'\0')[0].decode()
length = struct.unpack_from('<H', data, pos + 0x56)[0]
at = pos + struct.unpack_from('<I', data, pos + 0x64)[0]
letter = data[at:at + 2].decode('utf-16-le') if length == 4 and at > pos else ''
if letter in swap and re.fullmatch(r'T_[ABXY]_\d+', name):
data[at:at + 2] = swap[letter].encode('utf-16-le')
changed.append(name)
if not size:
break
pos += size
return changed
def bflyt_panes(data, base, end):
"""name -> absolute offset of each pane section (pan1, pic1, txt1, wnd1, ...) of the BFLYT at data[base:end], and
name -> absolute offset of each material of its mat1, and its texture names (txl1)."""
assert data[base:base + 4] == b'FLYT'
pos, panes, mats, textures = base + struct.unpack_from('<H', data, base + 6)[0], {}, {}, []
while pos + 8 <= end:
magic, size = data[pos:pos + 4], struct.unpack_from('<I', data, pos + 4)[0]
if magic in (b'pan1', b'pic1', b'txt1', b'wnd1', b'bnd1', b'prt1', b'ali1', b'scr1'):
panes[data[pos + 12:pos + 36].split(b'\0')[0].decode()] = pos
elif magic == b'mat1':
for i in range(struct.unpack_from('<H', data, pos + 8)[0]):
at = pos + struct.unpack_from('<I', data, pos + 12 + 4 * i)[0]
mats[data[at:at + 28].split(b'\0')[0].decode()] = at
elif magic == b'txl1':
for i in range(struct.unpack_from('<H', data, pos + 8)[0]):
at = pos + 12 + struct.unpack_from('<I', data, pos + 12 + 4 * i)[0]
textures.append(data[at:data.index(0, at)].decode())
if not size:
break
pos += size
return panes, mats, textures
# The action prompt's diamond (blyt/DoCommand_00.bflyt) for pad_layout = xbox and auto, where Switch A, B, X and Y are
# the buttons labelled so on an Xbox pad: A at the bottom, B on the right, X on the left, Y at the top. The layout has
# one diamond per button the prompt can name (N_A_00, N_B_00, N_X_00, N_Y_00; N_StopA_00 is A's while held), each
# with its button and bar (N_Btn<K>_00), the three other letters (T_<letter>_<n>) and a base image (P_Base<K>_00)
# that lights the button's side. A Switch button's place on an Xbox pad is that of its partner on a Switch (A of B,
# X of Y), so each piece takes the place and look its partner's piece has: the bar of a button on the left reaches
# left (Y's on a Switch), the others reach right.
DIAMOND_SLOTS = {'A': (39.0, 1.0), 'B': (2.0, -39.0), 'X': (1.0, 39.0), 'Y': (-38.0, 0.0)} # the letters' places
DIAMOND_PARTNER = {'A': 'B', 'B': 'A', 'X': 'Y', 'Y': 'X'}
# (pane, the pane whose place it takes): the buttons with their bars, and the flashes of a repeated press (Rush)
DIAMOND_MOVES = [('N_BtnA_00', 'N_BtnB_00'), ('N_BtnA_01', 'N_BtnB_00'), ('N_CompanionBtnA_00', 'N_BtnB_00'),
('N_BtnB_00', 'N_BtnA_00'), ('N_BtnX_00', 'N_BtnY_00'), ('N_BtnY_00', 'N_BtnX_00')]
DIAMOND_MOVES += [(f'P_Capture{k}_{n:02}', f'P_Capture{DIAMOND_PARTNER[k]}_{n:02}') for k in 'ABXY' for n in (1, 2, 3)]
# X and Y trade the side their bar reaches to: the flash, the bar (Y's mirrored) and the text (Y's right-aligned)
DIAMOND_MIRROR = [('P_CaptureX_00', 'P_CaptureY_00'), ('W_BaseX_00', 'W_BaseY_00'), ('T_GuideX_00', 'T_GuideY_00'),
('T_GuideX_TW', 'T_GuideY_TW')]
# the base image: its texture (by its material) and its rotation and scale
DIAMOND_BASES = [('P_BaseA_00', 'P_BaseB_00'), ('P_BaseA_01', 'P_BaseB_00'), ('P_BaseB_00', 'P_BaseA_00'),
('P_BaseX_00', 'P_BaseY_00'), ('P_BaseY_00', 'P_BaseX_00')]
def bflyt_xbox_diamond(data, base, end):
"""Lay the action prompt's diamond in the BFLYT at data[base:end] (blyt/DoCommand_00.bflyt) out as an Xbox pad's
face buttons (DIAMOND_* above); returns the panes changed. A pane section holds its origin at +0x09, translation
(3 floats) at +0x2C, rotation (3) at +0x38 and scale (2) at +0x44; a text pane its text position at +0x5C; a
picture pane its material index at +0x64, and a material its first texture's index at +0x2C."""
panes, mats, textures = bflyt_panes(data, base, end)
old = bytes(data) # every piece takes its partner's place as the game has it
changed = []
for name, at in panes.items():
m = re.fullmatch(r'T_([ABXY])_\d+', name)
if m: # a letter around a diamond: its partner's place
struct.pack_into('<2f', data, at + 0x2C, *DIAMOND_SLOTS[DIAMOND_PARTNER[m[1]]])
changed.append(name)
for name, like in DIAMOND_MOVES:
data[panes[name] + 0x2C:panes[name] + 0x34] = old[panes[like] + 0x2C:panes[like] + 0x34]
changed.append(name)
# A's hold flashes (N_LoopA_03) sit 129 right of the diamond, as A's repeated-press ones: the same move as those
dx, dy = (a - b for a, b in zip(struct.unpack_from('<2f', old, panes['P_CaptureB_01'] + 0x2C),
struct.unpack_from('<2f', old, panes['P_CaptureA_01'] + 0x2C)))
x, y = struct.unpack_from('<2f', old, panes['N_LoopA_03'] + 0x2C)
struct.pack_into('<2f', data, panes['N_LoopA_03'] + 0x2C, x + dx, y + dy)
changed.append('N_LoopA_03')
for a, b in DIAMOND_MIRROR:
pa, pb = panes[a], panes[b]
for lo, hi in ((0x09, 0x0A), (0x2C, 0x4C)) + (((0x5C, 0x5D),) if old[pa:pa + 4] == b'txt1' else ()):
data[pa + lo:pa + hi], data[pb + lo:pb + hi] = old[pb + lo:pb + hi], old[pa + lo:pa + hi]
changed += [a, b]
in_order = list(mats.values())
def material(name): # the offset of a picture pane's material
return in_order[struct.unpack_from('<H', old, panes[name] + 0x64)[0]]
for name, like in DIAMOND_BASES:
assert old[panes[name]:panes[name] + 4] == b'pic1', name
data[panes[name] + 0x38:panes[name] + 0x4C] = old[panes[like] + 0x38:panes[like] + 0x4C]
mine, theirs = material(name), material(like)
data[mine + 0x2C:mine + 0x2E] = old[theirs + 0x2C:theirs + 0x2E]
assert textures[struct.unpack_from('<H', data, mine + 0x2C)[0]].startswith('Nt_KeyBtnSubBase'), name
changed.append(name)
return changed
def msbt_letters(data, swap):
"""Swap the one-letter messages labelled T_<letter>_<n> (A, B, X or Y) in the UTF-16 MSBT data (a bytearray) by
swap; the labels changed. The game sets a layout's text panes from the messages of the same name in
Mals/<language>/LayoutMsg/<layout>.msbt, over the text stored in the layout."""
assert data[:8] == b'MsgStdBn' and data[0x0c] == 1, 'not a UTF-16 MSBT'
sections, pos = {}, 0x20
for _ in range(struct.unpack_from('<H', data, 0x0e)[0]):
size = struct.unpack_from('<I', data, pos + 4)[0]
sections[bytes(data[pos:pos + 4])] = (pos + 16, size)
pos = (pos + 16 + size + 15) & ~15
labels, (base, _) = {}, 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):
length = data[at]
labels[struct.unpack_from('<I', data, at + 1 + length)[0]] = data[at + 1:at + 1 + length].decode()
at += 1 + length + 4
(base, size), changed = sections[b'TXT2'], []
count = struct.unpack_from('<I', data, base)[0]
offsets = list(struct.unpack_from(f'<{count}I', data, base + 4)) + [size]
for i in range(count):
at, length = base + offsets[i], offsets[i + 1] - offsets[i]
letter = data[at:at + 2].decode('utf-16-le') if length == 4 else ''
if letter in swap and re.fullmatch(r'T_[ABXY]_\d+', labels.get(i, '')):
data[at:at + 2] = swap[letter].encode('utf-16-le')
changed.append(labels[i])
return changed
def sarc_replace(data, new):
"""The SARC data with the members named in new (name -> bytes) replaced. The header, SFAT and SFNT stay; the
members are laid out again in their old order, each at an offset with at least the alignment its old one had."""
start = struct.unpack_from('<I', data, 12)[0]
out = bytearray(data[:start])
for name, node, lo, hi in sorted(sarc_nodes(data), key=lambda n: n[2]):
align = min(lo & -lo, 0x2000) if lo else 0x2000
out += bytes(-len(out) % align)
body = new.get(name, data[lo:hi])
struct.pack_into('<II', out, node + 8, len(out) - start, len(out) - start + len(body))
out += body
struct.pack_into('<I', out, 8, len(out))
return bytes(out)
COMMON = {'v110': '110', 'v121': '110', 'v143': '140'} # the version in the name of each game's Common archive
ARCHIVE = f'UI/LayoutArchive/Common.Product.{COMMON[args.version]}.Nin_NX_NVN.blarc.zs'
MEMBERS = {
'anim/ShortCutBG_00_In.bflan': ('C_CaptureBG_00', 'C_CaptureClearBG_00', 'P_CaptureClearBG_00'),
'anim/AutoBuilder_00_In.bflan': ('C_CaptureBG_00',),
}
KEY_TEXTURES = (('Nt_KeyTexA_00', 'Nt_KeyTexB_00'), ('Nt_KeyTexX_00', 'Nt_KeyTexY_00'))
KEY_GLYPHS = ((0xE040, 0xE041), (0xE042, 0xE043)) # A B, X Y in scft/External.bfotf
KEY_LETTERS = {'A': 'B', 'B': 'A', 'X': 'Y', 'Y': 'X'} # the face-button diamond around the action prompt
FONTS = sorted(p for p in files if re.fullmatch(r'Font/Font(_\w+)?\.Nin_NX_NVN\.bfarc\.zs', p))
# The message archive of each language: the newest version in the RomFS (Mals/USen.Product.121.sarc.zs on 1.2.1).
MALS = {}
for p in files:
m = re.fullmatch(r'Mals/(\w+)\.Product\.(\d+)\.sarc\.zs', p)
if m and int(m[2]) > MALS.get(m[1], (0, ''))[0]:
MALS[m[1]] = (int(m[2]), p)
MALS = sorted(p for _, p in MALS.values())
LAYOUT_MSG = 'LayoutMsg/DoCommand_00.msbt'
# The resource size table (System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs, matched as loosely as
# MacNativeGames make_button_icons.py does), likewise the newest version in the RomFS.
RESTBL = max((p for p in files if p.startswith('System/Resource/ResourceSizeTable.') and p.endswith('.rsizetable.zs')),
key=lambda p: int((re.search(r'\.(\d+)\.', p) or [0, 0])[1]), default=None)
by_position = args.prompts == 'xbox' # the face buttons' prompts swapped to the labels (pad_layout = nintendo)
pc = args.prompts == 'pc'
letter_panes = by_position or pc # the one-letter panes around the action prompt change
if args.prompts:
assert FONTS, 'no Font/Font*.Nin_NX_NVN.bfarc.zs in the RomFS'
assert MALS or not letter_panes, 'no Mals/<language>.Product.<version>.sarc.zs in the RomFS'
assert RESTBL, 'no System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs in the RomFS'
spec = importlib.util.spec_from_file_location('button_icons', Path(__file__).with_name('button_icons.py'))
icons = importlib.util.module_from_spec(spec)
spec.loader.exec_module(icons)
assert icons.TEXT_ARCHIVE in FONTS, f'no {icons.TEXT_ARCHIVE} in the RomFS'
with (args.romfs / 'romfs.bin').open('rb') as image:
def read(path):
f = files[path]
image.seek(f['offset'])
b = image.read(f['size'])
assert len(b) == f['size']
return b
dictionaries = {}
pack = zstd.decompress(read('Pack/ZsDic.pack.zs'))
for name, (lo, hi) in sarc_members(pack).items():
d = zstd.ZstdDict(pack[lo:hi])
dictionaries[d.dict_id] = d
prompt_files = (FONTS + (MALS if letter_panes else []) + [RESTBL]) if args.prompts else []
originals = {path: read(path) for path in [ARCHIVE] + prompt_files}
raw = originals[ARCHIVE]
info = zstd.get_frame_info(raw)
dictionary = dictionaries[info.dictionary_id]
original = zstd.decompress(raw, zstd_dict=dictionary)
blarc = bytearray(original)
assert len(blarc) == info.decompressed_size
members = sarc_members(blarc)
patched = {}
for member, panes in MEMBERS.items():
lo, hi = members[member]
changed = []
for name, ko, keys in flvi_keys(blarc, lo):
if name not in panes:
continue
assert [(f, v) for f, v in keys] == [(0.0, 0), (1.0, 1)], (member, name, keys)
struct.pack_into('<f', blarc, ko + 8, 0.0)
changed.append(name)
assert sorted(changed) == sorted(panes), (member, changed)
patched[member] = changed
layout = []
if args.aspect:
width, height = (int(v) for v in args.aspect.lower().split('x'))
spec = importlib.util.spec_from_file_location('ultrawide_layout', Path(__file__).with_name('ultrawide_layout.py'))
ultrawide = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ultrawide)
layout = ultrawide.apply(blarc, members, width, height)
diff = [i for i in range(len(original)) if original[i] != blarc[i]]
touched = [members[m] for m in (*MEMBERS, *layout)]
assert diff and all(any(lo <= i < hi for lo, hi in touched) for i in diff), diff
outputs = {ARCHIVE: bytes(blarc)} # RomFS path -> new contents (before compression)
prompts = None
if args.prompts:
sizes = {ARCHIVE: len(original)} # RomFS path -> size of the original's contents
def unpack(path):
frame = zstd.get_frame_info(originals[path])
data = zstd.decompress(originals[path], zstd_dict=dictionaries.get(frame.dictionary_id))
sizes[path] = len(data)
return data
archives = {path: unpack(path) for path in FONTS}
text = sarc_members(archives[icons.TEXT_ARCHIVE])
missing = [name for name in icons.TEXT_FONTS if name not in text]
assert not missing, f'no {", ".join(missing)} in {icons.TEXT_ARCHIVE}'
glyphs = icons.text_glyphs(archives[icons.TEXT_ARCHIVE][slice(*text[name])] for name in icons.TEXT_FONTS)
if pc:
binds = icons.pc_binds(args.binds.read_text(encoding='utf-8', errors='replace') if args.binds else None)
advances = icons.font_advances(archives[icons.TEXT_ARCHIVE][slice(*text[icons.FONT])])
drawn = icons.pc_icons(glyphs, advances, binds)
pane_letters = {letter: icons.pc_letter(binds[letter]) for letter in 'ABXY'}
else:
drawn = icons.xbox_icons(glyphs)
pane_letters = KEY_LETTERS
bntx = members['timg/__Combined.bntx'][0]
letters = {}
if by_position:
bntx_swap(blarc, bntx, KEY_TEXTURES)
diamond = []
if args.prompts == 'xbox-label':
diamond = bflyt_xbox_diamond(blarc, *members['blyt/DoCommand_00.bflyt'])
if letter_panes:
for member, (lo, hi) in members.items():
if member.endswith('.bflyt'):
changed = bflyt_letters(blarc, lo, hi, pane_letters)
if changed:
letters[member] = changed
assert 'blyt/DoCommand_00.bflyt' in letters, 'no letter panes in blyt/DoCommand_00.bflyt'
hud = icons.bntx_redraw(blarc, bntx, icons.PC_HUD_TEXTURES if pc else icons.HUD_TEXTURES, drawn)
outputs[ARCHIVE] = bytes(blarc)
fonts = {} # each distinct font is drawn once (1.4.3's four archives hold the same one)
for path, bfarc in archives.items():
lo, hi = sarc_members(bfarc)[icons.FONT]
otf, key = icons.bfotf_decode(bfarc[lo:hi])
if otf not in fonts:
fonts[otf] = icons.redraw_font(otf, drawn, KEY_GLYPHS if by_position else ())
outputs[path] = sarc_replace(bfarc, {icons.FONT: icons.bfotf_encode(fonts[otf], key, bfarc[lo:hi])})
messages = {}
for path in MALS if letter_panes else []:
mals = bytearray(unpack(path))
lo, hi = sarc_members(mals)[LAYOUT_MSG]
msbt = bytearray(mals[lo:hi])
changed = msbt_letters(msbt, pane_letters)
assert len(changed) == sum(len(v) for v in letters.values()), (path, changed)
mals[lo:hi] = msbt
outputs[path] = bytes(mals)
messages[path] = len(changed)
# An archive that grew needs as much more room in the resource size table, or the game stops when it loads it.
grown = {path: len(data) - sizes[path] for path, data in outputs.items() if len(data) > sizes[path]}
if grown:
outputs[RESTBL] = icons.grow_restbl(unpack(RESTBL), grown)
prompts = dict(style=args.prompts, textures=KEY_TEXTURES if by_position else [], letters=letters,
diamond=len(diamond), messages=messages, hud=hud, icons=len(drawn), fonts=FONTS, grown=grown)
def reusable(path, contents, dictionary):
"""The --reuse folder's copy of path when it holds these contents under the same dictionary, else None. The level 19
compression is most of a run (about 80 s for the four fonts of 1.4.3), and romfs-patches-<style>-<w>x<h> differs
from romfs-patches-<style> in the Common archive alone, so the setup makes the fifteen shapes from the 16:9
folder."""
if not args.reuse or not (args.reuse / path).is_file():
return None
data = (args.reuse / path).read_bytes()
try:
same = (zstd.get_frame_info(data).dictionary_id == zstd.get_frame_info(originals[path]).dictionary_id
and zstd.decompress(data, zstd_dict=dictionary) == contents)
except zstd.ZstdError:
return None
return data if same else None
lines, reused = [], []
for path, contents in outputs.items():
raw = originals[path]
dictionary = dictionaries.get(zstd.get_frame_info(raw).dictionary_id)
target = args.out / path
target.parent.mkdir(parents=True, exist_ok=True)
# Unlinked first: a file linked from another folder (--reuse) is not written through, which would break the other
# folder's manifest.
target.unlink(missing_ok=True)
data = reusable(path, contents, dictionary)
if data is not None:
try: # a hard link takes no room; a volume without them gets a copy
os.link(args.reuse / path, target)
except OSError:
target.write_bytes(data)
reused.append(path)
else:
data = zstd.compress(contents, level=19, zstd_dict=dictionary)
assert zstd.decompress(data, zstd_dict=dictionary) == contents
assert zstd.get_frame_info(data).dictionary_id == zstd.get_frame_info(raw).dictionary_id
target.write_bytes(data)
lines.append('\t'.join([path, str(len(data)), hashlib.sha256(data).hexdigest(), hashlib.sha256(raw).hexdigest()]))
(args.out / 'manifest.tsv').write_text('# path\tsize\tsha256\toriginal_sha256 (tools/make_romfs_patches.py)\n'
+ ''.join(line + '\n' for line in lines))
print(json.dumps(dict(status='pass', path=ARCHIVE, members=patched, changed_bytes=len(diff),
ultrawide_layout=dict(aspect=args.aspect, members=len(layout)) if args.aspect else None,
prompts=prompts, reused=reused)))