mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
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>
496 lines
29 KiB
Python
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)))
|