Files
NX2navi/launcher/gamefiles.py
Claude c403547c8b Add the NX2navi launcher: a one-click setup that builds the game from the player's dump, starts it and keeps it up to date.
NX2navi-Setup.exe installs the launcher into %LOCALAPPDATA%\Programs\NX2navi without administrator rights, with
shortcuts and an Apps entry. The launcher finds the player's .nup, downloads the build tools itself (the MSYS2 UCRT64
packages toolchain.lock.json pins, checked by sha256 and unpacked without pacman, and zbic from PyPI) and runs
tools/make_game.py, whose steps it shows with their progress. Its window follows the game's options menu: colours
sampled from it, the menu's filigree, the white glowing cursor, the Triforce in the title band and as the programs'
icon, and all text in the game's Cetra Display, decoded from the player's own dump. Mouse, keyboard and an XInput
pad drive it.

Updates come from GitHub releases (NX2navi-update.json): the new version is downloaded, checked and staged beside
the running one, the stub restarts into it and falls back to the previous version when it fails to start, and the
game is rebuilt without the dump. make_game.py now keys its exefs and romfs steps on the game files alone, has
--rebuild and --events, compiles only the files whose source, headers, flags or compiler changed, and replaces
ocarina.exe only once the new one links. tools/make_launcher.py builds the release on Windows or Linux.

Tested: tests/launcher_test.py (19 tests), the real toolchain download and install, and under Wine 11 the setup,
the stub, the window on the bundled Windows Python, an update from a local feed by clicking Updates, the rollback
of a version that fails to start, and the downloaded clang compiling and linking a program against the game's
libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Merged onto the current port: make_game.py keeps the launcher's step keys, cached link and in-place swap, and its
own runtime manifest (windres), AMD's FidelityFX library and the shader preparation, which becomes a step of its own
("shaders") that the launcher shows in its install, rebuild and update jobs. make_launcher.py ships the shader tools
and, with --ffx, AMD's library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:50:52 -05:00

323 lines
11 KiB
Python

