Files
totk-pc-recomp/tools/build_game.py
T
Deon FoucheandClaude Opus 5.5 4c17621045 Write both Xbox prompt folders in the setup, the release and the builds
The setup runs make_romfs_patches.py --prompts xbox and --prompts xbox-label and warns once when either fails, the
release bundles tools/button_icons.py and fontTools, and build_game.py and package_build.py copy both folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:50:17 +10:00

764 lines
39 KiB
Python

"""Compile the generated C for one game version and link it with the runtime into build/<version>/totk.exe.
Writes build/<version>/build.ninja and runs MSYS2 UCRT64 Ninja on it. Ninja keeps the build incremental: a chunk is
recompiled only when it, a header it includes or its flags change.
.venv\\Scripts\\python tools\\build_game.py # generated/v121 -> build/v121/totk.exe
.venv\\Scripts\\python tools\\build_game.py --modules subsdk0 # one module (links if it has no calls into others)
.venv\\Scripts\\python tools\\build_game.py --jobs 8
.venv\\Scripts\\python tools\\build_game.py --out build/v121-t2 # a separate build directory
.venv\\Scripts\\python tools\\build_game.py --toolchain toolchain/llvm-mingw-<version>/llvm-mingw-<version>-ucrt-x86_64 \\
--out build/v121-llvmmingw # llvm-mingw instead of MSYS2 UCRT64
Every runtime/*.c is compiled. build/<version>/modules.c carries the layout, the linked function tables, the
expected SHA-256 of each ExeFS file (tools/data/expected-<display version>.json) and the default game paths
(--exefs, --romfs, --profile), which totk.exe uses unless its own command line names others. tools/romfs_index.py
writes build/<version>/romfs-index.bin and romfs_config.h from the RomFS directory's index.json (runtime/romfs.c reads
the index next to totk.exe).
Private builds reuse the main build's objects. When --out differs from build/<version>, the main build's generated
objects are up to date (checked with a dry run of its build.ninja) and every header they include from runtime/ is
byte-identical in --runtime (the list comes from the main build's Ninja deps log), build_game.py hardlinks those
objects into <out>/obj and compiles only runtime/*.c. A hardlink is a snapshot: clang and Ninja replace an object by
renaming a new file over it, so a later main rebuild leaves the linked copies alone. Otherwise (a header differs, a
main object is missing or stale, another --gen) it compiles everything and prints one line saying why. --full-build
skips the check.
Object manifest. A flipped bit in an object written by an earlier build stays forever, because Ninja rebuilds an object
only when its inputs change, and forks hardlink the object into every private build. So each build dir keeps
obj/objects.sha256 (SHA-256, size and mtime of every object it compiled). After Ninja finishes and before the link,
build_game.py hashes the objects, compares each one whose size and mtime match its manifest entry, and records the
objects Ninja just rebuilt (new mtime). A mismatch deletes the object, Ninja recompiles it, and the build prints the
summary line. A fork checks the hardlinked generated objects against build/<version>/obj/objects.sha256 (read-only; a
bad shared object stops the fork and asks for a main build run, which recompiles it and breaks the shared inode).
Compile jobs share a memory budget (MEM_BUDGET, default the smaller of 50 GiB and 80% of RAM) at JOB_MEM per job, so a
big machine does not run out of memory on the largest chunks. Each phase reports its wall time and the peak memory
of its processes (Windows job object accounting: committed private bytes).
--ninja PATH names the Ninja to run; without MSYS2 the bin/ninja.exe of the release folder (tools/make_release.py) is used.
--toolchain DIR builds with an llvm-mingw tree (DIR/bin/clang.exe) instead of MSYS2 UCRT64 clang. Ninja still comes
from MSYS2. The libraries and headers MSYS2 supplies (Vulkan, SDL3, Opus, libvpx, VMA) then come from --deps DIR
(default toolchain/deps-mingw: include/, lib/ with the import libraries and libvpx.a, bin/ with the DLLs), and the C++
runtime is libc++ from llvm-mingw. Such a build never reuses another build's generated objects.
Split build. --make-runtime-lib compiles only runtime/ into <out>/libtotkrt.a (what CI prebuilds). --runtime-lib PATH
then compiles only the generated C and the install-time files (modules.c and INSTALL_SIDE, which bake the player's
game hashes and version in) and links them with that library. The library holds no game-derived data.
Standard library only.
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import romfs_index
from build_shaders import stale as stale_shaders
from check_content import GENERATED_MARKER
ROOT = Path(__file__).resolve().parent.parent
# In a git worktree (tools/worktree.py) build/ and generated/ are junctions into the main checkout, and resolve() follows
# them. MAIN_ROOT is where they land, so resolved paths still print and compare relative to a root.
MAIN_ROOT = (ROOT / 'build').resolve().parent
UCRT = Path(r'C:\msys64\ucrt64\bin')
CLANG = UCRT / 'clang.exe'
NINJA = UCRT / 'ninja.exe'
SDL3_DLLS = [UCRT / 'SDL3.dll', UCRT / 'libiconv-2.dll', UCRT / 'libopus-0.dll']
# Flags for generated code as LAS Recomp builds it (Makefile GENFLAGS, Windows), plus -march for the FP/SIMD lowering.
GEN_FLAGS = ['-O1', '-fno-strict-aliasing', '-Wno-return-type', '-Wno-unused-label', '-fvisibility=hidden',
'-march=x86-64-v3']
# _WIN32_WINNT: Windows 10, the port's minimum. MSYS2's headers default to it, llvm-mingw's (the release toolchain) to
# Windows 7, which hides the per-monitor DPI functions gfx_window.c and dpi_probe.c call.
RT_FLAGS = ['-O2', '-g', '-Wall', '-fno-strict-aliasing', '-march=x86-64-v3', '-D_WIN32_WINNT=0x0A00']
# -Xlinker passes the map path as one argument; -Wl, would split it at any comma in the build dir path
LINK_FLAGS = ['-fuse-ld=lld', '-Wl,--stack,16777216', '-Xlinker', '"--Map=$builddir/totk.map"'] # the map feeds tools/prof_report.py
LINK_LIBS = ['-lpsapi', '-lbcrypt', '-lvulkan-1', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lSDL3', '-lopus', '-l:libvpx.a', '-l:libzstd.a', '-l:libstdc++.a', '-l:libwinpthread.a'] # Vulkan back end; libvpx static (movies); libzstd static (resolution.c)
VMA_INCLUDE = UCRT.parent / 'include' / 'vma' # vk_mem_alloc.h; MSYS2 package vulkan-memory-allocator
# --toolchain: an llvm-mingw tree. The libraries, headers and DLLs MSYS2 supplies come from the deps directory instead.
LLVM_LINK_LIBS = ['-lpsapi', '-lbcrypt', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lvulkan-1', '-lSDL3', '-lopus',
'-l:libvpx.a', '-l:libzstd.a', '-l:libc++.a', '-l:libc++abi.a', '-l:libunwind.a', '-l:libwinpthread.a']
DEFAULT_DEPS = ROOT / 'toolchain' / 'deps-mingw'
# Runtime sources compiled per install, not in the library: romfs.c includes romfs_config.h, patches.c and ov_zstd.c
# the patches.h of the player's game version (its patch page is the page after that version's main; ov_zstd.c's host
# functions sit on it).
INSTALL_SIDE = {'romfs.c', 'patches.c', 'ov_zstd.c'}
LIB_NAME = 'libtotkrt.a'
AR = UCRT / 'llvm-ar.exe'
WINDRES = UCRT / 'windres.exe' # runtime/totk.rc: the manifest of totk.exe
TOOLCHAIN_BIN = None # llvm-mingw bin directory, set by main() for --toolchain
DEPS = None # the deps directory of an llvm-mingw build
# tools/data/expected-<display version>.json for each build version name.
DISPLAY_VERSIONS = {'v0': '1.0.0', 'v112': '1.1.2', 'v121': '1.2.1', 'v143': '1.4.3'}
GiB = 1 << 30
# Peak commit of one compile, 1.2.1 at -O1 (2026-10-20): 120-180 MiB per ~3 MiB chunk, 568 MiB for main_table.c
# (295k-entry tables). 768 MiB leaves headroom for the largest file.
JOB_MEM = 768 << 20
MEM_BUDGET = 50 * GiB # total for all compile jobs
def below_root(p):
"""Resolved p relative to ROOT, else to MAIN_ROOT (the same relative path through the worktree's junctions), else
absolute."""
p = Path(p).resolve()
for root in (ROOT, MAIN_ROOT):
if p.is_relative_to(root):
return p.relative_to(root)
return p
def ninja_path(p):
"""A path for a build line: relative to the repo root where possible, forward slashes, Ninja-escaped."""
return below_root(p).as_posix().replace('$', '$$').replace(' ', '$ ').replace(':', '$:')
def flag_path(p):
return below_root(p).as_posix()
def cmd_arg(s):
"""s for a Ninja command line: Ninja-escaped, and quoted when it holds a space (a user or folder name), as clang and
lld split their Windows command line at unquoted spaces."""
s = s.replace('$', '$$')
return f'"{s}"' if ' ' in s else s
def cmd_path(p):
return cmd_arg(flag_path(p))
def read_layout(gen):
"""[(name, base, size)] from layout.json (same format as recomp/layout.py read(), without its NSO dependency)."""
data = json.loads((gen / 'layout.json').read_text())
return [(m['name'], int(m['base'], 16), int(m['size'], 16)) for m in data]
def chunk_files(gen, mod):
"""The C files of one module: the chunks listed in <mod>/manifest.json, then <mod>_table.c."""
d = gen / mod
data = json.loads((d / 'manifest.json').read_text())
entries = data if isinstance(data, list) else data.get('chunks', data.get('files'))
if entries is None:
sys.exit(f'{d / "manifest.json"}: no "chunks" or "files" list')
names = []
for e in entries:
name = e if isinstance(e, str) else e.get('file', e.get('path', e.get('name')))
names.append(Path(name).name)
table = f'{mod}_table.c'
if table not in names:
names.append(table)
files = [d / n for n in names]
missing = [str(f) for f in files if not f.is_file()]
if missing:
sys.exit(f'{mod}: {len(missing)} files listed in manifest.json are missing, first {missing[0]}')
return files
def write_if_changed(path, text):
"""Keep the mtime of an unchanged file so Ninja does not rebuild from it."""
if path.exists() and path.read_text(encoding='utf-8', errors='replace') == text:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n') # Ninja and clang read UTF-8; paths may be non-ASCII
def copy_romfs_patches(version, out):
"""totk.exe loads romfs-patches beside romfs-index.bin (runtime/romfs.c overlay_init). tools/make_romfs_patches.py
writes them into build/<version> only, so a build in another directory ran without the 60 fps overlay (the quick
menus' captured background turned black). Copies them when their manifest differs; romfs-patches-xbox and
romfs-patches-xbox-label (the same with Xbox button prompts, make_romfs_patches.py --prompts) the same way when they
were made."""
for name in ('romfs-patches', 'romfs-patches-xbox', 'romfs-patches-xbox-label'):
src = ROOT / 'build' / version / name
dst = out / name
if src.resolve() == dst.resolve() or not (src / 'manifest.tsv').is_file():
continue
if (dst / 'manifest.tsv').is_file() and (dst / 'manifest.tsv').read_bytes() == (src / 'manifest.tsv').read_bytes():
continue
shutil.rmtree(dst, ignore_errors=True)
shutil.copytree(src, dst)
print(f'romfs patches: copied {name} from {src}')
def c_string(s):
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
RUNTIME = ROOT / 'runtime' # --runtime: a copy of runtime/ lets a task build without touching the shared tree
def runtime_sources(skip=()):
"""Every runtime/*.c, *.cpp, platform/*.c, *.rc, the in-game menu (ui/*.cpp) and Dear ImGui except the names in skip,
so a task adds a file without editing this script."""
pats = ('*.c', '*.cpp', 'platform/*.c', '*.rc', 'ui/*.cpp', 'third_party/imgui/*.cpp',
'third_party/imgui/backends/*.cpp')
return sorted(p for pat in pats for p in RUNTIME.glob(pat) if p.name not in skip)
def runtime_object(out, src):
"""obj/runtime/<path below runtime>.o, so platform/win32.c cannot collide with a top-level win32.c."""
src = Path(src)
rel = src.relative_to(RUNTIME) if src.is_relative_to(RUNTIME) else Path(src.name)
return out / 'obj' / 'runtime' / rel.with_suffix('.o')
def module_glue(layout, linked, hashes, paths, version):
"""C for build/<version>/modules.c: the layout table, the linked modules' function tables (dispatch.h), the
expected ExeFS hashes, the game version and the default game paths (host.h)."""
out = [f'// Module glue written by tools/build_game.py; do not edit. {GENERATED_MARKER}',
'#include "dispatch.h"', '#include "host.h"', '#include "layout.h"', '']
for name, base, size in layout:
u = name.upper()
out.append(f'_Static_assert(MOD_{u}_BASE == {base:#x}ull && MOD_{u}_SIZE == {size:#x}ull, '
f'"layout.h and layout.json disagree on {name}");')
out += ['', 'const RtLayoutEntry rt_layout[] = {']
out += [f' {{"{n}", MOD_{n.upper()}_BASE, MOD_{n.upper()}_SIZE}},' for n, _, _ in layout]
out += ['};', f'const int rt_layout_count = {len(layout)};', '']
for name in linked:
out += [f'extern const uint64_t {name}_fn_addrs[];', f'extern const GuestFn {name}_fn_ptrs[];',
f'extern const uint64_t {name}_fn_count;']
out += ['', 'int rt_linked_modules(RtModule *out, int max) {', ' int n = 0;']
for name in linked:
u = name.upper()
out.append(f' if (n < max) out[n++] = (RtModule){{"{name}", MOD_{u}_BASE, MOD_{u}_SIZE, {name}_fn_addrs, '
f'{name}_fn_ptrs, {name}_fn_count}};')
out += [' return n;', '}', '']
out += ['const RtFileHash rt_exefs_sha256[] = {']
out += [f' {{{c_string(f)}, {c_string(h)}}},' for f, h in sorted(hashes.items())]
out += ['};', f'const int rt_exefs_sha256_count = {len(hashes)};', '']
for name in ('exefs', 'romfs', 'profile'):
out.append(f'const char rt_default_{name}[] = {c_string(paths[name].as_posix())};')
number = int(DISPLAY_VERSIONS[version].replace('.', '')) if version in DISPLAY_VERSIONS else 0 # 1.4.3 -> 143
out += [f'const int rt_game_version = {number};', f'const char rt_port_version[] = {c_string(port_version())};', '']
return '\n'.join(out)
def port_version():
"""VERSION at the root: the repository's semver, or in a release folder the release string (make_release.py
version)."""
path = ROOT / 'VERSION'
return path.read_text(encoding='utf-8').strip() if path.is_file() else 'unknown'
def ninja_file(gen, out, mods, pool_depth, skip=(), prebuilt=False, runtime_lib=None, make_lib=False):
"""build.ninja text, and the object lists per module. With prebuilt the generated objects already exist in
out/obj and are link inputs without a compile edge. runtime_lib: a prebuilt library replaces the runtime objects
except INSTALL_SIDE. make_lib: build only that library, from the runtime objects except INSTALL_SIDE."""
inc = f'-I {cmd_path(RUNTIME)} -I {cmd_path(gen)}'
vma = f'{cmd_path(DEPS / "include")} -I {cmd_path(DEPS / "include" / "vma")}' if DEPS else cmd_path(VMA_INCLUDE)
imgui = cmd_path(RUNTIME / 'third_party' / 'imgui')
rt_inc = f'{inc} -I {cmd_path(out)} -I {vma} -I {imgui}' # romfs_config.h, vk_mem_alloc.h (+ deps headers), imgui.h
libs = [f'-L {cmd_path(DEPS / "lib")}'] + LLVM_LINK_LIBS if DEPS else LINK_LIBS
# A compiler cache (sccache) may front clang: every lift rewrites the generated chunks with the same contents, so
# the cached objects save the compile step on the next build.
cache = f'{cmd_arg(os.environ["SCCACHE"])} ' if os.environ.get('SCCACHE') else ''
lines = [
'# Written by tools/build_game.py; do not edit.',
'ninja_required_version = 1.10',
f'builddir = {ninja_path(out)}',
f'cc = {cache}{cmd_arg(CLANG.as_posix())}',
f'genflags = {" ".join(GEN_FLAGS)} {inc}',
f'rtflags = {" ".join(RT_FLAGS)} {rt_inc}',
f'ldflags = {" ".join(LINK_FLAGS)}',
f'libs = {" ".join(libs)}',
'',
'pool gen',
f' depth = {pool_depth}',
'',
'rule cc_gen',
' command = $cc $genflags -MD -MF $out.d -c $in -o $out',
' deps = gcc',
' depfile = $out.d',
' description = CC $in',
'',
'rule cc_rt',
' command = $cc $rtflags -MD -MF $out.d -c $in -o $out',
' deps = gcc',
' depfile = $out.d',
' description = CC $in',
'',
'rule rc',
f' command = {cmd_arg(WINDRES.as_posix())} -I {cmd_path(RUNTIME)} -O coff $in -o $out',
' description = RC $in',
'',
'rule link',
' command = $cc $ldflags @$out.rsp $libs -o $out',
' rspfile = $out.rsp',
' rspfile_content = $in_newline',
' description = LINK $out',
'',
]
objs = {}
for mod, files in mods.items():
objs[mod] = []
for f in files:
o = out / 'obj' / mod / (f.stem + '.o')
if not prebuilt:
lines.append(f'build {ninja_path(o)}: cc_gen {ninja_path(f)}')
lines.append(' pool = gen')
objs[mod].append(o)
rt = []
sources = runtime_sources(skip)
if make_lib:
sources = [p for p in sources if p.name not in INSTALL_SIDE]
elif runtime_lib:
sources = [p for p in sources if p.name in INSTALL_SIDE] + [out / 'modules.c']
else:
sources += [out / 'modules.c']
for src in sources:
o = runtime_object(out, src)
if src.suffix == '.rc': # windres writes no depfile: the files totk.rc embeds are listed by hand
lines.append(f'build {ninja_path(o)}: rc {ninja_path(src)} | {ninja_path(RUNTIME / "totk.manifest")} '
f'{ninja_path(RUNTIME / "totk.ico")}')
else:
lines.append(f'build {ninja_path(o)}: cc_rt {ninja_path(src)}')
rt.append(o)
objs['runtime'] = rt
every = [o for v in objs.values() for o in v]
exe = out / 'totk.exe'
if make_lib:
exe = out / LIB_NAME
lines += ['', 'rule ar', f' command = {cmd_arg(AR.as_posix())} rcs $out $in', ' description = AR $out', '', f'build {ninja_path(exe)}: ar ' + ' '.join(ninja_path(o) for o in every),
'build objects: phony ' + ' '.join(ninja_path(o) for o in every), f'default {ninja_path(exe)}', '']
return '\n'.join(lines), objs, exe
rule = 'link'
if runtime_lib:
# Whole archive: the runtime's weak defaults (nvn_procs.c, nvn_api_core.c, vk_sync.c) would otherwise satisfy
# references before the linker pulls the members with the strong definitions, which would stay out of totk.exe.
rule = 'link_lib'
lines += ['', 'rule link_lib',
f' command = $cc $ldflags @$out.rsp -Wl,--whole-archive {cmd_path(runtime_lib)} -Wl,--no-whole-archive $libs -o $out',
' rspfile = $out.rsp', ' rspfile_content = $in_newline', ' description = LINK $out']
lines += ['', f'build {ninja_path(exe)}: {rule} ' + ' '.join(ninja_path(o) for o in every) +
(f' | {ninja_path(runtime_lib)}' if runtime_lib else ''),
'build objects: phony ' + ' '.join(ninja_path(o) for o in every), f'default {ninja_path(exe)}', '']
return '\n'.join(lines), objs, exe
def main_objects(gen, main_out, rt_dir, mods):
"""({relative object path: the main build's object}, '') when the main build's generated objects can stand in for
ours, else (None, why). That holds when the main build.ninja compiles the same sources with the same flags, its
objects are up to date, and every header they include from runtime/ is byte-identical in rt_dir."""
main_ninja = main_out / 'build.ninja'
if not main_ninja.is_file():
return None, f'{flag_path(main_ninja)} not found'
if gen != (ROOT / 'generated' / main_out.name).resolve():
return None, 'generated C directory is not the main one'
lines = main_ninja.read_text(encoding='utf-8').splitlines()
sources = dict(l[6:].split(': cc_gen ') for l in lines if l.startswith('build ') and ': cc_gen ' in l)
flags = next((l for l in lines if l.startswith('genflags = ')), '')
if not flags.startswith('genflags = ' + ' '.join(GEN_FLAGS) + ' -I '):
return None, 'main build uses other compile flags'
pairs = {}
for mod, files in mods.items():
for f in files:
o = Path('obj') / mod / (f.stem + '.o')
theirs = main_out / o
if sources.get(ninja_path(theirs)) != ninja_path(f):
return None, f'main build has no edge for {f.name}'
if not theirs.is_file():
return None, f'main object {flag_path(theirs)} is missing'
pairs[o] = theirs
# Ninja runs in the main checkout: the main build.ninja is relative to it, and the worktree's runtime/ files carry newer
# mtimes that would make every object look stale. Header equality is checked byte for byte below.
env = dict(os.environ, PATH=str(UCRT) + os.pathsep + os.environ.get('PATH', ''))
targets = [ninja_path(t) for t in pairs.values()]
for i in range(0, len(targets), 200):
r = subprocess.run([str(NINJA), '-f', str(main_ninja), '-n', '-d', 'explain'] + targets[i:i + 200], cwd=MAIN_ROOT,
env=env, capture_output=True, text=True)
if r.returncode or 'no work to do' not in r.stdout:
return None, f'main generated objects are stale ({flag_path(main_out)} needs a build)'
r = subprocess.run([str(NINJA), '-f', str(main_ninja), '-t', 'deps'], cwd=MAIN_ROOT, env=env, capture_output=True,
text=True)
ours = {flag_path(t) for t in pairs.values()}
headers, cur = set(), False
for l in r.stdout.splitlines():
if l.startswith(' '):
if cur:
headers.add(l.strip())
else:
cur = l.split(': #deps')[0] in ours
main_rt = MAIN_ROOT / 'runtime'
for h in sorted(headers):
p = Path(h) if Path(h).is_absolute() else MAIN_ROOT / h
try:
rel = p.resolve().relative_to(main_rt)
except ValueError:
continue
mine = rt_dir / rel
if not mine.is_file() or mine.read_bytes() != p.read_bytes():
return None, f'header {rel.as_posix()} differs in {flag_path(rt_dir)}'
return pairs, ''
def link_objects(out, pairs):
"""Hardlink the main build's objects into out. Clang and Ninja replace an object by renaming a new file over it,
so a link keeps the old bytes after a main rebuild. Copies where linking fails."""
for o, theirs in pairs.items():
dst = out / o
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
if os.path.samefile(dst, theirs):
continue
dst.unlink()
try:
os.link(theirs, dst)
except OSError:
shutil.copy2(theirs, dst)
def unlink_shared(out, mods):
"""Drop generated objects still hardlinked to another build, so the next compile writes a new file."""
for mod, files in mods.items():
for f in files:
o = out / 'obj' / mod / (f.stem + '.o')
if o.exists() and o.stat().st_nlink > 1:
o.unlink()
MANIFEST = 'objects.sha256'
def file_sha256(path):
h = hashlib.sha256()
with open(path, 'rb', buffering=0) as f:
buf = bytearray(1 << 20)
while n := f.readinto(buf):
h.update(memoryview(buf)[:n])
return h.hexdigest()
def read_manifest(path):
"""{object path below obj/: (sha256, size, mtime_ns)}; empty when the file is missing."""
man = {}
if path.is_file():
for line in path.read_text(encoding='utf-8').splitlines():
sha, size, mtime, rel = line.split(' ', 3)
man[rel] = (sha, int(size), int(mtime))
return man
def write_manifest(path, man):
write_if_changed(path, ''.join(f'{sha} {size} {mtime} {rel}\n' for rel, (sha, size, mtime) in sorted(man.items())))
def check_objects(out, objects, man, record):
"""Compare objects with the manifest man. An object whose size and mtime match its entry is hashed and must equal
it; any other object was just (re)built, so with record its hash enters man. Returns (verified, bad, unrecorded):
the objects with a wrong hash, and with record False the ones without a current entry."""
verified, bad, unrecorded = 0, [], []
for o in objects:
rel = o.relative_to(out / 'obj').as_posix()
st = o.stat()
e = man.get(rel)
if e and e[1] == st.st_size and e[2] == st.st_mtime_ns:
if file_sha256(o) == e[0]:
verified += 1
else:
bad.append(o)
elif record:
man[rel] = (file_sha256(o), st.st_size, st.st_mtime_ns)
verified += 1
else:
unrecorded.append(o)
return verified, bad, unrecorded
def ninja_build(base, targets, env):
for i in range(0, len(targets), 200):
code = subprocess.run(base + targets[i:i + 200], cwd=ROOT, env=env).returncode
if code:
sys.exit(code)
def verify_objects(out, objs, borrowed, main_out, pairs, base, env):
"""Verify every object before the link; see the module docstring. borrowed: the hardlinked generated objects of
main_out (checked against its manifest, never recorded); the others belong to out."""
t0 = time.monotonic()
owned = [o for mod, v in objs.items() for o in v if not (borrowed and mod != 'runtime')]
shared = [o for mod, v in objs.items() for o in v if borrowed and mod != 'runtime']
man_path = out / 'obj' / MANIFEST
man = read_manifest(man_path)
verified, bad, _ = check_objects(out, owned, man, True)
recompiled = 0
if bad:
for o in bad:
print(f'build_game: object {flag_path(o)} does not match its hash; deleting it to recompile')
o.unlink()
ninja_build(base, [ninja_path(o) for o in bad], env)
recompiled = len(bad)
verified += check_objects(out, bad, man, True)[0]
write_manifest(man_path, man)
if shared:
main_man = read_manifest(main_out / 'obj' / MANIFEST)
theirs = [pairs[o.relative_to(out)] for o in shared]
v, bad, unrecorded = check_objects(main_out, theirs, main_man, False)
verified += v
if bad:
for b in bad:
print(f'build_game: shared object {flag_path(b)} does not match its hash in '
f'{flag_path(main_out / "obj" / MANIFEST)}', file=sys.stderr)
sys.exit(f'build_game: run tools/build_game.py for {flag_path(main_out)} (it recompiles them), then rebuild '
f'{flag_path(out)}')
if unrecorded:
print(f'build_game: warning: {len(unrecorded)} shared objects have no current hash in '
f'{flag_path(main_out / "obj" / MANIFEST)}; build {flag_path(main_out)} once to record them')
print(f'objects verified: {verified}, recompiled after a hash mismatch: {recompiled} ({time.monotonic() - t0:.1f} s)')
# ---- Windows job object: peak memory of a process tree
class IoCounters(ctypes.Structure):
_fields_ = [(n, ctypes.c_ulonglong) for n in ('r_ops', 'w_ops', 'o_ops', 'r_bytes', 'w_bytes', 'o_bytes')]
class BasicLimits(ctypes.Structure):
_fields_ = [('PerProcessUserTimeLimit', ctypes.c_longlong), ('PerJobUserTimeLimit', ctypes.c_longlong),
('LimitFlags', wt.DWORD), ('MinimumWorkingSetSize', ctypes.c_size_t),
('MaximumWorkingSetSize', ctypes.c_size_t), ('ActiveProcessLimit', wt.DWORD),
('Affinity', ctypes.c_size_t), ('PriorityClass', wt.DWORD), ('SchedulingClass', wt.DWORD)]
class ExtendedLimits(ctypes.Structure):
_fields_ = [('BasicLimitInformation', BasicLimits), ('IoInfo', IoCounters),
('ProcessMemoryLimit', ctypes.c_size_t), ('JobMemoryLimit', ctypes.c_size_t),
('PeakProcessMemoryUsed', ctypes.c_size_t), ('PeakJobMemoryUsed', ctypes.c_size_t)]
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
CREATE_SUSPENDED = 0x4
def run_measured(cmd, env):
"""Run cmd in its own job object. Returns (exit code, seconds, peak bytes of one process, peak bytes of all)."""
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
k32.CreateJobObjectW.restype = wt.HANDLE
k32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wt.LPCWSTR]
k32.SetInformationJobObject.argtypes = [wt.HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD]
k32.QueryInformationJobObject.argtypes = [wt.HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD, ctypes.c_void_p]
k32.AssignProcessToJobObject.argtypes = [wt.HANDLE, wt.HANDLE]
k32.CloseHandle.argtypes = [wt.HANDLE]
ntdll = ctypes.WinDLL('ntdll')
ntdll.NtResumeProcess.argtypes = [wt.HANDLE]
job = k32.CreateJobObjectW(None, None)
info = ExtendedLimits()
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE # Ctrl+C stops every compile
k32.SetInformationJobObject(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(info), ctypes.sizeof(info))
t0 = time.monotonic()
# Suspended until it is in the job, so the compilers it starts are counted from the first one.
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, creationflags=CREATE_SUSPENDED)
if not k32.AssignProcessToJobObject(job, int(proc._handle)):
print(f'build_game: cannot track memory (error {ctypes.get_last_error()})', file=sys.stderr)
ntdll.NtResumeProcess(int(proc._handle))
try:
code = proc.wait()
except KeyboardInterrupt:
k32.CloseHandle(job)
raise
dt = time.monotonic() - t0
k32.QueryInformationJobObject(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(info),
ctypes.sizeof(info), None)
k32.CloseHandle(job)
return code, dt, info.PeakProcessMemoryUsed, info.PeakJobMemoryUsed
def total_ram():
class MemStatus(ctypes.Structure):
_fields_ = [('dwLength', wt.DWORD), ('dwMemoryLoad', wt.DWORD)] + \
[(n, ctypes.c_ulonglong) for n in ('total', 'avail', 'tpf', 'apf', 'tv', 'av', 'aev')]
s = MemStatus()
s.dwLength = ctypes.sizeof(s)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(s))
return s.total
def pe_sections(path):
"""{section name: virtual size} and SizeOfImage of a PE32+ file."""
data = path.read_bytes()[:4096]
pe = int.from_bytes(data[0x3C:0x40], 'little')
nsec = int.from_bytes(data[pe + 6:pe + 8], 'little')
opt = pe + 24
image = int.from_bytes(data[opt + 56:opt + 60], 'little')
table = opt + int.from_bytes(data[pe + 20:pe + 22], 'little')
out = {}
for i in range(nsec):
s = data[table + 40 * i:table + 40 * (i + 1)]
out[s[:8].rstrip(b'\0').decode(errors='replace')] = int.from_bytes(s[8:12], 'little')
return out, image
def mib(n):
return f'{n / (1 << 20):,.1f} MiB'
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--version', default='v121', help='game version (default v121)')
ap.add_argument('--gen', type=Path, help='generated C directory (default generated/<version>)')
ap.add_argument('--out', type=Path, help='build directory (default build/<version>)')
ap.add_argument('--modules', help='comma-separated subset of the layout modules (default all)')
ap.add_argument('--jobs', type=int, help='parallel compiles (default: by memory budget and CPU count)')
ap.add_argument('--compile-only', action='store_true', help='compile, do not link')
ap.add_argument('--runtime', type=Path, help='runtime source directory (default runtime)')
ap.add_argument('--full-build', action='store_true',
help="compile the generated C even where build/<version>'s objects could be reused")
ap.add_argument('--skip-runtime', default='',
help='comma-separated runtime/*.c names to leave out (the link needs another definition of what they '
'define)')
ap.add_argument('--exefs', type=Path,
help="totk.exe's default ExeFS directory (default build/extracted/<version>/exefs)")
ap.add_argument('--romfs', type=Path, help="totk.exe's default RomFS directory with romfs.bin and index.json "
'(default build/extracted/<version>/romfs)')
ap.add_argument('--profile', type=Path, help="totk.exe's default profile directory (default build/profile for v121, "
"build/profile-<version> otherwise, so trying another version leaves "
"the main profile alone; saves load on 1.2.1 and 1.4.3 alike)")
ap.add_argument('--toolchain', type=Path, help='llvm-mingw directory (with bin/clang.exe) to build with instead of '
'MSYS2 UCRT64 clang')
ap.add_argument('--ninja', type=Path, help='ninja.exe to build with (default: MSYS2 UCRT64, else bin/ninja.exe of the '
'release folder)')
ap.add_argument('--deps', type=Path, help=f'with --toolchain: include/, lib/ and bin/ of Vulkan, SDL3, Opus, libvpx '
f'and VMA (default {DEFAULT_DEPS.relative_to(ROOT).as_posix()})')
ap.add_argument('--make-runtime-lib', action='store_true',
help=f'build only <out>/{LIB_NAME} from runtime/ (no generated C, no totk.exe)')
ap.add_argument('--runtime-lib', type=Path, help=f'link against this {LIB_NAME} instead of compiling runtime/ '
f'({", ".join(sorted(INSTALL_SIDE))} and modules.c still compile)')
ap.add_argument('--expected', type=Path,
help='expected hashes (default tools/data/expected-<display version>.json)')
args = ap.parse_args()
gen = (args.gen or ROOT / 'generated' / args.version).resolve()
out = (args.out or ROOT / 'build' / args.version).resolve()
extracted = ROOT / 'build' / 'extracted' / args.version
paths = {'exefs': (args.exefs or extracted / 'exefs').resolve(),
'romfs': (args.romfs or extracted / 'romfs').resolve(),
'profile': (args.profile or ROOT / 'build' / ('profile' if args.version == 'v121'
else f'profile-{args.version}')).resolve()}
expected = args.expected
if not expected:
if args.version not in DISPLAY_VERSIONS:
sys.exit(f'no expected hashes known for {args.version}; pass --expected')
expected = ROOT / 'tools' / 'data' / f'expected-v{DISPLAY_VERSIONS[args.version]}.json'
hashes = json.loads(Path(expected).read_text())['exefs_sha256']
global CLANG, AR, WINDRES, NINJA, TOOLCHAIN_BIN, DEPS
if args.ninja:
NINJA = args.ninja.resolve()
elif not NINJA.is_file() and (ROOT / 'bin' / 'ninja.exe').is_file():
NINJA = ROOT / 'bin' / 'ninja.exe' # the release folder (tools/make_release.py)
dlls = SDL3_DLLS
if args.toolchain:
TOOLCHAIN_BIN = (args.toolchain / 'bin').resolve()
CLANG = TOOLCHAIN_BIN / 'clang.exe'
AR = TOOLCHAIN_BIN / 'llvm-ar.exe'
WINDRES = TOOLCHAIN_BIN / 'llvm-windres.exe'
DEPS = (args.deps or DEFAULT_DEPS).resolve()
dlls = sorted((DEPS / 'bin').glob('*.dll'))
for tool in (CLANG, NINJA):
if not tool.is_file():
sys.exit(f'{tool} not found: install MSYS2 UCRT64 clang, lld and ninja (docs/building.md)')
if not (gen / 'layout.json').is_file():
sys.exit(f'{gen / "layout.json"} not found: run the generator first')
layout = read_layout(gen)
names = [n for n, _, _ in layout]
wanted = args.modules.split(',') if args.modules else names
unknown = [m for m in wanted if m not in names]
if unknown:
sys.exit(f'unknown modules {unknown}; the layout has {names}')
linked = [m for m in names if m in wanted]
mods = {m: chunk_files(gen, m) for m in linked}
cpus = os.cpu_count() or 1
budget = min(MEM_BUDGET, int(total_ram() * 0.8))
jobs = args.jobs or max(1, min(cpus, budget // JOB_MEM))
missing = [n for n in names if n not in hashes]
if missing:
sys.exit(f'{expected}: no exefs_sha256 for {missing}')
write_if_changed(out / 'modules.c', module_glue(layout, linked, hashes, paths, args.version))
if (paths['romfs'] / 'index.json').is_file():
count, size, dt = romfs_index.write(paths['romfs'], out)
print(f'romfs index: {count} files, {mib(size)}, {dt:.1f} s')
else:
romfs_index.write_placeholder(out)
print(f'build_game: warning: no {paths["romfs"] / "index.json"}; totk.exe will have no RomFS', file=sys.stderr)
copy_romfs_patches(args.version, out)
global RUNTIME
RUNTIME = (args.runtime or RUNTIME).resolve()
stale = stale_shaders(RUNTIME)
if stale:
sys.exit(f'{stale[0]}: its SPIR-V header no longer matches; run python tools/build_shaders.py')
skip = {s for s in args.skip_runtime.split(',') if s}
main_out = (ROOT / 'build' / args.version).resolve()
pairs = None
if args.make_runtime_lib:
mods, linked, why = {}, [], ''
elif args.full_build:
why = '--full-build'
elif args.toolchain:
why = f'--toolchain {flag_path(args.toolchain)}'
elif out == main_out:
why = ''
else:
pairs, why = main_objects(gen, main_out, RUNTIME, mods)
if pairs is not None:
link_objects(out, pairs)
print(f'build_game: reusing {len(pairs)} generated objects of {flag_path(main_out)} (hardlinks)')
else:
if out != main_out: # forks hold hardlinks to the main build's objects; the main build keeps its own
unlink_shared(out, mods)
if why:
print(f'build_game: compiling all generated C: {why}')
runtime_lib = args.runtime_lib.resolve() if args.runtime_lib else None
if runtime_lib and not runtime_lib.is_file():
sys.exit(f'{runtime_lib} not found: build it with --make-runtime-lib')
text, objs, exe = ninja_file(gen, out, mods, jobs, skip, pairs is not None, runtime_lib,
args.make_runtime_lib)
write_if_changed(out / 'build.ninja', text)
env = dict(os.environ, PATH=str(TOOLCHAIN_BIN or UCRT) + os.pathsep + os.environ.get('PATH', ''))
base = [str(NINJA), '-f', str(out / 'build.ninja'), '-j', str(jobs)]
nfiles = sum(len(v) for v in mods.values())
print(f'build_game: {nfiles} generated files in {len(linked)} modules ({", ".join(linked)}), {jobs} jobs', flush=True)
code, dt, peak_one, peak_all = run_measured(base + ['objects'], env)
print(f'compile: {dt:.1f} s, peak {mib(peak_one)} per process, {mib(peak_all)} all processes')
if code:
sys.exit(code)
for mod, files in objs.items():
size = sum(o.stat().st_size for o in files)
print(f' {mod:8} {len(files):5} objects {mib(size):>14}')
verify_objects(out, objs, pairs is not None, main_out, pairs, base, env)
if args.compile_only:
return
if args.make_runtime_lib:
exe.unlink(missing_ok=True) # ar rcs would keep members of deleted sources
code, dt, peak_one, _ = run_measured(base + [flag_path(exe)], env)
print(f'link: {dt:.1f} s, peak {mib(peak_one)}')
if code:
print(f'build_game: link failed, {flag_path(exe)} is the old one (a running totk.exe from this dir locks it)',
file=sys.stderr)
sys.exit(code)
if args.make_runtime_lib:
print(f'{below_root(exe)}: {mib(exe.stat().st_size)}')
return
for dll in dlls: # dynamic SDL3 (zlib) and its libiconv (LGPL, unmodified DLL), found next to totk.exe
shutil.copy2(dll, exe.parent / dll.name)
sections, image = pe_sections(exe)
print(f'{below_root(exe)}: file {mib(exe.stat().st_size)}, '
f'image {mib(image)}; ' + ', '.join(f'{k} {mib(v)}' for k, v in sections.items() if k.startswith('.') and v))
if image > 3 * GiB // 2:
print('build_game: warning: image over 1.5 GiB; RIP-relative code reaches only 2 GiB', file=sys.stderr)
if __name__ == '__main__':
main()