Files
NX2navi/tools/pad_prompts.py
chrg6 ce66223fc7 Encode each note button picture once when drawing the button prompts.
The prompt overlays for the pad layouts repeat the same note pictures, and every one was fitted and encoded to ASTC again for each layout. The encoded bytes are now kept for the build under the picture and its box. With stand-in pictures, 42 note textures over seven layouts came out byte for byte the same from 12 encodes instead of 42, in 0.66 s instead of 2.30 s.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:14:00 -05:00

868 lines
44 KiB
Python

"""Xbox, PlayStation, Steam Deck and keyboard-and-mouse button prompts for Ocarina of Time (Switch 2), drawn from button
art on the player's machine.
python tools/pad_prompts.py --art FOLDER --game FOLDER [--style xbox|playstation|steamdeck|pc]
[--layout position|label]
[--binds nx2navi.ini]
--art is a folder of button art with the file names of STYLES (tools/prompt_art.py) and KEY_ART below: the Source
folder of a button prompt art pack, which holds XGamepad/Default, XGamepad/Light, P5Gamepad/Default, P5Gamepad/Light,
Keyboard_Mouse/Dark and Keyboard_Mouse/White (128x128 PNGs: a dark button or key with a white label, or a light one
with a dark or coloured label), or a release's prompt-art folder, which holds the same files (tools/make_game.py runs
this tool on it). The art is read where it lies and never enters the repository.
--game is the folder of the port's exe and romfs-index.bin. The RomFS is read from its romfs folder (index.json and
romfs.bin, as the setup writes them); --romfs names another, which may also be a folder of extracted files. Each pad
style and layout becomes the overlay folder <game>/romfs-patches-<style> (layout position) or
<game>/romfs-patches-<style>-label (layout label), and the keyboard and mouse <game>/romfs-patches-pc, each with a
manifest.tsv: runtime/romfs.c overlay_init loads it in place of the game's files when nx2navi.ini has prompts = xbox,
playstation, steamdeck or pc (runtime/input_map.c input_prompts_folder). Without --style or --layout, every style or
layout. The Steam Deck style has no art of its own: its buttons are named as an Xbox pad's face buttons and a
PlayStation pad's shoulders and stick clicks, so it is drawn from those two pads' pictures (prompt_art.py STYLES).
--out writes a single style and layout elsewhere.
The two pad layouts follow pad_layout (runtime/input_map.c). position, for pad_layout = nintendo: a pad is played by
position, so Switch A is the right face button, labelled B on an Xbox pad and circle on a PlayStation pad; the prompts
for A show that button (A as B or circle, B as A or cross, X as Y or triangle, Y as X or square). label, for
pad_layout = xbox, playstation and auto: Switch A is the bottom face button, so A shows A or cross, B shows B or
circle, X shows X or square and Y shows Y or triangle.
pc shows the first key each button has (PC_KEYS, Ocarina's defaults, with the bind_ lines of --binds over them; by
default <game>/nx2navi.ini), the mouse buttons as a mouse, and for the right stick the mouse moving. A key bound
later shows after the tool runs again.
What changes, the rest of each file staying as it is:
- Font2/Scalable/External.Nin_Ounce_NVN2.bfotf.zs, the button font every prompt in text uses (dialogue, the button
guides, the controls screen): the glyphs of the buttons (U+E040 A, B, X, Y, L, R, ZL, ZR, the d-pad, Plus, Minus,
and the sticks and d-pad with their directions) become outlines traced from the art (trace), at the size of the
game's own face buttons; Power, Home, the console and the arrows stay. A .bfotf is a CFF OpenType font behind an
8-byte header and an XOR (tools/button_icons.py bfotf_decode); fontTools writes it again.
- UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs, the shared interface textures: KeyTexFontA/B/X/Y_00,
the letter of the action prompt (DoCommand_00; 64x64 BC4 masks the layout tints), and OcarinaButtonA/B/X/Y/L/R_00,
the ocarina's note buttons (PaOcarinaSingleNote_00; 93x93 ASTC 4x4 sRGB, a white button with a dark letter). They are
painted again in place, in their own format, size and block-linear layout, so the archive keeps its size.
- the resource size table (System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs): the font grows, and the
game sizes the buffer it loads a file into from the table, so the font's entry is raised by as much
(button_icons.grow_restbl).
Needs fontTools, Pillow and zstandard (tools/requirements-py314.txt; the launcher's Python has them,
tools/requirements-launcher.txt).
"""
import argparse
import hashlib
import io
import json
import struct
import sys
from pathlib import Path
import zstandard
from PIL import Image, ImageDraw, ImageFont
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont
sys.path.insert(0, str(Path(__file__).resolve().parent))
import button_icons # noqa: E402 (bfotf_decode, bfotf_encode, redraw_font, grow_restbl)
from prompt_art import BLANKS, KM, OPTIONS, SHARE, STYLES, overlay_folder # noqa: E402 (the art, the folders)
FONT = 'Font2/Scalable/External.Nin_Ounce_NVN2.bfotf.zs'
TEXTURES = 'UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs'
DICTIONARIES = 'Pack/ZsDic.pack.zs'
# The Switch button each code point of the button font shows (the stick and d-pad glyphs name their directions).
# U+E06A is the d-pad with three directions lit; the art has no such picture, so it shows the whole d-pad.
GLYPHS = {
0xE040: 'A', 0xE041: 'B', 0xE042: 'X', 0xE043: 'Y', 0xE044: 'L', 0xE045: 'R', 0xE046: 'ZL', 0xE047: 'ZR',
0xE049: 'DPAD', 0xE04B: 'PLUS', 0xE04C: 'MINUS',
0xE050: 'LSTICK_DOWN', 0xE051: 'RSTICK_DOWN', 0xE052: 'LSTICK_UP', 0xE053: 'RSTICK_UP',
0xE054: 'LSTICK_LEFTRIGHT', 0xE055: 'RSTICK_LEFTRIGHT', 0xE056: 'LSTICK_PRESS', 0xE057: 'RSTICK_PRESS',
0xE058: 'LSTICK_RIGHT', 0xE059: 'RSTICK_RIGHT', 0xE05A: 'LSTICK_LEFT', 0xE05B: 'RSTICK_LEFT',
0xE05C: 'LSTICK_UPDOWN', 0xE05D: 'RSTICK_UPDOWN', 0xE05E: 'LSTICK', 0xE05F: 'RSTICK',
0xE060: 'DPAD_UP', 0xE061: 'DPAD_DOWN', 0xE062: 'DPAD_LEFT', 0xE063: 'DPAD_RIGHT',
0xE064: 'DPAD_UPDOWN', 0xE065: 'DPAD_LEFTRIGHT', 0xE068: 'RSTICK_ROTATE', 0xE06A: 'DPAD',
}
# The face button that does each Switch face button, by its place on the pad.
FACE = {'position': {'A': 'east', 'B': 'south', 'X': 'north', 'Y': 'west'},
'label': {'A': 'south', 'B': 'east', 'X': 'west', 'Y': 'north'}}
# The textures drawn again: the action prompt's letter masks and the ocarina's note buttons, by Switch button.
MASKS = {'KeyTexFontA_00': 'A', 'KeyTexFontB_00': 'B', 'KeyTexFontX_00': 'X', 'KeyTexFontY_00': 'Y'}
NOTES = {'OcarinaButtonA_00': 'A', 'OcarinaButtonB_00': 'B', 'OcarinaButtonX_00': 'X', 'OcarinaButtonY_00': 'Y',
'OcarinaButtonL_00': 'L', 'OcarinaButtonR_00': 'R'}
def pad_pictures(style, layout):
"""(glyph, note) for a pad style: Switch button -> picture, the face buttons through FACE."""
s = STYLES[style]
glyph = {b: s['glyph'][FACE[layout].get(b, b)] for b in set(GLYPHS.values())}
note = {b: s['note'][FACE[layout].get(b, b)] for b in NOTES.values()}
return glyph, note
# ---- keyboard and mouse (prompts = pc): every button shows the first key its bindings give it. The defaults are
# runtime/input_map.c DEFAULT_KEYS; --binds takes the bind_ lines of a nx2navi.ini over them. The right stick is the
# camera, which the mouse turns, so its prompts show the mouse moving (its arrow keys, a second source, do not show).
PC_KEYS = {'A': 'e', 'B': 'lctrl', 'X': 'space', 'Y': 'mouse_l', 'L': 'q', 'R': 'r', 'ZL': 'mouse_r', 'ZR': 'f',
'PLUS': 'tab', 'MINUS': 'm', 'LEFT': '1', 'UP': '2', 'RIGHT': '3', 'DOWN': '4', 'LCLICK': 'v', 'RCLICK': 'c',
'LSTICK_UP': 'w', 'LSTICK_LEFT': 'a', 'LSTICK_DOWN': 's', 'LSTICK_RIGHT': 'd'}
# The art's name of each key (nx2navi.ini names, runtime/input_map.c key_names); letters, digits and F keys are their
# own. The art spells Ctrl as Crtl.
KEY_ART = {'space': 'Space', 'tab': 'Tab', 'lshift': 'Shift', 'rshift': 'Shift', 'lctrl': 'Crtl', 'rctrl': 'Crtl',
'lalt': 'Alt', 'up': 'Up', 'down': 'Down', 'left': 'Left', 'right': 'Right', 'enter': 'Enter',
'backspace': 'BackSpace', 'capslock': 'CapsLock', 'insert': 'Ins', 'delete': 'Del', 'home': 'Home',
'end': 'End', 'pageup': 'PageUp', 'pagedown': 'PageDown', 'minus': 'Minus', 'num_sub': 'Minus',
'num_add': 'Plus', 'num_mul': 'Asterisk', 'num_div': 'Slash', 'lbracket': 'Brackets_L',
'rbracket': 'Brackets_R', 'semicolon': 'Semicolon', 'apostrophe': 'Quotation', 'slash': 'Slash',
'grave': 'Tilde', 'mouse_l': 'Mouse_Left', 'mouse_r': 'Mouse_Right', 'mouse_m': 'Mouse_Middle'}
# What a blank key shows for a key the art has no picture of (label_art); any other shows its name.
KEY_LABELS = {'comma': ',', 'period': '.', 'equals': '=', 'num_decimal': '.', 'backslash': '\\', 'mouse_x1': 'M4',
'mouse_x2': 'M5'}
def pc_keys(ini_text=None):
"""PC_KEYS with the first key of each bind_<button> line of a nx2navi.ini text over it. A line without a key
entry, or with key:default first, keeps the default (the prompt then shows a key the line took away)."""
keys = dict(PC_KEYS)
for line in (ini_text or '').splitlines():
line = line.split('#')[0].split(';')[0].strip()
if not line.lower().startswith('bind_') or '=' not in line:
continue
button, value = (s.strip() for s in line.split('=', 1))
entries = [e.strip()[4:].strip().lower() for e in value.split(',') if e.strip().lower().startswith('key:')]
if entries and entries[0] != 'default' and button[5:].upper() in keys:
keys[button[5:].upper()] = entries[0]
return keys
def key_picture(art, key, shade):
"""The art of a key or mouse button in shade (Dark: a dark key with a white label; White: the other way round), or
a blank key with its name drawn on it when the art has none. The art names its dark 4 key T_3_Key_Dark-1."""
stem = KEY_ART.get(key)
if not stem:
digit = key[3:] if key.startswith('num') and key[3:].isdigit() else key
stem = digit.upper() if len(digit) == 1 or (digit[0] == 'f' and digit[1:].isdigit()) else None
for name in ([f'T_{stem}_Key_{shade}.png'] + ([f'T_3_Key_{shade}-1.png'] if stem == '4' else [])) if stem else []:
if (art / KM / shade / name).is_file():
return f'{KM}{shade}/{name}'
return ('label', key, shade)
def pc_pictures(art, keys):
"""(glyph, note) for the keyboard and mouse: Switch button -> picture, by the key each button has. A d-pad or a
stick with several directions shows their keys in a grid, as they sit on the keyboard."""
def k(button, shade='Dark'):
return key_picture(art, keys[button], shade)
def grid(*cells):
return ('grid', tuple(cells))
def mouse(axes):
return f'{KM}Dark/T_Mouse_{axes}_Key_Dark.png'
glyph = {b: k(b) for b in ('A', 'B', 'X', 'Y', 'L', 'R', 'ZL', 'ZR', 'PLUS', 'MINUS')}
glyph.update({
'LSTICK_PRESS': k('LCLICK'), 'RSTICK_PRESS': k('RCLICK'),
'DPAD': grid((k('UP'), 1, 0), (k('LEFT'), 0, 1), (k('DOWN'), 1, 1), (k('RIGHT'), 2, 1)),
'DPAD_UP': k('UP'), 'DPAD_DOWN': k('DOWN'), 'DPAD_LEFT': k('LEFT'), 'DPAD_RIGHT': k('RIGHT'),
'DPAD_UPDOWN': grid((k('UP'), 0, 0), (k('DOWN'), 0, 1)), 'DPAD_LEFTRIGHT': grid((k('LEFT'), 0, 0), (k('RIGHT'), 1, 0)),
'LSTICK': grid((k('LSTICK_UP'), 1, 0), (k('LSTICK_LEFT'), 0, 1), (k('LSTICK_DOWN'), 1, 1),
(k('LSTICK_RIGHT'), 2, 1)),
'LSTICK_UP': k('LSTICK_UP'), 'LSTICK_DOWN': k('LSTICK_DOWN'), 'LSTICK_LEFT': k('LSTICK_LEFT'),
'LSTICK_RIGHT': k('LSTICK_RIGHT'), 'LSTICK_UPDOWN': grid((k('LSTICK_UP'), 0, 0), (k('LSTICK_DOWN'), 0, 1)),
'LSTICK_LEFTRIGHT': grid((k('LSTICK_LEFT'), 0, 0), (k('LSTICK_RIGHT'), 1, 0)),
'RSTICK': mouse('XY'), 'RSTICK_ROTATE': mouse('XY'), 'RSTICK_UP': mouse('Y'), 'RSTICK_DOWN': mouse('Y'),
'RSTICK_UPDOWN': mouse('Y'), 'RSTICK_LEFT': mouse('X'), 'RSTICK_RIGHT': mouse('X'), 'RSTICK_LEFTRIGHT': mouse('X'),
})
note = {b: k(b, 'White') for b in NOTES.values()}
return glyph, note
# ---- the RomFS: index.json and romfs.bin (each file's offset and size), or a folder of extracted files
class Romfs:
def __init__(self, root):
self.root = Path(root)
index = self.root / 'index.json'
self.files = None
if index.is_file():
data = json.loads(index.read_text())
assert data.get('status') == 'pass', f'{index} is not a verified RomFS index'
self.files = {f['path']: f for f in data['files']}
self.dicts = None
def raw(self, path):
if self.files is None:
return (self.root / path).read_bytes()
f = self.files[path]
with (self.root / 'romfs.bin').open('rb') as image:
image.seek(f['offset'])
data = image.read(f['size'])
assert len(data) == f['size'], f'{path}: short read'
return data
def paths(self):
if self.files is not None:
return list(self.files)
return [p.relative_to(self.root).as_posix() for p in self.root.rglob('*') if p.is_file()]
def dictionary(self, dict_id):
if self.dicts is None:
self.dicts = {}
pack = unpack(self.raw(DICTIONARIES))
for lo, hi in sarc_members(pack).values():
d = zstandard.ZstdCompressionDict(pack[lo:hi])
self.dicts[d.dict_id()] = d
return self.dicts[dict_id]
def load(self, path):
"""(contents, dictionary or None) of a RomFS file, a .zs file decompressed with the dictionary its frame names."""
raw = self.raw(path)
if not path.endswith('.zs'):
return raw, None
dict_id = zstandard.get_frame_parameters(raw).dict_id
d = self.dictionary(dict_id) if dict_id else None
return unpack(raw, d), d
# How much of a packed file unpack gives the decompressor at a time.
PIECE = 1 << 16
def unpack(raw, dictionary=None):
"""The contents of a zstd frame, with the dictionary it was packed with; bytes after the frame are left out.
The file goes to the decompressor in pieces of PIECE bytes and the pieces' outputs are joined once. Given the
whole file in one call, the library returns one bytes object that it grows by copying as the output comes: made-up
data of the size of the interface texture archive, 69 MB packed and 244 MB unpacked, took 21 s that way on a
fast PC against 0.3 s in pieces. Players' builds waited 44 to 52 s before this tool's first folder, most of
its minute."""
out, parts = zstandard.ZstdDecompressor(dict_data=dictionary).decompressobj(), []
for at in range(0, len(raw), PIECE):
parts.append(out.decompress(raw[at:at + PIECE]))
if out.eof:
break
return b''.join(parts)
def sarc_members(data):
"""name -> (absolute start, end) of every member of a SARC archive."""
assert data[:4] == b'SARC'
header, start = struct.unpack_from('<H', data, 4)[0], struct.unpack_from('<I', data, 12)[0]
count = struct.unpack_from('<H', data, header + 6)[0]
names, out = header + 12 + count * 16 + 8, {}
for i in range(count):
_, attribute, lo, hi = struct.unpack_from('<4I', data, header + 12 + 16 * i)
at = names + (attribute & 0xffffff) * 4
out[data[at:data.index(0, at)].decode()] = (start + lo, start + hi)
return out
def compress(contents, dictionary, level):
"""A zstd frame of contents with the dictionary the original used, so the game reads it the same way."""
return zstandard.ZstdCompressor(level=level, dict_data=dictionary, threads=-1).compress(contents)
# ---- art: a button as coverage, traced into outlines
def load_art(art, rel):
"""A picture as RGBA: a file under art, or one drawn from several (OPTIONS, ('grid', cells), ('label', key, shade))."""
if rel == OPTIONS:
return options_art(art)
if isinstance(rel, tuple) and rel[0] == 'grid':
return grid_art(art, rel[1])
if isinstance(rel, tuple) and rel[0] == 'label':
return label_art(art, *rel[1:])
path = art / rel
if not path.is_file():
sys.exit(f'{path} missing: --art is the art pack\'s Source folder')
return Image.open(path).convert('RGBA')
def opaque(img):
return img.crop(img.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox())
def grid_art(art, cells):
"""Keys side by side: cells are (picture, column, row), row 0 on top, each centred in a cell as large as the
largest key, with a small gap between cells."""
pics = [(opaque(load_art(art, rel)), c, r) for rel, c, r in cells]
cw, ch, gap, pad = max(p.width for p, _, _ in pics), max(p.height for p, _, _ in pics), 6, 4
cols, rows = max(c for _, c, _ in pics) + 1, max(r for _, _, r in pics) + 1
canvas = Image.new('RGBA', (cols * cw + (cols - 1) * gap + 2 * pad, rows * ch + (rows - 1) * gap + 2 * pad))
for p, c, r in pics:
canvas.alpha_composite(p, (pad + c * (cw + gap) + (cw - p.width) // 2, pad + r * (ch + gap) + (ch - p.height) // 2))
return canvas
def label_art(art, key, shade):
"""A key the art has no picture of: its blank key with the key's name written on it."""
blank = load_art(art, BLANKS[shade])
name = KEY_LABELS.get(key, key.upper())
d = ImageDraw.Draw(blank)
x0, y0, x1, y1 = blank.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
size = 48
while size > 10:
font = ImageFont.load_default(size)
l, t, r, b = d.textbbox((0, 0), name, font=font)
if r - l <= (x1 - x0) * 0.7 and b - t <= (y1 - y0) * 0.5:
break
size -= 2
d.text(((x0 + x1 - (r - l)) / 2 - l, (y0 + y1 - (b - t)) / 2 - t), name, font=font,
fill=(255, 255, 255, 255) if shade == 'Dark' else (40, 40, 40, 255))
return blank
def options_art(art):
"""PlayStation's Options button as the art draws its Create button's neighbour on the pad: the dark pill of the
share picture with three white bars, drawn four times larger and scaled down for smooth edges."""
share = load_art(art, SHARE)
x0, y0, x1, y1 = share.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
k = 4
big = Image.new('RGBA', (share.width * k, share.height * k), (0, 0, 0, 0))
d = ImageDraw.Draw(big)
d.rounded_rectangle((x0 * k, y0 * k, x1 * k - 1, y1 * k - 1), radius=(y1 - y0) * k // 2, fill=(42, 42, 42, 255))
cx, cy, half, bar = (x0 + x1) * k / 2, (y0 + y1) * k / 2, (y1 - y0) * k * 0.32, (y1 - y0) * k * 0.05
for dy in (-0.22, 0, 0.22):
y = cy + dy * (y1 - y0) * k
d.rounded_rectangle((cx - half, y - bar, cx + half, y + bar), radius=bar, fill=(255, 255, 255, 255))
return big.convert('RGBa').resize(share.size, Image.LANCZOS).convert('RGBA')
LIT_AREA, LIT_RING = 200, 6 # pixels of the art
def ink(img, lit=False):
"""Coverage 0..1 per pixel (rows of floats) of a button drawn as a dark body with white symbols, for one-colour
glyphs: the body counts, and so does white outside it (a stick's direction arrows, which touch the transparent
background); white inside it (letters, symbols) is a hole.
lit: a d-pad with its pressed directions in white. The art draws them as a white arm in a hairline outline (Xbox)
or as a white arm the others' colour reversed (PlayStation); at text size the one vanishes and the other looks like
the rest, so each white part of LIT_AREA pixels or more becomes a hollow arm, a LIT_RING-pixel band along its
edge."""
a, lum, near, parts = _parts(img)
w, h = img.size
field = [[a[y][x] * (1 - lum[y][x]) + (a[y][x] * lum[y][x] if near[y][x] else 0) for x in range(w)] for y in range(h)]
for part in parts if lit else ():
if len(part) < LIT_AREA:
continue
inside = set(part)
dist = {p: 1 for p in part if any((p[0] + dx, p[1] + dy) not in inside
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))}
front = list(dist)
while front:
nxt = []
for x, y in front:
for q in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if q in inside and q not in dist:
dist[q] = dist[x, y] + 1
nxt.append(q)
front = nxt
for x, y in part:
field[y][x] = a[y][x] if dist[x, y] <= LIT_RING else 0.0
return field
def symbol(img):
"""Coverage of the white letter or symbol inside a dark button (the action prompt's letter), as an L image: the
white inside the body, the body's own grey left out."""
a, lum, near, _ = _parts(img)
out = Image.new('L', img.size)
for y in range(img.height):
for x in range(img.width):
white = min(1.0, max(0.0, (lum[y][x] - 0.25) / 0.6))
out.putpixel((x, y), 0 if near[y][x] else round(255 * a[y][x] * white))
return out
def _parts(img):
"""Alpha and luminance (0..1) per pixel, whether each pixel is white outside the body or its soft edge, and the
white parts (lists of (x, y))."""
w, h = img.size
px = img.load()
a = [[px[x, y][3] / 255 for x in range(w)] for y in range(h)]
lum = [[(0.299 * px[x, y][0] + 0.587 * px[x, y][1] + 0.114 * px[x, y][2]) / 255 for x in range(w)] for y in range(h)]
white = [[a[y][x] > 0.5 and lum[y][x] > 0.5 for x in range(w)] for y in range(h)]
# the background: transparent pixels reachable from the edge
bg = [[False] * w for _ in range(h)]
stack = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)]
while stack:
x, y = stack.pop()
if 0 <= x < w and 0 <= y < h and not bg[y][x] and a[y][x] <= 0.5:
bg[y][x] = True
stack += [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
# each white part: outside the body when a quarter of its edge touches the background. A stick's arrows touch it
# all round; a lit d-pad direction inside a thin dark outline touches it only where the outline thins at a rounded
# corner, and stays a hole.
outside = [[False] * w for _ in range(h)]
seen = [[False] * w for _ in range(h)]
parts = []
for sy in range(h):
for sx in range(w):
if not white[sy][sx] or seen[sy][sx]:
continue
part, stack, edge, touching = [], [(sx, sy)], 0, 0
seen[sy][sx] = True
while stack:
x, y = stack.pop()
part.append((x, y))
if not all(0 <= nx < w and 0 <= ny < h and white[ny][nx]
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))):
edge += 1
touching += any(bg[yy][xx] for yy in range(max(0, y - 1), min(h, y + 2))
for xx in range(max(0, x - 1), min(w, x + 2)))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and white[ny][nx] and not seen[ny][nx]:
seen[ny][nx] = True
stack.append((nx, ny))
parts.append(part)
if touching * 4 >= edge:
for x, y in part:
outside[y][x] = True
# the soft edge of an outside part (antialiased pixels next to it) counts with it
near = [[outside[y][x] or any(outside[yy][xx] for yy in range(max(0, y - 1), min(h, y + 2))
for xx in range(max(0, x - 1), min(w, x + 2))) for x in range(w)] for y in range(h)]
return a, lum, near, parts
def trace(field, level=0.5):
"""Closed outlines (lists of (x, y) in pixels, y down) where field crosses level, by marching squares with the
crossings placed linearly along the cell edges. Each outline has the covered side on its left once y points up
(as in a font): outer outlines run counter-clockwise and holes clockwise, as a CFF font wants them."""
h, w = len(field), len(field[0])
def v(x, y): # one pixel of zero around the field, so every outline closes
return field[y - 1][x - 1] if 1 <= x <= w and 1 <= y <= h else 0.0
def cross(p, q):
(x0, y0), (x1, y1) = p, q
a, b = v(x0, y0), v(x1, y1)
t = (level - a) / (b - a)
return (x0 + (x1 - x0) * t - 1, y0 + (y1 - y0) * t - 1) # back to field coordinates
starts = {} # edge -> (start point, end edge, end point) of the segment leaving the cell through it
for y in range(h + 1):
for x in range(w + 1):
corners = [(x, y), (x + 1, y), (x + 1, y + 1), (x, y + 1)] # clockwise with y down
inside = [v(*c) >= level for c in corners]
if all(inside) or not any(inside):
continue
# the crossings met going round the cell clockwise: (edge key, point, entering the covered part)
hits = []
for i in range(4):
p, q = corners[i], corners[(i + 1) % 4]
if inside[i] != inside[(i + 1) % 4]:
hits.append((tuple(sorted((p, q))), cross(p, q), inside[(i + 1) % 4]))
# each segment joins the crossing entering a covered part to the one leaving it, so that going round the
# cell clockwise (y down) the covered part lies between them
if len(hits) == 2:
enter, leave = (hits[0], hits[1]) if hits[0][2] else (hits[1], hits[0])
pairs = [(enter, leave)]
else: # a saddle: the middle decides whether the covered corners join
order = hits if hits[0][2] else hits[1:] + hits[:1] # enter, leave, enter, leave
e1, l1, e2, l2 = order
joined = (v(x, y) + v(x + 1, y) + v(x + 1, y + 1) + v(x, y + 1)) / 4 >= level
pairs = [(e2, l1), (e1, l2)] if joined else [(e1, l1), (e2, l2)]
for (sk, sp, _), (ek, ep, _) in pairs:
starts[sk] = (sp, ek, ep)
outlines = []
while starts:
key, (point, end, _) = next(iter(starts.items()))
loop = []
while key in starts:
point, end, _ = starts.pop(key)
loop.append(point)
key = end
if len(loop) >= 3:
outlines.append(loop)
return outlines
def simplify(loop, tolerance):
"""A closed outline with the points that stray less than tolerance from a straight line dropped (Douglas-Peucker on
the two halves between the first point and the one farthest from it)."""
def dp(points):
if len(points) < 3:
return points
(ax, ay), (bx, by) = points[0], points[-1]
dx, dy = bx - ax, by - ay
norm = (dx * dx + dy * dy) ** 0.5
far, at = -1.0, 0
for i in range(1, len(points) - 1):
px, py = points[i]
d = abs(dy * (px - ax) - dx * (py - ay)) / norm if norm else ((px - ax) ** 2 + (py - ay) ** 2) ** 0.5
if d > far:
far, at = d, i
if far <= tolerance:
return [points[0], points[-1]]
return dp(points[:at + 1])[:-1] + dp(points[at:])
x0, y0 = loop[0]
k = max(range(len(loop)), key=lambda i: (loop[i][0] - x0) ** 2 + (loop[i][1] - y0) ** 2)
out = dp(loop[:k + 1])[:-1] + dp(loop[k:] + loop[:1])[:-1]
return out if len(out) >= 3 else loop
def area(loop):
return sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(loop, loop[1:] + loop[:1])) / 2
def outlines_of(img, lit=False):
"""The traced, simplified outlines of a dark-style button picture, specks dropped (lit: as ink)."""
return [s for s in (simplify(o, 0.3) for o in trace(ink(img, lit))) if abs(area(s)) >= 1.5]
def bounds(outlines):
xs = [x for o in outlines for x, _ in o]
ys = [y for o in outlines for _, y in o]
return min(xs), min(ys), max(xs), max(ys)
# ---- the button font
def draw_font(otf, glyph, traced, lit_dpad):
"""The button font with the glyphs of GLYPHS drawn from glyph (Switch button -> picture). Every picture is scaled
as A's picture is to the game's A (both fill their square the same way), centred in its advance and placed at the
height of the art's own square, so the arrows of a stick reach out as they do in the art. A grid of keys is made
no taller than the game's A and centred on it. A wider picture widens its glyph. lit_dpad: the d-pad directions
are lit pad pictures (ink lit)."""
font = TTFont(io.BytesIO(otf))
glyphs, cmap, hmtx = font.getGlyphSet(), font.getBestCmap(), font['hmtx']
box = BoundsPen(glyphs)
glyphs[cmap[0xE040]].draw(box)
gx0, gy0, gx1, gy1 = box.bounds # the game's A: a round button
rx0, ry0, rx1, ry1 = bounds(traced(glyph['A']))
scale = (gy1 - gy0) / (ry1 - ry0)
mid_y_px, mid_y = (ry0 + ry1) / 2, (gy0 + gy1) / 2
side = gx0 # the game's A has this much room on either side
drawn = {}
for cp, button in GLYPHS.items():
pic = glyph[button]
outlines = traced(pic, lit_dpad and button.startswith('DPAD_'))
x0, y0, x1, y1 = bounds(outlines)
s, cy = scale, mid_y_px
if isinstance(pic, tuple) and pic[0] == 'grid':
s, cy = min(scale, (gy1 - gy0) / (y1 - y0)), (y0 + y1) / 2
advance = max(hmtx[cmap[cp]][0], round((x1 - x0) * s + 2 * side))
ox = advance / 2 - (x0 + x1) / 2 * s
def draw(pen, outlines=outlines, ox=ox, s=s, cy=cy):
for o in outlines:
pen.moveTo((ox + o[0][0] * s, mid_y - (o[0][1] - cy) * s))
for x, y in o[1:]:
pen.lineTo((ox + x * s, mid_y - (y - cy) * s))
pen.closePath()
drawn[cp] = (advance, draw)
return button_icons.redraw_font(otf, drawn)
# ---- BNTX textures: block-linear in the Switch 2 GOB layout (runtime/tex_deswizzle.c, nvn_bl_offset)
def bntx_textures(data):
"""name -> (format, width, height, block height (log2 GOBs), level offset, level size) of every one-level 2D
texture of a BNTX."""
assert data[:4] == b'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]
assert data[info:info + 4] == b'BRTI'
at = struct.unpack_from('<Q', data, info + 0x60)[0]
name = data[at + 2:at + 2 + struct.unpack_from('<H', data, at)[0]].decode()
mips = struct.unpack_from('<H', data, info + 0x16)[0]
fmt, (w, h), th = struct.unpack_from('<I', data, info + 0x1c)[0], struct.unpack_from('<II', data, info + 0x24), \
struct.unpack_from('<I', data, info + 0x34)[0]
start = struct.unpack_from('<Q', data, struct.unpack_from('<Q', data, info + 0x70)[0])[0]
out[name] = dict(fmt=fmt, w=w, h=h, th=th, mips=mips, start=start, size=struct.unpack_from('<I', data, info + 0x50)[0])
return out
def level_tiles(th, rows):
"""The block height of a level rows block-rows high (runtime/nvn_tex.c adj_tile): halved while it is too tall."""
x = 8 << (th - 1) if th else 0
while th and x >= rows:
th -= 1
x >>= 1
return th
def block_linear(row_bytes, rows, th):
"""Offset of each 16-byte piece (row, piece) of a level, in the Switch 2 GOB order."""
th = level_tiles(th, rows)
unit, block, gobs = 8 << th, 512 << th, (row_bytes + 63) // 64
return lambda x, y: ((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)
def read_level(data, t, row_bytes, rows):
at = block_linear(row_bytes, rows, t['th'])
out = bytearray(row_bytes * rows)
for y in range(rows):
for x in range(0, row_bytes, 16):
o = t['start'] + at(x, y)
out[y * row_bytes + x:y * row_bytes + x + 16] = data[o:o + 16]
return out
def write_level(data, t, lin, row_bytes, rows):
at = block_linear(row_bytes, rows, t['th'])
for y in range(rows):
for x in range(0, row_bytes, 16):
o = at(x, y)
assert o + 16 <= t['size'], 'past the end of the texture'
data[t['start'] + o:t['start'] + o + 16] = lin[y * row_bytes + x:y * row_bytes + x + 16]
BC4_UNORM, ASTC_4X4_SRGB = 0x1d01, 0x2d06
def bc4_decode(lin, w, h):
out = Image.new('L', (w, h))
wb = (w + 3) // 4
for by in range((h + 3) // 4):
for bx in range(wb):
b = lin[(by * wb + bx) * 8:(by * wb + bx) * 8 + 8]
pal = bc4_palette(b[0], b[1])
bits = int.from_bytes(b[2:8], 'little')
for i in range(16):
x, y = bx * 4 + i % 4, by * 4 + i // 4
if x < w and y < h:
out.putpixel((x, y), pal[bits >> (3 * i) & 7])
return out
def bc4_palette(a, b):
if a > b:
return [a, b] + [((6 - i) * a + (i + 1) * b) // 7 for i in range(6)]
return [a, b] + [((4 - i) * a + (i + 1) * b) // 5 for i in range(4)] + [0, 255]
def bc4_encode(img):
"""BC4 blocks of an L image: each block's lowest and highest value as the ends, every pixel the nearest of the
eight levels between them."""
w, h = img.size
px = img.load()
out = bytearray()
for by in range((h + 3) // 4):
for bx in range((w + 3) // 4):
vals = [px[min(w - 1, bx * 4 + i % 4), min(h - 1, by * 4 + i // 4)] for i in range(16)]
hi, lo = max(vals), min(vals)
if hi == lo:
out += bytes([hi, lo]) + bytes(6)
continue
pal = bc4_palette(hi, lo)
bits = 0
for i, v in enumerate(vals):
bits |= min(range(8), key=lambda k: abs(pal[k] - v)) << (3 * i)
out += bytes([hi, lo]) + bits.to_bytes(6, 'little')
return bytes(out)
# ASTC 4x4, one partition, endpoint mode 12 (RGBA, direct), 8-bit endpoints and a 4x4 grid of 2-bit weights: 11 bits
# of block mode (0x042: weights 0..3, grid 4x4), 2 of partition count, 4 of endpoint mode, then the eight endpoint
# bytes from bit 17 (r0 r1 g0 g1 b0 b1 a0 a1) and the 32 weight bits from bit 127 down. The 79 bits left after the
# mode and the weights hold eight values of 0..255 exactly, so the decoder reads the endpoints as plain bytes.
ASTC_WEIGHTS = (0, 21, 43, 64)
def astc_block(texels):
"""One 128-bit block of 16 RGBA texels: the two ends of the colours' main direction, every texel its nearest of
the four steps between them."""
n = len(texels)
mean = [sum(t[c] for t in texels) / n for c in range(4)]
cov = [[sum((t[i] - mean[i]) * (t[j] - mean[j]) for t in texels) for j in range(4)] for i in range(4)]
axis = [1.0, 1.0, 1.0, 1.0]
for _ in range(12):
nxt = [sum(cov[i][j] * axis[j] for j in range(4)) for i in range(4)]
norm = sum(v * v for v in nxt) ** 0.5
if norm < 1e-9:
break
axis = [v / norm for v in nxt]
proj = [sum((t[c] - mean[c]) * axis[c] for c in range(4)) for t in texels]
lo, hi = min(proj), max(proj)
e0 = [max(0, min(255, round(mean[c] + lo * axis[c]))) for c in range(4)]
e1 = [max(0, min(255, round(mean[c] + hi * axis[c]))) for c in range(4)]
if sum(e1[:3]) < sum(e0[:3]): # the decoder would read the ends the other way (blue contraction)
e0, e1 = e1, e0
span = [e1[c] - e0[c] for c in range(4)]
length = sum(v * v for v in span)
weights = []
for t in texels:
f = sum((t[c] - e0[c]) * span[c] for c in range(4)) / length if length else 0.0
weights.append(min(range(4), key=lambda k: abs(ASTC_WEIGHTS[k] / 64 - f)))
bits = 0x042 | (12 << 13)
for i, v in enumerate((e0[0], e1[0], e0[1], e1[1], e0[2], e1[2], e0[3], e1[3])):
bits |= v << (17 + 8 * i)
for i, wt in enumerate(weights):
for b in range(2):
if wt >> b & 1:
bits |= 1 << (127 - (2 * i + b))
return bits.to_bytes(16, 'little')
def astc_encode(img):
w, h = img.size
px = img.load()
out = bytearray()
for by in range((h + 3) // 4):
for bx in range((w + 3) // 4):
out += astc_block([px[min(w - 1, bx * 4 + i % 4), min(h - 1, by * 4 + i // 4)] for i in range(16)])
return bytes(out)
def fit(img, box, size):
"""img's opaque part scaled to fit box (x0, y0, x1, y1) and centred in it, on a transparent canvas of size;
resampled with premultiplied alpha, so the edges keep their colour."""
crop = img.crop(img.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox())
bw, bh = box[2] - box[0], box[3] - box[1]
k = min(bw / crop.width, bh / crop.height)
scaled = crop.convert('RGBa').resize((max(1, round(crop.width * k)), max(1, round(crop.height * k))), Image.LANCZOS)
canvas = Image.new('RGBa', size, (0, 0, 0, 0))
canvas.paste(scaled, (round(box[0] + (bw - scaled.width) / 2), round(box[1] + (bh - scaled.height) / 2)))
return canvas.convert('RGBA')
def bleed(img):
"""The colour of the nearest covered pixel spread under transparent ones, so a compressed block that holds both
ends in the button's own colour rather than in black."""
w, h = img.size
px = img.load()
known = {(x, y) for y in range(h) for x in range(w) if px[x, y][3] > 8}
for _ in range(6):
grown = {}
for y in range(h):
for x in range(w):
if (x, y) in known:
continue
near = [px[x + dx, y + dy] for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
if 0 <= x + dx < w and 0 <= y + dy < h and (x + dx, y + dy) in known]
if near:
grown[x, y] = tuple(sum(p[c] for p in near) // len(near) for c in range(3)) + (px[x, y][3],)
for xy, p in grown.items():
px[xy] = p
known |= set(grown)
return img
def mask_of(img, rel):
"""The action prompt's mask of a dark picture: the white letter or symbol inside it, or for a mouse, whose white is
the pressed button rather than a label, the mouse itself with that button cut out."""
if '/T_Mouse_' not in str(rel):
return symbol(img)
field = ink(img)
out = Image.new('L', img.size)
out.putdata([round(255 * min(1.0, v)) for row in field for v in row])
return out
def note_texture(art, rel, box):
pic = fit(load_art(art, rel), box, (93, 93))
return astc_encode(bleed(pic))
def draw_textures(data, glyph, note, art, cache):
"""Paints the action prompt's letters (from glyph) and the ocarina's note buttons (from note; both Switch button ->
picture) into the BNTX data (a bytearray) in place. A new letter takes the height and centre of the game's own
letter in that mask (a wider one may use a little more width); a note button fills NOTE_BOXES."""
textures = bntx_textures(data)
for name, button in {**MASKS, **NOTES}.items():
assert name in textures, f'no {name} in {TEXTURES}'
t = textures[name]
assert t['mips'] == 1, f'{name}: {t["mips"]} levels'
wb, hb = (t['w'] + 3) // 4, (t['h'] + 3) // 4
if name in MASKS:
assert t['fmt'] == BC4_UNORM, f'{name}: format {t["fmt"]:#x}'
old = bc4_decode(read_level(data, t, wb * 8, hb), t['w'], t['h'])
box = old.point(lambda v: 255 if v > 64 else 0).getbbox()
# the old letter's height; a wider new symbol may use a little more width
cx, cy, half = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2, (box[3] - box[1]) / 2
rel = glyph[button]
sym = cache(('mask', rel), lambda: mask_of(load_art(art, rel), rel))
rgba = Image.merge('RGBA', (sym.point(lambda v: 255),) * 3 + (sym,))
mask = fit(rgba, (cx - half * 1.15, cy - half, cx + half * 1.15, cy + half), (t['w'], t['h']))
write_level(data, t, bc4_encode(mask.getchannel('A')), wb * 8, hb)
else:
assert t['fmt'] == ASTC_4X4_SRGB and (t['w'], t['h']) == (93, 93), f'{name}: format {t["fmt"]:#x}, ' \
f'{t["w"]}x{t["h"]}'
box = NOTE_BOXES['shoulder' if button in 'LR' else 'disc']
rel = note[button]
encoded = cache(('note', rel, box), lambda: note_texture(art, rel, box))
write_level(data, t, encoded, wb * 16, hb)
# Where the game's own note buttons are opaque in their 93x93 textures (1.1.1), which the new ones fill: the face
# buttons a disc, L and R a wider shape. Measured once with the runtime's ASTC decoder (tools/ui_textures.py), so this
# tool needs none.
NOTE_BOXES = {'disc': (13, 13, 80, 80), 'shoulder': (8, 15, 84, 76)}
# ---- the overlay folder
def write_overlay(out, outputs, originals):
"""outputs: RomFS path -> new file bytes; originals: RomFS path -> the original's bytes. Writes each file under out
and the manifest runtime/romfs.c overlay_load checks: path, size, SHA-256, the original's SHA-256."""
lines = []
for path, data in outputs.items():
target = out / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
lines.append('\t'.join([path, str(len(data)), hashlib.sha256(data).hexdigest(),
hashlib.sha256(originals[path]).hexdigest()]))
(out / 'manifest.tsv').write_text('# path\tsize\tsha256\toriginal_sha256 (tools/pad_prompts.py)\n'
+ ''.join(line + '\n' for line in lines))
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--art', type=Path, required=True,
help='the art pack\'s Source folder (XGamepad, P5Gamepad, Keyboard_Mouse)')
ap.add_argument('--game', type=Path, help='the folder of the exe and romfs-index.bin; the overlays go there')
ap.add_argument('--romfs', type=Path, help='default <game>/romfs')
ap.add_argument('--style', choices=sorted(STYLES) + ['pc'], help='default: every one')
ap.add_argument('--layout', choices=sorted(FACE), help='default: both (pc has one)')
ap.add_argument('--binds', type=Path, help='pc: a nx2navi.ini whose bind_ lines the keys follow '
'(default <game>/nx2navi.ini when there is one)')
ap.add_argument('--out', type=Path, help='the overlay folder, for one --style and --layout')
ap.add_argument('--level', type=int, default=9, help='zstd level of the written files')
args = ap.parse_args()
if not args.game and not (args.out and args.romfs):
ap.error('--game, or --out with --romfs')
if args.out and not (args.style and (args.layout or args.style == 'pc')):
ap.error('--out writes one folder: give --style and --layout')
binds = args.binds or (args.game / 'nx2navi.ini' if args.game and (args.game / 'nx2navi.ini').is_file() else None)
keys = pc_keys(binds.read_text(encoding='utf-8', errors='replace') if binds else None)
romfs = Romfs(args.romfs or args.game / 'romfs')
restbl = max((p for p in romfs.paths() if p.startswith('System/Resource/ResourceSizeTable.') and p.endswith('.zs')),
key=lambda p: int(p.split('.')[2]) if p.split('.')[2].isdigit() else 0, default=None)
assert restbl, 'no resource size table in the RomFS'
originals = {p: romfs.raw(p) for p in (FONT, TEXTURES, restbl)}
font_blob, font_dict = romfs.load(FONT)
otf, key = button_icons.bfotf_decode(font_blob)
table, table_dict = romfs.load(restbl)
textures, textures_dict = romfs.load(TEXTURES)
memo = {}
def cache(k, make):
if k not in memo:
memo[k] = make()
return memo[k]
def traced(rel, lit=False):
return cache(('outlines', rel, lit), lambda: outlines_of(load_art(args.art, rel), lit))
report = []
for style in [args.style] if args.style else sorted(STYLES) + ['pc']:
# the keyboard follows no pad layout: one folder, romfs-patches-pc
for layout in [None] if style == 'pc' else [args.layout] if args.layout else sorted(FACE):
out = args.out or args.game / overlay_folder(style, layout)
glyph, note = pc_pictures(args.art, keys) if style == 'pc' else pad_pictures(style, layout)
new_otf = draw_font(otf, glyph, traced, style != 'pc')
blob = button_icons.bfotf_encode(new_otf, key, font_blob)
data = bytearray(textures)
draw_textures(data, glyph, note, args.art, cache)
assert len(data) == len(textures)
outputs = {FONT: compress(blob, font_dict, 19), TEXTURES: compress(bytes(data), textures_dict, args.level)}
if len(blob) > len(font_blob):
outputs[restbl] = compress(button_icons.grow_restbl(table, {FONT: len(blob) - len(font_blob)}),
table_dict, 19)
write_overlay(out, outputs, originals)
report.append(dict(folder=str(out), style=style, layout=layout, font_bytes=len(blob),
font_grew=len(blob) - len(font_blob), files=sorted(outputs)))
print(f'{out}: {len(outputs)} files, the font grew by {len(blob) - len(font_blob)} bytes', flush=True)
print(json.dumps(dict(status='pass', folders=report)))
if __name__ == '__main__':
main()