Files
NX2navi/tools/make_game.py
chrg6 8e30ad7927 Carry the button art in releases and draw the button prompts during the game build.
make_launcher.py and make_kit.py take --prompt-art (folders holding XGamepad, P5Gamepad and Keyboard_Mouse, or those
folders) and copy only the pictures pad_prompts.py reads into the release's prompt-art folder; tools/prompt_art.py
holds the art tables and that list, with no imaging packages. make_game.py's new last step, prompts, runs
pad_prompts.py on that art into the game folder's romfs-patches-* folders. Its key names everything it reads (the
RomFS, the art, the tools, the bind_ lines of nx2navi.ini, the folders being there), so it runs again only when one of
those changed, and it makes no other step run. A kit without art skips it. The launcher's Python gets fonttools and
pillow, and its jobs show a Draw the Button Prompts row when the version has art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:09:26 -05:00

1363 lines
67 KiB
Python

"""Build Ocarina of Time (Switch 2, game 1.1.1) for Windows on the player's PC, from the player's own game files.
python -I tools/make_game.py --nup "C:\\path\\game.nup" --out "C:\\Games\\Ocarina"
python -I tools/make_game.py --exefs "C:\\path\\exefs" --romfs "C:\\path\\romfs" --out "C:\\Games\\Ocarina"
python -I tools/make_game.py --nup "C:\\path\\game.nup" --check # only checks the game's version
python -I tools/make_game.py --rebuild --out "C:\\Games\\Ocarina" # a newer kit, the files of the last build
Steps, each skipped when a run before finished it for the same inputs (state in <out>/build-cache/state.json):
1. check the title (0400486024DEC000) and main's build id (game 1.1.1)
2. exefs the five ExeFS files, every module's segments unpacked (ZBIC through the player's own `zbic` package)
and stored plainly, as the runtime's loader reads them -> <out>/exefs
3. romfs every RomFS file, checked against the container's sha256, into one image -> <out>/romfs/romfs.bin and
index.json (16-byte aligned, in the byte order of the paths). When the lift and the compile follow, it
runs on a thread of its own beside them: only the RomFS index and romfs.c wait for the image
4. lift recomp/gen.py with recomp/patches-v111.txt -> <out>/build-cache/lift, plus the two SDK bring-up fixes
5. compile the lifted C and runtime/ with MSYS2's clang, linked into <out>/ocarina.exe; the MSYS2 DLLs it loads,
the shader translator and romfs-index.bin beside it
6. shaders every shader in the game files (tools/prepare_shaders.py, default bind state) translated, and each one's
GPU driver build done once into the driver's own cache, so play stops for them far less often
(`ocarina.exe --prepare-shaders`); variants met in play with another bind state are still translated
then, and the driver cache is per executable and is lost when the driver clears it
7. prompts only with button art in the kit (<kit>/prompt-art, tools/make_launcher.py and make_kit.py
--prompt-art): tools/pad_prompts.py draws the Xbox, PlayStation and keyboard and mouse button prompts
from it into <out>/romfs-patches-* (the Controls menu's Button prompts loads them), the keys as the
bind_ lines of <out>/nx2navi.ini give them. Its key names everything it reads (the RomFS, the art, the
tools, those lines), so it runs again only when one of them changed or a folder is gone, and running
it makes no other step run again. It is last: a failure here leaves the game and its shaders complete.
Without art the step is skipped and the game shows its own prompts, as before
The exefs and romfs steps depend only on the game files; lift and compile depend on this kit's code too. A newer kit
therefore lifts and compiles again but keeps the unpacked game files, and --rebuild needs no game files at all. The
compile step keeps every object with the dependency list clang wrote for it (build-cache/obj/objects.json) and
compiles a file again only when it, a header it includes, the flags or the compiler changed; the kit's own folder
is not part of that key, so a kit in another folder (the launcher keeps each version in its own) reuses the objects.
A kit with lib/libnx2navirt.a built by --make-runtime-lib for the same runtime/ and the same compiler links the
runtime from it and compiles only the runtime files that include headers written per install. The new ocarina.exe
replaces the old one only once it links (with lld when the toolchain has it), so a failed build leaves the last good
one in place. Compiles run on every logical processor, the largest files first. The lifted code is built for this
PC's x86-64 level (cpu_level), with MSYS2 CLANG64's clang when it sits beside the UCRT64 folder and reports the same
version (fast_clang: the same objects, about a fifth faster).
python tools/make_game.py --make-runtime-lib <kit>/lib --msys <ucrt64> # the release's prebuilt runtime
Nothing is written outside --out (but for --make-runtime-lib DIR, which writes DIR). The game folder then holds
only what this PC built from the player's files: never
share it. Needs Python 3.10+ with numpy, lz4, zstandard and zbic (pip), with fonttools and pillow too when the kit has
button art, and MSYS2 UCRT64's clang and libraries (README-BuildKit.md lists the commands). Delete <out>/build-cache
afterwards to free about 5 GB; the next build then compiles everything again.
--events adds one machine-readable line per step and progress change to stdout, each starting with "@@" and followed
by a JSON object (launcher/build.py reads them): {"ev": "step", "step": ..., "state": "start" | "done" | "skip"},
{"ev": "progress", "step": ..., "done": n, "total": n, "unit": ...}, {"ev": "phase", ...}, {"ev": "error", "message":
...} and {"ev": "end", ...}.
"""
import argparse
import concurrent.futures
import hashlib
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
KIT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(KIT / 'tools'))
import nup # noqa: E402
import prompt_art # noqa: E402
TITLE_ID = 0x0400486024DEC000
MAIN_BUILD_ID = 'e0f3ccfaf50065ae4ea01eb5aeb13b1a' # main of game 1.1.1
EXEFS_FILES = ('main', 'main.npdm', 'sdk', 'subsdk0', 'rtld')
MODULES = ('main', 'sdk', 'subsdk0', 'rtld')
ALIGN = 16
STATE_VERSION = 2
STEPS = ('exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts')
# Steps whose key names every input they read rather than the step before them: a step before them that runs does
# not make them run again, and they make no step after them run again.
OWN_KEY_STEPS = ('prompts',)
# The Python packages tools/pad_prompts.py imports (pip name: import name), checked before a build with button art.
PROMPT_PACKAGES = {'zstandard': 'zstandard', 'fonttools': 'fontTools', 'pillow': 'PIL'}
# The line tools/pad_prompts.py prints for each overlay folder it wrote.
PROMPTS_WRITTEN = re.compile(r'^.+: \d+ files, the font grew by -?\d+ bytes$')
# Bring-up fixes in the lifted SDK, each anchored on exact generated text that must occur once.
LIFT_FIXES = [
# nn::oe::SetupGpuErrorHandler (sdk+0x59a430) registers a system worker the host does not provide: GPU error
# reporting is not needed, so it returns at once.
('sdk', 'void f_700579a430(Cpu *c) {\n FN_ENTER(0x700579a430ull);\n',
'void f_700579a430(Cpu *c) {\n c->x[0] = 0; return; /* bring-up: GpuErrorHandler (make_game.py) */\n'
' FN_ENTER(0x700579a430ull);\n'),
# The thread-start path loads TPIDR_EL0 from TLR+0x1F0 before the SDK stored its TLS there for a host-created
# thread: a zero slot keeps the TLS pointer the host seeded.
('sdk', ' { uint64_t ea = c->x[19] + 0x1b8ull; ST64(ea, x8); }\n x8 = (uint64_t)(c->tpidrro);\n'
' { uint64_t ea = x8 + 0x1f0ull; uint64_t v = LD64(ea); x8 = (uint64_t)(v); }\n c->tpidr = x8;\n',
' { uint64_t ea = c->x[19] + 0x1b8ull; ST64(ea, x8); }\n x8 = (uint64_t)(c->tpidrro);\n'
' { uint64_t ea = x8 + 0x1f0ull; uint64_t v = LD64(ea); x8 = (uint64_t)(v); }\n'
' if (x8) c->tpidr = x8; /* zero slot: keep the host\'s TLS (make_game.py) */\n'),
]
LINK_LIBS = ['-lvulkan-1', '-lSDL3', '-lopus', '-lvpx', '-l:libdav1d.a', '-lzstd', '-l:libatomic.a', '-lbcrypt',
'-limm32', '-luuid', '-lole32', '-luser32', '-lshell32', '-lgdi32', '-ladvapi32', '-lwinmm']
SYSTEM_DLLS_SKIP = {'vulkan-1.dll'} # the graphics driver's loader, never a copy
EXE_NAME = 'ocarina.exe'
# Headers this build writes per install (gen.py, romfs_index.py): a runtime source that includes one, directly or
# through a runtime header, is compiled on the player's PC; every other runtime source can come prebuilt.
GENERATED_HEADERS = ('layout.h', 'patches.h', 'romfs_config.h')
RUNTIME_LIB = 'libnx2navirt.a'
RUNTIME_PATTERNS = ('*.c', '*.cpp', 'platform/*.c', 'ui/*.cpp', 'third_party/imgui/*.cpp',
'third_party/imgui/backends/*.cpp')
INCLUDE = re.compile(r'^[ \t]*#[ \t]*include[ \t]+"([^"]+)"', re.M)
# Runs a script the way `python script.py` does, with the script's folder first on sys.path. The launcher's
# embeddable Python leaves the script folder off sys.path (its ._pth file), so every script this build starts goes
# through this.
RUN_SCRIPT = ('import os, runpy, sys; s = sys.argv[1]; sys.argv = sys.argv[1:]; '
'sys.path.insert(0, os.path.dirname(os.path.abspath(s))); runpy.run_path(s, run_name="__main__")')
EVENTS = False
OUT_LOCK = threading.Lock() # the RomFS step prints from its own thread (main)
def say(msg):
with OUT_LOCK:
print(msg, flush=True)
def emit(ev, **fields):
"""One "@@{json}" line for the launcher (--events); nothing otherwise."""
if EVENTS:
line = '@@' + json.dumps({'ev': ev, **fields}, ensure_ascii=False)
with OUT_LOCK:
print(line, flush=True)
def fail(msg):
emit('error', message=msg)
with OUT_LOCK:
print(f'\nerror: {msg}', file=sys.stderr, flush=True)
sys.exit(1)
class Background:
"""One step on a thread of its own. join() waits for it and raises its failure (fail's SystemExit included) in
the caller; failed() tells the caller early, so it can stop work that would be thrown away."""
def __init__(self, fn):
self.exc = None
self.thread = threading.Thread(target=self._run, args=(fn,), name='romfs', daemon=True)
self.thread.start()
def _run(self, fn):
try:
fn()
except BaseException as e: # noqa: B036 - handed to join()
self.exc = e
def failed(self):
return self.exc is not None
def join(self):
self.thread.join()
if self.exc is not None:
raise self.exc
def py_cmd(script, *args):
"""The command line that runs a Python script of this kit with this interpreter (see RUN_SCRIPT)."""
flags = ['-I'] if sys.flags.isolated else []
return [sys.executable, *flags, '-c', RUN_SCRIPT, str(script), *map(str, args)]
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def sha256_file(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
while b := f.read(8 << 20):
h.update(b)
return h.hexdigest()
def tree_digest(root, patterns):
"""sha256 over the relative names and contents of the files under root matching the glob patterns."""
h = hashlib.sha256()
files = sorted({p for pat in patterns for p in Path(root).glob(pat) if p.is_file() and '__pycache__' not in p.parts})
for p in files:
h.update(p.relative_to(root).as_posix().encode() + b'\0')
h.update(p.read_bytes())
h.update(b'\0')
return h.hexdigest()
# ---------------------------------------------------------------- input
class Source:
"""The game files, from a .nup container or from extracted exefs/romfs folders."""
def __init__(self, a):
self.n = None
if a.nup:
try:
self.n = nup.Nup(a.nup)
except (OSError, nup.NupError) as e:
fail(f'cannot read {a.nup}: {e}')
# The base title and its update (title id + 0x800), the update first and the newest of each kind first.
# A module comes from the first of them that holds it whole: an update's ExeFS may leave a module out or
# hold something else under its name.
pkgs = [p for p in self.n.packages if p['title_id'] in (TITLE_ID, TITLE_ID | 0x800)]
if not pkgs:
ids = ', '.join(f"{p['title_id']:016X}" for p in self.n.packages)
fail(f'this .nup holds {ids}, not Ocarina of Time (0400486024DEC000)')
pkgs.sort(key=lambda p: (p['kind'] != 1, -p['version']))
self.exefs = {}
for name in EXEFS_FILES:
for p in pkgs:
fi = dict(self.n.tree(p['exefs'])).get(name) if p['exefs'] < self.n.ndir else None
magic = b'META' if name.endswith('.npdm') else b'NSO0'
if fi is not None and self.n.size(fi) >= 0x100 and next(self.n.chunks(fi))[:4] == magic:
self.exefs[name] = fi
break
self.romfs = []
for p in pkgs:
if p['romfs'] < self.n.ndir:
self.romfs = list(self.n.tree(p['romfs']))
if self.romfs:
break
st = os.stat(a.nup)
self.identity = f'nup {os.path.abspath(a.nup)} {st.st_size} {int(st.st_mtime)}'
else:
self.exefs_dir, self.romfs_dir = Path(a.exefs), Path(a.romfs)
for d in (self.exefs_dir, self.romfs_dir):
if not d.is_dir():
fail(f'{d} is not a folder')
self.exefs = {name: self.exefs_dir / name for name in EXEFS_FILES if (self.exefs_dir / name).is_file()}
self.romfs = [(p.relative_to(self.romfs_dir).as_posix(), p) for p in self.romfs_dir.rglob('*') if p.is_file()]
# every file's size and modification time, not only their count: a RomFS extracted again (or edited)
# with the same number of files, or a replaced sdk, kept the old unpacked files ("done before")
h = hashlib.sha256()
for rel, f in sorted([(n, self.exefs[n]) for n in self.exefs] + self.romfs):
st = f.stat()
h.update(f'{rel}\0{st.st_size}\0{st.st_mtime_ns}\n'.encode('utf-8', 'surrogateescape'))
self.identity = (f'folders {self.exefs_dir.resolve()} {self.romfs_dir.resolve()} {len(self.romfs)} '
f'{h.hexdigest()[:32]}')
missing = [x for x in EXEFS_FILES if x not in self.exefs]
if missing:
fail(f'the ExeFS lacks {", ".join(missing)} (or holds something else under that name)')
if not self.romfs:
fail('the RomFS is empty')
def read_exefs(self, name):
return self.n.read(self.exefs[name]) if self.n else self.exefs[name].read_bytes()
def romfs_size(self, ref):
return self.n.size(ref) if self.n else ref.stat().st_size
def romfs_chunks(self, ref):
if self.n:
yield from self.n.chunks(ref)
return
with open(ref, 'rb') as f:
while b := f.read(nup.CHUNK):
yield b
class BuiltSource:
"""--rebuild: the ExeFS and RomFS an earlier run unpacked into the game folder, under that run's input identity.
The ExeFS modules are stored plainly there but keep their headers, so check() reads the same build id."""
def __init__(self, out, state):
self.out = out
if state.get('version') != STATE_VERSION or not state.get('input'):
fail(f'{out} holds no finished build of this kit to rebuild from: give --nup, or --exefs and --romfs')
for step in ('exefs', 'romfs'):
if step not in state.get('steps', {}):
fail(f'the last build in {out} did not finish its {step} step: give the game files again')
missing = [n for n in EXEFS_FILES if not (out / 'exefs' / n).is_file()]
missing += [f'romfs/{n}' for n in ('romfs.bin', 'index.json') if not (out / 'romfs' / n).is_file()]
if missing:
fail(f'{out} lacks {", ".join(missing)}: give the game files again')
self.identity = state['input']
def read_exefs(self, name):
return (self.out / 'exefs' / name).read_bytes()
def check(src):
main = src.read_exefs('main')
if main[:4] != b'NSO0':
fail('main is not an NSO module')
build = main[0x40:0x50].hex()
if build != MAIN_BUILD_ID:
fail(f'this is not game version 1.1.1: main has build {build[:8]}, the recomp supports build {MAIN_BUILD_ID[:8]}. '
'Install the 1.1.1 update and dump the game again.')
return main
# ---------------------------------------------------------------- exefs
def flat_nso(raw, name):
"""An NSO with its segments stored uncompressed (the runtime's loader reads only those); segment hashes kept."""
flags = struct.unpack_from('<I', raw, 0xC)[0]
out = bytearray(raw[:0x100])
for i, seg in enumerate(('text', 'ro', 'data')):
file_off, mem_off, size = struct.unpack_from('<III', raw, 0x10 + i * 0x10)
stored = struct.unpack_from('<I', raw, 0x60 + i * 4)[0]
data = raw[file_off:file_off + (stored if flags >> i & 1 else size)]
if flags >> i & 1:
if flags & 0x80:
try:
import zbic
except ImportError:
fail('the game\'s modules need the zbic package: pip install zbic')
data = zbic.decompress(data)
else:
import lz4.block
data = lz4.block.decompress(data, uncompressed_size=size)
if len(data) != size:
fail(f'{name}: {seg} unpacks to {len(data):#x} bytes, its header says {size:#x}')
if flags >> (3 + i) & 1 and hashlib.sha256(data).digest() != raw[0xA0 + 0x20 * i:0xC0 + 0x20 * i]:
fail(f'{name}: the {seg} segment is damaged (sha256 mismatch)')
struct.pack_into('<III', out, 0x10 + i * 0x10, len(out), mem_off, size)
struct.pack_into('<I', out, 0x60 + i * 4, size)
out += data
struct.pack_into('<I', out, 0xC, flags & ~0x87) # segments stored plainly, no ZBIC
return bytes(out)
def step_exefs(src, out):
dst = out / 'exefs'
dst.mkdir(parents=True, exist_ok=True)
for i, name in enumerate(EXEFS_FILES):
raw = src.read_exefs(name)
data = flat_nso(raw, name) if name in MODULES else raw
(dst / name).write_bytes(data)
say(f' {name:9} {len(raw) / 1e6:6.1f} MB -> {len(data) / 1e6:6.1f} MB')
emit('progress', step='exefs', done=i + 1, total=len(EXEFS_FILES), unit='files')
def exefs_digest(out):
return tree_digest(out / 'exefs', EXEFS_FILES)
# ---------------------------------------------------------------- romfs
def step_romfs(src, out, jobs):
dst = out / 'romfs'
dst.mkdir(parents=True, exist_ok=True)
files = sorted(src.romfs, key=lambda e: e[0].encode())
total = sum(src.romfs_size(ref) for _, ref in files)
free = shutil.disk_usage(dst).free
if free < total + (1 << 30):
fail(f'{dst} needs {total / 1e9:.1f} GB free for the RomFS image, it has {free / 1e9:.1f} GB')
say(f' {len(files)} files, {total / 1e9:.1f} GB')
entries, h, pos, done, t0 = [], hashlib.sha256(), 0, 0, time.time()
part = dst / 'romfs.bin.part'
emit('progress', step='romfs', done=0, total=total, unit='bytes')
def load(e): # small files are read whole on a worker; big ones are streamed by the writer
rel, ref = e
return None if src.romfs_size(ref) > (64 << 20) else b''.join(src.romfs_chunks(ref))
with open(part, 'wb', buffering=16 << 20) as img, concurrent.futures.ThreadPoolExecutor(jobs) as ex:
batch = 256
for start in range(0, len(files), batch):
group = files[start:start + batch]
for (rel, ref), data in zip(group, ex.map(load, group)):
pad = -pos % ALIGN
if pad:
img.write(b'\0' * pad)
h.update(b'\0' * pad)
pos += pad
size = src.romfs_size(ref)
entries.append({'path': rel, 'offset': pos, 'size': size})
for chunk in ((data,) if data is not None else src.romfs_chunks(ref)):
img.write(chunk)
h.update(chunk)
pos += size
done += size
emit('progress', step='romfs', done=done, total=total, unit='bytes')
if start // batch % 40 == 0:
say(f' RomFS {done / 1e9:5.1f} of {total / 1e9:.1f} GB, {time.time() - t0:.0f} s')
part.replace(dst / 'romfs.bin')
index = {'status': 'pass', 'romfs_size': pos, 'romfs_sha256': h.hexdigest(), 'files': entries}
(dst / 'index.json').write_text(json.dumps(index), encoding='utf-8')
say(f' RomFS image {pos / 1e9:.2f} GB in {time.time() - t0:.0f} s')
# ---------------------------------------------------------------- lift
EMITTED = re.compile(r'emitted (\d+)/(\d+) chunks')
def lift_digest():
"""The lifter's code, the patch table and the bring-up fixes: what the lifted C depends on besides the ExeFS."""
h = hashlib.sha256(tree_digest(KIT / 'recomp', ('*.py', 'patches-v111.txt')).encode())
h.update(repr(LIFT_FIXES).encode())
return h.hexdigest()
def step_lift(out, cache):
"""gen.py into build-cache/lift. The folder is kept: gen.py rewrites only the chunks whose text changed and
removes the ones it no longer writes."""
lift = cache / 'lift'
cmd = py_cmd(KIT / 'recomp' / 'gen.py', '--exefs', out / 'exefs', '--out', lift,
'--patches', KIT / 'recomp' / 'patches-v111.txt')
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8',
errors='replace', bufsize=1)
for line in p.stdout:
line = line.rstrip('\n')
say(f' {line}')
m = EMITTED.search(line)
if m:
emit('progress', step='lift', done=int(m.group(1)), total=int(m.group(2)), unit='chunks')
if p.wait():
fail('the code generator failed (see above)')
for mod, old, new in LIFT_FIXES:
hits = [p for p in sorted((lift / mod).glob('*.c')) if old in p.read_text(encoding='utf-8')]
if len(hits) != 1 or hits[0].read_text(encoding='utf-8').count(old) != 1:
fail(f'a bring-up fix found its anchor {len(hits)} times in the lifted {mod}, not once')
p = hits[0]
p.write_text(p.read_text(encoding='utf-8').replace(old, new), encoding='utf-8', newline='\n')
# ---------------------------------------------------------------- compile
def modules_c(lift, out, gen):
layout = json.loads((lift / 'layout.json').read_text(encoding='utf-8'))
L = ['#include "dispatch.h"', '#include "host.h"', '#include "layout.h"', '']
for m in layout:
n = m['name']
L += [f'extern const uint64_t {n}_fn_addrs[];', f'extern const GuestFn {n}_fn_ptrs[];',
f'extern const uint64_t {n}_fn_count;']
L += ['', 'const RtLayoutEntry rt_layout[] = {']
L += [f' {{"{m["name"]}", MOD_{m["name"].upper()}_BASE, MOD_{m["name"].upper()}_SIZE}},' for m in layout]
L += ['};', f'const int rt_layout_count = {len(layout)};', '', 'int rt_linked_modules(RtModule *out, int max) {',
' int n = 0;']
for m in layout:
n, u = m['name'], m['name'].upper()
L.append(f' if (n < max) out[n++] = (RtModule){{"{n}", MOD_{u}_BASE, MOD_{u}_SIZE, {n}_fn_addrs, {n}_fn_ptrs, '
f'{n}_fn_count}};')
L += [' return n;', '}', '', 'const RtFileHash rt_exefs_sha256[] = {']
L += [f' {{"{name}", "{sha256_file(out / "exefs" / name)}"}},' for name in EXEFS_FILES]
L += ['};', f'const int rt_exefs_sha256_count = {len(EXEFS_FILES)};', '',
f'const char rt_default_exefs[] = {json.dumps(str(out / "exefs"))};',
f'const char rt_default_romfs[] = {json.dumps(str(out / "romfs"))};',
f'const char rt_default_profile[] = {json.dumps(str(out / "profile"))};',
'const int rt_game_version = 111;',
'const unsigned long long rt_app_pool_bytes = 9809ull << 20; /* the Switch 2 application pool */',
'const int rt_nvn_api_version[2] = {231, 40}; /* NVN2: main asks for major 231, minor 40 or later */',
f'const char rt_port_version[] = {json.dumps(kit_version())};']
p = gen / 'modules.c'
write_if_changed(p, '\n'.join(L) + '\n')
write_if_changed(gen / 'shim.c', '/* Defined by the SDL window path only; this build uses the Win32 one. */\n'
'void menu_sdl_event(void *e) { (void)e; }\n')
return p
def write_if_changed(path, text):
if not path.is_file() or path.read_text(encoding='utf-8') != text:
path.write_text(text, encoding='utf-8')
def kit_version():
v = KIT / 'VERSION'
return v.read_text(encoding='utf-8').strip() if v.is_file() else '0.0.1-ocarina'
def pe_imports(path):
"""The DLL names a PE file imports (its import directory), lower case."""
d = Path(path).read_bytes()
pe = struct.unpack_from('<I', d, 0x3C)[0]
nsec, opt = struct.unpack_from('<H', d, pe + 6)[0], struct.unpack_from('<H', d, pe + 20)[0]
sec = pe + 24 + opt
rva = struct.unpack_from('<I', d, pe + 24 + 120)[0] # PE32+ data directory 1: imports
def off(r):
for i in range(nsec):
va, vs, raw = (struct.unpack_from('<I', d, sec + 40 * i + k)[0] for k in (12, 8, 20))
if va <= r < va + max(vs, 1):
return r - va + raw
return None
names, at = [], off(rva) if rva else None
while at is not None:
name_rva = struct.unpack_from('<I', d, at + 12)[0]
if not name_rva:
break
o = off(name_rva)
names.append(d[o:d.index(b'\0', o)].decode('ascii').lower())
at += 20
return names
def copy_if_changed(src, dst):
"""Copy src over dst unless dst already has its size and modification time. A file the running game holds open
cannot be replaced on Windows: that stops the build with a message instead of a traceback."""
s = os.stat(src)
try:
d = os.stat(dst)
if d.st_size == s.st_size and int(d.st_mtime) == int(s.st_mtime):
return False
except FileNotFoundError:
pass
try:
shutil.copy2(src, dst)
except PermissionError:
fail(f'{dst} is in use: close the game and try again')
return True
DLSS_DLL, DLSS_NOTICE = 'nvngx_dlss.dll', 'nvngx_dlss-NOTICE.txt' # tools/dlss_pin.py, in the kit's bin/
def notice_sha256(path):
"""The sha256 line of a DLSS notice: the DLL a kit placed beside the game."""
try:
for line in path.read_text(encoding='utf-8').splitlines():
if line.startswith('sha256 '):
return line.split()[1].lower()
except (OSError, UnicodeDecodeError):
pass
return None
def place_dlss(out):
"""NVIDIA's DLSS library from the kit's bin/ beside the game, where NGX loads it (runtime/vk_dlaa.c), with its
notice. A different nvngx_dlss.dll the player put there (another DLSS version) is kept; the one an earlier build
placed, named by the notice beside it, is replaced. Returns a line for the build's log when it kept the player's."""
src, dst, note = KIT / 'bin' / DLSS_DLL, out / DLSS_DLL, KIT / 'bin' / DLSS_NOTICE
if not src.is_file():
return None
if dst.is_file():
s, d = os.stat(src), os.stat(dst)
if d.st_size != s.st_size or int(d.st_mtime) != int(s.st_mtime):
have = sha256_file(dst)
if have != notice_sha256(out / DLSS_NOTICE) and have != sha256_file(src):
return f"{DLSS_DLL} kept: the one already beside the game is not the kit's (another DLSS version)"
copy_if_changed(src, dst)
if note.is_file() and (not (out / DLSS_NOTICE).is_file() or (out / DLSS_NOTICE).read_bytes() != note.read_bytes()):
shutil.copy2(note, out / DLSS_NOTICE) # by content: two versions' notices can share size and second
return None
def copy_dlls(exe, msys_bin, out):
seen, todo, copied = set(), [exe], []
while todo:
for name in pe_imports(todo.pop()):
src = msys_bin / name
if name in seen or name in SYSTEM_DLLS_SKIP or not src.is_file():
continue
seen.add(name)
copy_if_changed(src, out / src.name)
copied.append(src.name)
todo.append(src)
return copied
def parse_deps(text):
"""The prerequisites of a make-style dependency file as clang -MMD writes it (the target dropped)."""
text = text.replace('\\\r\n', ' ').replace('\\\n', ' ')
tokens, cur, i = [], '', 0
while i < len(text):
c = text[i]
if c == '\\' and i + 1 < len(text) and text[i + 1] in ' #':
cur += text[i + 1]
i += 2
continue
if c == '$' and text[i + 1:i + 2] == '$':
cur += '$'
i += 2
continue
if c.isspace():
if cur:
tokens.append(cur)
cur = ''
else:
cur += c
i += 1
if cur:
tokens.append(cur)
for k, t in enumerate(tokens): # the target ends with ':' (a drive letter's colon is followed by a separator)
if t.endswith(':'):
return tokens[k + 1:]
return tokens
class ObjectCache:
"""Which objects are current: per object the key of its command and source, and the sha256 (with size and
modification time, to skip hashing an untouched file) of every file its dependency list names
(build-cache/obj/objects.json). Paths under a root are kept relative to it ({'<kit>': KIT}), so the same files
in a kit at another place still count as the same."""
def __init__(self, path, roots=None):
self.path = path
self.roots = {k: os.path.normcase(os.path.abspath(v)) for k, v in (roots or {}).items()}
try:
self.entries = json.loads(path.read_text(encoding='utf-8')) if path.is_file() else {}
except (OSError, ValueError):
self.entries = {}
self._sha = {}
def norm(self, p):
a = os.path.normcase(os.path.abspath(p))
for name, root in self.roots.items():
if a == root or a.startswith(root + os.sep):
return name + a[len(root):].replace(os.sep, '/')
return a
def real(self, p):
for name, root in self.roots.items():
if p.startswith(name):
return root + p[len(name):].replace('/', os.sep)
return p
def file_sha(self, p):
p = os.path.normcase(os.path.abspath(p))
if p not in self._sha:
try:
self._sha[p] = sha256_file(p)
except OSError:
self._sha[p] = None
return self._sha[p]
def _same(self, p, rec):
path = self.real(p)
if isinstance(rec, str): # a cache without sizes and times
return self.file_sha(path) == rec
digest, size, mtime = rec
try:
st = os.stat(path)
except OSError:
return False
if st.st_size == size and st.st_mtime_ns == mtime:
return True
return st.st_size == size and self.file_sha(path) == digest
def current(self, obj, key):
e = self.entries.get(obj.name)
return (e is not None and e.get('key') == key and obj.is_file()
and all(self._same(p, rec) for p, rec in e.get('deps', {}).items()))
def record(self, obj, key, dep_file, src):
try:
deps = parse_deps(Path(dep_file).read_text(encoding='utf-8', errors='replace'))
except OSError:
deps = []
out = {}
for p in deps or [str(src)]:
try:
st = os.stat(p)
out[self.norm(p)] = [self.file_sha(p), st.st_size, st.st_mtime_ns]
except OSError:
out[self.norm(p)] = [None, -1, -1]
self.entries[obj.name] = {'key': key, 'deps': out}
def forget(self, obj):
self.entries.pop(obj.name, None)
def save(self):
tmp = self.path.with_suffix('.tmp')
tmp.write_text(json.dumps(self.entries), encoding='utf-8')
tmp.replace(self.path)
def compiler_id(clang, env):
p = subprocess.run([str(clang), '--version'], capture_output=True, text=True, env=env)
st = os.stat(clang)
return sha256_bytes(f'{p.stdout}\n{st.st_size}\n{int(st.st_mtime)}'.encode())
def compile_all(srcs, cmd_for, cache, jobs, env, progress=None, salt='', stop=None):
"""Compile each (source, object) whose object is not current in cache. cmd_for(src, dst) -> argv without the
dependency-file options; salt identifies the compiler. Returns (compiled, reused, failures) with failures as
(src, stderr tail). The cache's roots stand for their paths in the flags too. The largest sources start first:
compile time grows with size, and a big file that started last would run on alone after the others finished.
stop() true makes the compiles not yet started end at once, unrecorded (a step beside this one failed)."""
todo, keys = [], {}
for src, dst in srcs:
cmd = cmd_for(src, dst)
flags = [c for c in cmd[1:] if c not in (str(src), str(dst))]
for name, root in cache.roots.items():
flags = [name + os.path.normcase(f)[len(root):].replace(os.sep, '/') if os.path.normcase(f).startswith(root)
else f for f in flags]
flags = [os.path.basename(cmd[0])] + flags
keys[dst] = sha256_bytes(json.dumps([salt, flags, cache.file_sha(src)]).encode())
if not cache.current(dst, keys[dst]):
todo.append((src, dst))
reused = len(srcs) - len(todo)
todo.sort(key=lambda job: job[0].stat().st_size, reverse=True)
def cc(job):
src, dst = job
dep = dst.with_suffix('.d')
if stop and stop():
return src, dst, dep, None, ''
cmd = cmd_for(src, dst)
cmd[1:1] = ['-MMD', '-MF', str(dep)]
p = subprocess.run(cmd, capture_output=True, text=True, env=env, errors='replace')
return src, dst, dep, p.returncode, p.stderr[-1500:]
done, bad = reused, []
if progress:
progress(done, len(srcs))
with concurrent.futures.ThreadPoolExecutor(max(1, jobs)) as ex:
for src, dst, dep, rc, err in ex.map(cc, todo):
done += 1
if rc is None: # not started (stop)
continue
if rc:
bad.append((src, err))
cache.forget(dst)
else:
cache.record(dst, keys[dst], dep, src)
if progress:
progress(done, len(srcs))
cache.save()
return len(todo), reused, bad
def runtime_sources():
"""(source, object name) of every runtime file the Windows build compiles."""
rt = KIT / 'runtime'
out = []
for pat in RUNTIME_PATTERNS:
for p in sorted(rt.glob(pat)):
if p.stem.endswith('_sdl') or p.name == 'linux.c':
continue
out.append((p, 'rt_' + p.relative_to(rt).as_posix().replace('/', '_').rsplit('.', 1)[0] + '.o'))
return out
def per_install_names(generated=GENERATED_HEADERS):
"""The basenames of the generated headers and of every runtime header that includes one, at any depth."""
rt = KIT / 'runtime'
headers = {}
for p in list(rt.rglob('*.h')) + list(rt.rglob('*.inc')):
headers[p.name] = {os.path.basename(i) for i in INCLUDE.findall(p.read_text(encoding='utf-8', errors='replace'))}
tainted = set(generated)
grew = True
while grew:
grew = False
for name, incs in headers.items():
if name not in tainted and incs & tainted:
tainted.add(name)
grew = True
return tainted
def per_install(src, tainted):
incs = {os.path.basename(i) for i in INCLUDE.findall(Path(src).read_text(encoding='utf-8', errors='replace'))}
return bool(incs & tainted)
def clang_version(clang, env):
p = subprocess.run([str(clang), '--version'], capture_output=True, text=True, env=env)
return (p.stdout.splitlines() or [''])[0].strip()
def runtime_digest():
return tree_digest(KIT / 'runtime', ('**/*',))
# The x86-64 levels the lifted code is built and tested for (docs/building.md, "Checks"), as the macros clang defines
# for a CPU that has them.
CPU_V2 = {'__SSE3__', '__SSSE3__', '__SSE4_1__', '__SSE4_2__', '__POPCNT__', '__LAHF_SAHF__',
'__GCC_HAVE_SYNC_COMPARE_AND_SWAP_16'}
CPU_V3 = CPU_V2 | {'__AVX__', '__AVX2__', '__BMI__', '__BMI2__', '__F16C__', '__FMA__', '__LZCNT__', '__MOVBE__',
'__XSAVE__'}
def cpu_level(clang, env):
"""This PC's x86-64 level for the lifted code: 3 (AVX2, FMA, BMI2), 2 (SSE4.2, POPCNT) or 0 when clang cannot
tell. The game runs on the PC that builds it, so it takes this CPU's level, as tools/build_game.py --cpu does by
hand. Without one the lifted code would be generic x86-64: every fused multiply-add of the game a call to
runtime/fma.c's software fma, and a level the differential tests do not cover."""
p = subprocess.run([str(clang), '-march=native', '-E', '-dM', '-x', 'c', os.devnull], capture_output=True,
text=True, env=env, errors='replace')
have = set(re.findall(r'^#define (__\w+) ', p.stdout, re.M)) if p.returncode == 0 else set()
return 3 if CPU_V3 <= have else 2 if CPU_V2 <= have else 0
def clang_ident(clang, env):
"""(version, target) a clang reports, or None when it does not run."""
try:
p = subprocess.run([str(clang), '--version'], capture_output=True, text=True, env=env, errors='replace')
except OSError:
return None
v, t = re.search(r'clang version (\S+)', p.stdout), re.search(r'^Target: (\S+)', p.stdout, re.M)
return (v.group(1), t.group(1)) if p.returncode == 0 and v and t else None
def fast_clang(msys, clang, env):
"""MSYS2's CLANG64 clang beside the UCRT64 one (msys64/clang64, which the launcher installs): the same LLVM release
built with profile-guided optimisation, about a fifth faster on the lifted code. With UCRT64's headers
(--sysroot) it writes the same objects as UCRT64's clang, byte for byte but the COFF timestamp; it is used only
when it reports the same version and target. None otherwise."""
fast = Path(msys).parent / 'clang64' / 'bin' / 'clang.exe'
if not fast.is_file():
return None
mine = clang_ident(clang, env)
return fast if mine and clang_ident(fast, env) == mine else None
def runtime_cmd(clang, clangxx, inc, level=0, fast=None, msys=None):
"""fast: fast_clang's compiler for the lifted code, with msys as its sysroot."""
def cmd_for(src, dst):
cpp = src.suffix in ('.cpp', '.cxx')
# the lifted code at -O1 (its size makes -O2 slow to build), the runtime at -O2; neither may assume strict
# aliasing: the lifted code reaches guest memory through casts of one address to several types
# (tools/build_game.py's flags). The lifted code is built for this PC's level (cpu_level); the runtime
# stays generic, so the prebuilt one serves every PC (its vectors are 16 bytes, passed alike at every level).
guest = dst.name.startswith(('main_', 'sdk_', 'subsdk0_'))
march = [f'-march=x86-64-v{level}'] if guest and level else []
cc = [str(fast), f'--sysroot={msys}'] if guest and fast else [str(clangxx if cpp else clang)]
return [*cc, '-c', '-std=gnu++17' if cpp else '-std=gnu11', '-O1' if guest else '-O2',
*march, '-fno-strict-aliasing', '-w', *inc, '-o', str(dst), str(src)]
return cmd_for
def make_runtime_lib(dest, msys, jobs, obj=None):
"""--make-runtime-lib: compile every runtime source that does not include a per-install header into
<dest>/libnx2navirt.a, with runtime-lib.json naming the runtime/ tree and the compiler it was built from. The
objects stay in obj (default <dest>/obj), which a release keeps out of what it ships."""
dest = Path(dest).resolve()
dest.mkdir(parents=True, exist_ok=True)
obj = Path(obj).resolve() if obj else dest / 'obj'
obj.mkdir(parents=True, exist_ok=True)
clang, clangxx = msys / 'bin' / 'clang.exe', msys / 'bin' / 'clang++.exe'
env = dict(os.environ, PATH=str(msys / 'bin') + os.pathsep + os.environ.get('PATH', ''))
rt = KIT / 'runtime'
tainted = per_install_names()
srcs = [(p, obj / name) for p, name in runtime_sources() if not per_install(p, tainted)]
inc = ['-I', str(rt), '-I', str(rt / 'third_party' / 'imgui'), '-I', str(msys / 'include' / 'vma')]
cache = ObjectCache(obj / 'objects.json', {'<kit>': KIT})
t0 = time.time()
compiled, reused, bad = compile_all(srcs, runtime_cmd(clang, clangxx, inc), cache, jobs, env,
lambda d, t: (d % 20 == 0 or d == t) and say(f' {d} of {t}'),
salt=compiler_id(clang, env))
if bad:
for src, err in bad[:3]:
print(f'--- {src}\n{err}', file=sys.stderr)
fail(f'{len(bad)} runtime files did not compile')
lib = dest / RUNTIME_LIB
lib.unlink(missing_ok=True)
rsp = dest / 'ar.rsp'
rsp.write_text('\n'.join('"%s"' % o.as_posix() for _, o in srcs), encoding='utf-8')
ar = os.environ.get('NX2NAVI_AR') or str(msys / 'bin' / 'ar.exe')
if subprocess.run([ar, 'rcs', str(lib), '@' + str(rsp)], env=env).returncode:
fail('the runtime library could not be archived')
meta = {'runtime': runtime_digest(), 'compiler': clang_version(clang, env),
'objects': sorted(o.name for _, o in srcs)}
(dest / 'runtime-lib.json').write_text(json.dumps(meta, indent=1), encoding='utf-8')
rsp.unlink(missing_ok=True)
say(f'{lib}: {len(srcs)} runtime files ({compiled} compiled, {reused} kept), {lib.stat().st_size / 1e6:.1f} MB, '
f'{time.time() - t0:.0f} s')
def prebuilt_runtime(clang, env):
"""(library, object names it holds) when the kit's lib/ matches this runtime/ and this compiler, else None."""
lib, meta = KIT / 'lib' / RUNTIME_LIB, KIT / 'lib' / 'runtime-lib.json'
try:
m = json.loads(meta.read_text(encoding='utf-8'))
except (OSError, ValueError):
return None
if not lib.is_file() or m.get('runtime') != runtime_digest() or m.get('compiler') != clang_version(clang, env):
say(' the prebuilt runtime does not match this kit or compiler: compiling runtime/')
return None
return lib, set(m.get('objects', []))
def romfs_index(out, gen):
if subprocess.run(py_cmd(KIT / 'tools' / 'romfs_index.py', '--romfs', out / 'romfs', '--out', gen)).returncode:
fail('the RomFS index could not be written')
def windres_command(msys, rco):
"""MSYS2's windres on nx2navi.rc, run with runtime/ as the working directory (compile_resources says why)."""
pp = ('-E', '-xc', '-DRC_INVOKED') # windres's own preprocessor flags, which --preprocessor replaces
return [str(msys / 'bin' / 'windres.exe'), '--use-temp-file', f'--preprocessor={msys / "bin" / "gcc.exe"}',
*(f'--preprocessor-arg={a}' for a in pp), '-O', 'coff', '-i', 'nx2navi.rc', '-o', str(rco)]
def compile_resources(msys, rt, obj, env):
"""runtime/nx2navi.rc into obj/nx2navi_rc.o: the manifest of the game's exe (per-monitor DPI awareness, so the
picture keeps the monitor's real size rather than one Windows stretches) and its icon.
windres preprocesses the .rc with gcc through one command line it writes itself, quoting each path that holds a
space. By default it hands that line to cmd.exe /c, which drops the first and the last double quote of a line
holding more than two: gcc's quoted path loses its opening quote, and under a user folder with a space in its
name (C:\\Users\\Ali Sayed) cmd.exe stops at that space ("'C:\\Users\\Ali' is not recognized").
With --use-temp-file windres starts gcc itself instead, no shell involved: it splits the line into arguments and
passes each one to gcc intact. Its splitter reads a quoted first word right, but it cuts a quoted -I path at its
spaces and reads past the end of the line after a quoted last word, so the line holds no path but gcc's, its
first word: the .rc goes by its bare name from runtime/ as the working directory, where windres also finds the
manifest and the icon, and there is no -I. windres's temporary files go to a folder of the build cache, deleted
afterwards (it leaves two of them behind on every run).
The object is written under another name and replaces the old one only when its bytes differ, so an unchanged
resource keeps its time and does not make the link key change (a rebuild with nothing new does not link again)."""
rco, new = obj / 'nx2navi_rc.o', obj / 'nx2navi_rc.o.new'
tmp = obj / 'windres-tmp'
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir()
try:
r = subprocess.run(windres_command(msys, new), cwd=rt, env=dict(env, TMP=str(tmp), TEMP=str(tmp)),
capture_output=True, text=True, errors='replace')
finally:
shutil.rmtree(tmp, ignore_errors=True)
if r.returncode:
new.unlink(missing_ok=True)
fail(f'windres failed on runtime/nx2navi.rc: {r.stderr.strip()[:400]}')
if rco.is_file() and rco.read_bytes() == new.read_bytes():
new.unlink()
else:
os.replace(new, rco)
return rco
def step_compile(out, cache_dir, msys, jobs, romfs_job=None):
"""romfs_job: the RomFS step still running beside this one (a first build). Everything that does not include
romfs_config.h compiles meanwhile; the RomFS index and the few files that include it follow once it is done."""
lift, gen, obj = cache_dir / 'lift', cache_dir / 'gen', cache_dir / 'obj'
gen.mkdir(parents=True, exist_ok=True)
obj.mkdir(parents=True, exist_ok=True)
rt = KIT / 'runtime'
clang, clangxx = msys / 'bin' / 'clang.exe', msys / 'bin' / 'clang++.exe'
env = dict(os.environ, PATH=str(msys / 'bin') + os.pathsep + os.environ.get('PATH', ''))
if not romfs_job:
romfs_index(out, gen)
srcs = [(p, obj / f'{mod}_{p.stem}.o') for mod in ('main', 'sdk', 'subsdk0') for p in sorted((lift / mod).glob('*.c'))]
prebuilt = prebuilt_runtime(clang, env)
runtime = [(p, obj / name) for p, name in runtime_sources()]
if prebuilt:
runtime = [(p, o) for p, o in runtime if o.name not in prebuilt[1]]
say(f' the runtime comes prebuilt; {len(runtime)} runtime files are compiled here')
srcs += runtime
srcs += [(modules_c(lift, out, gen), obj / 'modules.o'), (gen / 'shim.c', obj / 'shim.o')]
inc = ['-I', str(rt), '-I', str(lift), '-I', str(gen), '-I', str(rt / 'third_party' / 'imgui'),
'-I', str(msys / 'include' / 'vma')]
cid = compiler_id(clang, env)
level = cpu_level(clang, env)
say(f' the lifted code is built for x86-64-v{level}, this processor\'s level' if level else
' the lifted code is built for generic x86-64: the processor\'s level could not be read')
fast = fast_clang(msys, clang, env)
if fast:
cid += compiler_id(fast, env)
say(f' the lifted code compiles with {fast}')
cmd_for = runtime_cmd(clang, clangxx, inc, level, fast, msys)
t0 = time.time()
last = [0.0]
def progress(done, total):
now = time.time()
if done == total or now - last[0] >= 0.25:
last[0] = now
emit('progress', step='compile', done=done, total=total, unit='files')
if done % 50 == 0 or done == total:
say(f' compiled {done} of {total}, {now - t0:.0f} s')
say(f' {len(srcs)} files on {jobs} threads')
cache = ObjectCache(obj / 'objects.json', {'<kit>': KIT})
late = []
if romfs_job: # the lifted chunks include only cpu.h and simd.h: only the runtime and generated files are read
tainted = per_install_names(('romfs_config.h',))
late = [(s, o) for s, o in runtime + srcs[-2:] if per_install(s, tainted)]
say(f' {len(late)} files wait for the RomFS index: {", ".join(s.name for s, _ in late)}')
early = [j for j in srcs if j not in late]
compiled, reused, bad = compile_all(early, cmd_for, cache, jobs, env, lambda d, t: progress(d, len(srcs)),
salt=cid, stop=romfs_job.failed if romfs_job else None)
if romfs_job:
if romfs_job.thread.is_alive():
say(' waiting for the RomFS image')
romfs_job.join() # raises the RomFS step's failure; a failed compile still keeps the finished image
if not bad:
romfs_index(out, gen)
c2, r2, bad = compile_all(late, cmd_for, cache, jobs, env,
lambda d, t: progress(len(early) + d, len(srcs)), salt=cid)
compiled, reused = compiled + c2, reused + r2
say(f' {compiled} compiled, {reused} unchanged since the last build')
if bad:
for src, err in bad[:3]:
print(f'--- {src}\n{err}', file=sys.stderr)
fail(f'{len(bad)} files did not compile')
rco = compile_resources(msys, rt, obj, env)
objs = [o for _, o in srcs] + [rco]
exe = out / EXE_NAME
rsp = cache_dir / 'link.rsp'
rsp.write_text('\n'.join('"%s"' % o.as_posix() for o in objs), encoding='utf-8')
# lld links several times faster than GNU ld; the stack matches tools/build_game.py's build
# 16 MiB for the main thread with either linker (GNU ld takes the same flag): the default 2 MiB is too little
ld = (['-fuse-ld=lld'] if (msys / 'bin' / 'ld.lld.exe').is_file() else []) + ['-Wl,--stack,16777216']
lib = ['-Wl,--whole-archive', str(prebuilt[0]), '-Wl,--no-whole-archive'] if prebuilt else []
stamp = [[o.name, o.stat().st_size, o.stat().st_mtime_ns] for o in objs]
if prebuilt:
stamp.append([prebuilt[0].name, prebuilt[0].stat().st_size, sha256_file(prebuilt[0])])
link_key = sha256_bytes(json.dumps([LINK_LIBS, ld, cid] + stamp).encode())
state_link = cache_dir / 'link.key'
if exe.is_file() and state_link.is_file() and state_link.read_text(encoding='utf-8') == link_key:
say(' ocarina.exe is current, not linked again')
else:
emit('phase', step='compile', phase='link')
say(' linking')
tmp = out / (EXE_NAME + '.new')
p = subprocess.run([str(clangxx), *ld, '-o', str(tmp), '@' + str(rsp), *lib, '-L', str(msys / 'lib'),
*LINK_LIBS], capture_output=True, text=True, env=env, errors='replace')
if p.returncode:
print(p.stderr[-4000:], file=sys.stderr)
tmp.unlink(missing_ok=True)
fail('the link failed')
try:
os.replace(tmp, exe)
except PermissionError:
tmp.unlink(missing_ok=True)
fail(f'{exe} is in use: close the game and try again')
state_link.write_text(link_key, encoding='utf-8')
copy_if_changed(gen / 'romfs-index.bin', out / 'romfs-index.bin')
for name in ('nx2navi_xlate.exe', 'nx2navi_xlate.id'):
copy_if_changed(KIT / 'bin' / name, out / name)
if (KIT / 'bin' / 'amd_fidelityfx_vk.dll').is_file(): # FSR 3.1 (runtime/vk_ffx.cpp)
copy_if_changed(KIT / 'bin' / 'amd_fidelityfx_vk.dll', out / 'amd_fidelityfx_vk.dll')
kept = place_dlss(out) # NVIDIA DLSS (runtime/vk_dlaa.c)
if kept:
say(f' {kept}')
say(f' {EXE_NAME} {exe.stat().st_size / 1e6:.0f} MB; beside it: {", ".join(copy_dlls(exe, msys / "bin", out))}')
(out / 'profile').mkdir(exist_ok=True)
# ---------------------------------------------------------------- main
SHADER_PROGRESS = re.compile(r'^prepare-shaders: progress (translate|build) (\d+) (\d+)\s*$')
SHADER_STALL_S = 180 # no count moved for this long: the launcher says so (the step still runs)
SHADER_BEAT_S = 10 # a progress event at least this often while the step runs
def shader_progress(phase, done, total, quiet_s, elapsed_s):
"""The shaders step's progress event fields: the translation fills the first half of the bar, the build into the
driver's cache the second (nx2navi.exe prints both counts, runtime/gfx_shader.c prepare_files and
runtime/vk_pipeline.c lb_warm_prepared); before either count, reading the game's shaders. quiet_s: seconds since a
count last moved, said once it passes SHADER_STALL_S, so a slow step never looks frozen without a word. The
detail ends without a full stop: the launcher adds one (launcher/ui/app.py)."""
clock = f'{int(elapsed_s) // 60}:{int(elapsed_s) % 60:02d}'
if phase is None:
fraction, detail = 0.0, f'Reading the game\'s shaders ({clock} so far)'
elif phase == 'translate':
fraction, detail = 0.5 * done / max(total, 1), f'Translated {done:,} of {total:,} shaders'
else:
fraction, detail = 0.5 + 0.5 * done / max(total, 1), f'Built {done:,} of {total:,} shaders for your graphics card'
if quiet_s >= SHADER_STALL_S:
detail += (f'. No change for {int(quiet_s) // 60} min, but it is still running: one large shader can take '
'a while')
return {'step': 'shaders', 'fraction': round(fraction, 4), 'detail': detail}
def step_shaders(out):
"""Every shader of the game files into the game's shader cache and the driver's own cache, before the first start.
Its output is read as it comes: the counts nx2navi.exe prints become progress events, the other lines go on to
the log, and while nothing moves an event every few seconds keeps saying the step is running and for how long."""
cmd = [sys.executable, '-u', str(KIT / 'tools' / 'prepare_shaders.py'), str(out), '--romfs', str(out / 'romfs'),
'--exefs', str(out / 'exefs'), '--no-list', '--exe', 'ocarina.exe']
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8',
errors='replace', bufsize=1)
t0 = time.monotonic()
state = {'phase': None, 'done': 0, 'total': 0, 'moved': t0}
lock = threading.Lock()
stop = threading.Event()
def send():
now = time.monotonic()
emit('progress', **shader_progress(state['phase'], state['done'], state['total'], now - state['moved'],
now - t0))
def heartbeat():
while not stop.wait(SHADER_BEAT_S):
with lock:
send()
beat = threading.Thread(target=heartbeat, daemon=True)
beat.start()
last_log = 0.0
try:
for line in proc.stdout:
m = SHADER_PROGRESS.match(line)
if not m:
say(line.rstrip('\n'))
continue
phase, done, total = m.group(1), int(m.group(2)), int(m.group(3))
with lock:
if (phase, done) != (state['phase'], state['done']):
state['moved'] = time.monotonic()
state.update(phase=phase, done=done, total=total)
send()
if time.monotonic() - last_log >= 30 or done == total: # the log gets a count now and then, not each one
last_log = time.monotonic()
say(f' shaders: {"translated" if phase == "translate" else "built"} {done} of {total}, '
f'{time.monotonic() - t0:.0f} s')
finally:
rc = proc.wait()
proc.stdout.close()
stop.set()
beat.join()
if rc:
fail('preparing the shaders failed (the lines above say why); the game still runs, compiling shaders as it '
'meets them. Run the build again to retry this step')
for seed in ('seed-programs.bin', 'seed-answers.bin'): # translated now: the shader cache holds them
(out / seed).unlink(missing_ok=True)
def prompt_art_dir():
"""The kit's button art (<kit>/prompt-art, tools/prompt_art.py), or None when the kit has none."""
art = KIT / prompt_art.FOLDER
return art if art.is_dir() else None
def bind_lines(out):
"""The bind_ lines of the game folder's nx2navi.ini, comments cut off: the keys the keyboard and mouse prompts
show (tools/pad_prompts.py pc_keys reads the same lines)."""
try:
text = (out / 'nx2navi.ini').read_text(encoding='utf-8', errors='replace')
except OSError:
return []
lines = (ln.split('#')[0].split(';')[0].strip() for ln in text.splitlines())
return [ln for ln in lines if ln.lower().startswith('bind_')]
def step_prompts(out):
"""The button prompts drawn from the kit's art into the game folder: tools/pad_prompts.py writes one overlay
folder per style and layout (prompt_art.OVERLAYS), which runtime/romfs.c loads in place of the game's button font
and prompt textures when the Controls menu's Button prompts is Xbox, PlayStation or Keyboard and mouse. Its lines
go on to the log, each folder written is a progress event."""
total = len(prompt_art.OVERLAYS)
proc = subprocess.Popen(py_cmd(KIT / 'tools' / 'pad_prompts.py', '--art', prompt_art_dir(), '--game', out),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8',
errors='replace', bufsize=1)
done = 0
emit('progress', step='prompts', fraction=0.0, detail=f'Drawing {total} sets of button prompts')
try:
for line in proc.stdout:
line = line.rstrip('\n')
if line.startswith('{'): # its closing report, one long JSON line
continue
say(f' {line}')
if PROMPTS_WRITTEN.match(line):
done += 1
emit('progress', step='prompts', fraction=round(done / total, 4),
detail=f'Drew {done} of {total} sets of button prompts')
finally:
rc = proc.wait()
proc.stdout.close()
if rc:
fail('drawing the button prompts failed (the lines above say why); the game still runs. Run the build again '
'to retry this step')
def keep_awake():
"""Hold off sleep for this process's life (Windows): the build runs for many minutes with no input, and a PC that
sleeps in the middle stops the compiles. The hold ends with the process."""
if os.name != 'nt':
return
try:
import ctypes
ES_CONTINUOUS, ES_SYSTEM_REQUIRED = 0x80000000, 0x00000001
ctypes.windll.kernel32.SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)
except (OSError, AttributeError):
pass
def load_state(path):
try:
state = json.loads(path.read_text(encoding='utf-8')) if path.is_file() else {}
except (OSError, ValueError):
state = {}
if state.get('version') == 1: # the first kit: a list of finished steps, all tied to one kit version
done = state.get('done', [])
state = {'version': STATE_VERSION, 'input': state.get('input'), 'steps': {}}
for step in ('exefs', 'romfs'):
if step in done:
state['steps'][step] = step_key(step, state['input'])
return state
def cpu_tag():
"""This processor's features the lifted code's level follows (cpu_level), as Windows reports them; part of the
compile key, so a game folder moved to a PC without them (or a new processor) is compiled again rather than kept
with instructions the processor lacks. 'any' where it cannot be read."""
if os.name != 'nt':
return 'any'
try:
import ctypes
present = ctypes.windll.kernel32.IsProcessorFeaturePresent
# PF_SSE4_2_INSTRUCTIONS_AVAILABLE 38, PF_AVX2_INSTRUCTIONS_AVAILABLE 40, PF_AVX512F_INSTRUCTIONS_AVAILABLE 41
return ''.join('1' if present(f) else '0' for f in (38, 40, 41))
except (OSError, AttributeError):
return 'any'
def step_key(step, identity, out=None):
"""What a step's output depends on. exefs and romfs: the game files only; lift: the unpacked ExeFS and the
lifter; compile: the lift, the runtime sources, this script and the processor (cpu_tag); prompts: the game files,
the kit's button art, the tools that draw it, the bind_ lines of nx2navi.ini, and whether every folder it writes
is still there."""
if step in ('exefs', 'romfs'):
return f'{step}-1|{identity}'
if step == 'prompts':
tools = tree_digest(KIT / 'tools', ('pad_prompts.py', 'prompt_art.py', 'button_icons.py'))
binds = sha256_bytes(json.dumps(bind_lines(out)).encode())
written = all((out / f / 'manifest.tsv').is_file() for f in prompt_art.OVERLAYS)
return (f'prompts-1|{identity}|{tree_digest(prompt_art_dir(), ("**/*.png",))}|{tools}|{binds}'
f'|{"written" if written else "missing"}')
lift = f'lift|{exefs_digest(out)}|{lift_digest()}'
if step == 'lift':
return lift
compile_key = (f'compile|{lift}|{tree_digest(KIT / "runtime", ("**/*",))}|{sha256_file(Path(__file__))}|{kit_version()}'
f'|cpu {cpu_tag()}')
if step == 'compile':
return compile_key
# shaders: the built game and the translator that prepared them
return f'shaders-1|{compile_key}|{sha256_file(KIT / "tools" / "prepare_shaders.py")}|{sha256_file(KIT / "bin" / "nx2navi_xlate.id")}'
def main():
global EVENTS
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--nup', help='the game as one .nup file')
ap.add_argument('--exefs', help='the game\'s exefs folder (with --romfs, instead of --nup)')
ap.add_argument('--romfs', help='the game\'s romfs folder')
ap.add_argument('--rebuild', action='store_true',
help='build again from the game files the last run unpacked into --out (after a kit update)')
ap.add_argument('--out', help='the folder for the finished game')
ap.add_argument('--check', action='store_true', help='only check the game\'s version; builds nothing')
ap.add_argument('--msys', default=r'C:\msys64\ucrt64', help='MSYS2 UCRT64 folder (default C:\\msys64\\ucrt64)')
ap.add_argument('--jobs', type=int, default=os.cpu_count() or 4, help='parallel compiles')
ap.add_argument('--events', action='store_true', help='machine-readable progress lines on stdout ("@@" + JSON)')
ap.add_argument('--make-runtime-lib', metavar='DIR', help='build the prebuilt runtime library of a release into DIR')
ap.add_argument('--obj', help='with --make-runtime-lib: the folder for its objects')
ap.add_argument('--until', choices=STEPS, help=argparse.SUPPRESS) # tests: stop after this step
a = ap.parse_args()
EVENTS = a.events
if a.make_runtime_lib:
make_runtime_lib(a.make_runtime_lib, Path(a.msys), a.jobs, a.obj)
return
given = bool(a.nup) + bool(a.exefs or a.romfs) + bool(a.rebuild)
if given != 1 or (not a.nup and not a.rebuild and not (a.exefs and a.romfs)):
ap.error('give --nup, both --exefs and --romfs, or --rebuild')
if a.rebuild and not a.out:
ap.error('--rebuild needs --out')
out = Path(a.out).resolve() if a.out else None
cache = out / 'build-cache' if out else None
state_path = cache / 'state.json' if cache else None
state = load_state(state_path) if state_path else {}
emit('step', step='check', state='start')
src = BuiltSource(out, state) if a.rebuild else Source(a)
check(src)
say('check: Ocarina of Time 1.1.1 (build e0f3ccfa)')
emit('check', version='1.1.1', build=MAIN_BUILD_ID[:8])
emit('step', step='check', state='done')
if a.check:
return
if not out:
ap.error('--out is needed to build')
msys = Path(a.msys)
for need in ('bin/clang.exe', 'bin/clang++.exe', 'bin/SDL3.dll', 'lib/libSDL3.dll.a', 'include/vma'):
if not (msys / need).exists():
fail(f'{msys / need} is missing: install MSYS2 and the packages README-BuildKit.md lists')
packages = {'numpy': 'numpy', 'lz4': 'lz4', **({} if a.rebuild else {'zbic': 'zbic'}),
**(PROMPT_PACKAGES if prompt_art_dir() else {})}
for pip_name, mod in packages.items():
try:
__import__(mod)
except ImportError:
extra = ' fonttools pillow' if prompt_art_dir() else ''
fail(f'the Python package {pip_name} is missing: pip install numpy lz4 zstandard zbic{extra}')
cache.mkdir(parents=True, exist_ok=True)
reset = state.get('version') != STATE_VERSION or state.get('input') != src.identity
if reset:
state = {'version': STATE_VERSION, 'input': src.identity, 'steps': {}}
state['kit'] = kit_version()
keep_awake()
t0 = time.time()
romfs_job = None
runs = {'exefs': lambda: step_exefs(src, out), 'romfs': lambda: step_romfs(src, out, min(a.jobs, 8)),
'lift': lambda: step_lift(out, cache), 'compile': lambda: step_compile(out, cache, msys, a.jobs, romfs_job),
'shaders': lambda: step_shaders(out), 'prompts': lambda: step_prompts(out)}
state_lock = threading.Lock() # the RomFS step records itself from its own thread
def save():
# whole or not at all: a cut write (a full disk, Cancel ending the process) would leave a state.json that
# loads as {}, and the launcher would take the built game for none and ask for the dump again
with state_lock:
tmp = state_path.with_name(state_path.name + '.tmp')
tmp.write_text(json.dumps(state, indent=1), encoding='utf-8')
os.replace(tmp, state_path)
if reset:
# on disk at once: the exefs step rewrites <out>/exefs in place, and a run cut short there would leave the
# old state.json saying the old game's steps are done, which --rebuild trusts
save()
def run(name):
t = time.time()
runs[name]()
with state_lock:
state['steps'][name] = step_key(name, src.identity, out)
save()
say(f'{name}: {time.time() - t:.0f} s')
emit('step', step=name, state='done', seconds=round(time.time() - t, 1))
try:
for name in STEPS:
if name == 'prompts' and not prompt_art_dir():
say('prompts: none to draw, the kit has no button art')
emit('step', step=name, state='skip')
elif state['steps'].get(name) == step_key(name, src.identity, out):
say(f'{name}: done before')
emit('step', step=name, state='skip')
else:
# a step that runs makes every step after it run too: their keys hash its output, not its key (but
# for OWN_KEY_STEPS, whose keys name their inputs)
with state_lock:
for later in STEPS[STEPS.index(name):]:
if later == name or not {name, later} & set(OWN_KEY_STEPS):
state['steps'].pop(later, None)
say(f'{name}:')
emit('step', step=name, state='start')
if name in ('shaders', 'prompts') and romfs_job: # they read the RomFS image
romfs_job.join()
if name == 'romfs' and a.until != 'romfs':
# The RomFS image is mostly disk work, the lift and the compile are processor work: they run side
# by side, and only the RomFS index and the files that include it wait for the image
# (step_compile). A first build takes about as long as the longer of the two, not their sum.
romfs_job = Background(lambda: run('romfs'))
else:
run(name)
if romfs_job and romfs_job.failed():
romfs_job.join()
if name == a.until:
break
if romfs_job:
romfs_job.join()
except (SystemExit, Exception):
# a failed lift or compile, or an error of this script: the RomFS image still finishes and is kept, so the
# next run starts after it (the thread is a daemon, and the process ending would cut its .part file short)
if romfs_job and romfs_job.thread.is_alive():
say('waiting for the RomFS image before stopping')
romfs_job.thread.join()
raise
save()
say(f'\nfinished in {(time.time() - t0) / 60:.1f} min: {out / EXE_NAME}')
emit('end', exe=str(out / EXE_NAME), seconds=round(time.time() - t0, 1), kit=kit_version())
if __name__ == '__main__':
main()