"""The player's game files, as far as the launcher reads them itself: finding a .nup dump, and the game's Cetra
Display font for the window (runtime/menu_assets.c reads the same font for the in-game menu's titles).
The font comes from the built game's romfs/ (romfs.bin and index.json, tools/make_game.py) or, before the first
build, straight from the .nup. They are decoded once into data/cache/fonts, which stays on the player's PC like the
rest of what is built from their game. Nothing of the game ships with the launcher.
- .zs files are zstd frames, most with one of the dictionaries of Pack/ZsDic.pack.zs (a SARC, compressed without a
dictionary; a frame names its dictionary by id).
- A .bfotf font is an OpenType file behind an 8-byte header, every big-endian 32-bit word XORed with one key: the
first word is 0x7f9a0218 under it, the second the payload size.
"""
import json
import os
import struct
import sys
from pathlib import Path
TITLE_ID = 0x0400486024DEC000
FONTS = { # role -> RomFS path; the window sets all its text in Cetra Display
'title': 'Font2/Scalable/CetraDisplayPro-Medium.Nin_Ounce_NVN2.bfotf.zs',
}
DICTS = 'Pack/ZsDic.pack.zs'
DICT_NAMES = ('zs.zsdic', 'pack.zsdic', 'bcett.byml.zsdic', 'bkprb.zsdic')
MAX_RAW = 64 << 20
def _nup_module(layout):
if str(layout.tools) not in sys.path:
sys.path.insert(0, str(layout.tools))
import nup
return nup
# ---------------------------------------------------------------- finding a dump
def candidate_dirs(extra=()):
home = Path.home()
dirs = [Path(d) for d in extra if d]
dirs += [home / 'Downloads', home / 'Desktop', home / 'Documents', home]
if os.name == 'nt':
import string
dirs += [Path(f'{c}:\\') for c in string.ascii_uppercase[2:] if os.path.isdir(f'{c}:\\')]
seen, out = set(), []
for d in dirs:
key = os.path.normcase(str(d))
if key not in seen and d.is_dir():
seen.add(key)
out.append(d)
return out
def is_game_nup(layout, path):
"""True when the file is a .nup holding Ocarina of Time (title 0400486024DEC000 or its update)."""
try:
n = _nup_module(layout).Nup(path)
except Exception:
return False
return any(p['title_id'] in (TITLE_ID, TITLE_ID | 0x800) for p in n.packages)
def find_dumps(layout, extra=(), depth=1):
""".nup files with the game in the usual places (the setup's folder first), newest first."""
found = []
for d in candidate_dirs(extra):
pattern_dirs = [d]
if depth > 0:
try:
pattern_dirs += [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(('.', '$'))][:200]
except OSError:
pass
for pd in pattern_dirs:
try:
for f in pd.glob('*.nup'):
if f.is_file() and is_game_nup(layout, f):
found.append(f)
except OSError:
continue
uniq = {os.path.normcase(str(f)): f for f in found}
return sorted(uniq.values(), key=lambda f: f.stat().st_mtime, reverse=True)
def is_game_folder(path):
p = Path(path)
return (p / 'exefs' / 'main').is_file() and (p / 'romfs').is_dir()
# ---------------------------------------------------------------- reading RomFS files
class RomfsImage:
"""The built game's romfs/romfs.bin through its index.json."""
def __init__(self, folder):
folder = Path(folder)
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
self.files = {e['path']: (e['offset'], e['size']) for e in index['files']}
self.bin = folder / 'romfs.bin'
def read(self, rel):
if rel not in self.files:
return None
off, size = self.files[rel]
with open(self.bin, 'rb') as f:
f.seek(off)
return f.read(size)
class NupRomfs:
"""The RomFS of the game's update (or base) package inside a .nup."""
def __init__(self, layout, path):
nup = _nup_module(layout)
self.n = nup.Nup(path)
pkgs = [p for p in self.n.packages if p['title_id'] in (TITLE_ID, TITLE_ID | 0x800)]
pkgs.sort(key=lambda p: (p['kind'] != 1, -p['version']))
self.trees = []
for p in pkgs:
if p['romfs'] < self.n.ndir:
self.trees.append(dict(self.n.tree(p['romfs'])))
def read(self, rel):
for t in self.trees:
if rel in t:
return self.n.read(t[rel])
return None
def open_romfs(layout, game_dir='', dump=''):
"""A RomFS reader for the built game, else the dump, else None."""
if game_dir and (Path(game_dir) / 'romfs' / 'index.json').is_file():
try:
return RomfsImage(Path(game_dir) / 'romfs')
except (OSError, ValueError, KeyError):
pass
if dump and Path(dump).is_file():
try:
return NupRomfs(layout, dump)
except Exception:
pass
if dump and is_game_folder(dump):
return FolderRomfs(Path(dump) / 'romfs')
return None
class FolderRomfs:
def __init__(self, folder):
self.folder = Path(folder)
def read(self, rel):
p = self.folder / rel
return p.read_bytes() if p.is_file() else None
# ---------------------------------------------------------------- zstd with the game's dictionaries
def _zstd():
try:
from compression import zstd # Python 3.14
return 'stdlib', zstd
except ImportError:
import zstandard
return 'zstandard', zstandard
def _frame_dict_id(data):
kind, z = _zstd()
if kind == 'stdlib':
return z.get_frame_info(data).dictionary_id
return z.get_frame_parameters(data).dict_id
def _decompress(data, dict_bytes=None):
kind, z = _zstd()
if kind == 'stdlib':
d = z.ZstdDict(dict_bytes) if dict_bytes else None
return z.decompress(data, zstd_dict=d)
dctx = z.ZstdDecompressor(dict_data=z.ZstdCompressionDict(dict_bytes) if dict_bytes else None)
return dctx.decompressobj().decompress(data)
def _dict_id(dict_bytes):
"""The id a zstd dictionary carries (magic 0xEC30A437, then the id)."""
if len(dict_bytes) >= 8 and struct.unpack_from('<I', dict_bytes, 0)[0] == 0xEC30A437:
return struct.unpack_from('<I', dict_bytes, 4)[0]
return 0
def sarc_members(data):
"""{name: bytes} of a SARC archive."""
if len(data) < 0x14 or data[:4] != b'SARC':
return {}
hl = struct.unpack_from('<H', data, 4)[0]
base = struct.unpack_from('<I', data, 0xC)[0]
if data[hl:hl + 4] != b'SFAT':
return {}
count = struct.unpack_from('<H', data, hl + 6)[0]
ents = hl + 0xC
names = ents + 16 * count + 8
if data[ents + 16 * count:ents + 16 * count + 4] != b'SFNT':
return {}
out = {}
for i in range(count):
_h, attr, start, end = struct.unpack_from('<IIII', data, ents + 16 * i)
no = names + (attr & 0xFFFF) * 4
name = data[no:data.index(b'\0', no)].decode('utf-8', 'replace')
if start <= end and base + end <= len(data):
out[name] = data[base + start:base + end]
return out
class ZsReader:
def __init__(self, romfs):
self.romfs = romfs
self._dicts = None
def dicts(self):
if self._dicts is None:
self._dicts = {}
raw = self.romfs.read(DICTS)
if raw:
members = sarc_members(_decompress(raw))
for name in DICT_NAMES:
if name in members:
self._dicts[_dict_id(members[name])] = members[name]
return self._dicts
def read(self, rel):
data = self.romfs.read(rel)
if data is None or not rel.endswith('.zs'):
return data
did = _frame_dict_id(data)
if did:
d = self.dicts().get(did)
if d is None:
return None
return _decompress(data, d)
return _decompress(data)
# ---------------------------------------------------------------- fonts
def bfotf_to_otf(b):
"""The OpenType (or TrueType) bytes inside a .bfotf/.bfttf, or None."""
if not b or len(b) < 12:
return None
key = struct.unpack_from('>I', b, 0)[0] ^ 0x7F9A0218
size = struct.unpack_from('>I', b, 4)[0] ^ key
if size < 12 or size > len(b) - 8 or size > MAX_RAW:
return None
body = b[8:8 + size] + b'\0' * (-size % 4)
words = struct.unpack(f'>{len(body) // 4}I', body)
out = struct.pack(f'>{len(words)}I', *(w ^ key for w in words))[:size]
if out[:4] != b'OTTO' and out[:4] != b'\x00\x01\x00\x00':
return None
return out
def font_family(otf):
"""The family name in an OpenType 'name' table (name id 16, else 1), or None."""
try:
num = struct.unpack_from('>H', otf, 4)[0]
for i in range(num):
tag, _cs, off, _len = struct.unpack_from('>4sIII', otf, 12 + 16 * i)
if tag != b'name':
continue
_fmt, count, strings = struct.unpack_from('>HHH', otf, off)
found = {}
for k in range(count):
pid, eid, lid, nid, ln, so = struct.unpack_from('>HHHHHH', otf, off + 6 + 12 * k)
if nid not in (1, 16):
continue
raw = otf[off + strings + so:off + strings + so + ln]
if pid == 3 or pid == 0:
name = raw.decode('utf-16-be', 'replace')
elif pid == 1:
name = raw.decode('mac_roman', 'replace')
else:
continue
rank = (nid == 16, pid == 3 and lid == 0x409)
if nid not in found or rank > found[nid][0]:
found[nid] = (rank, name)
for nid in (16, 1):
if nid in found:
return found[nid][1]
except (struct.error, IndexError):
return None
return None
def cached_fonts(layout):
"""{role: (path, family)} of the fonts decoded earlier."""
d = layout.cache / 'fonts'
out = {}
for role in FONTS:
p = d / f'{role}.otf'
meta = d / f'{role}.family'
if p.is_file() and meta.is_file():
out[role] = (p, meta.read_text(encoding='utf-8').strip())
return out
def extract_fonts(layout, game_dir='', dump=''):
"""Decode the game's Cetra Display into data/cache/fonts; {role: (path, family)} of the fonts found."""
romfs = open_romfs(layout, game_dir, dump)
if romfs is None:
return {}
zs = ZsReader(romfs)
d = layout.cache / 'fonts'
d.mkdir(parents=True, exist_ok=True)
out = {}
for role, rel in FONTS.items():
try:
otf = bfotf_to_otf(zs.read(rel))
except Exception:
otf = None
fam = font_family(otf) if otf else None
if not otf or not fam:
continue
(d / f'{role}.otf').write_bytes(otf)
(d / f'{role}.family').write_text(fam, encoding='utf-8')
out[role] = (d / f'{role}.otf', fam)
return out